|
|
|
|
@ -60,6 +60,34 @@ def _peer_connected(peer: dict[str, Any]) -> bool:
|
|
|
|
|
return peer.get("CONNECTION") == "YES"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_options_kv(options: Any) -> dict[str, str]:
|
|
|
|
|
"""Parse RPTO OPTIONS string into upper-case keys (legacy normalisation)."""
|
|
|
|
|
if options is None:
|
|
|
|
|
return {}
|
|
|
|
|
if isinstance(options, bytes):
|
|
|
|
|
text = options.decode("utf-8", errors="replace")
|
|
|
|
|
else:
|
|
|
|
|
text = str(options)
|
|
|
|
|
text = text.rstrip("\x00").encode("ascii", "ignore").decode()
|
|
|
|
|
text = re.sub(r"['\"]", "", text).strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return {}
|
|
|
|
|
parsed: dict[str, str] = {}
|
|
|
|
|
for part in text.split(";"):
|
|
|
|
|
part = part.strip()
|
|
|
|
|
if "=" not in part:
|
|
|
|
|
continue
|
|
|
|
|
key, value = part.split("=", 1)
|
|
|
|
|
parsed[key.strip().upper()] = value.strip()
|
|
|
|
|
for old, new in (("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC"), ("TIMER", "DEFAULT_UA_TIMER")):
|
|
|
|
|
if old in parsed and new not in parsed:
|
|
|
|
|
parsed[new] = parsed[old]
|
|
|
|
|
return parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_STATIC_TG_TOKEN_RE = re.compile(r"^\d+$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def static_tg_list(value: Any) -> list[str]:
|
|
|
|
|
"""Normalize legacy TS1_STATIC / TS2_STATIC (comma string or list) to string TG ids."""
|
|
|
|
|
if value is None:
|
|
|
|
|
@ -93,33 +121,61 @@ def normalize_static_tg_slot_lists(
|
|
|
|
|
return dedupe_static_tg_list(ts1), dedupe_static_tg_list(ts2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_options_kv(options: Any) -> dict[str, str]:
|
|
|
|
|
"""Parse RPTO OPTIONS string into upper-case keys (legacy normalisation)."""
|
|
|
|
|
if options is None:
|
|
|
|
|
return {}
|
|
|
|
|
if isinstance(options, bytes):
|
|
|
|
|
text = options.decode("utf-8", errors="replace")
|
|
|
|
|
else:
|
|
|
|
|
text = str(options)
|
|
|
|
|
text = text.rstrip("\x00").encode("ascii", "ignore").decode()
|
|
|
|
|
text = re.sub(r"['\"]", "", text).strip()
|
|
|
|
|
if not text:
|
|
|
|
|
return {}
|
|
|
|
|
parsed: dict[str, str] = {}
|
|
|
|
|
for part in text.split(";"):
|
|
|
|
|
part = part.strip()
|
|
|
|
|
if "=" not in part:
|
|
|
|
|
def _static_slot_csv_valid(raw: str) -> bool:
|
|
|
|
|
"""True when every comma-separated token in TS1/TS2 is a numeric talkgroup id."""
|
|
|
|
|
val = raw.strip()
|
|
|
|
|
if not val:
|
|
|
|
|
return True
|
|
|
|
|
if re.search(r"[^\d,]", val):
|
|
|
|
|
return False
|
|
|
|
|
for token in val.split(","):
|
|
|
|
|
token = token.strip()
|
|
|
|
|
if not token:
|
|
|
|
|
continue
|
|
|
|
|
key, value = part.split("=", 1)
|
|
|
|
|
parsed[key.strip().upper()] = value.strip()
|
|
|
|
|
for old, new in (("TS1", "TS1_STATIC"), ("TS2", "TS2_STATIC"), ("TIMER", "DEFAULT_UA_TIMER")):
|
|
|
|
|
if old in parsed and new not in parsed:
|
|
|
|
|
parsed[new] = parsed[old]
|
|
|
|
|
return parsed
|
|
|
|
|
if not _STATIC_TG_TOKEN_RE.match(token):
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def peer_options_pass_only(options: Any) -> bool:
|
|
|
|
|
"""True when OPTIONS is exclusively ``PASS=password`` (self-service RPTO)."""
|
|
|
|
|
parsed = _parse_options_kv(options)
|
|
|
|
|
return list(parsed.keys()) == ["PASS"] and bool(parsed["PASS"].strip())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def peer_options_pass_valid(options: Any) -> bool:
|
|
|
|
|
"""True when ``PASS=`` is absent or is the only key with a non-empty value."""
|
|
|
|
|
parsed = _parse_options_kv(options)
|
|
|
|
|
if "PASS" not in parsed:
|
|
|
|
|
return True
|
|
|
|
|
return peer_options_pass_only(options)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def peer_options_static_valid(options: Any) -> bool:
|
|
|
|
|
"""False when TS1/TS2 static lists contain non-numeric tokens (legacy bridge_master parity).
|
|
|
|
|
|
|
|
|
|
``PASS=`` must be sent alone (self-service); combined PASS+OPTIONS is rejected here.
|
|
|
|
|
"""
|
|
|
|
|
parsed = _parse_options_kv(options)
|
|
|
|
|
if not parsed:
|
|
|
|
|
return True
|
|
|
|
|
if "PASS" in parsed:
|
|
|
|
|
return False
|
|
|
|
|
for slot in (1, 2):
|
|
|
|
|
for i in range(1, 10):
|
|
|
|
|
key = f"TS{slot}_{i}"
|
|
|
|
|
if key in parsed and not _STATIC_TG_TOKEN_RE.match(parsed[key].strip()):
|
|
|
|
|
return False
|
|
|
|
|
for key in ("TS1_STATIC", "TS2_STATIC"):
|
|
|
|
|
if key in parsed and not _static_slot_csv_valid(parsed[key]):
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_peer_options_fields(options: Any) -> dict[str, Any]:
|
|
|
|
|
"""Parse OPTIONS into static lists plus optional ``SINGLE`` / ``TIMER`` when present."""
|
|
|
|
|
if not peer_options_static_valid(options):
|
|
|
|
|
return {}
|
|
|
|
|
parsed = _parse_options_kv(options)
|
|
|
|
|
if not parsed:
|
|
|
|
|
return {}
|
|
|
|
|
@ -170,6 +226,8 @@ def resolve_peer_single_and_timer(
|
|
|
|
|
|
|
|
|
|
def parse_peer_options_static(options: Any) -> tuple[list[str], list[str]]:
|
|
|
|
|
"""Parse hotspot RPTO OPTIONS (``TS1=…;TS2=…;``) into static TG id lists."""
|
|
|
|
|
if not peer_options_static_valid(options):
|
|
|
|
|
return [], []
|
|
|
|
|
parsed = _parse_options_kv(options)
|
|
|
|
|
if not parsed:
|
|
|
|
|
return [], []
|
|
|
|
|
@ -284,7 +342,7 @@ def _topology_peer_row(
|
|
|
|
|
if text is not None:
|
|
|
|
|
row[json_key] = text
|
|
|
|
|
yaml_cfg = sys_cfg if isinstance(sys_cfg, dict) else {}
|
|
|
|
|
if "OPTIONS" in peer:
|
|
|
|
|
if "OPTIONS" in peer and peer_options_static_valid(peer.get("OPTIONS")):
|
|
|
|
|
opt_text = _sanitized_peer_options_text(peer.get("OPTIONS"))
|
|
|
|
|
if opt_text:
|
|
|
|
|
row["options"] = opt_text
|
|
|
|
|
|