mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-08 09:52:55 +02:00
feat: add region/default-scope / cli commands update
standardize default region on mesh.default_region (remove legacy region_default_scope usage) add/align CLI support for owner.info, path.hash.mode, and loop.detect with validation wire UI terminal get/set + autocomplete/help for owner.info, path.hash.mode, loop.detect extend update_radio_config to persist owner_info for UI set owner.info add and use shared packet utility for advert creation + default-region transport scoping refactor repeater and room-server advert paths to reuse shared packet logic
This commit is contained in:
@@ -311,6 +311,10 @@ mesh:
|
||||
# true = allow unscoped flooding, false = deny flooding globally
|
||||
unscoped_flood_allow: true
|
||||
|
||||
# Legacy alias for unscoped flood policy. Keep in sync with unscoped_flood_allow.
|
||||
# true = allow unscoped flooding, false = deny
|
||||
global_flood_allow: true
|
||||
|
||||
# Path hash mode for flood packets (0-hop): per-hop hash size in path encoding
|
||||
# 0 = 1-byte hashes (legacy), 1 = 2-byte, 2 = 3-byte. Must match mesh convention.
|
||||
# Affects originated adverts and any other flood packets sent by the repeater.
|
||||
@@ -320,6 +324,10 @@ mesh:
|
||||
# off = disabled, minimal = allow up to 3 self-hashes, moderate = allow up to 1, strict = allow 0
|
||||
loop_detect: minimal
|
||||
|
||||
# Default flood scope for locally-originated flood adverts.
|
||||
# Use null or "<null>" via CLI command "region default <null>" to clear.
|
||||
default_region: null
|
||||
|
||||
# Multiple Identity Configuration (Optional)
|
||||
# Define additional identities for the repeater to manage
|
||||
# Each identity operates independently with its own key pair and configuration
|
||||
|
||||
@@ -2143,11 +2143,29 @@ class SQLiteHandler:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
cursor = conn.execute("DELETE FROM adverts WHERE id = ?", (advert_id,))
|
||||
self._neighbors_cache = {"timestamp": 0.0, "value": None}
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete advert: {e}")
|
||||
return False
|
||||
|
||||
def delete_neighbors_by_pubkey_prefix(self, pubkey_prefix: Optional[str]) -> int:
|
||||
"""Delete neighbor adverts by pubkey prefix (or all when prefix is None)."""
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
if pubkey_prefix is None:
|
||||
cursor = conn.execute("DELETE FROM adverts")
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM adverts WHERE lower(pubkey) LIKE ?",
|
||||
(f"{pubkey_prefix.lower()}%",),
|
||||
)
|
||||
self._neighbors_cache = {"timestamp": 0.0, "value": None}
|
||||
return int(cursor.rowcount)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete neighbors by prefix: {e}")
|
||||
raise
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Room Server Methods
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2755,7 +2773,9 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to count companion messages: {e}")
|
||||
return 0
|
||||
|
||||
def companion_load_messages(self, companion_hash: str, limit: int = 100) -> Optional[List[Dict]]:
|
||||
def companion_load_messages(
|
||||
self, companion_hash: str, limit: int = 100
|
||||
) -> Optional[List[Dict]]:
|
||||
"""Load queued messages for a companion (oldest first for queue order).
|
||||
|
||||
Returns [] when the companion has no persisted messages, or None when
|
||||
|
||||
@@ -551,6 +551,9 @@ class StorageCollector:
|
||||
def delete_advert(self, advert_id: int) -> bool:
|
||||
return self.sqlite_handler.delete_advert(advert_id)
|
||||
|
||||
def delete_neighbors_by_pubkey_prefix(self, pubkey_prefix: str | None) -> int:
|
||||
return self.sqlite_handler.delete_neighbors_by_pubkey_prefix(pubkey_prefix)
|
||||
|
||||
def get_hardware_stats(self) -> Optional[dict]:
|
||||
"""Get current hardware statistics"""
|
||||
try:
|
||||
|
||||
@@ -57,7 +57,11 @@ class PacketWebSocket(WebSocket):
|
||||
try:
|
||||
payload = jwt_handler.verify_jwt(token)
|
||||
if payload:
|
||||
if client_id and payload.get("client_id") and payload.get("client_id") != client_id:
|
||||
if (
|
||||
client_id
|
||||
and payload.get("client_id")
|
||||
and payload.get("client_id") != client_id
|
||||
):
|
||||
logger.warning("WebSocket connection rejected: client_id mismatch")
|
||||
self.close(code=1008, reason="unauthorized")
|
||||
return
|
||||
|
||||
@@ -37,6 +37,112 @@ class MeshCLI:
|
||||
|
||||
# Get repeater config shortcut
|
||||
self.repeater_config = config.get("repeater", {})
|
||||
self.mesh_config = config.setdefault("mesh", {})
|
||||
|
||||
def _get_node_name(self) -> str:
|
||||
"""Return the configured node name, preferring the newer key when present."""
|
||||
return self.repeater_config.get("node_name") or self.repeater_config.get("name", "Unknown")
|
||||
|
||||
def _set_node_name(self, value: str) -> None:
|
||||
"""Persist node name to both legacy and current config keys for compatibility."""
|
||||
self.repeater_config["node_name"] = value
|
||||
self.repeater_config["name"] = value
|
||||
|
||||
def _get_local_pubkey_hex(self) -> Optional[str]:
|
||||
"""Return local node public key (hex) when available."""
|
||||
try:
|
||||
if self.identity and hasattr(self.identity, "get_public_key"):
|
||||
pubkey = self.identity.get_public_key()
|
||||
if isinstance(pubkey, (bytes, bytearray)):
|
||||
return bytes(pubkey).hex().lower()
|
||||
if isinstance(pubkey, str):
|
||||
normalized = pubkey.strip().lower()
|
||||
if normalized.startswith("0x"):
|
||||
normalized = normalized[2:]
|
||||
if normalized:
|
||||
return normalized
|
||||
except Exception as exc:
|
||||
logger.debug("Unable to read local identity pubkey: %s", exc)
|
||||
|
||||
key = self.repeater_config.get("identity_key")
|
||||
if isinstance(key, (bytes, bytearray)):
|
||||
return bytes(key).hex().lower()
|
||||
if isinstance(key, str):
|
||||
normalized = key.strip().lower()
|
||||
if normalized.startswith("0x"):
|
||||
normalized = normalized[2:]
|
||||
if normalized and all(ch in "0123456789abcdef" for ch in normalized):
|
||||
return normalized
|
||||
|
||||
return None
|
||||
|
||||
def _is_local_pubkey(self, pubkey_hex: str) -> bool:
|
||||
"""Return True when a discovery result pubkey matches the local node."""
|
||||
candidate = (pubkey_hex or "").strip().lower()
|
||||
if not candidate:
|
||||
return False
|
||||
if candidate.startswith("0x"):
|
||||
candidate = candidate[2:]
|
||||
|
||||
local_pubkey = self._get_local_pubkey_hex()
|
||||
if not local_pubkey:
|
||||
return False
|
||||
|
||||
# Prefix-only discovery may return fewer bytes than full identity pubkey.
|
||||
return local_pubkey.startswith(candidate) or candidate.startswith(local_pubkey)
|
||||
|
||||
def _auto_add_discovery_result(self, result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Persist discovered neighbors automatically, excluding this node itself."""
|
||||
enriched = dict(result)
|
||||
pub_key = str(enriched.get("pub_key") or "").strip().lower()
|
||||
if not pub_key:
|
||||
return enriched
|
||||
|
||||
if self._is_local_pubkey(pub_key):
|
||||
enriched["is_self"] = True
|
||||
enriched["known_neighbor"] = True
|
||||
return enriched
|
||||
|
||||
if not self.storage_handler:
|
||||
return enriched
|
||||
|
||||
record_advert = getattr(self.storage_handler, "record_advert", None)
|
||||
if not callable(record_advert):
|
||||
return enriched
|
||||
|
||||
try:
|
||||
import time
|
||||
|
||||
node_type = int(enriched.get("node_type", 0) or 0)
|
||||
contact_type = {
|
||||
1: "Chat Node",
|
||||
2: "Repeater",
|
||||
3: "Room Server",
|
||||
}.get(node_type, "Unknown")
|
||||
|
||||
rssi = enriched.get("rssi")
|
||||
snr = enriched.get("response_snr", enriched.get("snr"))
|
||||
advert_record = {
|
||||
"timestamp": time.time(),
|
||||
"pubkey": pub_key,
|
||||
"node_name": enriched.get("node_name"),
|
||||
"is_repeater": node_type == 2,
|
||||
"route_type": 2,
|
||||
"contact_type": contact_type,
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"rssi": int(rssi) if rssi is not None else None,
|
||||
"snr": float(snr) if snr is not None else None,
|
||||
"is_new_neighbor": True,
|
||||
"zero_hop": True,
|
||||
}
|
||||
record_advert(advert_record)
|
||||
enriched["known_neighbor"] = True
|
||||
enriched["auto_added"] = True
|
||||
except Exception as exc:
|
||||
logger.debug("Auto-add discovery result failed for %s: %s", pub_key, exc)
|
||||
|
||||
return enriched
|
||||
|
||||
def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str:
|
||||
|
||||
@@ -119,6 +225,8 @@ class MeshCLI:
|
||||
return self._cmd_neighbors()
|
||||
elif command.startswith("neighbor.remove "):
|
||||
return self._cmd_neighbor_remove(command)
|
||||
elif command.startswith("discover.neighbors"):
|
||||
return self._cmd_discover_neighbors(command)
|
||||
|
||||
# Temporary radio params
|
||||
elif command.startswith("tempradio "):
|
||||
@@ -152,7 +260,7 @@ class MeshCLI:
|
||||
return self._help_detail(parts[1])
|
||||
|
||||
lines = [
|
||||
"=== pyMC CLI Commands ===",
|
||||
"=== openHop CLI Commands ===",
|
||||
"",
|
||||
"System:",
|
||||
" reboot Restart the repeater service",
|
||||
@@ -174,11 +282,14 @@ class MeshCLI:
|
||||
" get repeat Repeat mode (on/off)",
|
||||
" get lat / get lon GPS coordinates",
|
||||
" get role Identity role",
|
||||
" get owner.info Owner info text",
|
||||
" get guest.password Guest password",
|
||||
" get allow.read.only Read-only access setting",
|
||||
" get advert.interval Advert interval (minutes)",
|
||||
" get flood.advert.interval Flood advert interval (hours)",
|
||||
" get flood.max Max flood hops",
|
||||
" get path.hash.mode Flood advert path hash mode (0-2)",
|
||||
" get loop.detect Flood loop detection mode",
|
||||
" get rxdelay RX delay base",
|
||||
" get txdelay TX delay factor",
|
||||
" get direct.txdelay Direct TX delay factor",
|
||||
@@ -192,6 +303,7 @@ class MeshCLI:
|
||||
"Other:",
|
||||
" neighbors List neighbors",
|
||||
" neighbor.remove <key> Remove neighbor by pubkey",
|
||||
" discover.neighbors Send zero-hop neighbor discovery",
|
||||
" tempradio <freq> <bw> <sf> <cr> <timeout_mins>",
|
||||
" setperm <pubkey> <perm> Set ACL permissions",
|
||||
" log start|stop|erase Logging control",
|
||||
@@ -216,10 +328,13 @@ class MeshCLI:
|
||||
" set lat <deg> Latitude\n"
|
||||
" set lon <deg> Longitude\n"
|
||||
" set guest.password <pw> Guest password\n"
|
||||
" set owner.info <text> Owner info text\n"
|
||||
" set allow.read.only on|off Read-only access\n"
|
||||
" set advert.interval <min> 60-240 minutes\n"
|
||||
" set flood.advert.interval <hr> 3-168 hours\n"
|
||||
" set flood.max <hops> Max flood hops (max 64)\n"
|
||||
" set path.hash.mode <0-2> Path hash mode (0=1B,1=2B,2=3B)\n"
|
||||
" set loop.detect <off|minimal|moderate|strict> Flood loop detection\n"
|
||||
" set rxdelay <val> RX delay base (>=0)\n"
|
||||
" set txdelay <val> TX delay factor (>=0)\n"
|
||||
" set direct.txdelay <val> Direct TX delay (>=0)\n"
|
||||
@@ -242,6 +357,7 @@ class MeshCLI:
|
||||
" freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8"
|
||||
),
|
||||
"neighbors": "List known neighbor nodes from the routing table.",
|
||||
"discover.neighbors": "Send a neighbor discovery request.",
|
||||
"setperm": "setperm <pubkey_hex> <permission_int> \u2014 Set ACL permissions for a node.",
|
||||
"log": "log start|stop|erase \u2014 Control logging.",
|
||||
}
|
||||
@@ -372,8 +488,7 @@ class MeshCLI:
|
||||
return f"> {af}"
|
||||
|
||||
elif param == "name":
|
||||
name = self.repeater_config.get("name", "Unknown")
|
||||
return f"> {name}"
|
||||
return f"> {self._get_node_name()}"
|
||||
|
||||
elif param == "repeat":
|
||||
mode = self.repeater_config.get("mode", "forward")
|
||||
@@ -426,6 +541,10 @@ class MeshCLI:
|
||||
guest_pw = self.config.get("security", {}).get("guest_password", "")
|
||||
return f"> {guest_pw}"
|
||||
|
||||
elif param == "owner.info":
|
||||
owner_info = self.repeater_config.get("owner_info", "")
|
||||
return f"> {owner_info}"
|
||||
|
||||
elif param == "allow.read.only":
|
||||
allow = self.config.get("security", {}).get("allow_read_only", False)
|
||||
return f"> {'on' if allow else 'off'}"
|
||||
@@ -442,6 +561,14 @@ class MeshCLI:
|
||||
max_flood = self.repeater_config.get("max_flood_hops", 64)
|
||||
return f"> {max_flood}"
|
||||
|
||||
elif param == "path.hash.mode":
|
||||
path_hash_mode = self.mesh_config.get("path_hash_mode", 0)
|
||||
return f"> {path_hash_mode}"
|
||||
|
||||
elif param == "loop.detect":
|
||||
loop_detect = self.mesh_config.get("loop_detect", "off")
|
||||
return f"> {loop_detect}"
|
||||
|
||||
elif param == "rxdelay":
|
||||
delay = self.repeater_config.get("rx_delay_base", 0.0)
|
||||
return f"> {delay}"
|
||||
@@ -487,7 +614,7 @@ class MeshCLI:
|
||||
return "OK"
|
||||
|
||||
elif key == "name":
|
||||
self.repeater_config["node_name"] = value
|
||||
self._set_node_name(value)
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
@@ -551,6 +678,12 @@ class MeshCLI:
|
||||
self.config_manager.live_update_daemon(["security"])
|
||||
return "OK"
|
||||
|
||||
elif key == "owner.info":
|
||||
self.repeater_config["owner_info"] = value.replace("|", "\n")
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
elif key == "allow.read.only":
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
@@ -586,6 +719,24 @@ class MeshCLI:
|
||||
self.config_manager.live_update_daemon(["repeater"])
|
||||
return "OK"
|
||||
|
||||
elif key == "path.hash.mode":
|
||||
mode = int(value)
|
||||
if mode not in (0, 1, 2):
|
||||
return "Error: path.hash.mode must be 0, 1, or 2"
|
||||
self.mesh_config["path_hash_mode"] = mode
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(["mesh"])
|
||||
return "OK"
|
||||
|
||||
elif key == "loop.detect":
|
||||
mode = str(value).strip().lower()
|
||||
if mode not in ("off", "minimal", "moderate", "strict"):
|
||||
return "Error: loop.detect must be off, minimal, moderate, or strict"
|
||||
self.mesh_config["loop_detect"] = mode
|
||||
saved, _ = self.config_manager.save_to_file()
|
||||
self.config_manager.live_update_daemon(["mesh"])
|
||||
return "OK"
|
||||
|
||||
elif key == "rxdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
@@ -665,22 +816,336 @@ class MeshCLI:
|
||||
# ==================== Region Commands ====================
|
||||
|
||||
def _cmd_region(self, command: str) -> str:
|
||||
"""Handle region commands."""
|
||||
"""Handle region commands with MeshCore-compatible response shapes."""
|
||||
parts = command.split()
|
||||
|
||||
if len(parts) == 1:
|
||||
return "Error: Region commands not implemented in Python repeater"
|
||||
return self._region_export_tree()
|
||||
|
||||
subcommand = parts[1]
|
||||
|
||||
if subcommand == "load":
|
||||
return "Error: Region commands not implemented"
|
||||
elif subcommand == "save":
|
||||
return "Error: Region commands not implemented"
|
||||
elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"):
|
||||
return "Error: Region commands not implemented"
|
||||
else:
|
||||
return "Err - ??"
|
||||
return "Err - region load not supported"
|
||||
if subcommand == "def":
|
||||
return "Err - region def not supported"
|
||||
if subcommand == "save":
|
||||
return self._region_save()
|
||||
if subcommand == "allowf" and len(parts) >= 3:
|
||||
return self._region_set_flood(parts[2], allow=True)
|
||||
if subcommand == "denyf" and len(parts) >= 3:
|
||||
return self._region_set_flood(parts[2], allow=False)
|
||||
if subcommand == "get" and len(parts) >= 3:
|
||||
return self._region_get(parts[2])
|
||||
if subcommand == "home":
|
||||
if len(parts) >= 3:
|
||||
return self._region_home_set(parts[2])
|
||||
return self._region_home_get()
|
||||
if subcommand == "default":
|
||||
if len(parts) >= 3:
|
||||
return self._region_default_set(parts[2])
|
||||
return self._region_default_get()
|
||||
if subcommand == "put" and len(parts) >= 3:
|
||||
parent = parts[3] if len(parts) >= 4 else "*"
|
||||
return self._region_put(parts[2], parent)
|
||||
if subcommand == "remove" and len(parts) >= 3:
|
||||
return self._region_remove(parts[2])
|
||||
if subcommand == "list" and len(parts) >= 3:
|
||||
return self._region_list(parts[2])
|
||||
|
||||
return "Err - ??"
|
||||
|
||||
def _region_storage_available(self) -> bool:
|
||||
return bool(
|
||||
self.storage_handler
|
||||
and hasattr(self.storage_handler, "get_transport_keys")
|
||||
and callable(getattr(self.storage_handler, "get_transport_keys"))
|
||||
)
|
||||
|
||||
def _region_load_records(self) -> list[dict]:
|
||||
if not self._region_storage_available():
|
||||
return []
|
||||
records = self.storage_handler.get_transport_keys()
|
||||
return records if isinstance(records, list) else []
|
||||
|
||||
@staticmethod
|
||||
def _region_display_name(raw_name: str) -> str:
|
||||
name = str(raw_name or "").strip()
|
||||
if name.startswith("#"):
|
||||
return name[1:]
|
||||
return name
|
||||
|
||||
def _region_find_prefix(self, query: str) -> Optional[dict]:
|
||||
q = str(query or "").strip()
|
||||
if not q:
|
||||
return None
|
||||
if q == "*":
|
||||
return {
|
||||
"id": 0,
|
||||
"name": "*",
|
||||
"display_name": "*",
|
||||
"flood_policy": "allow" if self._region_unscoped_allow() else "deny",
|
||||
"parent_id": None,
|
||||
}
|
||||
|
||||
q_lower = q.lower()
|
||||
for rec in self._region_load_records():
|
||||
display = self._region_display_name(rec.get("name", ""))
|
||||
if display.lower().startswith(q_lower):
|
||||
return {**rec, "display_name": display}
|
||||
return None
|
||||
|
||||
def _region_find_exact(self, query: str) -> Optional[dict]:
|
||||
q = str(query or "").strip()
|
||||
if not q:
|
||||
return None
|
||||
if q == "*":
|
||||
return {
|
||||
"id": 0,
|
||||
"name": "*",
|
||||
"display_name": "*",
|
||||
"flood_policy": "allow" if self._region_unscoped_allow() else "deny",
|
||||
"parent_id": None,
|
||||
}
|
||||
|
||||
q_lower = q.lower()
|
||||
for rec in self._region_load_records():
|
||||
display = self._region_display_name(rec.get("name", ""))
|
||||
if display.lower() == q_lower:
|
||||
return {**rec, "display_name": display}
|
||||
return None
|
||||
|
||||
def _region_unscoped_allow(self) -> bool:
|
||||
return bool(
|
||||
self.mesh_config.get(
|
||||
"unscoped_flood_allow",
|
||||
self.mesh_config.get("global_flood_allow", True),
|
||||
)
|
||||
)
|
||||
|
||||
def _region_set_unscoped_allow(self, allow: bool) -> bool:
|
||||
self.mesh_config["unscoped_flood_allow"] = bool(allow)
|
||||
self.mesh_config["global_flood_allow"] = bool(allow)
|
||||
save_result = self.config_manager.save_to_file()
|
||||
saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result)
|
||||
self.config_manager.live_update_daemon(["mesh"])
|
||||
return bool(saved)
|
||||
|
||||
def _region_get_default_name(self) -> Optional[str]:
|
||||
default_name = self.mesh_config.get("default_region")
|
||||
text = str(default_name).strip() if default_name is not None else ""
|
||||
return text or None
|
||||
|
||||
def _region_set_default_name(self, value: Optional[str]) -> bool:
|
||||
self.mesh_config["default_region"] = value
|
||||
save_result = self.config_manager.save_to_file()
|
||||
saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result)
|
||||
self.config_manager.live_update_daemon(["mesh"])
|
||||
return bool(saved)
|
||||
|
||||
def _region_export_tree(self) -> str:
|
||||
records = self._region_load_records()
|
||||
children_by_parent: Dict[int, list[dict]] = {}
|
||||
for rec in records:
|
||||
parent_id = rec.get("parent_id")
|
||||
parent_key = int(parent_id) if isinstance(parent_id, int) and parent_id > 0 else 0
|
||||
children_by_parent.setdefault(parent_key, []).append(rec)
|
||||
|
||||
for parent_list in children_by_parent.values():
|
||||
parent_list.sort(key=lambda r: str(r.get("name", "")).lower())
|
||||
|
||||
home_name = str(self.repeater_config.get("region_home") or "").strip().lower()
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
def append_line(indent: int, display_name: str, flood_policy: str):
|
||||
home_mark = "^" if home_name and display_name.lower() == home_name else ""
|
||||
flood_mark = " F" if flood_policy == "allow" else ""
|
||||
lines.append(f"{' ' * indent}{display_name}{home_mark}{flood_mark}")
|
||||
|
||||
append_line(0, "*", "allow" if self._region_unscoped_allow() else "deny")
|
||||
|
||||
def walk(parent_id: int, indent: int):
|
||||
for rec in children_by_parent.get(parent_id, []):
|
||||
display_name = self._region_display_name(rec.get("name", ""))
|
||||
append_line(indent, display_name, str(rec.get("flood_policy", "deny")))
|
||||
walk(int(rec.get("id", 0)), indent + 1)
|
||||
|
||||
walk(0, 1)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _region_save(self) -> str:
|
||||
save_result = self.config_manager.save_to_file()
|
||||
saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result)
|
||||
return "OK" if saved else "Err - save failed"
|
||||
|
||||
def _region_set_flood(self, name_prefix: str, allow: bool) -> str:
|
||||
region = self._region_find_prefix(name_prefix)
|
||||
if not region:
|
||||
return "Err - unknown region"
|
||||
|
||||
if region.get("id") == 0:
|
||||
return "OK" if self._region_set_unscoped_allow(allow) else "Err - save failed"
|
||||
|
||||
update_fn = getattr(self.storage_handler, "update_transport_key", None)
|
||||
if not callable(update_fn):
|
||||
return "Error: Region commands not supported by storage backend"
|
||||
|
||||
ok = update_fn(int(region["id"]), flood_policy="allow" if allow else "deny")
|
||||
return "OK" if ok else "Err - unknown region"
|
||||
|
||||
def _region_get(self, name_prefix: str) -> str:
|
||||
region = self._region_find_prefix(name_prefix)
|
||||
if not region:
|
||||
return "Err - unknown region"
|
||||
|
||||
display_name = str(
|
||||
region.get("display_name") or self._region_display_name(region.get("name", ""))
|
||||
)
|
||||
flood_suffix = "F" if region.get("flood_policy") == "allow" else ""
|
||||
|
||||
parent_name = None
|
||||
parent_id = region.get("parent_id")
|
||||
if isinstance(parent_id, int) and parent_id > 0:
|
||||
for rec in self._region_load_records():
|
||||
if int(rec.get("id", -1)) == parent_id:
|
||||
parent_name = self._region_display_name(rec.get("name", ""))
|
||||
break
|
||||
|
||||
if parent_name:
|
||||
return f" {display_name} ({parent_name}) {flood_suffix}".rstrip()
|
||||
return f" {display_name} {flood_suffix}".rstrip()
|
||||
|
||||
def _region_home_get(self) -> str:
|
||||
home = str(self.repeater_config.get("region_home") or "").strip()
|
||||
return f" home is {home or '*'}"
|
||||
|
||||
def _region_home_set(self, name_prefix: str) -> str:
|
||||
region = self._region_find_prefix(name_prefix)
|
||||
if not region:
|
||||
return "Err - unknown region"
|
||||
|
||||
display_name = str(region.get("display_name") or "*")
|
||||
self.repeater_config["region_home"] = display_name
|
||||
save_result = self.config_manager.save_to_file()
|
||||
saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result)
|
||||
return f" home is now {display_name}" if saved else "Err - save failed"
|
||||
|
||||
def _region_default_get(self) -> str:
|
||||
default_region = self._region_get_default_name()
|
||||
if default_region is None:
|
||||
return " default scope is <null>"
|
||||
return f" default scope is {default_region}"
|
||||
|
||||
def _region_default_set(self, value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if text == "<null>":
|
||||
saved = self._region_set_default_name(None)
|
||||
return " default scope is now <null>" if saved else "Err - save failed"
|
||||
|
||||
region = self._region_find_prefix(text)
|
||||
if region:
|
||||
display_name = str(region.get("display_name") or text)
|
||||
if region.get("id") not in (None, 0):
|
||||
update_fn = getattr(self.storage_handler, "update_transport_key", None)
|
||||
if callable(update_fn):
|
||||
update_fn(int(region["id"]), flood_policy="allow")
|
||||
saved = self._region_set_default_name(display_name)
|
||||
return f" default scope is now {display_name}" if saved else "Err - save failed"
|
||||
|
||||
put_result = self._region_put(text, "*")
|
||||
if not put_result.startswith("OK"):
|
||||
return "Err - region table full"
|
||||
|
||||
saved = self._region_set_default_name(text)
|
||||
return f" default scope is now {text}" if saved else "Err - save failed"
|
||||
|
||||
def _region_put(self, name: str, parent_name: str) -> str:
|
||||
region_name = str(name or "").strip()
|
||||
if not region_name:
|
||||
return "Err - unable to put"
|
||||
|
||||
parent = self._region_find_prefix(parent_name)
|
||||
if not parent:
|
||||
return "Err - unknown parent"
|
||||
|
||||
parent_id = int(parent.get("id", 0))
|
||||
parent_storage_id = None if parent_id == 0 else parent_id
|
||||
|
||||
existing = self._region_find_exact(region_name)
|
||||
if existing and existing.get("id") != 0:
|
||||
update_fn = getattr(self.storage_handler, "update_transport_key", None)
|
||||
if not callable(update_fn):
|
||||
return "Err - unable to put"
|
||||
ok = update_fn(
|
||||
int(existing["id"]),
|
||||
flood_policy="allow",
|
||||
parent_id=parent_storage_id,
|
||||
)
|
||||
return "OK - (flood allowed)" if ok else "Err - unable to put"
|
||||
|
||||
create_fn = getattr(self.storage_handler, "create_transport_key", None)
|
||||
if not callable(create_fn):
|
||||
return "Err - unable to put"
|
||||
|
||||
key_id = create_fn(
|
||||
region_name,
|
||||
"allow",
|
||||
None,
|
||||
parent_storage_id,
|
||||
None,
|
||||
)
|
||||
return "OK - (flood allowed)" if key_id else "Err - unable to put"
|
||||
|
||||
def _region_remove(self, name: str) -> str:
|
||||
region = self._region_find_exact(name)
|
||||
if not region or region.get("id") == 0:
|
||||
return "Err - not found"
|
||||
|
||||
region_id = int(region["id"])
|
||||
for rec in self._region_load_records():
|
||||
if int(rec.get("parent_id") or 0) == region_id:
|
||||
return "Err - not empty"
|
||||
|
||||
delete_fn = getattr(self.storage_handler, "delete_transport_key", None)
|
||||
if not callable(delete_fn):
|
||||
return "Err - not found"
|
||||
|
||||
ok = delete_fn(region_id)
|
||||
if not ok:
|
||||
return "Err - not found"
|
||||
|
||||
removed_name = str(region.get("display_name") or "")
|
||||
if (
|
||||
str(self.repeater_config.get("region_home") or "").strip().lower()
|
||||
== removed_name.lower()
|
||||
):
|
||||
self.repeater_config["region_home"] = ""
|
||||
default_name = self._region_get_default_name()
|
||||
if str(default_name or "").strip().lower() == removed_name.lower():
|
||||
self.mesh_config["default_region"] = None
|
||||
return "OK"
|
||||
|
||||
def _region_list(self, filter_name: str) -> str:
|
||||
mode = str(filter_name or "").strip().lower()
|
||||
if mode not in ("allowed", "denied"):
|
||||
return "Err - use 'allowed' or 'denied'"
|
||||
|
||||
names: list[str] = []
|
||||
unscoped_allowed = self._region_unscoped_allow()
|
||||
if (mode == "allowed" and unscoped_allowed) or (mode == "denied" and not unscoped_allowed):
|
||||
names.append("*")
|
||||
|
||||
records = sorted(
|
||||
self._region_load_records(),
|
||||
key=lambda r: self._region_display_name(r.get("name", "")).lower(),
|
||||
)
|
||||
for rec in records:
|
||||
flood_policy = str(rec.get("flood_policy", "deny")).lower()
|
||||
allowed = flood_policy == "allow"
|
||||
if (mode == "allowed" and allowed) or (mode == "denied" and not allowed):
|
||||
names.append(self._region_display_name(rec.get("name", "")))
|
||||
|
||||
return ",".join(names) if names else "-none-"
|
||||
|
||||
# ==================== Neighbor Commands ====================
|
||||
|
||||
@@ -695,15 +1160,15 @@ class MeshCLI:
|
||||
if not neighbors:
|
||||
return "No neighbors discovered yet"
|
||||
|
||||
# Filter to only show repeaters and zero hop nodes
|
||||
# Match MeshCore behavior: show only zero-hop repeaters.
|
||||
filtered_neighbors = {
|
||||
pubkey: info
|
||||
for pubkey, info in neighbors.items()
|
||||
if info.get("is_repeater", False) or info.get("zero_hop", False)
|
||||
if info.get("is_repeater", False) and info.get("zero_hop", False)
|
||||
}
|
||||
|
||||
if not filtered_neighbors:
|
||||
return "No repeaters or zero hop neighbors discovered yet"
|
||||
return "No zero hop repeaters discovered yet"
|
||||
|
||||
# Format output similar to C++ version
|
||||
# Format: "<pubkey_prefix> heard Xs ago"
|
||||
@@ -731,14 +1196,71 @@ class MeshCLI:
|
||||
|
||||
def _cmd_neighbor_remove(self, command: str) -> str:
|
||||
"""Remove a neighbor."""
|
||||
pubkey_hex = command[16:].strip()
|
||||
raw_suffix = command[16:]
|
||||
pubkey_hex = raw_suffix.strip()
|
||||
|
||||
if not pubkey_hex:
|
||||
# Keep MeshCore parity: plain empty is invalid, whitespace-only means remove all.
|
||||
if raw_suffix == "":
|
||||
return "ERR: Missing pubkey"
|
||||
|
||||
# TODO: Remove neighbor from routing table
|
||||
logger.info(f"neighbor.remove: {pubkey_hex}")
|
||||
return "Error: Not yet implemented"
|
||||
if not self.storage_handler:
|
||||
return "Error: Storage not available"
|
||||
|
||||
delete_fn = getattr(self.storage_handler, "delete_neighbors_by_pubkey_prefix", None)
|
||||
if not callable(delete_fn):
|
||||
return "Error: neighbor.remove not supported by storage backend"
|
||||
|
||||
try:
|
||||
if pubkey_hex == "":
|
||||
delete_fn(None)
|
||||
return "OK"
|
||||
|
||||
if any(ch not in "0123456789abcdefABCDEF" for ch in pubkey_hex):
|
||||
return "ERR: bad pubkey"
|
||||
|
||||
delete_fn(pubkey_hex)
|
||||
return "OK"
|
||||
except Exception as e:
|
||||
logger.error(f"neighbor.remove failed: {e}", exc_info=True)
|
||||
return f"Error: {e}"
|
||||
|
||||
def _cmd_discover_neighbors(self, command: str) -> str:
|
||||
"""Send a discovery request for nearby repeaters."""
|
||||
sub = command[18:]
|
||||
if sub.strip():
|
||||
return "Err - discover.neighbors has no options"
|
||||
|
||||
daemon_instance = getattr(self.config_manager, "daemon", None)
|
||||
discovery_helper = getattr(daemon_instance, "discovery_helper", None)
|
||||
if not discovery_helper:
|
||||
return "Error: Discovery helper not available"
|
||||
|
||||
import asyncio
|
||||
|
||||
loop = self._event_loop
|
||||
if loop is None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop is None or not loop.is_running():
|
||||
return "Error: Event loop not available"
|
||||
|
||||
try:
|
||||
discovery_helper.cleanup_sessions()
|
||||
session = discovery_helper.create_session(
|
||||
timeout=5,
|
||||
filter_mask=(1 << 2),
|
||||
since=0,
|
||||
prefix_only=False,
|
||||
result_enricher=self._auto_add_discovery_result,
|
||||
)
|
||||
loop.call_soon_threadsafe(discovery_helper.start_session_task, session["session_id"])
|
||||
return "OK - Discover sent"
|
||||
except Exception as e:
|
||||
logger.error(f"discover.neighbors failed: {e}", exc_info=True)
|
||||
return f"Error: {e}"
|
||||
|
||||
# ==================== Temporary Radio Commands ====================
|
||||
|
||||
|
||||
@@ -1,700 +0,0 @@
|
||||
"""
|
||||
Mesh CLI Handler
|
||||
Handles administrative commands sent to repeaters and room servers via TXT_MSG packets.
|
||||
Only users with admin permissions (via ACL) can execute these commands.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MeshCLI:
|
||||
"""
|
||||
CLI command handler for mesh node administration (repeaters and room servers).
|
||||
Commands follow the format: XX|command params
|
||||
where XX is an optional sequence number that gets echoed in the reply.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str,
|
||||
config: Dict[str, Any],
|
||||
save_config_callback: Callable,
|
||||
identity_type: str = "repeater",
|
||||
enable_regions: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the CLI handler.
|
||||
|
||||
Args:
|
||||
config_path: Path to the config.yaml file
|
||||
config: Current configuration dictionary
|
||||
save_config_callback: Callback to save config changes
|
||||
identity_type: Type of identity ('repeater' or 'room_server')
|
||||
enable_regions: Whether to enable region commands (only for repeaters)
|
||||
"""
|
||||
self.config_path = Path(config_path)
|
||||
self.config = config
|
||||
self.save_config = save_config_callback
|
||||
self.identity_type = identity_type
|
||||
self.enable_regions = enable_regions
|
||||
|
||||
# Get repeater config shortcut
|
||||
self.repeater_config = config.get("repeater", {})
|
||||
|
||||
def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str:
|
||||
"""
|
||||
Handle an incoming command from a client.
|
||||
|
||||
Args:
|
||||
sender_pubkey: Public key of sender
|
||||
command: Command string (may include XX| prefix)
|
||||
is_admin: Whether sender has admin permissions
|
||||
|
||||
Returns:
|
||||
Reply string to send back to sender
|
||||
"""
|
||||
# Check admin permission first
|
||||
if not is_admin:
|
||||
return "Error: Admin permission required"
|
||||
|
||||
logger.debug(f"handle_command received: '{command}' (len={len(command)})")
|
||||
|
||||
# Extract optional sequence prefix (XX|)
|
||||
prefix = ""
|
||||
if len(command) > 4 and command[2] == "|":
|
||||
prefix = command[:3]
|
||||
command = command[3:]
|
||||
logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'")
|
||||
|
||||
# Strip leading/trailing whitespace
|
||||
command = command.strip()
|
||||
logger.debug(f"After strip: '{command}'")
|
||||
|
||||
# Route to appropriate handler
|
||||
reply = self._route_command(command)
|
||||
|
||||
# Add prefix back to reply if present
|
||||
if prefix:
|
||||
return prefix + reply
|
||||
return reply
|
||||
|
||||
def _route_command(self, command: str) -> str:
|
||||
"""Route command to appropriate handler method."""
|
||||
|
||||
# Help
|
||||
if command == "help" or command.startswith("help "):
|
||||
return self._cmd_help(command)
|
||||
|
||||
# System commands
|
||||
elif command == "reboot":
|
||||
return self._cmd_reboot()
|
||||
elif command == "advert":
|
||||
return self._cmd_advert()
|
||||
elif command.startswith("clock"):
|
||||
return self._cmd_clock(command)
|
||||
elif command.startswith("time "):
|
||||
return self._cmd_time(command)
|
||||
elif command == "start ota":
|
||||
return "Error: OTA not supported in Python repeater"
|
||||
elif command.startswith("password "):
|
||||
return self._cmd_password(command)
|
||||
elif command == "clear stats":
|
||||
return self._cmd_clear_stats()
|
||||
elif command == "ver":
|
||||
return self._cmd_version()
|
||||
|
||||
# Get commands
|
||||
elif command.startswith("get "):
|
||||
return self._cmd_get(command[4:])
|
||||
|
||||
# Set commands
|
||||
elif command.startswith("set "):
|
||||
return self._cmd_set(command[4:])
|
||||
|
||||
# ACL commands
|
||||
elif command.startswith("setperm "):
|
||||
return self._cmd_setperm(command)
|
||||
elif command == "get acl":
|
||||
return "Error: Use 'get acl' via serial console only"
|
||||
|
||||
# Region commands (repeaters only)
|
||||
elif command.startswith("region"):
|
||||
if self.enable_regions:
|
||||
return self._cmd_region(command)
|
||||
else:
|
||||
return "Error: Region commands not available for room servers"
|
||||
|
||||
# Neighbor commands
|
||||
elif command == "neighbors":
|
||||
return self._cmd_neighbors()
|
||||
elif command.startswith("neighbor.remove "):
|
||||
return self._cmd_neighbor_remove(command)
|
||||
|
||||
# Temporary radio params
|
||||
elif command.startswith("tempradio "):
|
||||
return self._cmd_tempradio(command)
|
||||
|
||||
# Sensor commands
|
||||
elif command.startswith("sensor "):
|
||||
return "Error: Sensor commands not implemented in Python repeater"
|
||||
|
||||
# GPS commands
|
||||
elif command.startswith("gps"):
|
||||
return "Error: GPS commands not implemented in Python repeater"
|
||||
|
||||
# Logging commands
|
||||
elif command.startswith("log "):
|
||||
return self._cmd_log(command)
|
||||
|
||||
# Statistics commands
|
||||
elif command.startswith("stats-"):
|
||||
return "Error: Stats commands not fully implemented yet"
|
||||
|
||||
else:
|
||||
return "Unknown command"
|
||||
|
||||
# ==================== Help Command ====================
|
||||
|
||||
def _cmd_help(self, command: str) -> str:
|
||||
"""Show available commands or detailed help for a specific command."""
|
||||
parts = command.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
return self._help_detail(parts[1])
|
||||
|
||||
lines = [
|
||||
"=== pyMC CLI Commands ===",
|
||||
"",
|
||||
"System:",
|
||||
" reboot Restart the repeater service",
|
||||
" advert Send self advertisement",
|
||||
" clock Show current UTC time",
|
||||
" clock sync Sync clock (no-op, uses system time)",
|
||||
" ver Show version info",
|
||||
" password <pw> Change admin password",
|
||||
" clear stats Clear statistics",
|
||||
"",
|
||||
"Get:",
|
||||
" get name Node name",
|
||||
" get radio Radio params (freq,bw,sf,cr)",
|
||||
" get freq Frequency (MHz)",
|
||||
" get tx TX power",
|
||||
" get af Airtime factor",
|
||||
" get repeat Repeat mode (on/off)",
|
||||
" get lat / get lon GPS coordinates",
|
||||
" get role Identity role",
|
||||
" get guest.password Guest password",
|
||||
" get allow.read.only Read-only access setting",
|
||||
" get advert.interval Advert interval (minutes)",
|
||||
" get flood.advert.interval Flood advert interval (hours)",
|
||||
" get flood.max Max flood hops",
|
||||
" get rxdelay RX delay base",
|
||||
" get txdelay TX delay factor",
|
||||
" get direct.txdelay Direct TX delay factor",
|
||||
" get multi.acks Multi-ack count",
|
||||
" get int.thresh Interference threshold",
|
||||
" get agc.reset.interval AGC reset interval",
|
||||
"",
|
||||
"Set: (use 'help set' for details)",
|
||||
" set <param> <value>",
|
||||
"",
|
||||
"Other:",
|
||||
" neighbors List neighbors",
|
||||
" neighbor.remove <key> Remove neighbor by pubkey",
|
||||
" tempradio <freq> <bw> <sf> <cr> <timeout_mins>",
|
||||
" setperm <pubkey> <perm> Set ACL permissions",
|
||||
" log start|stop|erase Logging control",
|
||||
]
|
||||
if self.enable_regions:
|
||||
lines.append(" region ... Region commands")
|
||||
lines += ["", "Type 'help <command>' for details on a specific command."]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _help_detail(self, topic: str) -> str:
|
||||
"""Return detailed help for a specific command topic."""
|
||||
topic = topic.strip()
|
||||
details = {
|
||||
"set": (
|
||||
"Set commands — set <param> <value>:\n"
|
||||
" set name <name> Set node name\n"
|
||||
" set radio <f> <bw> <sf> <cr> Set radio (restart required)\n"
|
||||
" set freq <mhz> Set frequency (restart required)\n"
|
||||
" set tx <power> Set TX power\n"
|
||||
" set af <factor> Airtime factor\n"
|
||||
" set repeat on|off Enable/disable repeating\n"
|
||||
" set lat <deg> Latitude\n"
|
||||
" set lon <deg> Longitude\n"
|
||||
" set guest.password <pw> Guest password\n"
|
||||
" set allow.read.only on|off Read-only access\n"
|
||||
" set advert.interval <min> 60-240 minutes\n"
|
||||
" set flood.advert.interval <hr> 3-168 hours\n"
|
||||
" set flood.max <hops> Max flood hops (max 64)\n"
|
||||
" set rxdelay <val> RX delay base (>=0)\n"
|
||||
" set txdelay <val> TX delay factor (>=0)\n"
|
||||
" set direct.txdelay <val> Direct TX delay (>=0)\n"
|
||||
" set multi.acks <n> Multi-ack count\n"
|
||||
" set int.thresh <dbm> Interference threshold\n"
|
||||
" set agc.reset.interval <n> AGC reset (rounded to x4)"
|
||||
),
|
||||
"get": "Get commands — type 'help' to see all 'get' parameters.",
|
||||
"reboot": "Restart the repeater service via systemd.",
|
||||
"advert": "Trigger a self-advertisement flood packet.",
|
||||
"clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).",
|
||||
"ver": "Show repeater version and identity type.",
|
||||
"password": "password <new_password> — Change the admin password.",
|
||||
"tempradio": (
|
||||
"tempradio <freq_mhz> <bw_khz> <sf> <cr> <timeout_mins>\n"
|
||||
" Apply temporary radio parameters that revert after timeout.\n"
|
||||
" freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8"
|
||||
),
|
||||
"neighbors": "List known neighbor nodes from the routing table.",
|
||||
"setperm": "setperm <pubkey_hex> <permission_int> — Set ACL permissions for a node.",
|
||||
"log": "log start|stop|erase — Control logging.",
|
||||
}
|
||||
return details.get(topic, f"No detailed help for '{topic}'. Type 'help' for command list.")
|
||||
|
||||
# ==================== System Commands ==
|
||||
|
||||
def _cmd_reboot(self) -> str:
|
||||
"""Reboot the repeater process."""
|
||||
from repeater.service_utils import restart_service
|
||||
|
||||
logger.warning("Reboot command received via repeater CLI")
|
||||
success, message = restart_service()
|
||||
|
||||
if success:
|
||||
return f"OK - {message}"
|
||||
else:
|
||||
return f"Error: {message}"
|
||||
|
||||
def _cmd_advert(self) -> str:
|
||||
"""Send self advertisement."""
|
||||
logger.info("Advert command received")
|
||||
# TODO: Trigger advertisement through packet handler
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
def _cmd_clock(self, command: str) -> str:
|
||||
"""Handle clock commands."""
|
||||
if command == "clock":
|
||||
# Display current time
|
||||
import datetime
|
||||
|
||||
dt = datetime.datetime.now(datetime.timezone.utc)
|
||||
return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC"
|
||||
elif command == "clock sync":
|
||||
# Clock sync happens automatically via sender_timestamp in protocol
|
||||
return "OK - clock sync not needed (system time used)"
|
||||
else:
|
||||
return "Unknown clock command"
|
||||
|
||||
def _cmd_time(self, command: str) -> str:
|
||||
"""Set time - not supported in Python (use system time)."""
|
||||
return "Error: Time setting not supported (system time is used)"
|
||||
|
||||
def _cmd_password(self, command: str) -> str:
|
||||
"""Change admin password."""
|
||||
new_password = command[9:].strip()
|
||||
|
||||
if not new_password:
|
||||
return "Error: Password cannot be empty"
|
||||
|
||||
# Update security config
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
|
||||
self.config["security"]["password"] = new_password
|
||||
|
||||
# Save config
|
||||
try:
|
||||
self.save_config()
|
||||
return f"password now: {new_password}"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save password: {e}")
|
||||
return "Error: Failed to save password"
|
||||
|
||||
def _cmd_clear_stats(self) -> str:
|
||||
"""Clear statistics."""
|
||||
# TODO: Implement stats clearing
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
def _cmd_version(self) -> str:
|
||||
"""Get version information."""
|
||||
role = "room_server" if self.identity_type == "room_server" else "repeater"
|
||||
version = self.config.get("version", "1.0.0")
|
||||
return f"pyMC_{role} v{version}"
|
||||
|
||||
# ==================== Get Commands ====================
|
||||
|
||||
def _cmd_get(self, param: str) -> str:
|
||||
"""Handle get commands."""
|
||||
param = param.strip()
|
||||
logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})")
|
||||
|
||||
if param == "af":
|
||||
af = self.repeater_config.get("airtime_factor", 1.0)
|
||||
return f"> {af}"
|
||||
|
||||
elif param == "name":
|
||||
name = self.repeater_config.get("name", "Unknown")
|
||||
return f"> {name}"
|
||||
|
||||
elif param == "repeat":
|
||||
mode = self.repeater_config.get("mode", "forward")
|
||||
return f"> {'on' if mode == 'forward' else 'off'}"
|
||||
|
||||
elif param == "lat":
|
||||
lat = self.repeater_config.get("latitude", 0.0)
|
||||
return f"> {lat}"
|
||||
|
||||
elif param == "lon":
|
||||
lon = self.repeater_config.get("longitude", 0.0)
|
||||
return f"> {lon}"
|
||||
|
||||
elif param == "radio":
|
||||
radio = self.config.get("radio", {})
|
||||
freq_hz = radio.get("frequency", 915000000)
|
||||
bw_hz = radio.get("bandwidth", 125000)
|
||||
sf = radio.get("spreading_factor", 7)
|
||||
cr = radio.get("coding_rate", 5)
|
||||
# Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output)
|
||||
freq_mhz = freq_hz / 1_000_000.0
|
||||
bw_khz = bw_hz / 1_000.0
|
||||
return f"> {freq_mhz},{bw_khz},{sf},{cr}"
|
||||
|
||||
elif param == "freq":
|
||||
freq_hz = self.config.get("radio", {}).get("frequency", 915000000)
|
||||
freq_mhz = freq_hz / 1_000_000.0
|
||||
return f"> {freq_mhz}"
|
||||
|
||||
elif param == "tx":
|
||||
power = self.config.get("radio", {}).get("tx_power", 20)
|
||||
return f"> {power}"
|
||||
|
||||
elif param == "public.key":
|
||||
# TODO: Get from identity
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
elif param == "role":
|
||||
role = "room_server" if self.identity_type == "room_server" else "repeater"
|
||||
return f"> {role}"
|
||||
|
||||
elif param == "guest.password":
|
||||
guest_pw = self.config.get("security", {}).get("guest_password", "")
|
||||
return f"> {guest_pw}"
|
||||
|
||||
elif param == "allow.read.only":
|
||||
allow = self.config.get("security", {}).get("allow_read_only", False)
|
||||
return f"> {'on' if allow else 'off'}"
|
||||
|
||||
elif param == "advert.interval":
|
||||
interval = self.repeater_config.get("advert_interval_minutes", 120)
|
||||
return f"> {interval}"
|
||||
|
||||
elif param == "flood.advert.interval":
|
||||
interval = self.repeater_config.get("flood_advert_interval_hours", 24)
|
||||
return f"> {interval}"
|
||||
|
||||
elif param == "flood.max":
|
||||
max_flood = self.repeater_config.get("max_flood_hops", 64)
|
||||
return f"> {max_flood}"
|
||||
|
||||
elif param == "rxdelay":
|
||||
delay = self.repeater_config.get("rx_delay_base", 0.0)
|
||||
return f"> {delay}"
|
||||
|
||||
elif param == "txdelay":
|
||||
delay = self.repeater_config.get("tx_delay_factor", 1.0)
|
||||
return f"> {delay}"
|
||||
|
||||
elif param == "direct.txdelay":
|
||||
delay = self.repeater_config.get("direct_tx_delay_factor", 0.5)
|
||||
return f"> {delay}"
|
||||
|
||||
elif param == "multi.acks":
|
||||
acks = self.repeater_config.get("multi_acks", 0)
|
||||
return f"> {acks}"
|
||||
|
||||
elif param == "int.thresh":
|
||||
thresh = self.repeater_config.get("interference_threshold", -120)
|
||||
return f"> {thresh}"
|
||||
|
||||
elif param == "agc.reset.interval":
|
||||
interval = self.repeater_config.get("agc_reset_interval", 0)
|
||||
return f"> {interval}"
|
||||
|
||||
else:
|
||||
return f"??: {param}"
|
||||
|
||||
# ==================== Set Commands ====================
|
||||
|
||||
def _cmd_set(self, param: str) -> str:
|
||||
"""Handle set commands."""
|
||||
parts = param.split(None, 1)
|
||||
if len(parts) < 2:
|
||||
return "Error: Missing value"
|
||||
|
||||
key, value = parts[0], parts[1]
|
||||
|
||||
try:
|
||||
if key == "af":
|
||||
self.repeater_config["airtime_factor"] = float(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "name":
|
||||
self.repeater_config["name"] = value
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "repeat":
|
||||
self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor"
|
||||
self.save_config()
|
||||
return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}"
|
||||
|
||||
elif key == "lat":
|
||||
self.repeater_config["latitude"] = float(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "lon":
|
||||
self.repeater_config["longitude"] = float(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "radio":
|
||||
# Format: freq bw sf cr
|
||||
radio_parts = value.split()
|
||||
if len(radio_parts) != 4:
|
||||
return "Error: Expected freq bw sf cr"
|
||||
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
|
||||
self.config["radio"]["frequency"] = float(radio_parts[0])
|
||||
self.config["radio"]["bandwidth"] = float(radio_parts[1])
|
||||
self.config["radio"]["spreading_factor"] = int(radio_parts[2])
|
||||
self.config["radio"]["coding_rate"] = int(radio_parts[3])
|
||||
self.save_config()
|
||||
return "OK - restart repeater to apply"
|
||||
|
||||
elif key == "freq":
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
self.config["radio"]["frequency"] = float(value)
|
||||
self.save_config()
|
||||
return "OK - restart repeater to apply"
|
||||
|
||||
elif key == "tx":
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
self.config["radio"]["tx_power"] = int(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "guest.password":
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
self.config["security"]["guest_password"] = value
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "allow.read.only":
|
||||
if "security" not in self.config:
|
||||
self.config["security"] = {}
|
||||
self.config["security"]["allow_read_only"] = value.lower() == "on"
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "advert.interval":
|
||||
mins = int(value)
|
||||
if mins > 0 and (mins < 60 or mins > 240):
|
||||
return "Error: interval range is 60-240 minutes"
|
||||
self.repeater_config["advert_interval_minutes"] = mins
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "flood.advert.interval":
|
||||
hours = int(value)
|
||||
if (hours > 0 and hours < 3) or hours > 168:
|
||||
return "Error: interval range is 3-168 hours"
|
||||
self.repeater_config["flood_advert_interval_hours"] = hours
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "flood.max":
|
||||
max_val = int(value)
|
||||
if max_val > 64:
|
||||
return "Error: max 64"
|
||||
self.repeater_config["max_flood_hops"] = max_val
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "rxdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config["rx_delay_base"] = delay
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "txdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config["tx_delay_factor"] = delay
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "direct.txdelay":
|
||||
delay = float(value)
|
||||
if delay < 0:
|
||||
return "Error: cannot be negative"
|
||||
self.repeater_config["direct_tx_delay_factor"] = delay
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "multi.acks":
|
||||
self.repeater_config["multi_acks"] = int(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "int.thresh":
|
||||
self.repeater_config["interference_threshold"] = int(value)
|
||||
self.save_config()
|
||||
return "OK"
|
||||
|
||||
elif key == "agc.reset.interval":
|
||||
interval = int(value)
|
||||
# Round to nearest multiple of 4
|
||||
rounded = (interval // 4) * 4
|
||||
self.repeater_config["agc_reset_interval"] = rounded
|
||||
self.save_config()
|
||||
return f"OK - interval rounded to {rounded}"
|
||||
|
||||
else:
|
||||
return f"unknown config: {key}"
|
||||
|
||||
except ValueError as e:
|
||||
return f"Error: invalid value - {e}"
|
||||
except Exception as e:
|
||||
logger.error(f"Set command error: {e}")
|
||||
return f"Error: {e}"
|
||||
|
||||
# ==================== ACL Commands ====================
|
||||
|
||||
def _cmd_setperm(self, command: str) -> str:
|
||||
"""Set permissions for a public key."""
|
||||
# Format: setperm {pubkey-hex} {permissions-int}
|
||||
parts = command[8:].split()
|
||||
if len(parts) < 2:
|
||||
return "Err - bad params"
|
||||
|
||||
pubkey_hex = parts[0]
|
||||
try:
|
||||
permissions = int(parts[1])
|
||||
except ValueError:
|
||||
return "Err - invalid permissions"
|
||||
|
||||
# TODO: Apply permissions via ACL
|
||||
logger.info(f"setperm command: {pubkey_hex} -> {permissions}")
|
||||
return "Error: Not yet implemented - use config file"
|
||||
|
||||
# ==================== Region Commands ====================
|
||||
|
||||
def _cmd_region(self, command: str) -> str:
|
||||
"""Handle region commands."""
|
||||
parts = command.split()
|
||||
|
||||
if len(parts) == 1:
|
||||
return "Error: Region commands not implemented in Python repeater"
|
||||
|
||||
subcommand = parts[1]
|
||||
|
||||
if subcommand == "load":
|
||||
return "Error: Region commands not implemented"
|
||||
elif subcommand == "save":
|
||||
return "Error: Region commands not implemented"
|
||||
elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"):
|
||||
return "Error: Region commands not implemented"
|
||||
else:
|
||||
return "Err - ??"
|
||||
|
||||
# ==================== Neighbor Commands ====================
|
||||
|
||||
def _cmd_neighbors(self) -> str:
|
||||
"""List neighbors."""
|
||||
# TODO: Get neighbors from routing table
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
def _cmd_neighbor_remove(self, command: str) -> str:
|
||||
"""Remove a neighbor."""
|
||||
pubkey_hex = command[16:].strip()
|
||||
|
||||
if not pubkey_hex:
|
||||
return "ERR: Missing pubkey"
|
||||
|
||||
# TODO: Remove neighbor from routing table
|
||||
logger.info(f"neighbor.remove: {pubkey_hex}")
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
# ==================== Temporary Radio Commands ====================
|
||||
|
||||
def _cmd_tempradio(self, command: str) -> str:
|
||||
"""Apply temporary radio parameters."""
|
||||
# Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins}
|
||||
parts = command[10:].split()
|
||||
|
||||
if len(parts) < 5:
|
||||
return "Error: Expected freq bw sf cr timeout_mins"
|
||||
|
||||
try:
|
||||
freq = float(parts[0])
|
||||
bw = float(parts[1])
|
||||
sf = int(parts[2])
|
||||
cr = int(parts[3])
|
||||
timeout_mins = int(parts[4])
|
||||
|
||||
# Validate
|
||||
if not (300.0 <= freq <= 2500.0):
|
||||
return "Error: invalid frequency"
|
||||
if not (7.0 <= bw <= 500.0):
|
||||
return "Error: invalid bandwidth"
|
||||
if not (5 <= sf <= 12):
|
||||
return "Error: invalid spreading factor"
|
||||
if not (5 <= cr <= 8):
|
||||
return "Error: invalid coding rate"
|
||||
if timeout_mins <= 0:
|
||||
return "Error: invalid timeout"
|
||||
|
||||
# TODO: Apply temporary radio parameters
|
||||
logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min")
|
||||
return "Error: Not yet implemented"
|
||||
|
||||
except ValueError:
|
||||
return "Error, invalid params"
|
||||
|
||||
# ==================== Logging Commands ====================
|
||||
|
||||
def _cmd_log(self, command: str) -> str:
|
||||
"""Handle log commands."""
|
||||
if command == "log start":
|
||||
# TODO: Enable logging
|
||||
return "Error: Not yet implemented"
|
||||
elif command == "log stop":
|
||||
# TODO: Disable logging
|
||||
return "Error: Not yet implemented"
|
||||
elif command == "log erase":
|
||||
# TODO: Clear log file
|
||||
return "Error: Not yet implemented"
|
||||
elif command == "log":
|
||||
return "Error: Use journalctl to view logs"
|
||||
else:
|
||||
return "Unknown log command"
|
||||
|
||||
|
||||
# Backward compatibility alias
|
||||
RepeaterCLI = MeshCLI
|
||||
@@ -18,6 +18,7 @@ from .room_server import RoomServer
|
||||
|
||||
logger = logging.getLogger("TextHelper")
|
||||
|
||||
|
||||
# Text message type flags
|
||||
TXT_TYPE_PLAIN = 0x00
|
||||
TXT_TYPE_CLI_DATA = 0x01
|
||||
@@ -464,6 +465,7 @@ class TextHelper:
|
||||
"board",
|
||||
"neighbors",
|
||||
"neighbor.",
|
||||
"discover.",
|
||||
"tempradio ",
|
||||
"setperm ",
|
||||
"region",
|
||||
|
||||
+11
-8
@@ -35,6 +35,7 @@ from repeater.handler_helpers import (
|
||||
from repeater.identity_manager import IdentityManager
|
||||
from repeater.packet_router import PacketRouter
|
||||
from repeater.sensors import SensorManager
|
||||
from repeater.utils_packet import create_scoped_advert_packet
|
||||
from repeater.web.http_server import HTTPStatsServer, _log_buffer
|
||||
|
||||
logger = logging.getLogger("RepeaterDaemon")
|
||||
@@ -1222,7 +1223,6 @@ class RepeaterDaemon:
|
||||
return False
|
||||
|
||||
try:
|
||||
from openhop_core.protocol import PacketBuilder
|
||||
from openhop_core.protocol.constants import (
|
||||
ADVERT_FLAG_HAS_NAME,
|
||||
ADVERT_FLAG_IS_REPEATER,
|
||||
@@ -1243,15 +1243,16 @@ class RepeaterDaemon:
|
||||
|
||||
flags = ADVERT_FLAG_IS_REPEATER | ADVERT_FLAG_HAS_NAME
|
||||
|
||||
packet = PacketBuilder.create_advert(
|
||||
mesh_config = self.config.get("mesh", {})
|
||||
default_region = mesh_config.get("default_region")
|
||||
packet, scoped_region_name = create_scoped_advert_packet(
|
||||
local_identity=self.local_identity,
|
||||
name=node_name,
|
||||
lat=latitude,
|
||||
lon=longitude,
|
||||
feature1=0,
|
||||
feature2=0,
|
||||
node_name=node_name,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
flags=flags,
|
||||
route_type="flood",
|
||||
default_region=default_region,
|
||||
scope_label="advert",
|
||||
)
|
||||
|
||||
# Send via dispatcher
|
||||
@@ -1270,6 +1271,8 @@ class RepeaterDaemon:
|
||||
longitude,
|
||||
location_source,
|
||||
)
|
||||
if scoped_region_name:
|
||||
logger.info("Advert scoped to default region '%s'", scoped_region_name)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from openhop_core.protocol import PacketBuilder
|
||||
from openhop_core.protocol.constants import ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
|
||||
logger = logging.getLogger("RepeaterPacketUtils")
|
||||
|
||||
|
||||
def create_scoped_advert_packet(
|
||||
*,
|
||||
local_identity,
|
||||
node_name: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
flags: int,
|
||||
default_region,
|
||||
scope_label: str,
|
||||
) -> Tuple[object, Optional[str]]:
|
||||
"""Create a flood advert packet and apply default-region transport scope when configured."""
|
||||
packet = PacketBuilder.create_advert(
|
||||
local_identity=local_identity,
|
||||
name=node_name,
|
||||
lat=latitude,
|
||||
lon=longitude,
|
||||
feature1=0,
|
||||
feature2=0,
|
||||
flags=flags,
|
||||
route_type="flood",
|
||||
)
|
||||
|
||||
scoped_region_name = _apply_default_region_scope(
|
||||
packet=packet,
|
||||
default_region=default_region,
|
||||
scope_label=scope_label,
|
||||
)
|
||||
return packet, scoped_region_name
|
||||
|
||||
|
||||
def _apply_default_region_scope(*, packet, default_region, scope_label: str) -> Optional[str]:
|
||||
"""Apply transport-flood scoping for a default region if provided."""
|
||||
region_name = str(default_region).strip() if default_region not in (None, "") else ""
|
||||
if not region_name:
|
||||
return None
|
||||
|
||||
try:
|
||||
from openhop_core.protocol.transport_keys import calc_transport_code, get_auto_key_for
|
||||
|
||||
region_key = get_auto_key_for(region_name)
|
||||
packet.transport_codes[0] = calc_transport_code(region_key, packet)
|
||||
packet.transport_codes[1] = 0 # reserved for home region
|
||||
packet.header = (packet.header & ~0x03) | ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
return region_name
|
||||
except Exception as scope_err:
|
||||
logger.warning(
|
||||
"Failed to apply default region scope '%s' to %s; sending unscoped flood: %s",
|
||||
region_name,
|
||||
scope_label,
|
||||
scope_err,
|
||||
)
|
||||
return None
|
||||
@@ -28,6 +28,7 @@ from repeater.companion.utils import (
|
||||
from repeater.config import resolve_storage_dir
|
||||
from repeater.policy_engine import PolicyEngine
|
||||
from repeater.service_utils import get_buildroot_image_info
|
||||
from repeater.utils_packet import create_scoped_advert_packet
|
||||
|
||||
from .auth.middleware import require_auth
|
||||
from .auth_endpoints import AuthAPIEndpoints
|
||||
@@ -341,6 +342,8 @@ class APIEndpoints:
|
||||
if not pub_key:
|
||||
return enriched
|
||||
|
||||
enriched["is_self"] = self._is_local_discovery_pubkey(pub_key)
|
||||
|
||||
try:
|
||||
enriched["node_hash"] = self._fmt_hash(bytes.fromhex(pub_key))
|
||||
except ValueError:
|
||||
@@ -381,6 +384,52 @@ class APIEndpoints:
|
||||
enriched["advert_count"] = neighbor_info.get("advert_count")
|
||||
return enriched
|
||||
|
||||
def _get_local_pubkey_hex(self) -> Optional[str]:
|
||||
"""Return local node public key in hex when available."""
|
||||
daemon = getattr(self, "daemon_instance", None)
|
||||
identity = getattr(daemon, "local_identity", None)
|
||||
|
||||
try:
|
||||
if identity and hasattr(identity, "get_public_key"):
|
||||
pubkey = identity.get_public_key()
|
||||
if isinstance(pubkey, (bytes, bytearray)):
|
||||
return bytes(pubkey).hex().lower()
|
||||
if isinstance(pubkey, str):
|
||||
normalized = pubkey.strip().lower()
|
||||
if normalized.startswith("0x"):
|
||||
normalized = normalized[2:]
|
||||
if normalized:
|
||||
return normalized
|
||||
except Exception as exc:
|
||||
logger.debug("Unable to read local identity pubkey: %s", exc)
|
||||
|
||||
repeater_cfg = self.config.get("repeater", {}) if isinstance(self.config, dict) else {}
|
||||
key = repeater_cfg.get("identity_key")
|
||||
if isinstance(key, (bytes, bytearray)):
|
||||
return bytes(key).hex().lower()
|
||||
if isinstance(key, str):
|
||||
normalized = key.strip().lower()
|
||||
if normalized.startswith("0x"):
|
||||
normalized = normalized[2:]
|
||||
if normalized and all(ch in "0123456789abcdef" for ch in normalized):
|
||||
return normalized
|
||||
|
||||
return None
|
||||
|
||||
def _is_local_discovery_pubkey(self, pub_key: str) -> bool:
|
||||
"""Return True if discovery pub_key matches the local node key (including prefix form)."""
|
||||
candidate = str(pub_key or "").strip().lower()
|
||||
if not candidate:
|
||||
return False
|
||||
if candidate.startswith("0x"):
|
||||
candidate = candidate[2:]
|
||||
|
||||
local_pubkey = self._get_local_pubkey_hex()
|
||||
if not local_pubkey:
|
||||
return False
|
||||
|
||||
return local_pubkey.startswith(candidate) or candidate.startswith(local_pubkey)
|
||||
|
||||
def _process_counter_data(self, data_points, timestamps_ms):
|
||||
rates = []
|
||||
prev_value = None
|
||||
@@ -3323,6 +3372,7 @@ class APIEndpoints:
|
||||
"direct_tx_delay_factor": 0.5, # Direct TX delay (0.0-5.0)
|
||||
"rx_delay_base": 0.0, # RX delay base (>= 0)
|
||||
"node_name": "MyNode", # Node name
|
||||
"owner_info": "Owner text", # Owner info text
|
||||
"latitude": 0.0, # Latitude (-90 to 90)
|
||||
"longitude": 0.0, # Longitude (-180 to 180)
|
||||
"max_flood_hops": 64, # Max flood hops (0-64)
|
||||
@@ -3434,6 +3484,11 @@ class APIEndpoints:
|
||||
self.config["repeater"]["node_name"] = name
|
||||
applied.append(f"name={name}")
|
||||
|
||||
if "owner_info" in data:
|
||||
owner_info = str(data["owner_info"]).replace("|", "\n")
|
||||
self.config["repeater"]["owner_info"] = owner_info
|
||||
applied.append("owner.info")
|
||||
|
||||
# Update latitude
|
||||
if "latitude" in data:
|
||||
lat = float(data["latitude"])
|
||||
@@ -3950,6 +4005,90 @@ class APIEndpoints:
|
||||
else:
|
||||
return self._error("Method not supported")
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
def default_region(self):
|
||||
"""
|
||||
Get or update mesh default region configuration.
|
||||
|
||||
GET /default_region
|
||||
POST /default_region
|
||||
Body: {"default_region": "region-name" | null}
|
||||
"""
|
||||
if cherrypy.request.method == "GET":
|
||||
try:
|
||||
mesh_cfg = self.config.get("mesh", {}) if isinstance(self.config, dict) else {}
|
||||
default_region = mesh_cfg.get("default_region")
|
||||
value = str(default_region).strip() if default_region not in (None, "") else None
|
||||
return self._success({"default_region": value})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting default region: {e}")
|
||||
return self._error(e)
|
||||
|
||||
if cherrypy.request.method == "POST":
|
||||
try:
|
||||
data = cherrypy.request.json or {}
|
||||
if "default_region" not in data:
|
||||
return self._error("Missing required field: default_region")
|
||||
|
||||
raw_value = data.get("default_region")
|
||||
default_region = None
|
||||
if raw_value is not None:
|
||||
text = str(raw_value).strip()
|
||||
if text and text != "<null>":
|
||||
default_region = text
|
||||
|
||||
if "mesh" not in self.config:
|
||||
self.config["mesh"] = {}
|
||||
|
||||
# Keep region table compatible with firmware "region default": if non-null
|
||||
# and missing, auto-create region and ensure flood allow.
|
||||
if default_region:
|
||||
storage = self._get_storage()
|
||||
records = storage.get_transport_keys() or []
|
||||
|
||||
existing = None
|
||||
needle = default_region.lower()
|
||||
for rec in records:
|
||||
name = str(rec.get("name") or "").strip()
|
||||
display = name[1:] if name.startswith("#") else name
|
||||
if display.lower() == needle:
|
||||
existing = rec
|
||||
break
|
||||
|
||||
if existing:
|
||||
key_id = existing.get("id")
|
||||
if key_id is not None:
|
||||
storage.update_transport_key(int(key_id), flood_policy="allow")
|
||||
else:
|
||||
storage.create_transport_key(
|
||||
default_region, "allow", None, None, time.time()
|
||||
)
|
||||
|
||||
self.config["mesh"]["default_region"] = default_region
|
||||
|
||||
saved = self.config_manager.save_to_file()
|
||||
if not saved:
|
||||
return self._error("Failed to save configuration to file")
|
||||
|
||||
if hasattr(self.config_manager, "live_update_daemon"):
|
||||
self.config_manager.live_update_daemon(["mesh"])
|
||||
|
||||
return self._success(
|
||||
{"default_region": default_region},
|
||||
message=(
|
||||
"Default region cleared"
|
||||
if default_region is None
|
||||
else f"Default region set to {default_region}"
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating default region: {e}")
|
||||
return self._error(e)
|
||||
|
||||
return self._error("Method not supported")
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
@@ -4297,6 +4436,19 @@ class APIEndpoints:
|
||||
if not re.fullmatch(r"[0-9a-f]{16}|[0-9a-f]{64}", pub_key):
|
||||
return self._error("pub_key must be 8-byte or 32-byte hex")
|
||||
|
||||
if self._is_local_discovery_pubkey(pub_key):
|
||||
enriched = self._enrich_discovery_result(
|
||||
{
|
||||
"pub_key": pub_key,
|
||||
"node_name": node_name,
|
||||
"node_type": node_type,
|
||||
"rssi": rssi,
|
||||
"response_snr": snr,
|
||||
}
|
||||
)
|
||||
enriched["is_self"] = True
|
||||
return self._success(enriched, message="Skipped local node: not added to neighbors")
|
||||
|
||||
contact_type = {
|
||||
1: "Chat Node",
|
||||
2: "Repeater",
|
||||
@@ -5279,7 +5431,6 @@ class APIEndpoints:
|
||||
):
|
||||
"""Send advert for a room server identity"""
|
||||
try:
|
||||
from openhop_core.protocol import PacketBuilder
|
||||
from openhop_core.protocol.constants import (
|
||||
ADVERT_FLAG_HAS_NAME,
|
||||
ADVERT_FLAG_IS_ROOM_SERVER,
|
||||
@@ -5292,15 +5443,16 @@ class APIEndpoints:
|
||||
# Build flags - just use HAS_NAME for room servers
|
||||
flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME
|
||||
|
||||
packet = PacketBuilder.create_advert(
|
||||
mesh_config = self.config.get("mesh", {}) if isinstance(self.config, dict) else {}
|
||||
default_region = mesh_config.get("default_region")
|
||||
packet, scoped_region_name = create_scoped_advert_packet(
|
||||
local_identity=identity,
|
||||
name=node_name,
|
||||
lat=latitude,
|
||||
lon=longitude,
|
||||
feature1=0,
|
||||
feature2=0,
|
||||
node_name=node_name,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
flags=flags,
|
||||
route_type="flood",
|
||||
default_region=default_region,
|
||||
scope_label="room server advert",
|
||||
)
|
||||
|
||||
# Send via dispatcher
|
||||
@@ -5314,6 +5466,8 @@ class APIEndpoints:
|
||||
logger.info(
|
||||
f"Sent flood advert for room server '{node_name}' at ({latitude:.6f}, {longitude:.6f})"
|
||||
)
|
||||
if scoped_region_name:
|
||||
logger.info("Room server advert scoped to default region '%s'", scoped_region_name)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1541,6 +1541,61 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessResponse'
|
||||
|
||||
/default_region:
|
||||
get:
|
||||
tags: [Network Policy]
|
||||
summary: Get default region
|
||||
description: Get current mesh default region used for locally-originated flood adverts.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ApiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Current default region
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
default_region:
|
||||
type: string
|
||||
nullable: true
|
||||
message:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
post:
|
||||
tags: [Network Policy]
|
||||
summary: Update default region
|
||||
description: Set or clear mesh default region. Pass null to clear.
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ApiKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [default_region]
|
||||
properties:
|
||||
default_region:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Region name to set, or null to clear.
|
||||
responses:
|
||||
'200':
|
||||
description: Default region updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SuccessResponse'
|
||||
|
||||
/ping_neighbor:
|
||||
post:
|
||||
tags: [Network Policy]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import asyncio
|
||||
from concurrent.futures import TimeoutError as FutureTimeoutError
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
|
||||
|
||||
import cherrypy
|
||||
import pytest
|
||||
@@ -311,6 +312,43 @@ def test_add_discovered_neighbor_normalizes_unknown_name(cherrypy_ctx, monkeypat
|
||||
assert advert_record["node_name"] is None
|
||||
|
||||
|
||||
def test_add_discovered_neighbor_skips_local_node(cherrypy_ctx, monkeypatch):
|
||||
request, _ = cherrypy_ctx
|
||||
request.method = "POST"
|
||||
request.headers = {"Authorization": "Bearer test-token"}
|
||||
request.path_info = "/api/add_discovered_neighbor"
|
||||
request.json = {
|
||||
"pub_key": "aa" * 32,
|
||||
"node_name": "Self",
|
||||
"node_type": 2,
|
||||
}
|
||||
|
||||
jwt_handler = MagicMock()
|
||||
jwt_handler.verify_jwt.return_value = {"sub": "tester", "client_id": "ui"}
|
||||
token_manager = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
cherrypy,
|
||||
"config",
|
||||
{"jwt_handler": jwt_handler, "token_manager": token_manager},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
storage = MagicMock()
|
||||
api = _make_api()
|
||||
api._enrich_discovery_result = APIEndpoints._enrich_discovery_result.__get__(api, APIEndpoints)
|
||||
_attach_storage(api, storage)
|
||||
api.daemon_instance.local_identity = SimpleNamespace(
|
||||
get_public_key=lambda: bytes.fromhex("aa" * 32)
|
||||
)
|
||||
|
||||
result = api.add_discovered_neighbor()
|
||||
|
||||
assert result["success"] is True
|
||||
assert "Skipped local node" in result["message"]
|
||||
assert result["data"]["is_self"] is True
|
||||
storage.record_advert.assert_not_called()
|
||||
|
||||
|
||||
def test_enrich_discovery_result_treats_placeholder_name_as_unknown():
|
||||
api = _make_api()
|
||||
|
||||
@@ -1473,6 +1511,41 @@ def test_send_advert_paths(cherrypy_ctx):
|
||||
future_timeout.cancel.assert_called_once()
|
||||
|
||||
|
||||
def test_room_server_advert_applies_default_region_scope():
|
||||
from openhop_core.protocol.constants import ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
|
||||
api = _make_api({"mesh": {"default_region": "alpha"}, "repeater": {}})
|
||||
dispatcher = SimpleNamespace(send_packet=AsyncMock())
|
||||
api.daemon_instance = SimpleNamespace(dispatcher=dispatcher, repeater_handler=None)
|
||||
|
||||
packet = SimpleNamespace(
|
||||
header=ROUTE_TYPE_FLOOD,
|
||||
transport_codes=[0, 0],
|
||||
get_payload_type=lambda: 3,
|
||||
get_payload=lambda: b"room_server_advert_payload",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("openhop_core.protocol.PacketBuilder.create_advert", return_value=packet),
|
||||
patch("openhop_core.protocol.transport_keys.get_auto_key_for", return_value=b"\x01" * 16),
|
||||
patch("openhop_core.protocol.transport_keys.calc_transport_code", return_value=0xCAFE),
|
||||
):
|
||||
result = asyncio.run(
|
||||
api._send_room_server_advert_async(
|
||||
identity=SimpleNamespace(),
|
||||
node_name="RoomAlpha",
|
||||
latitude=1.0,
|
||||
longitude=2.0,
|
||||
disable_fwd=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert packet.transport_codes == [0xCAFE, 0]
|
||||
assert (packet.header & 0x03) == ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
dispatcher.send_packet.assert_awaited_once_with(packet, wait_for_ack=False)
|
||||
|
||||
|
||||
def test_set_mode_and_set_duty_cycle_paths(cherrypy_ctx):
|
||||
request, _ = cherrypy_ctx
|
||||
api = _make_api({"repeater": {}, "duty_cycle": {}})
|
||||
@@ -1757,13 +1830,40 @@ def test_transport_keys_and_transport_key_and_unscoped_policy(cherrypy_ctx):
|
||||
request.method = "GET"
|
||||
assert api.unscoped_flood_policy()["success"] is False
|
||||
|
||||
got_default = api.default_region()
|
||||
assert got_default["success"] is True
|
||||
assert got_default["data"]["default_region"] is None
|
||||
|
||||
request.method = "POST"
|
||||
request.json = {}
|
||||
assert api.unscoped_flood_policy()["success"] is False
|
||||
|
||||
request.json = {}
|
||||
assert api.default_region()["success"] is False
|
||||
|
||||
request.json = {"unscoped_flood_allow": "yes"}
|
||||
assert api.unscoped_flood_policy()["success"] is False
|
||||
|
||||
request.json = {"default_region": "alpha"}
|
||||
set_default = api.default_region()
|
||||
assert set_default["success"] is True
|
||||
assert api.config["mesh"]["default_region"] == "alpha"
|
||||
assert any(
|
||||
call.args and call.args[0] == "alpha" and call.args[1] == "allow"
|
||||
for call in storage.create_transport_key.call_args_list
|
||||
)
|
||||
|
||||
request.method = "GET"
|
||||
got_default_after = api.default_region()
|
||||
assert got_default_after["success"] is True
|
||||
assert got_default_after["data"]["default_region"] == "alpha"
|
||||
|
||||
request.method = "POST"
|
||||
request.json = {"default_region": None}
|
||||
clear_default = api.default_region()
|
||||
assert clear_default["success"] is True
|
||||
assert api.config["mesh"]["default_region"] is None
|
||||
|
||||
api.config_manager.save_to_file.return_value = True
|
||||
request.json = {"unscoped_flood_allow": True}
|
||||
ok = api.unscoped_flood_policy()
|
||||
@@ -1771,6 +1871,31 @@ def test_transport_keys_and_transport_key_and_unscoped_policy(cherrypy_ctx):
|
||||
assert api.config["mesh"]["unscoped_flood_allow"] is True
|
||||
|
||||
|
||||
def test_update_radio_config_owner_info_and_mesh_fields(cherrypy_ctx):
|
||||
request, _ = cherrypy_ctx
|
||||
request.method = "POST"
|
||||
request.json = {
|
||||
"owner_info": "Alice|Ops",
|
||||
"path_hash_mode": 2,
|
||||
"loop_detect": "strict",
|
||||
}
|
||||
|
||||
api = _make_api({"repeater": {}, "mesh": {}, "radio": {}, "delays": {}})
|
||||
api.config_manager.update_and_save.return_value = {
|
||||
"success": True,
|
||||
"saved": True,
|
||||
"live_updated": True,
|
||||
}
|
||||
|
||||
out = api.update_radio_config()
|
||||
|
||||
assert out["success"] is True
|
||||
assert api.config["repeater"]["owner_info"] == "Alice\nOps"
|
||||
assert api.config["mesh"]["path_hash_mode"] == 2
|
||||
assert api.config["mesh"]["loop_detect"] == "strict"
|
||||
assert "owner.info" in out["data"].get("applied", [])
|
||||
|
||||
|
||||
class _FakeIdentityObj:
|
||||
def __init__(self, first=0x42):
|
||||
self._pk = bytes([first]) + (b"A" * 31)
|
||||
|
||||
@@ -30,7 +30,9 @@ class TestLoadCompanionRowsVerified:
|
||||
async def test_genuinely_empty_returns_without_retry(self):
|
||||
loader = MagicMock(return_value=[])
|
||||
counter = MagicMock(return_value=0)
|
||||
rows, stored = await _load_companion_rows_verified(loader, counter, "channels", _HASH, _NAME)
|
||||
rows, stored = await _load_companion_rows_verified(
|
||||
loader, counter, "channels", _HASH, _NAME
|
||||
)
|
||||
assert rows == []
|
||||
assert stored == 0
|
||||
assert loader.call_count == 1
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from repeater.handler_helpers.mesh_cli import MeshCLI
|
||||
|
||||
|
||||
@@ -11,6 +10,7 @@ def _base_config():
|
||||
"repeater": {
|
||||
"name": "node-a",
|
||||
"mode": "forward",
|
||||
"owner_info": "owner-a",
|
||||
"latitude": 1.2,
|
||||
"longitude": 3.4,
|
||||
"airtime_factor": 1.1,
|
||||
@@ -31,6 +31,7 @@ def _base_config():
|
||||
"coding_rate": 5,
|
||||
"tx_power": 22,
|
||||
},
|
||||
"mesh": {"path_hash_mode": 0, "loop_detect": "minimal"},
|
||||
"security": {"guest_password": "guest", "allow_read_only": True},
|
||||
}
|
||||
|
||||
@@ -52,12 +53,13 @@ def test_handle_command_admin_and_prefix_behavior():
|
||||
def test_help_routing_and_basic_unknown_paths():
|
||||
cli = MeshCLI("/tmp/cfg.yaml", _base_config(), _cfg_mgr(), enable_regions=False)
|
||||
|
||||
assert "pyMC CLI Commands" in cli._route_command("help")
|
||||
assert "openHop CLI Commands" in cli._route_command("help")
|
||||
assert "No detailed help" in cli._route_command("help nope")
|
||||
assert cli._route_command("start ota").startswith("Error:")
|
||||
assert cli._route_command("sensor read").startswith("Error:")
|
||||
assert cli._route_command("gps on").startswith("Error:")
|
||||
assert cli._route_command("stats-foo").startswith("Error:")
|
||||
assert cli._route_command("discover.neighbors").startswith("Error:")
|
||||
assert cli._route_command("region load x").startswith("Error: Region commands not available")
|
||||
assert cli._route_command("unknown") == "Unknown command"
|
||||
|
||||
@@ -129,17 +131,24 @@ def test_cmd_get_public_key_and_neighbor_branches():
|
||||
storage.get_neighbors = lambda: {
|
||||
"aa": {"is_repeater": False, "zero_hop": False, "last_seen": 1}
|
||||
}
|
||||
assert "No repeaters or zero hop" in cli._cmd_neighbors()
|
||||
assert "No zero hop repeaters" in cli._cmd_neighbors()
|
||||
|
||||
storage.get_neighbors = lambda: {
|
||||
"abcdef12feed": {"is_repeater": True, "zero_hop": False, "last_seen": 10, "snr": 4.9},
|
||||
"11223344aabb": {"is_repeater": False, "zero_hop": True, "last_seen": 20, "snr": 1.2},
|
||||
"99aabbccddeeff00": {
|
||||
"is_repeater": True,
|
||||
"zero_hop": True,
|
||||
"last_seen": 21,
|
||||
"snr": 6.3,
|
||||
},
|
||||
}
|
||||
with patch("time.time", return_value=30):
|
||||
out = cli._cmd_neighbors()
|
||||
|
||||
assert "abcdef12:20:4" in out
|
||||
assert "11223344:10:1" in out
|
||||
assert "99aabbcc:9:6" in out
|
||||
assert "abcdef12:20:4" not in out
|
||||
assert "11223344:10:1" not in out
|
||||
|
||||
cli.storage_handler = SimpleNamespace(
|
||||
get_neighbors=MagicMock(side_effect=RuntimeError("db fail"))
|
||||
@@ -147,6 +156,14 @@ def test_cmd_get_public_key_and_neighbor_branches():
|
||||
assert cli._cmd_neighbors().startswith("Error:")
|
||||
|
||||
|
||||
def test_cmd_get_owner_info_path_hash_mode_and_loop_detect():
|
||||
cli = MeshCLI("/tmp/cfg.yaml", _base_config(), _cfg_mgr())
|
||||
|
||||
assert cli._cmd_get("owner.info") == "> owner-a"
|
||||
assert cli._cmd_get("path.hash.mode") == "> 0"
|
||||
assert cli._cmd_get("loop.detect") == "> minimal"
|
||||
|
||||
|
||||
def test_cmd_set_updates_and_validation_errors():
|
||||
cfg = _base_config()
|
||||
mgr = _cfg_mgr()
|
||||
@@ -167,7 +184,18 @@ def test_cmd_set_updates_and_validation_errors():
|
||||
assert cli._cmd_set("freq 868000000").startswith("OK")
|
||||
assert cli._cmd_set("tx 17") == "OK"
|
||||
assert cli._cmd_set("guest.password g") == "OK"
|
||||
assert cli._cmd_set("owner.info Alice|Ops") == "OK"
|
||||
assert cfg["repeater"]["owner_info"] == "Alice\nOps"
|
||||
assert cli._cmd_set("allow.read.only off") == "OK"
|
||||
assert cli._cmd_set("path.hash.mode 2") == "OK"
|
||||
assert cfg["mesh"]["path_hash_mode"] == 2
|
||||
assert cli._cmd_set("path.hash.mode 3") == "Error: path.hash.mode must be 0, 1, or 2"
|
||||
assert cli._cmd_set("loop.detect strict") == "OK"
|
||||
assert cfg["mesh"]["loop_detect"] == "strict"
|
||||
assert (
|
||||
cli._cmd_set("loop.detect weird")
|
||||
== "Error: loop.detect must be off, minimal, moderate, or strict"
|
||||
)
|
||||
|
||||
assert cli._cmd_set("advert.interval 59").startswith("Error: interval range")
|
||||
assert cli._cmd_set("flood.advert.interval 2").startswith("Error: interval range")
|
||||
@@ -185,10 +213,12 @@ def test_cmd_set_updates_and_validation_errors():
|
||||
def test_misc_commands_and_routes():
|
||||
cli = MeshCLI("/tmp/cfg.yaml", _base_config(), _cfg_mgr(), enable_regions=True)
|
||||
|
||||
assert cli._cmd_region("region").startswith("Error:")
|
||||
assert cli._cmd_region("region load us").startswith("Error:")
|
||||
assert cli._cmd_region("region save").startswith("Error:")
|
||||
assert cli._cmd_region("region remove x").startswith("Error:")
|
||||
cli.storage_handler = SimpleNamespace(get_transport_keys=lambda: [])
|
||||
|
||||
assert cli._cmd_region("region") == "* F"
|
||||
assert cli._cmd_region("region load us").startswith("Err -")
|
||||
assert cli._cmd_region("region save") == "OK"
|
||||
assert cli._cmd_region("region remove x") == "Err - not found"
|
||||
assert cli._cmd_region("region unknown").startswith("Err -")
|
||||
|
||||
assert cli._cmd_setperm("setperm") == "Err - bad params"
|
||||
@@ -204,11 +234,170 @@ def test_misc_commands_and_routes():
|
||||
assert cli._cmd_tempradio("tempradio 915 125 7 5 nope") == "Error, invalid params"
|
||||
assert cli._cmd_tempradio("tempradio 915 125 7 5 10").startswith("Error:")
|
||||
|
||||
assert cli._cmd_neighbor_remove("neighbor.remove ") == "ERR: Missing pubkey"
|
||||
assert cli._cmd_neighbor_remove("neighbor.remove abc").startswith("Error:")
|
||||
assert cli._cmd_neighbor_remove("neighbor.remove") == "ERR: Missing pubkey"
|
||||
|
||||
storage = SimpleNamespace(delete_neighbors_by_pubkey_prefix=MagicMock(return_value=1))
|
||||
cli.storage_handler = storage
|
||||
assert cli._cmd_neighbor_remove("neighbor.remove abc") == "OK"
|
||||
storage.delete_neighbors_by_pubkey_prefix.assert_called_with("abc")
|
||||
|
||||
assert cli._cmd_neighbor_remove("neighbor.remove ") == "OK"
|
||||
storage.delete_neighbors_by_pubkey_prefix.assert_called_with(None)
|
||||
|
||||
assert cli._cmd_neighbor_remove("neighbor.remove zz") == "ERR: bad pubkey"
|
||||
|
||||
no_delete = SimpleNamespace()
|
||||
cli.storage_handler = no_delete
|
||||
assert "not supported" in cli._cmd_neighbor_remove("neighbor.remove ab")
|
||||
|
||||
cli.storage_handler = None
|
||||
assert cli._cmd_neighbor_remove("neighbor.remove ab") == "Error: Storage not available"
|
||||
|
||||
discovery = SimpleNamespace(
|
||||
cleanup_sessions=MagicMock(),
|
||||
create_session=MagicMock(return_value={"session_id": "s1"}),
|
||||
start_session_task=MagicMock(),
|
||||
)
|
||||
loop = SimpleNamespace(is_running=lambda: True, call_soon_threadsafe=MagicMock())
|
||||
cli._event_loop = loop
|
||||
cli.config_manager.daemon = SimpleNamespace(discovery_helper=discovery)
|
||||
|
||||
assert cli._cmd_discover_neighbors("discover.neighbors") == "OK - Discover sent"
|
||||
discovery.cleanup_sessions.assert_called_once()
|
||||
assert callable(discovery.create_session.call_args.kwargs["result_enricher"])
|
||||
loop.call_soon_threadsafe.assert_called_once_with(discovery.start_session_task, "s1")
|
||||
|
||||
assert cli._cmd_discover_neighbors("discover.neighbors foo").startswith("Err -")
|
||||
|
||||
cli.config_manager.daemon = SimpleNamespace(discovery_helper=None)
|
||||
assert cli._cmd_discover_neighbors("discover.neighbors").startswith("Error: Discovery helper")
|
||||
|
||||
assert cli._cmd_log("log start").startswith("Error:")
|
||||
assert cli._cmd_log("log stop").startswith("Error:")
|
||||
assert cli._cmd_log("log erase").startswith("Error:")
|
||||
assert cli._cmd_log("log") == "Error: Use journalctl to view logs"
|
||||
assert cli._cmd_log("log whatever") == "Unknown log command"
|
||||
|
||||
|
||||
def test_region_command_response_shapes_and_ops():
|
||||
keys = [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "alpha",
|
||||
"flood_policy": "allow",
|
||||
"parent_id": None,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "#beta",
|
||||
"flood_policy": "deny",
|
||||
"parent_id": 1,
|
||||
},
|
||||
]
|
||||
|
||||
created = []
|
||||
updated = []
|
||||
deleted = []
|
||||
|
||||
def get_transport_keys():
|
||||
return list(keys)
|
||||
|
||||
def update_transport_key(key_id, **kwargs):
|
||||
updated.append((key_id, kwargs))
|
||||
for rec in keys:
|
||||
if rec["id"] == key_id:
|
||||
rec.update(kwargs)
|
||||
return True
|
||||
return False
|
||||
|
||||
def create_transport_key(name, flood_policy, transport_key, parent_id, last_used):
|
||||
new_id = max([r["id"] for r in keys] + [0]) + 1
|
||||
keys.append(
|
||||
{
|
||||
"id": new_id,
|
||||
"name": name,
|
||||
"flood_policy": flood_policy,
|
||||
"parent_id": parent_id,
|
||||
}
|
||||
)
|
||||
created.append((name, flood_policy, parent_id))
|
||||
return new_id
|
||||
|
||||
def delete_transport_key(key_id):
|
||||
for idx, rec in enumerate(keys):
|
||||
if rec["id"] == key_id:
|
||||
deleted.append(key_id)
|
||||
del keys[idx]
|
||||
return True
|
||||
return False
|
||||
|
||||
cfg = _base_config()
|
||||
cfg["mesh"] = {"unscoped_flood_allow": True, "default_region": None}
|
||||
cli = MeshCLI("/tmp/cfg.yaml", cfg, _cfg_mgr(), enable_regions=True)
|
||||
cli.storage_handler = SimpleNamespace(
|
||||
get_transport_keys=get_transport_keys,
|
||||
update_transport_key=update_transport_key,
|
||||
create_transport_key=create_transport_key,
|
||||
delete_transport_key=delete_transport_key,
|
||||
)
|
||||
|
||||
tree = cli._cmd_region("region")
|
||||
assert "* F" in tree
|
||||
assert " alpha F" in tree
|
||||
assert " beta" in tree
|
||||
|
||||
assert cli._cmd_region("region get alpha") == " alpha F"
|
||||
assert cli._cmd_region("region get beta") == " beta (alpha)"
|
||||
assert cli._cmd_region("region get missing") == "Err - unknown region"
|
||||
|
||||
assert cli._cmd_region("region allowf beta") == "OK"
|
||||
assert keys[1]["flood_policy"] == "allow"
|
||||
assert cli._cmd_region("region denyf beta") == "OK"
|
||||
assert keys[1]["flood_policy"] == "deny"
|
||||
|
||||
assert cli._cmd_region("region home alpha") == " home is now alpha"
|
||||
assert cli._cmd_region("region home") == " home is alpha"
|
||||
|
||||
assert cli._cmd_region("region default") == " default scope is <null>"
|
||||
assert cli._cmd_region("region default alpha") == " default scope is now alpha"
|
||||
assert cfg["mesh"]["default_region"] == "alpha"
|
||||
assert cli._cmd_region("region default") == " default scope is alpha"
|
||||
assert cli._cmd_region("region default <null>") == " default scope is now <null>"
|
||||
assert cfg["mesh"]["default_region"] is None
|
||||
|
||||
assert cli._cmd_region("region put gamma alpha") == "OK - (flood allowed)"
|
||||
assert any(r[0] == "gamma" for r in created)
|
||||
|
||||
assert cli._cmd_region("region remove alpha") == "Err - not empty"
|
||||
assert cli._cmd_region("region remove gamma") == "OK"
|
||||
assert deleted
|
||||
|
||||
assert cli._cmd_region("region list denied") == "beta"
|
||||
listed = cli._cmd_region("region list allowed")
|
||||
assert listed.startswith("*,")
|
||||
assert "alpha" in listed
|
||||
assert cli._cmd_region("region list nope") == "Err - use 'allowed' or 'denied'"
|
||||
|
||||
|
||||
def test_discovery_auto_add_skips_local_node_and_persists_remote():
|
||||
identity = SimpleNamespace(get_public_key=lambda: bytes.fromhex("11" * 32))
|
||||
storage = SimpleNamespace(record_advert=MagicMock())
|
||||
cli = MeshCLI(
|
||||
"/tmp/cfg.yaml", _base_config(), _cfg_mgr(), identity=identity, storage_handler=storage
|
||||
)
|
||||
|
||||
self_result = cli._auto_add_discovery_result({"pub_key": "11" * 8, "node_type": 2})
|
||||
assert self_result["is_self"] is True
|
||||
storage.record_advert.assert_not_called()
|
||||
|
||||
remote_result = cli._auto_add_discovery_result(
|
||||
{
|
||||
"pub_key": "22" * 32,
|
||||
"node_name": "Remote Repeater",
|
||||
"node_type": 2,
|
||||
"rssi": -70,
|
||||
"response_snr": 4.25,
|
||||
}
|
||||
)
|
||||
assert remote_result["auto_added"] is True
|
||||
storage.record_advert.assert_called_once()
|
||||
|
||||
@@ -312,6 +312,7 @@ def test_text_helper_cli_prefix_and_admin_permission_checks():
|
||||
|
||||
assert helper._is_cli_command("get status") is True
|
||||
assert helper._is_cli_command("99|get status") is True
|
||||
assert helper._is_cli_command("04|discover.neighbors") is True
|
||||
assert helper._is_cli_command("hello world") is False
|
||||
|
||||
assert helper._check_admin_permission_for_identity(0x21, 0x41) is True
|
||||
|
||||
@@ -6,9 +6,9 @@ import pytest
|
||||
|
||||
from repeater.handler_helpers.room_server import (
|
||||
MAX_UNSYNCED_POSTS,
|
||||
RoomServer,
|
||||
TXT_TYPE_PLAIN,
|
||||
TXT_TYPE_SIGNED_PLAIN,
|
||||
RoomServer,
|
||||
)
|
||||
|
||||
|
||||
@@ -208,7 +208,9 @@ async def test_room_server_push_expected_ack_matches_firmware_signed_ack():
|
||||
assert ok is False
|
||||
assert len(sent) == 1
|
||||
|
||||
upserts = [c.kwargs for c in db.upsert_client_sync.call_args_list if "pending_ack_crc" in c.kwargs]
|
||||
upserts = [
|
||||
c.kwargs for c in db.upsert_client_sync.call_args_list if "pending_ack_crc" in c.kwargs
|
||||
]
|
||||
assert len(upserts) == 1
|
||||
expected_ack_crc = upserts[0]["pending_ack_crc"]
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from repeater.identity_manager import IdentityManager
|
||||
|
||||
|
||||
class _FakeIdentity:
|
||||
def __init__(self, pubkey: bytes, addr: bytes = b"\xaa\xbb"):
|
||||
self._pubkey = pubkey
|
||||
self._addr = addr
|
||||
|
||||
def get_public_key(self):
|
||||
return self._pubkey
|
||||
|
||||
def get_address_bytes(self):
|
||||
return self._addr
|
||||
|
||||
|
||||
def test_identity_manager_register_lookup_and_collision_paths():
|
||||
mgr = IdentityManager(config={})
|
||||
id_a = _FakeIdentity(bytes([0x11]) + b"A" * 31, addr=b"\x01\x02")
|
||||
id_b_collision = _FakeIdentity(bytes([0x11]) + b"B" * 31, addr=b"\x03\x04")
|
||||
|
||||
assert mgr.register_identity("alpha", id_a, {"k": 1}, "repeater") is True
|
||||
assert mgr.has_identity(0x11) is True
|
||||
assert mgr.get_identity_by_hash(0x11)[0] is id_a
|
||||
assert mgr.get_identity_by_name("alpha")[0] is id_a
|
||||
|
||||
assert mgr.register_identity("beta", id_b_collision, {"k": 2}, "room_server") is False
|
||||
|
||||
|
||||
def test_identity_manager_list_and_type_filtering():
|
||||
mgr = IdentityManager(config={})
|
||||
id_a = _FakeIdentity(bytes([0x22]) + b"A" * 31)
|
||||
id_b = _FakeIdentity(bytes([0x33]) + b"B" * 31)
|
||||
|
||||
mgr.register_identity("rep-main", id_a, {"x": 1}, "repeater")
|
||||
mgr.register_identity("room-a", id_b, {"y": 2}, "room_server")
|
||||
|
||||
listed = mgr.list_identities()
|
||||
assert len(listed) == 2
|
||||
assert any(item["hash"] == "0x22" and item["name"] == "repeater:rep-main" for item in listed)
|
||||
assert any(item["hash"] == "0x33" and item["type"] == "room_server" for item in listed)
|
||||
|
||||
assert mgr.has_identity_type("repeater") is True
|
||||
assert mgr.has_identity_type("room_server") is True
|
||||
assert mgr.has_identity_type("unknown") is False
|
||||
|
||||
by_type = mgr.get_identities_by_type("room_server")
|
||||
assert len(by_type) == 1
|
||||
assert by_type[0][0] == "room-a"
|
||||
|
||||
|
||||
def test_identity_manager_list_handles_none_identity_fields():
|
||||
mgr = IdentityManager(config={})
|
||||
mgr.identities[0x44] = (None, {}, "repeater")
|
||||
mgr.registered_hashes[0x44] = "repeater:ghost"
|
||||
|
||||
listed = mgr.list_identities()
|
||||
assert listed[0]["address"] == "N/A"
|
||||
assert listed[0]["public_key"] is None
|
||||
@@ -1,267 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
from repeater.handler_helpers.repeater_cli import MeshCLI, RepeaterCLI
|
||||
from repeater.identity_manager import IdentityManager
|
||||
|
||||
|
||||
class _FakeIdentity:
|
||||
def __init__(self, pubkey: bytes, addr: bytes = b"\xaa\xbb"):
|
||||
self._pubkey = pubkey
|
||||
self._addr = addr
|
||||
|
||||
def get_public_key(self):
|
||||
return self._pubkey
|
||||
|
||||
def get_address_bytes(self):
|
||||
return self._addr
|
||||
|
||||
|
||||
def _base_config():
|
||||
return {
|
||||
"version": "9.9.9",
|
||||
"repeater": {
|
||||
"name": "node-1",
|
||||
"mode": "forward",
|
||||
"latitude": 12.3,
|
||||
"longitude": 45.6,
|
||||
"airtime_factor": 1.1,
|
||||
"advert_interval_minutes": 120,
|
||||
"flood_advert_interval_hours": 24,
|
||||
"max_flood_hops": 32,
|
||||
"rx_delay_base": 0.4,
|
||||
"tx_delay_factor": 1.2,
|
||||
"direct_tx_delay_factor": 0.7,
|
||||
"multi_acks": 2,
|
||||
"interference_threshold": -111,
|
||||
"agc_reset_interval": 8,
|
||||
},
|
||||
"radio": {
|
||||
"frequency": 915000000,
|
||||
"bandwidth": 125000,
|
||||
"spreading_factor": 7,
|
||||
"coding_rate": 5,
|
||||
"tx_power": 22,
|
||||
},
|
||||
"security": {"guest_password": "guest", "allow_read_only": True},
|
||||
}
|
||||
|
||||
|
||||
def test_identity_manager_register_lookup_and_collision_paths():
|
||||
mgr = IdentityManager(config={})
|
||||
id_a = _FakeIdentity(bytes([0x11]) + b"A" * 31, addr=b"\x01\x02")
|
||||
id_b_collision = _FakeIdentity(bytes([0x11]) + b"B" * 31, addr=b"\x03\x04")
|
||||
|
||||
assert mgr.register_identity("alpha", id_a, {"k": 1}, "repeater") is True
|
||||
assert mgr.has_identity(0x11) is True
|
||||
assert mgr.get_identity_by_hash(0x11)[0] is id_a
|
||||
assert mgr.get_identity_by_name("alpha")[0] is id_a
|
||||
|
||||
# Collision on first pubkey byte should be rejected.
|
||||
assert mgr.register_identity("beta", id_b_collision, {"k": 2}, "room_server") is False
|
||||
|
||||
|
||||
def test_identity_manager_list_and_type_filtering():
|
||||
mgr = IdentityManager(config={})
|
||||
id_a = _FakeIdentity(bytes([0x22]) + b"A" * 31)
|
||||
id_b = _FakeIdentity(bytes([0x33]) + b"B" * 31)
|
||||
|
||||
mgr.register_identity("rep-main", id_a, {"x": 1}, "repeater")
|
||||
mgr.register_identity("room-a", id_b, {"y": 2}, "room_server")
|
||||
|
||||
listed = mgr.list_identities()
|
||||
assert len(listed) == 2
|
||||
assert any(item["hash"] == "0x22" and item["name"] == "repeater:rep-main" for item in listed)
|
||||
assert any(item["hash"] == "0x33" and item["type"] == "room_server" for item in listed)
|
||||
|
||||
assert mgr.has_identity_type("repeater") is True
|
||||
assert mgr.has_identity_type("room_server") is True
|
||||
assert mgr.has_identity_type("unknown") is False
|
||||
|
||||
by_type = mgr.get_identities_by_type("room_server")
|
||||
assert len(by_type) == 1
|
||||
assert by_type[0][0] == "room-a"
|
||||
|
||||
|
||||
def test_identity_manager_list_handles_none_identity_fields():
|
||||
mgr = IdentityManager(config={})
|
||||
mgr.identities[0x44] = (None, {}, "repeater")
|
||||
mgr.registered_hashes[0x44] = "repeater:ghost"
|
||||
|
||||
listed = mgr.list_identities()
|
||||
assert listed[0]["address"] == "N/A"
|
||||
assert listed[0]["public_key"] is None
|
||||
|
||||
|
||||
def test_repeater_cli_alias_points_to_mesh_cli():
|
||||
assert RepeaterCLI is MeshCLI
|
||||
|
||||
|
||||
def test_cli_non_admin_and_prefix_passthrough():
|
||||
cfg = _base_config()
|
||||
save = MagicMock()
|
||||
cli = MeshCLI("/tmp/config.yaml", cfg, save)
|
||||
|
||||
assert cli.handle_command(b"x", "help", is_admin=False) == "Error: Admin permission required"
|
||||
assert cli.handle_command(b"x", "01|help set", is_admin=True).startswith("01|")
|
||||
|
||||
|
||||
def test_cli_help_and_route_unknown_commands():
|
||||
cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock())
|
||||
|
||||
help_text = cli._route_command("help")
|
||||
assert "pyMC CLI Commands" in help_text
|
||||
|
||||
assert "No detailed help" in cli._route_command("help not-a-topic")
|
||||
assert cli._route_command("start ota").startswith("Error:")
|
||||
assert cli._route_command("gps now").startswith("Error:")
|
||||
assert cli._route_command("stats-air").startswith("Error:")
|
||||
assert cli._route_command("totally-unknown") == "Unknown command"
|
||||
|
||||
|
||||
def test_cli_reboot_uses_service_utils_result():
|
||||
cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock())
|
||||
|
||||
with patch("repeater.service_utils.restart_service", return_value=(True, "restarted")):
|
||||
assert cli._cmd_reboot() == "OK - restarted"
|
||||
|
||||
with patch("repeater.service_utils.restart_service", return_value=(False, "denied")):
|
||||
assert cli._cmd_reboot() == "Error: denied"
|
||||
|
||||
|
||||
def test_cli_clock_time_password_and_version_commands():
|
||||
cfg = _base_config()
|
||||
save = MagicMock()
|
||||
cli = MeshCLI("/tmp/config.yaml", cfg, save, identity_type="room_server")
|
||||
|
||||
assert "UTC" in cli._cmd_clock("clock")
|
||||
assert "not needed" in cli._cmd_clock("clock sync")
|
||||
assert cli._cmd_clock("clock bad") == "Unknown clock command"
|
||||
assert cli._cmd_time("time 1 2").startswith("Error:")
|
||||
|
||||
assert cli._cmd_password("password ") == "Error: Password cannot be empty"
|
||||
assert cli._cmd_password("password newpass") == "password now: newpass"
|
||||
assert cfg["security"]["password"] == "newpass"
|
||||
save.assert_called()
|
||||
|
||||
assert cli._cmd_version() == "pyMC_room_server v9.9.9"
|
||||
|
||||
|
||||
def test_cli_get_commands_cover_expected_fields():
|
||||
cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock())
|
||||
|
||||
assert cli._cmd_get("af") == "> 1.1"
|
||||
assert cli._cmd_get("name") == "> node-1"
|
||||
assert cli._cmd_get("repeat") == "> on"
|
||||
assert cli._cmd_get("lat") == "> 12.3"
|
||||
assert cli._cmd_get("lon") == "> 45.6"
|
||||
assert cli._cmd_get("radio") == "> 915.0,125.0,7,5"
|
||||
assert cli._cmd_get("freq") == "> 915.0"
|
||||
assert cli._cmd_get("tx") == "> 22"
|
||||
assert cli._cmd_get("role") == "> repeater"
|
||||
assert cli._cmd_get("guest.password") == "> guest"
|
||||
assert cli._cmd_get("allow.read.only") == "> on"
|
||||
assert cli._cmd_get("advert.interval") == "> 120"
|
||||
assert cli._cmd_get("flood.advert.interval") == "> 24"
|
||||
assert cli._cmd_get("flood.max") == "> 32"
|
||||
assert cli._cmd_get("rxdelay") == "> 0.4"
|
||||
assert cli._cmd_get("txdelay") == "> 1.2"
|
||||
assert cli._cmd_get("direct.txdelay") == "> 0.7"
|
||||
assert cli._cmd_get("multi.acks") == "> 2"
|
||||
assert cli._cmd_get("int.thresh") == "> -111"
|
||||
assert cli._cmd_get("agc.reset.interval") == "> 8"
|
||||
assert cli._cmd_get("public.key").startswith("Error:")
|
||||
assert cli._cmd_get("missing") == "??: missing"
|
||||
|
||||
|
||||
def test_cli_set_commands_apply_and_validate_ranges():
|
||||
cfg = _base_config()
|
||||
save = MagicMock()
|
||||
cli = MeshCLI("/tmp/config.yaml", cfg, save)
|
||||
|
||||
assert cli._cmd_set("af 2.5") == "OK"
|
||||
assert cfg["repeater"]["airtime_factor"] == 2.5
|
||||
|
||||
assert cli._cmd_set("name repeater-z") == "OK"
|
||||
assert cfg["repeater"]["name"] == "repeater-z"
|
||||
|
||||
assert cli._cmd_set("repeat off").endswith("OFF")
|
||||
assert cfg["repeater"]["mode"] == "monitor"
|
||||
|
||||
assert cli._cmd_set("lat 1.25") == "OK"
|
||||
assert cli._cmd_set("lon 2.5") == "OK"
|
||||
|
||||
assert cli._cmd_set("radio 900000000 250000 9 6").startswith("OK")
|
||||
assert cfg["radio"]["frequency"] == 900000000.0
|
||||
|
||||
assert cli._cmd_set("freq 868000000").startswith("OK")
|
||||
assert cli._cmd_set("tx 17") == "OK"
|
||||
assert cli._cmd_set("guest.password gpw") == "OK"
|
||||
assert cli._cmd_set("allow.read.only off") == "OK"
|
||||
|
||||
assert cli._cmd_set("advert.interval 59").startswith("Error: interval range")
|
||||
assert cli._cmd_set("advert.interval 60") == "OK"
|
||||
|
||||
assert cli._cmd_set("flood.advert.interval 2").startswith("Error: interval range")
|
||||
assert cli._cmd_set("flood.advert.interval 168") == "OK"
|
||||
|
||||
assert cli._cmd_set("flood.max 65") == "Error: max 64"
|
||||
assert cli._cmd_set("flood.max 64") == "OK"
|
||||
|
||||
assert cli._cmd_set("rxdelay -1") == "Error: cannot be negative"
|
||||
assert cli._cmd_set("txdelay -1") == "Error: cannot be negative"
|
||||
assert cli._cmd_set("direct.txdelay -1") == "Error: cannot be negative"
|
||||
|
||||
assert cli._cmd_set("multi.acks 5") == "OK"
|
||||
assert cli._cmd_set("int.thresh -120") == "OK"
|
||||
assert cli._cmd_set("agc.reset.interval 10") == "OK - interval rounded to 8"
|
||||
|
||||
|
||||
def test_cli_set_command_error_paths():
|
||||
cfg = _base_config()
|
||||
save = MagicMock()
|
||||
cli = MeshCLI("/tmp/config.yaml", cfg, save)
|
||||
|
||||
assert cli._cmd_set("af") == "Error: Missing value"
|
||||
assert cli._cmd_set("radio 1 2 3") == "Error: Expected freq bw sf cr"
|
||||
assert cli._cmd_set("unknown.key 1") == "unknown config: unknown.key"
|
||||
assert cli._cmd_set("tx not-int").startswith("Error: invalid value")
|
||||
|
||||
cli.save_config = MagicMock(side_effect=RuntimeError("disk full"))
|
||||
assert cli._cmd_set("name x").startswith("Error:")
|
||||
|
||||
|
||||
def test_cli_setperm_region_neighbor_tempradio_log_paths():
|
||||
cli = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock(), enable_regions=False)
|
||||
|
||||
assert cli._cmd_setperm("setperm") == "Err - bad params"
|
||||
assert cli._cmd_setperm("setperm deadbeef zz") == "Err - invalid permissions"
|
||||
assert cli._cmd_setperm("setperm deadbeef 2").startswith("Error:")
|
||||
|
||||
assert "not available" in cli._route_command("region load us")
|
||||
|
||||
cli_regions = MeshCLI("/tmp/config.yaml", _base_config(), MagicMock(), enable_regions=True)
|
||||
assert cli_regions._cmd_region("region").startswith("Error:")
|
||||
assert cli_regions._cmd_region("region load x").startswith("Error:")
|
||||
assert cli_regions._cmd_region("region save").startswith("Error:")
|
||||
assert cli_regions._cmd_region("region allowf").startswith("Error:")
|
||||
assert cli_regions._cmd_region("region what").startswith("Err -")
|
||||
|
||||
assert cli._cmd_neighbors().startswith("Error:")
|
||||
assert cli._cmd_neighbor_remove("neighbor.remove ") == "ERR: Missing pubkey"
|
||||
assert cli._cmd_neighbor_remove("neighbor.remove 001122").startswith("Error:")
|
||||
|
||||
assert cli._cmd_tempradio("tempradio 1 2 3").startswith("Error:")
|
||||
assert cli._cmd_tempradio("tempradio 299 125 7 5 10") == "Error: invalid frequency"
|
||||
assert cli._cmd_tempradio("tempradio 915 6 7 5 10") == "Error: invalid bandwidth"
|
||||
assert cli._cmd_tempradio("tempradio 915 125 4 5 10") == "Error: invalid spreading factor"
|
||||
assert cli._cmd_tempradio("tempradio 915 125 7 9 10") == "Error: invalid coding rate"
|
||||
assert cli._cmd_tempradio("tempradio 915 125 7 5 0") == "Error: invalid timeout"
|
||||
assert cli._cmd_tempradio("tempradio 915 125 7 5 x") == "Error, invalid params"
|
||||
assert cli._cmd_tempradio("tempradio 915 125 7 5 10").startswith("Error:")
|
||||
|
||||
assert cli._cmd_log("log start").startswith("Error:")
|
||||
assert cli._cmd_log("log stop").startswith("Error:")
|
||||
assert cli._cmd_log("log erase").startswith("Error:")
|
||||
assert cli._cmd_log("log") == "Error: Use journalctl to view logs"
|
||||
assert cli._cmd_log("log weird") == "Unknown log command"
|
||||
@@ -264,6 +264,39 @@ async def test_send_advert_branches_and_success_path():
|
||||
daemon.dispatcher.packet_filter.track_packet.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_advert_applies_transport_scope_when_default_region_set():
|
||||
from openhop_core.protocol.constants import ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
|
||||
daemon = RepeaterDaemon(_base_config(), radio=object())
|
||||
daemon.dispatcher = SimpleNamespace(
|
||||
send_packet=AsyncMock(), packet_filter=SimpleNamespace(track_packet=MagicMock())
|
||||
)
|
||||
daemon.local_identity = _FakeIdentity(b"\x22" + b"y" * 31)
|
||||
daemon.repeater_handler = SimpleNamespace(mark_seen=MagicMock())
|
||||
daemon.config["mesh"] = {"default_region": "alpha"}
|
||||
|
||||
packet = SimpleNamespace(
|
||||
header=ROUTE_TYPE_FLOOD,
|
||||
transport_codes=[0, 0],
|
||||
get_payload_type=lambda: 3,
|
||||
get_payload=lambda: b"minimal_advert_payload",
|
||||
calculate_packet_hash=lambda: b"\xcd" * 16,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("openhop_core.protocol.PacketBuilder.create_advert", return_value=packet),
|
||||
patch("openhop_core.protocol.transport_keys.get_auto_key_for", return_value=b"\x01" * 16),
|
||||
patch("openhop_core.protocol.transport_keys.calc_transport_code", return_value=0xBEEF),
|
||||
):
|
||||
ok = await daemon.send_advert()
|
||||
|
||||
assert ok is True
|
||||
assert packet.transport_codes == [0xBEEF, 0]
|
||||
assert (packet.header & 0x03) == ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
daemon.dispatcher.send_packet.assert_awaited_once_with(packet, wait_for_ack=False)
|
||||
|
||||
|
||||
def test_update_repeater_location_from_gps_branches():
|
||||
daemon = RepeaterDaemon(_base_config(), radio=object())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user