Merge branch 'dev' into main

This commit is contained in:
MarekWo
2026-07-19 22:07:28 +02:00
20 changed files with 4837 additions and 85 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
+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.
+199 -60
View File
@@ -220,6 +220,15 @@ class DeviceManager:
self._last_rx_at: float = 0.0 # unix ts of last RX_LOG_DATA / event from device
self._consecutive_stats_failures: int = 0 # incremented on get_stats_* / get_bat failures
# Repeater administration (My Repeaters panel + console commands).
# The companion firmware tracks a single pending remote request, so
# concurrent repeater operations would corrupt each other's matching.
self._repeater_lock = threading.Lock()
self._repeater_sessions = {} # {public_key: {is_admin, permissions, logged_in_at}}
# Single-slot waiter for a repeater CLI text reply (guarded by
# _repeater_lock: only one remote command is ever in flight).
self._cli_waiter = None # {'prefix': 12-hex, 'event': threading.Event, 'reply': str|None}
# In-place reconnect (heals degraded long-lived TCP without container restart)
self._reconnect_lock = threading.Lock() # prevents concurrent force_reconnect calls
self._last_force_reconnect_at: float = 0.0
@@ -838,6 +847,20 @@ class DeviceManager:
"""Handle incoming direct message."""
try:
data = getattr(event, 'payload', {})
# Repeater CLI replies (txt_type=1 CLI_DATA) awaited by
# repeater_cmd_wait() are consumed here and never stored as
# chat DMs. Unmatched CLI replies (e.g. console fire-and-forget
# `cmd`) keep the legacy behavior and land in the DM panel.
if data.get('txt_type') == 1:
waiter = self._cli_waiter
prefix = (data.get('pubkey_prefix') or '').lower()
if waiter and prefix and waiter['prefix'] == prefix:
waiter['reply'] = data.get('text', '')
waiter['event'].set()
logger.debug(f"CLI reply consumed from {prefix}")
return
ts = data.get('timestamp', int(time.time()))
content = data.get('text', '')
sender_key = data.get('public_key', data.get('pubkey_prefix', ''))
@@ -3047,37 +3070,93 @@ class DeviceManager:
# ── Repeater Management ──────────────────────────────────────────
REPEATER_BUSY_ERROR = 'Another repeater operation is in progress'
def _locked_repeater_execute(self, coro, timeout: int, error_label: str) -> Dict:
"""Run one remote repeater request under the repeater lock.
The companion firmware tracks a single pending remote request
(each new send clears the previous one), so all repeater
operations must be serialized app-side.
"""
if not self._repeater_lock.acquire(timeout=180):
coro.close()
return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True}
try:
result = self.execute(coro, timeout=timeout)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': f'No {error_label} response (timeout)'}
finally:
self._repeater_lock.release()
def repeater_login(self, name_or_key: str, password: str) -> Dict:
"""Log into a repeater with given password."""
"""Log into a repeater and capture the granted role.
A wrong password is indistinguishable from an unreachable
repeater: the firmware simply never replies, so both surface
as a timeout.
"""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
coro = self._repeater_login_async(contact, password)
if not self._repeater_lock.acquire(timeout=180):
coro.close()
return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True}
try:
from meshcore.events import EventType
res = self.execute(
self.mc.commands.send_login(contact, password),
timeout=10
)
# Wait for LOGIN_SUCCESS or LOGIN_FAILED
timeout = 30
if res and hasattr(res, 'payload') and 'suggested_timeout' in res.payload:
timeout = res.payload['suggested_timeout'] / 800
timeout = max(timeout, contact.get('timeout', 0) or 30)
event = self.execute(
self.mc.wait_for_event(EventType.LOGIN_SUCCESS, timeout=timeout),
timeout=timeout + 5
)
if event and hasattr(event, 'type') and event.type == EventType.LOGIN_SUCCESS:
return {'success': True, 'message': f'Logged into {contact.get("adv_name", name_or_key)}'}
return {'success': False, 'error': 'Login failed (timeout)'}
result = self.execute(coro, timeout=75)
if result.get('success'):
pubkey = (contact.get('public_key') or '').lower()
self._repeater_sessions[pubkey] = {
'is_admin': result.get('is_admin', False),
'permissions': result.get('permissions'),
'logged_in_at': time.time(),
}
return result
except Exception as e:
err = str(e)
if 'LOGIN_FAILED' in err or 'login' in err.lower():
return {'success': False, 'error': 'Login failed (wrong password?)'}
logger.error(f"Repeater login failed: {e}")
return {'success': False, 'error': err}
return {'success': False, 'error': str(e)}
finally:
self._repeater_lock.release()
async def _repeater_login_async(self, contact: Dict, password: str) -> Dict:
"""Send login and wait for a LOGIN_SUCCESS from this repeater."""
from meshcore.events import EventType
res = await self.mc.commands.send_login(contact, password)
if res is None or getattr(res, 'type', None) == EventType.ERROR:
detail = getattr(res, 'payload', None) if res is not None else 'no response from device'
return {'success': False, 'error': f'Failed to send login: {detail}'}
timeout = 30.0
payload = getattr(res, 'payload', None) or {}
if isinstance(payload, dict) and 'suggested_timeout' in payload:
timeout = payload['suggested_timeout'] / 800
timeout = min(max(timeout, contact.get('timeout', 0) or 0, 15.0), 60.0)
prefix = (contact.get('public_key') or '')[:12]
event = await self.mc.wait_for_event(
EventType.LOGIN_SUCCESS,
attribute_filters={'pubkey_prefix': prefix},
timeout=timeout,
)
if event is None:
return {
'success': False,
'error': 'No response — repeater unreachable or wrong password',
'timeout': True,
}
ev_payload = getattr(event, 'payload', None) or {}
return {
'success': True,
'is_admin': bool(ev_payload.get('is_admin')),
'permissions': ev_payload.get('permissions'),
'name': contact.get('adv_name', ''),
}
def get_repeater_session(self, public_key: str) -> Optional[Dict]:
"""Return the in-memory login session for a repeater, if any."""
return self._repeater_sessions.get((public_key or '').lower())
def repeater_logout(self, name_or_key: str) -> Dict:
"""Log out of a repeater."""
@@ -3086,27 +3165,83 @@ class DeviceManager:
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
coro = self.mc.commands.send_logout(contact)
if not self._repeater_lock.acquire(timeout=180):
coro.close()
return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True}
try:
self.execute(self.mc.commands.send_logout(contact), timeout=10)
self.execute(coro, timeout=15)
self._repeater_sessions.pop((contact.get('public_key') or '').lower(), None)
return {'success': True, 'message': f'Logged out of {contact.get("adv_name", name_or_key)}'}
except Exception as e:
logger.error(f"Repeater logout failed: {e}")
return {'success': False, 'error': str(e)}
finally:
self._repeater_lock.release()
def repeater_cmd(self, name_or_key: str, cmd: str) -> Dict:
"""Send a command to a repeater."""
"""Send a command to a repeater (fire-and-forget; reply arrives as a DM)."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
coro = self.mc.commands.send_cmd(contact, cmd)
if not self._repeater_lock.acquire(timeout=180):
coro.close()
return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True}
try:
res = self.execute(self.mc.commands.send_cmd(contact, cmd), timeout=10)
self.execute(coro, timeout=15)
msg = f'Command sent to {contact.get("adv_name", name_or_key)}: {cmd}'
return {'success': True, 'message': msg}
except Exception as e:
logger.error(f"Repeater cmd failed: {e}")
return {'success': False, 'error': str(e)}
finally:
self._repeater_lock.release()
def repeater_cmd_wait(self, name_or_key: str, cmd: str, timeout: float = 45.0) -> Dict:
"""Send a CLI command to a repeater and wait for its text reply.
The reply arrives asynchronously as a CONTACT_MSG_RECV with
txt_type=1 and carries no protocol-level correlation, so we rely
on serialization (repeater lock = single command in flight) plus
a sender-prefix match in _on_dm_received.
"""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
coro = self.mc.commands.send_cmd(contact, cmd)
if not self._repeater_lock.acquire(timeout=180):
coro.close()
return {'success': False, 'error': self.REPEATER_BUSY_ERROR, 'busy': True}
try:
prefix = (contact.get('public_key') or '')[:12].lower()
waiter = {'prefix': prefix, 'event': threading.Event(), 'reply': None}
self._cli_waiter = waiter
started = time.time()
res = self.execute(coro, timeout=15)
wait_s = 30.0
payload = getattr(res, 'payload', None) or {}
if isinstance(payload, dict) and 'suggested_timeout' in payload:
wait_s = payload['suggested_timeout'] / 800
wait_s = min(max(wait_s, 10.0), float(timeout))
if waiter['event'].wait(timeout=wait_s):
elapsed_ms = int((time.time() - started) * 1000)
return {'success': True, 'reply': waiter['reply'] or '', 'elapsed_ms': elapsed_ms}
return {'success': False,
'error': f'No reply from repeater within {wait_s:.0f}s',
'timeout': True}
except Exception as e:
logger.error(f"repeater_cmd_wait failed: {e}")
return {'success': False, 'error': str(e)}
finally:
self._cli_waiter = None
self._repeater_lock.release()
def repeater_req_status(self, name_or_key: str) -> Dict:
"""Request status from a repeater."""
@@ -3117,13 +3252,11 @@ class DeviceManager:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
return self._locked_repeater_execute(
self.mc.commands.req_status_sync(contact, contact_timeout, min_timeout=15),
timeout=120
timeout=120,
error_label='status',
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No status response (timeout)'}
except Exception as e:
logger.error(f"req_status failed: {e}")
return {'success': False, 'error': str(e)}
@@ -3137,13 +3270,11 @@ class DeviceManager:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
return self._locked_repeater_execute(
self.mc.commands.req_regions_sync(contact, contact_timeout),
timeout=120
timeout=120,
error_label='regions',
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No regions response (timeout)'}
except Exception as e:
logger.error(f"req_regions failed: {e}")
return {'success': False, 'error': str(e)}
@@ -3157,13 +3288,11 @@ class DeviceManager:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
return self._locked_repeater_execute(
self.mc.commands.req_owner_sync(contact, contact_timeout),
timeout=120
timeout=120,
error_label='owner',
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No owner response (timeout)'}
except Exception as e:
logger.error(f"req_owner failed: {e}")
return {'success': False, 'error': str(e)}
@@ -3177,13 +3306,11 @@ class DeviceManager:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
return self._locked_repeater_execute(
self.mc.commands.req_acl_sync(contact, contact_timeout, min_timeout=15),
timeout=120
timeout=120,
error_label='ACL',
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No ACL response (timeout)'}
except Exception as e:
logger.error(f"req_acl failed: {e}")
return {'success': False, 'error': str(e)}
@@ -3197,13 +3324,11 @@ class DeviceManager:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
return self._locked_repeater_execute(
self.mc.commands.req_basic_sync(contact, contact_timeout),
timeout=120
timeout=120,
error_label='clock',
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No clock response (timeout)'}
except Exception as e:
logger.error(f"req_clock failed: {e}")
return {'success': False, 'error': str(e)}
@@ -3217,17 +3342,33 @@ class DeviceManager:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
return self._locked_repeater_execute(
self.mc.commands.req_mma_sync(contact, from_secs, to_secs, contact_timeout, min_timeout=15),
timeout=120
timeout=120,
error_label='MMA',
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No MMA response (timeout)'}
except Exception as e:
logger.error(f"req_mma failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_telemetry(self, name_or_key: str) -> Dict:
"""Request telemetry (Cayenne LPP) from a repeater."""
if not self.is_connected:
return {'success': False, 'error': 'Device not connected'}
contact = self.resolve_contact(name_or_key)
if not contact:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
return self._locked_repeater_execute(
self.mc.commands.req_telemetry_sync(contact, contact_timeout, min_timeout=15),
timeout=120,
error_label='telemetry',
)
except Exception as e:
logger.error(f"req_telemetry failed: {e}")
return {'success': False, 'error': str(e)}
def repeater_req_neighbours(self, name_or_key: str) -> Dict:
"""Request neighbours from a repeater."""
if not self.is_connected:
@@ -3237,13 +3378,11 @@ class DeviceManager:
return {'success': False, 'error': f"Contact not found: {name_or_key}"}
try:
contact_timeout = contact.get('timeout', 0) or 0
result = self.execute(
return self._locked_repeater_execute(
self.mc.commands.fetch_all_neighbours(contact, timeout=contact_timeout, min_timeout=15),
timeout=120
timeout=120,
error_label='neighbours',
)
if result is not None:
return {'success': True, 'data': result}
return {'success': False, 'error': 'No neighbours response (timeout)'}
except Exception as e:
logger.error(f"req_neighbours failed: {e}")
return {'success': False, 'error': str(e)}
+2 -1
View File
@@ -675,7 +675,8 @@ def _execute_console_command(args: list) -> str:
password = ' '.join(args[2:])
result = device_manager.repeater_login(name, password)
if result.get('success'):
return result.get('message', 'OK')
role = 'admin' if result.get('is_admin') else 'guest'
return f"Logged into {result.get('name') or name} as {role}"
return f"Error: {result.get('error')}"
elif cmd == 'login':
+672 -16
View File
@@ -181,6 +181,27 @@ def invalidate_contacts_cache():
logger.debug("Contacts cache invalidated")
def _format_path_display(out_path_len, out_path, out_path_hash_mode):
"""Format device out_path fields as 'Flood' / 'Direct' / 'AB→CD12…'.
meshcore lib 2.x: out_path_len already holds the hop count (6 LSB) and
the hash-size mode is stored separately in out_path_hash_mode. The raw
out_path is truncated to the meaningful bytes (firmware buffer may have
trailing garbage).
"""
if out_path_len and out_path_len > 0 and out_path:
hash_mode = out_path_hash_mode or 0
hash_size = max(1, hash_mode + 1) if hash_mode >= 0 else 1
chunk = hash_size * 2
meaningful_hex = out_path[:out_path_len * chunk]
hops = [meaningful_hex[i:i + chunk].upper()
for i in range(0, len(meaningful_hex), chunk)]
return ''.join(hops) if hops else out_path
if out_path_len == 0:
return 'Direct'
return 'Flood'
# =============================================================================
# Protected Contacts Management
# =============================================================================
@@ -3018,25 +3039,10 @@ def get_contacts_detailed_api():
blocked_keys = db.get_blocked_keys() if db else set()
for public_key, details in contacts_detailed.items():
# Compute path display string.
# meshcore lib 2.x: out_path_len already holds the hop count (6 LSB)
# and the hash-size mode is stored separately in out_path_hash_mode.
out_path_len = details.get('out_path_len', -1)
out_path_raw = details.get('out_path', '')
out_path_hash_mode = details.get('out_path_hash_mode', 0)
if out_path_len > 0 and out_path_raw:
hop_count = out_path_len
hash_size = max(1, out_path_hash_mode + 1) if out_path_hash_mode >= 0 else 1
chunk = hash_size * 2
# Truncate to meaningful bytes (firmware buffer may have trailing garbage)
meaningful_hex = out_path_raw[:hop_count * chunk]
# Format as HEX→HEX→HEX (each hop is hash_size*2 hex chars)
hops = [meaningful_hex[i:i+chunk].upper() for i in range(0, len(meaningful_hex), chunk)]
path_or_mode = ''.join(hops) if hops else out_path_raw
elif out_path_len == 0:
path_or_mode = 'Direct'
else:
path_or_mode = 'Flood'
path_or_mode = _format_path_display(out_path_len, out_path_raw, out_path_hash_mode)
contact = {
# All original fields from contact_info
@@ -5666,3 +5672,653 @@ def get_logs_api():
except Exception as e:
logger.error(f"Error getting logs: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# =============================================================================
# My Repeaters API (repeater administration panel)
# =============================================================================
_PUBKEY_RE = re.compile(r'^[0-9a-f]{64}$')
def _normalize_repeater_key(public_key):
"""Lowercase and validate a full 64-hex repeater public key."""
pk = (public_key or '').strip().lower()
return pk if _PUBKEY_RE.match(pk) else None
def _repeater_result_status(result):
"""Map a device_manager repeater result to an HTTP status code."""
if result.get('success'):
return 200
if result.get('busy'):
return 429
if result.get('timeout'):
return 504
error = (result.get('error') or '').lower()
if 'not connected' in error:
return 503
if 'not found' in error:
return 404
return 500
@api_bp.route('/repeaters', methods=['GET'])
def list_my_repeaters():
"""List saved repeaters merged with device contact truth."""
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
try:
rows = db.get_repeaters()
device_by_key = {}
if rows:
force_refresh = request.args.get('refresh', 'false').lower() == 'true'
success, contacts_detailed, _error = get_contacts_detailed_cached(force_refresh)
if success and contacts_detailed:
device_by_key = {k.lower(): v for k, v in contacts_detailed.items()}
repeaters = [_merged_repeater_entry(row, device_by_key) for row in rows]
return jsonify({'success': True, 'repeaters': repeaters}), 200
except Exception as e:
logger.error(f"Error listing repeaters: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def _merged_repeater_entry(row, device_by_key):
"""Merge a repeaters DB row with device contact truth for API output."""
pk = row['public_key'].lower()
details = device_by_key.get(pk)
entry = {
'public_key': pk,
'password_set': bool(row.get('password')),
'added_at': row.get('added_at'),
'last_login_at': row.get('last_login_at'),
'last_login_role': row.get('last_login_role'),
'on_device': details is not None,
'name': '',
'out_path_len': None,
'out_path': '',
'out_path_hash_mode': 0,
'path_or_mode': '',
'adv_lat': None,
'adv_lon': None,
'last_advert': None,
}
if details:
entry.update({
'name': details.get('adv_name', ''),
'out_path_len': details.get('out_path_len', -1),
'out_path': details.get('out_path', ''),
'out_path_hash_mode': details.get('out_path_hash_mode', 0),
'path_or_mode': _format_path_display(
details.get('out_path_len', -1),
details.get('out_path', ''),
details.get('out_path_hash_mode', 0)),
'adv_lat': details.get('adv_lat'),
'adv_lon': details.get('adv_lon'),
'last_advert': details.get('last_advert'),
})
return entry
def _repeater_session_payload(dm, pk):
"""Session dict for API output ({'logged_in': False} when absent)."""
session = dm.get_repeater_session(pk) if dm else None
if not session:
return {'logged_in': False}
return {
'logged_in': True,
'is_admin': session.get('is_admin', False),
'permissions': session.get('permissions'),
'logged_in_at': session.get('logged_in_at'),
}
@api_bp.route('/repeaters/<public_key>', methods=['GET'])
def get_my_repeater(public_key):
"""Single saved repeater merged with device truth + login session state."""
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
try:
row = db.get_repeater(pk)
if not row:
return jsonify({'success': False, 'error': 'Repeater not in list'}), 404
device_by_key = {}
success, contacts_detailed, _error = get_contacts_detailed_cached()
if success and contacts_detailed:
device_by_key = {k.lower(): v for k, v in contacts_detailed.items()}
return jsonify({
'success': True,
'repeater': _merged_repeater_entry(row, device_by_key),
'session': _repeater_session_payload(_get_dm(), pk),
}), 200
except Exception as e:
logger.error(f"Error getting repeater: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/session', methods=['GET'])
def get_my_repeater_session(public_key):
"""Login session state for a repeater (in-memory; cleared on app restart)."""
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
return jsonify({'success': True, **_repeater_session_payload(_get_dm(), pk)}), 200
@api_bp.route('/repeaters/<public_key>/logout', methods=['POST'])
def logout_my_repeater(public_key):
"""Log out of a repeater and drop the in-memory session."""
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
try:
result = dm.repeater_logout(pk)
if result.get('success'):
return jsonify({'success': True}), 200
return jsonify({'success': False,
'error': result.get('error', 'Logout failed')}), _repeater_result_status(result)
except Exception as e:
logger.error(f"Error logging out of repeater: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def _require_repeater_login(dm, pk):
"""Return (None) if a session exists, else an (json, status) error tuple.
A remote request only succeeds if the repeater has us in its ACL, which
requires a prior login. Failing fast here avoids a pointless ~2 min
timeout when the panel is opened without a live session.
"""
if not dm.get_repeater_session(pk):
return jsonify({'success': False, 'error': 'Not logged in', 'need_login': True}), 401
return None
def _require_repeater_admin(dm, pk):
"""Like _require_repeater_login, but also demands an admin session.
The firmware silently drops text CLI commands from non-admin clients
(which would surface as a pointless timeout), so guest sessions are
rejected up front.
"""
session = dm.get_repeater_session(pk)
if not session:
return jsonify({'success': False, 'error': 'Not logged in', 'need_login': True}), 401
if not session.get('is_admin'):
return jsonify({'success': False, 'error': 'Admin login required'}), 403
return None
@api_bp.route('/repeaters/<public_key>/status', methods=['GET'])
def repeater_status(public_key):
"""Binary status request to a repeater (battery, radio and packet stats)."""
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_status(pk)
if result.get('success'):
return jsonify({'success': True, 'data': result['data']}), 200
return jsonify({'success': False,
'error': result.get('error', 'No status response')}), _repeater_result_status(result)
except Exception as e:
logger.error(f"Error getting repeater status: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/telemetry', methods=['GET'])
def repeater_telemetry(public_key):
"""Telemetry request to a repeater (Cayenne LPP entries, all channels)."""
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_telemetry(pk)
if result.get('success'):
return jsonify({'success': True, 'lpp': result['data']}), 200
return jsonify({'success': False,
'error': result.get('error', 'No telemetry response')}), _repeater_result_status(result)
except Exception as e:
logger.error(f"Error getting repeater telemetry: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/cli', methods=['POST'])
def repeater_cli(public_key):
"""Send a CLI text command to a repeater and return its reply. Admin-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
auth_error = _require_repeater_admin(dm, pk)
if auth_error:
return auth_error
data = request.get_json(silent=True) or {}
command = (data.get('command') or '').strip()
if not command:
return jsonify({'success': False, 'error': 'Missing command'}), 400
try:
result = dm.repeater_cmd_wait(pk, command)
if result.get('success'):
return jsonify({'success': True,
'output': result.get('reply', ''),
'elapsed_ms': result.get('elapsed_ms')}), 200
return jsonify({'success': False,
'error': result.get('error', 'Command failed')}), _repeater_result_status(result)
except Exception as e:
logger.error(f"Error running repeater CLI command: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# Settings section → ordered CLI-readable fields (`get <field>`), mirrored by
# SETTINGS_SECTIONS in repeater-manage.js. The admin password is write-only
# (`password <pw>`, no get), so it is POST-able but never part of a GET batch.
_REPEATER_SETTINGS_FIELDS = {
'basic': ('name', 'guest.password'),
'radio': ('radio', 'tx', 'radio.rxgain'),
'location': ('lat', 'lon'),
'features': ('repeat', 'allow.read.only', 'multi.acks'),
'network': ('loop.detect', 'dutycycle'),
'advert': ('advert.interval', 'flood.advert.interval', 'flood.max'),
'operator': ('owner.info',),
'advanced': ('path.hash.mode', 'txdelay', 'direct.txdelay', 'int.thresh',
'agc.reset.interval'),
}
_REPEATER_SETTINGS_WRITABLE = frozenset(
field for fields in _REPEATER_SETTINGS_FIELDS.values() for field in fields
) | {'password'}
# Per CLI round-trip; a lost packet should not stall the batch for long.
_SETTINGS_FIELD_TIMEOUT = 30.0
def _settings_batch_fatal(result):
"""True when a repeater_cmd_wait failure will also hit every remaining field."""
error = (result.get('error') or '').lower()
return bool(result.get('busy')) or 'not connected' in error
@api_bp.route('/repeaters/<public_key>/settings', methods=['GET'])
def repeater_settings_get(public_key):
"""Read one settings section from a repeater as a sequential `get` batch.
Every field is a full mesh round-trip, so sections are fetched lazily
by the UI. One failed field only marks that field ({errors}) the
rest of the section still loads. Admin-gated like all text CLI traffic.
"""
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
auth_error = _require_repeater_admin(dm, pk)
if auth_error:
return auth_error
section = request.args.get('section', '')
fields = _REPEATER_SETTINGS_FIELDS.get(section)
if fields is None:
return jsonify({'success': False, 'error': f'Unknown section: {section}'}), 400
values = {}
errors = {}
abort_error = None
try:
for field in fields:
if abort_error:
errors[field] = abort_error
continue
result = dm.repeater_cmd_wait(pk, f'get {field}',
timeout=_SETTINGS_FIELD_TIMEOUT)
if not result.get('success'):
errors[field] = result.get('error') or 'Request failed'
if _settings_batch_fatal(result):
abort_error = errors[field]
continue
reply = (result.get('reply') or '').strip()
if reply.startswith('>'):
values[field] = reply[1:].strip()
else:
errors[field] = f'Unexpected reply: {reply}' if reply else 'Empty reply'
return jsonify({'success': True, 'section': section,
'values': values, 'errors': errors}), 200
except Exception as e:
logger.error(f"Error reading repeater settings: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/settings', methods=['POST'])
def repeater_settings_post(public_key):
"""Apply changed settings to a repeater as a sequential `set` batch.
Body: {'values': {field: value}} dirty fields only. Per-field result
status: 'ok' | 'failed' | 'reboot_required', classified from the CLI
reply text (`ok`/`password now` = ok, mention of reboot = stored but
applied after a reboot, anything else = failed).
"""
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
auth_error = _require_repeater_admin(dm, pk)
if auth_error:
return auth_error
data = request.get_json(silent=True) or {}
values = data.get('values')
if not isinstance(values, dict) or not values:
return jsonify({'success': False, 'error': 'No values to apply'}), 400
unknown = sorted(set(values) - _REPEATER_SETTINGS_WRITABLE)
if unknown:
return jsonify({'success': False,
'error': f"Unknown fields: {', '.join(unknown)}"}), 400
results = {}
abort_error = None
try:
for field, raw in values.items():
if abort_error:
results[field] = {'status': 'failed', 'error': abort_error}
continue
value = str('' if raw is None else raw)
value = value.replace('\r', ' ').replace('\n', ' ').strip()
if not value and field != 'owner.info':
results[field] = {'status': 'failed', 'error': 'Empty value not allowed'}
continue
cmd = f'password {value}' if field == 'password' else f'set {field} {value}'
result = dm.repeater_cmd_wait(pk, cmd, timeout=_SETTINGS_FIELD_TIMEOUT)
if not result.get('success'):
results[field] = {'status': 'failed',
'error': result.get('error') or 'Request failed'}
if _settings_batch_fatal(result):
abort_error = results[field]['error']
continue
reply = (result.get('reply') or '').strip()
low = reply.lower()
if 'reboot' in low:
results[field] = {'status': 'reboot_required', 'reply': reply}
elif low.startswith('ok') or low.startswith('password now'):
results[field] = {'status': 'ok', 'reply': reply}
else:
results[field] = {'status': 'failed', 'reply': reply,
'error': reply or 'Empty reply'}
return jsonify({'success': True, 'results': results}), 200
except Exception as e:
logger.error(f"Error applying repeater settings: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# Action key → CLI command. `advert` alone floods the whole mesh;
# `advert.zerohop` reaches direct neighbours only. `reboot` never
# replies: the firmware restarts immediately without building one.
_REPEATER_ACTIONS = {
'zerohop_advert': {'cmd': 'advert.zerohop'},
'flood_advert': {'cmd': 'advert'},
'clock_sync': {'cmd': 'clock sync'},
'reboot': {'cmd': 'reboot', 'no_reply': True},
}
@api_bp.route('/repeaters/<public_key>/action', methods=['POST'])
def repeater_action(public_key):
"""Run a one-shot action on a repeater (adverts, clock sync, reboot).
Body: {'action': key}. Replies are surfaced verbatim with an `ok`
flag (reply starts with OK). For `reboot`, a clean send followed by
silence is reported as success the firmware never replies to it.
"""
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
auth_error = _require_repeater_admin(dm, pk)
if auth_error:
return auth_error
data = request.get_json(silent=True) or {}
action = data.get('action') or ''
spec = _REPEATER_ACTIONS.get(action)
if not spec:
return jsonify({'success': False, 'error': f'Unknown action: {action}'}), 400
try:
timeout = 15.0 if spec.get('no_reply') else 45.0
result = dm.repeater_cmd_wait(pk, spec['cmd'], timeout=timeout)
if result.get('success'):
reply = (result.get('reply') or '').strip()
return jsonify({'success': True, 'reply': reply,
'ok': reply.lower().startswith('ok'),
'elapsed_ms': result.get('elapsed_ms')}), 200
if spec.get('no_reply') and result.get('timeout'):
return jsonify({'success': True, 'ok': True, 'no_reply': True,
'reply': 'Reboot command sent — the repeater should be '
'restarting (no reply is expected)'}), 200
return jsonify({'success': False,
'error': result.get('error', 'Action failed')}), _repeater_result_status(result)
except Exception as e:
logger.error(f"Error running repeater action: {e}")
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)."""
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_clock(pk)
if not result.get('success'):
return jsonify({'success': False,
'error': result.get('error', 'No clock response')}), _repeater_result_status(result)
hex_data = (result['data'] or {}).get('data', '')
timestamp = int.from_bytes(bytes.fromhex(hex_data[0:8]), byteorder='little', signed=False)
return jsonify({'success': True, 'timestamp': timestamp}), 200
except Exception as e:
logger.error(f"Error getting repeater clock: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters', methods=['POST'])
def add_my_repeater():
"""Add a device repeater contact to the My Repeaters list."""
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
data = request.get_json(silent=True) or {}
pk = _normalize_repeater_key(data.get('public_key'))
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key (expected 64 hex chars)'}), 400
try:
if not db.add_repeater(pk):
return jsonify({'success': False, 'error': 'Repeater already added'}), 409
return jsonify({'success': True}), 201
except Exception as e:
logger.error(f"Error adding repeater: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>', methods=['PUT'])
def update_my_repeater(public_key):
"""Set or clear the saved password for a repeater ('' clears)."""
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
data = request.get_json(silent=True) or {}
if 'password' not in data or not isinstance(data['password'], str):
return jsonify({'success': False, 'error': 'Missing password field'}), 400
try:
if not db.set_repeater_password(pk, data['password']):
return jsonify({'success': False, 'error': 'Repeater not in list'}), 404
return jsonify({'success': True, 'password_set': bool(data['password'])}), 200
except Exception as e:
logger.error(f"Error updating repeater: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>', methods=['DELETE'])
def delete_my_repeater(public_key):
"""Remove a repeater from the list (device contact is untouched)."""
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
try:
if not db.delete_repeater(pk):
return jsonify({'success': False, 'error': 'Repeater not in list'}), 404
return jsonify({'success': True}), 200
except Exception as e:
logger.error(f"Error deleting repeater: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/login', methods=['POST'])
def login_my_repeater(public_key):
"""Log into a repeater using the provided or saved password.
Body: {password?: str, save?: bool}. When a password is provided with
save=true it is persisted (even if the login then times out the
repeater may simply be offline while the password is correct).
"""
db = _get_db()
dm = _get_dm()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
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
row = db.get_repeater(pk)
if not row:
return jsonify({'success': False, 'error': 'Repeater not in list'}), 404
data = request.get_json(silent=True) or {}
provided = data.get('password')
if provided is not None and not isinstance(provided, str):
return jsonify({'success': False, 'error': 'Invalid password'}), 400
password = provided if provided else row.get('password', '')
if not password:
return jsonify({'success': False, 'error': 'No password set for this repeater',
'need_password': True}), 400
try:
if provided and data.get('save'):
db.set_repeater_password(pk, provided)
result = dm.repeater_login(pk, password)
if result.get('success'):
role = 'admin' if result.get('is_admin') else 'guest'
db.update_repeater_login(pk, role)
return jsonify({
'success': True,
'is_admin': result.get('is_admin', False),
'permissions': result.get('permissions'),
'name': result.get('name', ''),
}), 200
return jsonify({'success': False,
'error': result.get('error', 'Login failed')}), _repeater_result_status(result)
except Exception as e:
logger.error(f"Error logging into repeater: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
+18
View File
@@ -103,6 +103,24 @@ def console():
)
@views_bp.route('/repeaters')
def repeaters():
"""My Repeaters - repeater administration panel (list + login)."""
return render_template(
'repeaters.html',
device_name=runtime_config.get_device_name()
)
@views_bp.route('/repeaters/manage')
def repeater_manage():
"""Repeater Management panel for one repeater (?pubkey=<64 hex>)."""
return render_template(
'repeater-manage.html',
device_name=runtime_config.get_device_name()
)
@views_bp.route('/logs')
def logs():
"""System log viewer - real-time log streaming with filters."""
+10
View File
@@ -223,6 +223,16 @@ CREATE TABLE IF NOT EXISTS app_settings (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- My Repeaters: repeaters selected for administration, with saved login.
-- No FK to contacts: a saved password must survive contact cleanup/re-add.
CREATE TABLE IF NOT EXISTS repeaters (
public_key TEXT PRIMARY KEY, -- hex, lowercase (64 chars)
password TEXT NOT NULL DEFAULT '', -- plaintext (single-user LAN app)
added_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login_at TEXT,
last_login_role TEXT -- 'admin' | 'guest'
);
-- ============================================================
-- Indexes
-- ============================================================
+12 -3
View File
@@ -1631,6 +1631,12 @@ main {
color: white;
}
/* My Repeaters FAB button (REP green) */
.fab-repeaters {
background: linear-gradient(135deg, #198754 0%, #146c43 100%);
color: white;
}
/* Filter bar overlay - slides down from top of chat area */
.filter-bar {
position: absolute;
@@ -1876,7 +1882,8 @@ emoji-picker {
#dmModal .modal-dialog.modal-fullscreen,
#contactsModal .modal-dialog.modal-fullscreen,
#logsModal .modal-dialog.modal-fullscreen,
#consoleModal .modal-dialog.modal-fullscreen {
#consoleModal .modal-dialog.modal-fullscreen,
#repeatersModal .modal-dialog.modal-fullscreen {
margin: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
@@ -1887,7 +1894,8 @@ emoji-picker {
#dmModal .modal-content,
#contactsModal .modal-content,
#logsModal .modal-content,
#consoleModal .modal-content {
#consoleModal .modal-content,
#repeatersModal .modal-content {
border: none !important;
border-radius: 0 !important;
height: 100vh !important;
@@ -1896,7 +1904,8 @@ emoji-picker {
#dmModal .modal-body,
#contactsModal .modal-body,
#logsModal .modal-body,
#consoleModal .modal-body {
#consoleModal .modal-body,
#repeatersModal .modal-body {
overflow: hidden !important;
}
+2 -1
View File
@@ -6029,6 +6029,7 @@ const ITEM_PLACEMENT_DEFS = {
floodadvert: { fab: '#fab-floodadvert', menu: '#floodadvBtn' },
map: { fab: '#fab-map', menu: '#mapBtn' },
console: { fab: '#fab-console', menu: '#consoleBtn' },
repeaters: { fab: '#fab-repeaters', menu: '#repeatersBtn' },
deviceinfo: { fab: '#fab-deviceinfo', menu: '#deviceInfoBtn' },
syslog: { fab: '#fab-syslog', menu: '#logsBtn' },
};
@@ -6036,7 +6037,7 @@ const ITEM_PLACEMENT_DEFS = {
const ITEM_PLACEMENT_DEFAULTS = {
filter: 'fab', search: 'fab', dm: 'fab', contacts: 'fab', settings: 'fab',
advert: 'menu', floodadvert: 'menu', map: 'menu',
console: 'menu', deviceinfo: 'menu', syslog: 'menu'
console: 'menu', repeaters: 'menu', deviceinfo: 'menu', syslog: 'menu'
};
function readItemPlacements() {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18
View File
@@ -198,6 +198,13 @@
<small class="d-block text-muted">Direct meshcli commands</small>
</div>
</button>
<button id="repeatersBtn" class="list-group-item list-group-item-action d-flex align-items-center gap-3" data-bs-toggle="modal" data-bs-target="#repeatersModal" data-bs-dismiss="offcanvas">
<i class="bi bi-diagram-3" style="font-size: 1.5rem;"></i>
<div>
<span>My Repeaters</span>
<small class="d-block text-muted">Repeater administration</small>
</div>
</button>
<!-- System -->
<div class="list-group-item py-2 mt-2">
@@ -876,6 +883,17 @@
</div>
</td>
</tr>
<tr data-placement-key="repeaters">
<td class="ps-0">My Repeaters</td>
<td class="pe-0 text-end">
<div class="btn-group btn-group-sm" role="group" aria-label="My Repeaters placement">
<input type="radio" class="btn-check" name="place-repeaters" id="place-repeaters-fab" value="fab" autocomplete="off">
<label class="btn btn-outline-primary" for="place-repeaters-fab">Quick Access</label>
<input type="radio" class="btn-check" name="place-repeaters" id="place-repeaters-menu" value="menu" autocomplete="off">
<label class="btn btn-outline-primary" for="place-repeaters-menu">Main Menu</label>
</div>
</td>
</tr>
<tr data-placement-key="deviceinfo">
<td class="ps-0">Device Info</td>
<td class="pe-0 text-end">
+31
View File
@@ -145,6 +145,9 @@
<button class="fab fab-console d-none" id="fab-console" data-bs-toggle="modal" data-bs-target="#consoleModal" title="Console">
<i class="bi bi-terminal"></i>
</button>
<button class="fab fab-repeaters d-none" id="fab-repeaters" data-bs-toggle="modal" data-bs-target="#repeatersModal" title="My Repeaters">
<i class="bi bi-diagram-3"></i>
</button>
<button class="fab fab-deviceinfo d-none" id="fab-deviceinfo" data-bs-toggle="modal" data-bs-target="#deviceInfoModal" title="Device Info">
<i class="bi bi-cpu"></i>
</button>
@@ -204,6 +207,23 @@
</div>
</div>
<!-- My Repeaters Modal (Full Screen) -->
<div class="modal fade" id="repeatersModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title"><i class="bi bi-diagram-3"></i> My Repeaters</h5>
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> Close
</button>
</div>
<div class="modal-body p-0">
<iframe id="repeatersFrame" style="width: 100%; height: 100%; border: none;"></iframe>
</div>
</div>
</div>
</div>
<!-- System Log Modal (Full Screen) -->
<div class="modal fade" id="logsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
@@ -293,6 +313,17 @@
});
}
// My Repeaters modal - (re)load iframe on open for fresh data
const repeatersModal = document.getElementById('repeatersModal');
if (repeatersModal) {
repeatersModal.addEventListener('show.bs.modal', function () {
const repeatersFrame = document.getElementById('repeatersFrame');
if (repeatersFrame) {
repeatersFrame.src = '/repeaters';
}
});
}
// System Log modal - load iframe on open, clear on close
const logsModal = document.getElementById('logsModal');
if (logsModal) {
+399
View File
@@ -0,0 +1,399 @@
<!DOCTYPE html>
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Repeater Management - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS (local) -->
<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) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
/* Standalone page: allow normal scrolling (style.css sets overflow hidden) */
html, body {
overflow: auto !important;
height: 100%;
}
body {
display: flex;
flex-direction: column;
background-color: var(--bg-body);
color: var(--text-primary);
}
.manage-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.manage-content {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 1rem;
}
/* Header card */
.rpt-header-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.rpt-header-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(25, 135, 84, 0.12);
border: 2px solid #198754;
color: #198754;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
flex-shrink: 0;
}
.rpt-header-meta {
font-size: 0.85rem;
color: var(--text-secondary, #6c757d);
}
.rpt-pubkey {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.8rem;
}
/* Tools grid */
.tool-tile {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
display: flex;
align-items: center;
gap: 0.85rem;
cursor: pointer;
transition: box-shadow 0.15s;
height: 100%;
}
.tool-tile:hover {
box-shadow: var(--card-shadow-hover, 0 2px 8px rgba(0, 0, 0, 0.15));
}
.tool-tile.disabled {
opacity: 0.55;
cursor: not-allowed;
}
.tool-tile.disabled:hover {
box-shadow: none;
}
.tool-icon {
width: 44px;
height: 44px;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
flex-shrink: 0;
}
.tool-icon.status { background: rgba(13, 110, 253, 0.12); color: #0d6efd; }
.tool-icon.telemetry { background: rgba(111, 66, 193, 0.12); color: #6f42c1; }
.tool-icon.neighbors { background: rgba(25, 135, 84, 0.12); color: #198754; }
.tool-icon.cli { background: rgba(255, 143, 0, 0.12); color: #e65100; }
.tool-icon.settings { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
.tool-icon.actions { background: rgba(13, 202, 240, 0.12); color: #0aa2c0; }
.tool-tile h6 {
margin: 0;
font-weight: 600;
}
.tool-tile .tool-desc {
font-size: 0.8rem;
color: var(--text-secondary, #6c757d);
margin: 0;
}
/* Tool pane */
.tool-pane-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.tool-pane-body {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
}
/* CLI terminal (dark regardless of theme, like the Console module) */
.cli-terminal {
background-color: #1a1a2e;
color: #e0e0e0;
font-family: 'Courier New', Consolas, monospace;
font-size: 0.85rem;
border-radius: 0.375rem;
padding: 0.75rem;
/* Fill the viewport below header card + pane chrome, never tiny */
height: max(260px, calc(100vh - 430px));
overflow-y: auto;
}
.cli-line {
margin-bottom: 0.4rem;
white-space: pre-wrap;
word-break: break-word;
}
.cli-line.cmd { color: #00ff88; }
.cli-line.cmd::before { content: '> '; color: #888; }
.cli-line.reply {
background-color: #16213e;
padding: 0.4rem 0.5rem;
border-radius: 0.25rem;
border-left: 3px solid #0f3460;
}
.cli-line.error { color: #ff6b6b; }
.cli-line.meta {
color: #4ecdc4;
font-style: italic;
font-size: 0.75rem;
}
.cli-pending::after {
content: '';
display: inline-block;
width: 11px;
height: 11px;
border: 2px solid #4ecdc4;
border-top-color: transparent;
border-radius: 50%;
animation: cli-spin 1s linear infinite;
margin-left: 0.4rem;
vertical-align: middle;
}
@keyframes cli-spin { to { transform: rotate(360deg); } }
/* 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;
}
/* Settings tool */
#settingsAccordion .accordion-button {
padding: 0.6rem 0.9rem;
font-size: 0.95rem;
}
.sf-row.sf-dirty .sf-input,
.sf-row.sf-dirty .sf-sub {
border-color: var(--bs-warning);
}
.sf-badge .spinner-border {
width: 0.8rem;
height: 0.8rem;
}
/* Centered state screens */
.state-screen {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary, #6c757d);
}
.state-screen i {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
}
</style>
</head>
<body>
<!-- Toolbar -->
<div class="manage-toolbar">
<button type="button" class="btn btn-sm btn-outline-secondary" id="backBtn" title="Back to My Repeaters">
<i class="bi bi-arrow-left"></i>
</button>
<span class="fw-semibold flex-grow-1">Repeater Management</span>
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="logoutBtn" title="Log out of this repeater">
<i class="bi bi-box-arrow-right"></i> Logout
</button>
</div>
<div class="manage-content">
<!-- Loading / logging-in state -->
<div class="state-screen" id="loadingState">
<div class="spinner-border text-success" role="status" style="width: 3rem; height: 3rem;"></div>
<p class="mt-3 mb-0" id="loadingText">Loading…</p>
</div>
<!-- Error state -->
<div class="state-screen" id="errorState" style="display: none;">
<i class="bi bi-exclamation-triangle text-warning"></i>
<p class="mb-3" id="errorText">Something went wrong.</p>
<div class="d-flex gap-2 justify-content-center">
<button type="button" class="btn btn-sm btn-outline-secondary" id="errorBackBtn">
<i class="bi bi-arrow-left"></i> Back to list
</button>
<button type="button" class="btn btn-sm btn-primary" id="errorRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
</button>
</div>
</div>
<!-- Panel content (after login) -->
<div id="panelContent" style="display: none;">
<!-- Header card -->
<div class="rpt-header-card">
<div class="rpt-header-icon"><i class="bi bi-diagram-3"></i></div>
<div class="flex-grow-1" style="min-width: 0;">
<div class="d-flex align-items-center gap-2 flex-wrap">
<h5 class="mb-0 text-truncate" id="rptName"></h5>
<span class="badge" id="roleBadge"></span>
</div>
<div class="rpt-pubkey text-muted mt-1">
<span id="rptPubkey"></span>
<button type="button" class="btn btn-link btn-sm p-0 ms-1 align-baseline" id="copyPubkeyBtn" title="Copy full public key">
<i class="bi bi-copy"></i>
</button>
</div>
<div class="rpt-header-meta mt-1">
<i class="bi bi-signpost-split"></i> <span class="font-monospace" id="rptPath"></span>
<span class="ms-2"><i class="bi bi-geo-alt"></i> <span id="rptLocation"></span></span>
</div>
</div>
</div>
<!-- Tools grid -->
<div id="toolsGrid">
<div class="text-muted small text-uppercase fw-bold mb-2">Management Tools</div>
<div class="row g-3" id="toolsRow"></div>
</div>
<!-- Tool pane (shown instead of the grid when a tool is open) -->
<div id="toolPane" style="display: none;">
<div class="tool-pane-header">
<button type="button" class="btn btn-sm btn-outline-secondary" id="paneBackBtn" title="Back to tools">
<i class="bi bi-arrow-left"></i>
</button>
<span class="tool-icon" id="paneIcon" style="width: 32px; height: 32px; font-size: 1rem;"></span>
<h6 class="mb-0" id="paneTitle"></h6>
</div>
<div class="tool-pane-body" id="paneBody"></div>
</div>
</div>
</div>
<!-- Password Modal (login prompt) -->
<div class="modal fade" id="passwordModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-key"></i> <span id="passwordModalTitle">Log in</span></h6>
</div>
<div class="modal-body">
<div class="mb-2 small text-muted" id="passwordModalInfo"></div>
<div class="input-group input-group-sm mb-2">
<input type="password" class="form-control" id="passwordInput"
placeholder="Repeater password" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="togglePasswordBtn" title="Show/hide password">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="savePasswordCheck" checked>
<label class="form-check-label small" for="savePasswordCheck">
Remember password (stored in the app database)
</label>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" id="passwordCancelBtn">Back to list</button>
<button type="button" class="btn btn-sm btn-primary" id="passwordSubmitBtn">Log in</button>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications (position applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">Repeater Management</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<!-- 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>
</html>
+440
View File
@@ -0,0 +1,440 @@
<!DOCTYPE html>
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>My Repeaters - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap 5 CSS (local) -->
<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 (for the repeater map picker) -->
<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) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
/* Standalone page: allow normal scrolling (style.css sets overflow hidden) */
html, body {
overflow: auto !important;
height: 100%;
}
body {
display: flex;
flex-direction: column;
background-color: var(--bg-body);
color: var(--text-primary);
}
.repeaters-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.repeaters-list-wrap {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.75rem 1rem;
}
.repeater-row {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.repeater-row .rpt-main {
cursor: pointer;
min-width: 0;
}
.repeater-row.rpt-offline .rpt-main {
cursor: default;
}
.repeater-row.rpt-offline {
opacity: 0.65;
}
.rpt-path {
font-size: 0.8rem;
}
.empty-state {
text-align: center;
color: var(--text-secondary, #6c757d);
padding: 3rem 1rem;
}
.empty-state i {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
}
/* Device repeater picker rows */
.device-rpt-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
}
.device-rpt-item:last-child {
border-bottom: none;
}
.device-rpt-item:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
.device-rpt-item.added {
opacity: 0.55;
cursor: default;
}
/* Path list items (adapted from DM Contact Info) */
.path-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.path-list-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
margin-bottom: 0.35rem;
font-size: 0.85rem;
}
.path-list-item.primary {
border-color: #ffc107;
}
.path-list-item .path-hex {
font-family: var(--bs-font-monospace, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-list-item .path-label {
color: var(--text-secondary, #6c757d);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
}
.path-list-item .path-actions {
margin-left: auto;
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.path-uniqueness-warning {
font-size: 0.75rem;
color: #b58900;
}
.repeater-picker-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
}
.repeater-picker-item:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
}
</style>
</head>
<body>
<!-- Toolbar -->
<div class="repeaters-toolbar">
<span class="text-muted small flex-grow-1" id="repeaterCount"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="refreshBtn" title="Refresh list">
<i class="bi bi-arrow-clockwise"></i>
</button>
<button type="button" class="btn btn-sm btn-success" id="addRepeaterBtn">
<i class="bi bi-plus-lg"></i> Add repeater
</button>
</div>
<!-- Repeater list -->
<div class="repeaters-list-wrap">
<div id="repeaterList"></div>
<div class="empty-state" id="emptyState" style="display: none;">
<i class="bi bi-diagram-3"></i>
<p class="mb-1">No repeaters yet.</p>
<p class="small mb-0">Use <strong>Add repeater</strong> to pick repeaters stored on your device.<br>
Only repeaters saved in the device contacts can be managed.</p>
</div>
</div>
<!-- Add Repeater Picker Modal -->
<div class="modal fade" id="addRepeaterModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-plus-circle"></i> Add Repeater</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 d-flex flex-column">
<div class="p-2 border-bottom">
<input type="text" class="form-control form-control-sm" id="deviceRptSearch"
placeholder="Search repeaters on device..." autocomplete="off">
</div>
<div id="deviceRptList" style="overflow-y: auto;">
<div class="text-muted small p-3">Loading device contacts...</div>
</div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small text-muted" id="deviceRptCount"></span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Password Modal (set password / login prompt) -->
<div class="modal fade" id="passwordModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-key"></i> <span id="passwordModalTitle">Set password</span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2 small text-muted" id="passwordModalInfo"></div>
<div class="input-group input-group-sm mb-2">
<input type="password" class="form-control" id="passwordInput"
placeholder="Repeater password" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="togglePasswordBtn" title="Show/hide password">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-check" id="savePasswordWrap">
<input class="form-check-input" type="checkbox" id="savePasswordCheck" checked>
<label class="form-check-label small" for="savePasswordCheck">
Remember password (stored in the app database)
</label>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="passwordSubmitBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Path Management Modal (per repeater) -->
<div class="modal fade" id="pathModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> Paths — <span id="pathModalName"></span></h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pb-1">
<div class="small text-muted mb-2">
Device path: <span class="font-monospace" id="pathModalCurrent"></span>
</div>
<div class="path-section-header">
<h6 class="mb-0 small fw-bold">Configured paths</h6>
<button type="button" class="btn btn-outline-primary btn-sm" id="addPathBtn" title="Add path">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div id="pathList"></div>
<div class="d-flex justify-content-end gap-2 mt-1 mb-2">
<button type="button" class="btn btn-outline-secondary btn-sm" id="clearPathsBtn"
title="Delete all configured paths from database">
<i class="bi bi-trash"></i> Clear Paths
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="resetFloodBtn"
title="Reset device path to FLOOD mode">
<i class="bi bi-broadcast"></i> Reset to FLOOD
</button>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Add Path Modal (adapted from DM panel) -->
<div class="modal fade" id="addPathModal" tabindex="-1" style="z-index: 1070;">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> Add Path</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small mb-1">Hash Size</label>
<div class="btn-group btn-group-sm w-100" role="group">
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
<label class="btn btn-outline-secondary" for="pathHash1">1B (max 64)</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
<label class="btn btn-outline-secondary" for="pathHash2">2B (max 32)</label>
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
<label class="btn btn-outline-secondary" for="pathHash3">3B (max 21)</label>
</div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Path (hex)</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control font-monospace" id="pathHexInput"
placeholder="e.g. 5e,e7 or 5e34,e761" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterBtn"
title="Pick repeater from list">
<i class="bi bi-plus-circle"></i>
</button>
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterMapBtn"
title="Pick repeater from map">
<i class="bi bi-geo-alt"></i>
</button>
</div>
<div id="pathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
</div>
<!-- Repeater picker (hidden by default) -->
<div id="repeaterPicker" style="display: none;" class="border rounded mb-2">
<div class="d-flex border-bottom">
<div class="btn-group btn-group-sm flex-shrink-0" role="group">
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchName" value="name" checked>
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchName">Name</label>
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchId" value="id">
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchId">ID</label>
</div>
<input type="text" class="form-control form-control-sm border-0"
id="repeaterSearch" placeholder="Search by name..." autocomplete="off">
</div>
<div id="repeaterList2" style="max-height: 180px; overflow-y: auto;"></div>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Label (optional)</label>
<input type="text" class="form-control form-control-sm" id="pathLabelInput"
placeholder="e.g. via Mountain RPT" maxlength="50">
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="savePathBtn">Add Path</button>
</div>
</div>
</div>
</div>
<!-- Repeater Map Picker Modal (adapted from DM panel) -->
<div class="modal fade" id="repeaterMapModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> Select Repeater from Map</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="rptMapCachedSwitch">
<label class="form-check-label small" for="rptMapCachedSwitch">Cached</label>
</div>
<span class="text-muted small ms-auto" id="rptMapCount"></span>
</div>
<div id="rptLeafletMap" style="height: 400px; width: 100%;"></div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small text-muted" id="rptMapSelected">Click a repeater on the map</span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>Add</button>
</div>
</div>
</div>
</div>
<!-- Remove Confirmation Modal -->
<div class="modal fade" id="removeRepeaterModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-trash"></i> Remove Repeater</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-1">Remove <strong id="removeRepeaterName"></strong> from My Repeaters?</p>
<p class="small text-muted mb-0">The saved password will be deleted.
The contact on the device is not affected.</p>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-danger" id="removeRepeaterConfirmBtn">Remove</button>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications (position applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">My Repeaters</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
</div>
</div>
<!-- Bootstrap JS Bundle (local) -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- My Repeaters JS -->
<script src="{{ url_for('static', filename='js/repeaters.js') }}"></script>
</body>
</html>
+35
View File
@@ -94,6 +94,17 @@ The `DeviceManager` handles the connection to the MeshCore device via a direct s
- **Advert scheduler** — an observer-owned daemon thread checks every 10 minutes and sends a flood advert once `advert_interval_hours` has elapsed since the `observer_last_advert_at` timestamp persisted in `app_settings` (restart-safe)
- **Live status**`observer_status` events on the `/chat` namespace (throttled to one per 2 s on the packet path) drive the Settings-tab badges and counters; `GET /api/observer/status` returns the full merged view
### Repeater administration (My Repeaters)
The `/repeaters` (list) and `/repeaters/manage` (per-repeater tools) panels are standalone iframe pages built on the companion protocol's remote-request commands plus the repeater text CLI:
- **Serialization** — the companion firmware tracks a single pending remote request (each new send silently clears the previous one), so every repeater operation runs under `DeviceManager._repeater_lock` (180 s acquire timeout; a held lock returns `{'busy': True}` → HTTP 429). This also guards against two browser tabs operating at once
- **Login sessions**`repeater_login()` waits for `LOGIN_SUCCESS` filtered by the contact's `pubkey_prefix` and stores `{is_admin, permissions, logged_in_at}` in the in-memory `_repeater_sessions` dict (cleared on logout/app restart; the UI auto-relogs with the saved password). All remote endpoints fail fast with 401 `need_login` when no session exists — a repeater only answers pubkeys in its ACL, so requesting without a login would just burn a multi-minute timeout. A wrong password is indistinguishable from an unreachable repeater (the firmware never replies to failed logins), so timeout messages always name both causes
- **CLI reply correlation**`repeater_cmd_wait()` sends one text command and blocks for its reply. Replies arrive as `CONTACT_MSG_RECV` with `txt_type=1` (CLI_DATA) and carry no protocol-level correlation, so correlation = the repeater lock (single command in flight) + a single-slot waiter matched against the sender's 12-hex pubkey prefix at the top of `_on_dm_received`. Matched replies are consumed there and never stored as chat DMs; unmatched CLI replies keep the legacy behavior (stored as a DM) so the Console's fire-and-forget `cmd` flow is unchanged. Wait time derives from the device-suggested timeout (clamped 1045 s)
- **Settings batches** — reads run sequential `get <field>` commands per section and parse the firmware's `> value` replies, reporting per-field errors without failing the batch; writes send only dirty fields and classify each reply as `ok` (starts with `ok`/`password now`), `reboot_required` (reply mentions reboot — e.g. `set radio`), or `failed` (anything else, surfaced verbatim). Connection-level failures abort the rest of the batch
- **Role gating** — the firmware silently drops text CLI from non-admin logins (which would surface as a timeout), so the CLI/Settings/Actions endpoints reject guest sessions with 403 up front instead
- **Actions whitelist**`advert.zerohop`, `advert` (flood), `clock sync`, `reboot`. `reboot` never replies (the firmware restarts immediately without building one), so a clean send followed by silence is reported as success. Text `erase` is firmware-gated to the USB serial console (`sender_timestamp == 0`), hence no erase in the UI
---
## Project Structure
@@ -156,6 +167,7 @@ Key tables:
- `read_status` - Per-channel read counters and favorites (`is_favorite` column; used to pin channels in the sidebar/dropdown sort order)
- `analyzers` - User-configured MeshCore Analyzer services (`name`, `url_template` with `{packetHash}` placeholder, `is_default`, `is_disabled`; partial unique index enforces a single default)
- `observer_brokers` - MQTT brokers for the Observer packet-capture feature (`name`, `host`, `port`, `username`, `password` — stored plaintext, `use_tls`, `tls_verify`, `is_disabled`)
- `repeaters` - Repeaters saved in the My Repeaters panel (`public_key` PK, `password` — stored plaintext per the observer_brokers precedent, `added_at`, `last_login_at`, `last_login_role`). Everything else about a repeater (name, path, position) comes from the device contact at read time
`direct_messages` gained a `delivery_path_hash_size` column (auto-migrated, defaults to 1) so reloaded DM bubbles render multi-byte routes correctly. The `path_len` column on `channel_messages`, `direct_messages`, and `paths` now stores the raw firmware byte (masked hop count plus path_hash_mode in the upper bits), recombined at write time via `pack_path_len()`; the API endpoints decode it back into `path_hash_size` on read. `channel_messages` also gained a `raw_packet` column (the full hex wire snapshot captured at send time, indexed by `idx_cm_pkt` on `pkt_payload` for fast self-echo lookups) that powers raw resend; it is `NULL` for received and pre-migration rows, so the resend button stays disabled there.
@@ -280,6 +292,29 @@ The backend no longer ships a pre-built `analyzer_url` per message — channel-m
Every mutating endpoint hot-reloads the `ObserverManager`, so broker and setting changes take effect without an app restart.
### My Repeaters (repeater administration)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/repeaters` | Saved repeaters merged with device contact truth (`?refresh=true` bypasses the contacts cache) |
| POST | `/api/repeaters` | Add a repeater (`{public_key}`; 409 when already saved) |
| GET | `/api/repeaters/<pk>` | Single merged entry + login session state |
| PUT | `/api/repeaters/<pk>` | Set or clear the saved password (`{password}`; empty string clears) |
| DELETE | `/api/repeaters/<pk>` | Remove from the list (device contact untouched) |
| POST | `/api/repeaters/<pk>/login` | Log in with `{password?, save?}` — omitted password uses the saved one |
| GET | `/api/repeaters/<pk>/session` | In-memory session state (`logged_in`, `is_admin`, `permissions`) |
| POST | `/api/repeaters/<pk>/logout` | Log out and drop the session |
| GET | `/api/repeaters/<pk>/status` | Binary status request (battery, radio, packet stats) |
| GET | `/api/repeaters/<pk>/clock` | Repeater clock (LE epoch from `req_basic_sync`) |
| GET | `/api/repeaters/<pk>/telemetry` | All Cayenne LPP channels |
| GET | `/api/repeaters/<pk>/neighbours` | Zero-hop neighbours enriched with contact names/positions |
| POST | `/api/repeaters/<pk>/cli` | Text CLI command (`{command}``{output, elapsed_ms}`); admin only |
| GET | `/api/repeaters/<pk>/settings` | Read one settings section (`?section=basic\|radio\|location\|features\|network\|advert\|operator\|advanced`) as a `get` batch; admin only |
| POST | `/api/repeaters/<pk>/settings` | Apply dirty fields (`{values}`) as a `set` batch with per-field `ok\|failed\|reboot_required` results; admin only |
| POST | `/api/repeaters/<pk>/action` | One-shot action (`{action: zerohop_advert\|flood_advert\|clock_sync\|reboot}`); admin only |
Error mapping is shared across the family: 401 `need_login` (no session), 403 (guest on an admin endpoint), 429 (another repeater operation in progress — single firmware request slot), 503 (device not connected), 504 (timeout: repeater unreachable *or* wrong password — indistinguishable by protocol). Passwords are never returned by any GET (`password_set` boolean only). The panels are served by `GET /repeaters` and `GET /repeaters/manage?pubkey=<64-hex>`.
### Direct Messages
| Method | Endpoint | Description |
+2
View File
@@ -1,5 +1,7 @@
# How to Manage Your Repeater
> **Note:** mc-webui now ships a built-in repeater administration panel — **My Repeaters** in the main menu — with status, telemetry, neighbours, a remote CLI, settings, and actions. See [My Repeaters in the user guide](user-guide.md#my-repeaters-repeater-administration). The DM-based method below still works and remains useful as a fallback.
This guide explains how to manage a MeshCore repeater device directly from the mc-webui interface using Direct Messages.
---
+81 -2
View File
@@ -16,6 +16,7 @@ This guide covers all features and functionality of mc-webui. For installation i
- [Adding Contacts](#adding-contacts)
- [DM Path Management](#dm-path-management)
- [Interactive Console](#interactive-console)
- [My Repeaters (Repeater Administration)](#my-repeaters-repeater-administration)
- [Device Dashboard](#device-dashboard)
- [Quick-Access FAB Buttons](#quick-access-fab-buttons)
- [Settings](#settings)
@@ -504,6 +505,8 @@ The console supports a comprehensive set of MeshCore commands organized into cat
- `set_regions <name> <value>` - Set repeater regions
- `set_clock <name>` - Sync repeater clock
For a graphical alternative to these commands, see [My Repeaters](#my-repeaters-repeater-administration).
**Contact Management:**
- `contacts` - List all device contacts (paths shown with commas, e.g. `D1,90,05,54`)
- `.contacts` - List contacts (JSON format)
@@ -534,6 +537,82 @@ The console supports a comprehensive set of MeshCore commands organized into cat
---
## My Repeaters (Repeater Administration)
Administer MeshCore repeaters you hold the password to — check status and telemetry, browse neighbours, run CLI commands, change settings, and trigger actions like adverts or a reboot, all over the mesh:
1. Click the menu icon (☰) in the navbar
2. Select **My Repeaters** from the menu
The panel opens full-screen, like Contact Management, and lists the repeaters you have added with their current routing path. (The menu item can be moved to the Quick Access FAB cluster in Settings → Appearance.)
### Adding a Repeater
Click **Add repeater**. The picker lists repeater contacts stored **on your device** — if the one you need is missing, it has to be heard and approved first (see [Contact Management](#contact-management)). Use the search box to filter, then click a repeater to add it. Adding is purely local: nothing is sent over the mesh yet.
### Passwords and Logging In
Click a repeater row to log in. The first time, you are asked for the repeater password; leave **Save password** checked and the app remembers it per repeater, so future logins are one click.
- The repeater decides your role from the password: **ADMIN** (full access) or **GUEST** (read-only: Status, Telemetry, Neighbors).
- **Important:** a wrong password and an unreachable repeater look identical — MeshCore repeaters simply never answer a bad login. When a login times out, check reachability first (is the repeater several flood hops away?) before assuming the password is wrong.
- Logins can take up to a minute on flood paths. Pinning a direct path (below) makes them much faster.
### Row Actions
- **Set path** - Configure the routing path used to reach the repeater, with the same editor as DM Contact Info: saved paths, apply to device, reset to flood, and a map-based repeater picker
- **Set password** (key icon; filled when a password is saved) - Save, change, or clear the stored password
- **Remove** - Remove the repeater from your list and delete its saved password. The device contact is untouched
### Repeater Management Panel
After a successful login the management panel opens. The header shows the repeater's name, shortened public key (with a copy button), current path, location, and your role badge (ADMIN / GUEST). Six tools are available; with a guest login, CLI, Settings, and Actions are locked.
#### Status
Fetched automatically when opened. A compact table in three groups:
- **System** - Battery (estimated % and volts), uptime, on-board clock, TX queue length, error events
- **Radio** - Last RSSI / SNR, noise floor, TX and RX airtime
- **Packets** - Sent and received counts (flood/direct split), duplicates, RX errors, and channel utilization (TX+RX airtime as a percentage of uptime)
#### Telemetry
Shows **all** Cayenne LPP channels at once — no channel picker. Each channel renders as a card with typed, unit-formatted rows (voltage, temperature, humidity, GPS position, and so on). Channel 1 is the repeater's own vitals.
#### Neighbors
Lists every zero-hop neighbour the repeater hears: resolved contact name (or the raw pubkey prefix for unknown nodes), how long ago it was heard, and SNR. When at least one neighbour has a known position, a **Map** toggle appears: the managed repeater is shown as a red marker, positioned neighbours in green, and the dashed connection lines carry SNR labels. A footnote counts neighbours that could not be placed on the map.
#### CLI (admin only)
A remote text console for the repeater, styled like the Interactive Console. Type a command (e.g. `get name`, `ver`, `clock`) and the reply comes back into the terminal together with the round-trip time. Quick-command chips, Enter to send, and per-repeater command history (arrow keys) are built in.
Replies travel over LoRa, so an occasional lost reply (timeout) is normal — just send the command again.
#### Settings (admin only)
Repeater configuration organized into collapsible sections: **Basic** (name, admin and guest passwords), **Radio** (frequency / bandwidth / SF / CR, TX power, RX gain), **Location**, **Features** (repeat, read-only access, multiple ACKs), **Network health** (loop detection, duty cycle), **Advertisement** (advert intervals, max flood hops), **Operator info**, and **Advanced**.
- Each section loads its values live from the repeater when you first expand it (every field is one mesh round-trip, so a section takes a few seconds) and has its own **Refresh** and **Apply** buttons
- Changed fields are highlighted and counted on the Apply button; only those fields are sent
- Every field reports back individually: a green check (applied), a **reboot required** badge (stored, takes effect after a reboot — radio parameters work this way), or a red error showing the repeater's own reply (e.g. an out-of-range value). Failed fields stay marked so you can correct and re-apply
- Changing radio parameters asks for confirmation first — wrong values can make the repeater unreachable over the mesh
- The admin password is write-only (the current one is never displayed). After a successful change, the password saved in mc-webui is updated automatically so one-click login keeps working. Changing it does **not** log out sessions that are already active
#### Actions (admin only)
One-click operations:
- **Send zero-hop advert** - Announce the repeater to its direct neighbours. The recommended way to make it visible
- **Send flood advert** - Advert flooded across the whole mesh. Not recommended — high network load; use sparingly
- **Sync clock** - Set the repeater clock from your device's current time. The firmware refuses to move a clock backwards and says so
- **Danger zone: Reboot** - Restarts the repeater after a confirmation prompt. The firmware does not reply to this command; the repeater simply drops off the mesh for a few seconds and comes back
Erasing the repeater's file system is **not** available over the mesh — the firmware only accepts it on the USB serial console (use the MeshCore flasher for that).
---
## Device Dashboard
Access device information and statistics:
@@ -586,7 +665,7 @@ Tap the toggle button (short click, no drag) to hide or show the rest of the FAB
Open Settings → **Appearance** tab to adjust:
- **Hide Quick Access** - Master switch: hides the FAB cluster entirely and moves all actions to the Main Menu
- **Per-item placement** - Choose whether each of the 11 actions appears in the FAB or in the Main Menu. Changes take effect immediately
- **Per-item placement** - Choose whether each of the 12 actions appears in the FAB or in the Main Menu. Changes take effect immediately
- **Button size** - 28 to 72 pixels (default: 56)
- **Spacing** - 2 to 24 pixels between buttons (default: 12)
- **Reset position** - Reset both main chat and DM FAB positions to their defaults
@@ -672,7 +751,7 @@ Controls small notification toasts shown after actions (e.g. "Advert Sent", erro
**Quick Access Buttons:**
- **Hide Quick Access** - Master switch that hides the entire floating FAB cluster and moves all items to the Main Menu (slide-out)
- **Per-item placement** - A table of all 11 configurable actions (Filter, Search, Direct Messages, Contacts, Settings, Send Advert, Flood Advert, Backup, Cleanup Contacts, System Log, Repeater Mgmt). Each row has two radio buttons: **Quick Access** (shows in FAB) and **Main Menu** (shows in the slide-out). Changes take effect immediately
- **Per-item placement** - A table of all 12 configurable actions (Filter messages, Search messages, Direct Messages, Contact Management, Settings, Send Advert, Flood Advert, Map, Console, My Repeaters, Device Info, System Log). Each row has two radio buttons: **Quick Access** (shows in FAB) and **Main Menu** (shows in the slide-out). Changes take effect immediately
- **Button size (px)** - Adjust the size of FAB buttons (default: 56)
- **Spacing (px)** - Space between FAB buttons (default: 12)
- **Position** - Reset FAB position to default (top-right)
+9 -1
View File
@@ -10,12 +10,20 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
### New features
- **My Repeaters — administer your repeaters from the browser.** A new full-screen panel (main menu → **My Repeaters**) for the MeshCore repeaters you hold the password to. Add repeaters from your device's contacts, save the admin password once, and from then on logging in is one click — the app shows whether the repeater granted you **ADMIN** or **GUEST** rights. Each entry also gets the same path editor as DMs, so you can pin a direct route to make logins and commands fast. Heads-up: repeaters never answer a bad login, so a wrong password looks exactly like an unreachable repeater — the app's error messages say so instead of guessing.
- **Monitoring tools per repeater: Status, Telemetry, Neighbors.** After login you land in a management panel. **Status** shows battery, uptime, clock, RSSI/SNR/noise floor, packet counters, and channel utilization in one compact table. **Telemetry** lists every Cayenne LPP channel at once, with proper units. **Neighbors** shows every zero-hop node the repeater hears (name, last heard, SNR) — plus a map view that draws SNR-labeled links to the neighbours it can place. Works with guest logins too.
- **Remote CLI, Settings, and Actions (admin logins).** **CLI** is a real terminal to the repeater — quick-command chips, per-repeater history, round-trip times. **Settings** edits the repeater configuration in collapsible sections (Basic, Radio, Location, Features, Network health, Advertisement, Operator info, Advanced): values load live from the repeater, only the fields you change are sent, and every field reports back individually — including a "reboot required" badge for radio parameters and the firmware's own error text for rejected values. **Actions** covers zero-hop advert, flood advert (marked "not recommended" — high network load), clock sync, and a confirmation-guarded reboot in a Danger zone. Erasing the file system stays USB-serial-only by firmware design, and the panel says so.
- **Observer mode — feed packet analyzers straight from mc-webui.** The new **Settings → Observer** tab turns your node into a MeshCore observer: every packet the device overhears is published to one or more MQTT brokers in the standard `meshcore-packet-capture` format, so analyzer services (a self-hosted Corescope, letsmesh-style maps) see your local mesh traffic without a separate capture script or a dedicated second node. Configure brokers with host/port, optional username/password and TLS; each row shows a live connected/error badge, and the tab counts packets captured vs published in real time. Capture is completely passive — chat and direct messages are unaffected — and all changes apply immediately, no restart needed. (LetsMesh token-authenticated brokers are not supported yet.)
- **Scheduled flood adverts.** The Observer tab includes an optional advert interval in hours: the app sends a flood advert on that schedule so your observer stays visible on analyzer maps. The timer survives restarts, so a redeploy won't send an extra advert early. Set it to 0 to keep adverts fully manual.
### Reliability & polish
- **Console `login` reports your role.** A successful repeater login in the Interactive Console now answers "Logged into X as admin" (or guest) instead of a bare success line.
### Deploy notes
- This update adds a new Python dependency (`paho-mqtt`), so the Docker image must be rebuilt — the standard `mcupdate` flow does this automatically. Broker passwords entered in the Observer tab are stored in plain text in the app database; use dedicated MQTT credentials.
- This update adds a new Python dependency (`paho-mqtt`) and raises the `meshcore` library requirement to 2.3.7, so the Docker image must be rebuilt — the standard `mcupdate` flow does this automatically.
- Passwords you save in the app — MQTT broker credentials in the Observer tab and repeater admin passwords in My Repeaters — are stored in plain text in the app database. That's a deliberate trade-off for a private single-user LAN app; use dedicated credentials where you can.
---
+1 -1
View File
@@ -35,4 +35,4 @@ python-socketio==5.10.0
python-engineio==4.8.1
# v2: Direct MeshCore device communication (replaces bridge subprocess)
meshcore>=2.2.0
meshcore>=2.3.7