"""Helpers for Meshtastic MQTT roots, topic parsing, and subscriptions.""" from __future__ import annotations import re from typing import Iterable # Default subscription roots — US-only to avoid flooding the public broker. # Users can opt into additional regions via MESH_MQTT_EXTRA_ROOTS. DEFAULT_ROOTS: tuple[str, ...] = ("US",) DEFAULT_CHANNEL = "LongFast" # Every known official region root (for UI dropdowns / manual opt-in). ALL_OFFICIAL_ROOTS: tuple[str, ...] = ( "US", "EU_868", "EU_433", "CN", "JP", "KR", "TW", "RU", "IN", "ANZ", "ANZ_433", "NZ_865", "TH", "UA_868", "UA_433", "MY_433", "MY_919", "SG_923", "LORA_24", ) # Legacy/community roots still seen in the wild on public/community brokers. COMMUNITY_ROOTS: tuple[str, ...] = ( "EU", "AU", "UA", "BR", "AF", "ME", "SEA", "SA", "PL", ) _ROOT_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_+\-]+$") _TOPIC_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_+\-#]+$") def _dedupe(values: Iterable[str]) -> list[str]: out: list[str] = [] seen: set[str] = set() for value in values: if value not in seen: out.append(value) seen.add(value) return out def _split_config_values(raw: str) -> list[str]: if not raw: return [] normalized = raw.replace("\n", ",").replace(";", ",") return [item.strip() for item in normalized.split(",") if item.strip()] def normalize_root(value: str) -> str | None: """Normalize a Meshtastic root like `PL` or `US/rob/snd`.""" raw = str(value or "").strip() if not raw: return None if raw.startswith("msh/"): raw = raw[4:] raw = raw.strip("/") if raw.endswith("/#"): raw = raw[:-2].rstrip("/") if not raw: return None parts = [part for part in raw.split("/") if part] if not parts: return None if any(part in {"+", "#"} for part in parts): return None if any(not _ROOT_SEGMENT_RE.match(part) for part in parts): return None return "/".join(parts) def normalize_topic_filter(value: str) -> str | None: """Normalize a full MQTT subscription filter.""" raw = str(value or "").strip() if not raw: return None if not raw.startswith("msh/"): root = normalize_root(raw) return f"msh/{root}/#" if root else None raw = raw.strip("/") parts = [part for part in raw.split("/") if part] if not parts or parts[0] != "msh": return None if any(part != "+" and not _TOPIC_SEGMENT_RE.match(part) for part in parts[1:]): return None return "/".join(parts) def _default_topics_for_root(root: str) -> list[str]: """Return the default LongFast subscriptions for a region root. The public broker carries protobuf/encrypted traffic under ``/e/`` and companion decoded JSON traffic under ``/json/``. Positions often arrive on the protobuf path, while public text is commonly easiest to observe on the JSON path. """ return [ f"msh/{root}/2/e/{DEFAULT_CHANNEL}/#", f"msh/{root}/2/json/{DEFAULT_CHANNEL}/#", ] def build_subscription_topics( extra_roots: str = "", extra_topics: str = "", include_defaults: bool = True, ) -> list[str]: roots: list[str] = [] if include_defaults: roots.extend(DEFAULT_ROOTS) # Community roots are no longer subscribed by default — users opt in # via MESH_MQTT_EXTRA_ROOTS to avoid flooding the public broker. roots.extend(root for root in (normalize_root(item) for item in _split_config_values(extra_roots)) if root) topics = [ topic for root in _dedupe(roots) for topic in _default_topics_for_root(root) ] topics.extend( topic for topic in ( normalize_topic_filter(item) for item in _split_config_values(extra_topics) ) if topic ) return _dedupe(topics) def known_roots(extra_roots: str = "", include_defaults: bool = True) -> list[str]: """Return the roots we are *currently subscribed* to.""" topics = build_subscription_topics(extra_roots=extra_roots, include_defaults=include_defaults) roots: list[str] = [] for topic in topics: if not topic.startswith("msh/") or not topic.endswith("/#"): continue root = normalize_root(parse_topic_metadata(topic)["root"]) if root: roots.append(root) return _dedupe(roots) def all_available_roots() -> list[str]: """Return every region the UI should list (for dropdowns), regardless of subscription state.""" return _dedupe(list(ALL_OFFICIAL_ROOTS) + list(COMMUNITY_ROOTS)) def parse_topic_metadata(topic: str) -> dict[str, str]: """Extract region/root/channel metadata from a Meshtastic MQTT topic.""" parts = [part for part in str(topic or "").strip("/").split("/") if part] if not parts or parts[0] != "msh": return {"region": "?", "root": "?", "channel": "LongFast", "mode": "", "version": ""} mode_idx = -1 for idx in range(1, len(parts)): if parts[idx] in {"e", "c", "json"}: mode_idx = idx break version = "" root_parts = parts[1:] channel = "LongFast" mode = "" if mode_idx != -1: mode = parts[mode_idx] maybe_version_idx = mode_idx - 1 if maybe_version_idx >= 1 and parts[maybe_version_idx].isdigit(): version = parts[maybe_version_idx] root_parts = parts[1:maybe_version_idx] else: root_parts = parts[1:mode_idx] if len(parts) > mode_idx + 1: channel = parts[mode_idx + 1] root = "/".join(root_parts) if root_parts else "?" region = root_parts[0] if root_parts else "?" return { "region": region, "root": root, "channel": channel or "LongFast", "mode": mode, "version": version, }