diff --git a/Makefile b/Makefile index 64f3c74..e57e675 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,8 @@ pre-commit: pdm run pre-commit run --all-files lint: - pdm run ruff check . --fix pdm run ruff format . + pdm run ruff check . --fix mypy: pdm run mypy meshinfo diff --git a/meshinfo/aredn.py b/meshinfo/aredn.py index a700f4c..1db94c6 100644 --- a/meshinfo/aredn.py +++ b/meshinfo/aredn.py @@ -191,15 +191,6 @@ def __attrs_post_init__(self): if self.ip_address == "none": self.ip_address = None - @classmethod - def from_json(cls, raw_data: dict[str, str]) -> Interface: - return cls( - name=raw_data["name"], - # some tunnel interfaces lack a MAC address - mac_address=raw_data.get("mac", ""), - ip_address=raw_data.get("ip"), - ) - @attrs.define class Service: @@ -229,50 +220,6 @@ class LinkInfo: rx_rate: float | None = None olsr_cost: float | None = None - @classmethod - def from_json( - cls, raw_data: dict[str, Any], *, source: str, ip_address: str - ) -> LinkInfo: - """Construct the `Link` dataclass from the AREDN JSON information. - - Needs the name of the source node passed in as well. - - Args: - source: Hostname of source node (lowercase, no domain) - ip_address: IP address of link destination - - """ - # fix example of a DTD link that wasn't properly identified as such - missing_dtd = ( - raw_data["linkType"] == "" and raw_data["olsrInterface"] == "br-dtdlink" - ) - type_ = "DTD" if missing_dtd else raw_data["linkType"] - try: - link_type = getattr(LinkType, type_) - except AttributeError as exc: - logger.warning("Unknown link type", error=str(exc)) - link_type = LinkType.UNKNOWN - - # ensure consistent node names - node_name = raw_data["hostname"].replace(".local.mesh", "").lstrip(".").lower() - if (link_cost := raw_data.get("linkCost")) is not None and link_cost > 99.99: - link_cost = 99.99 - - return LinkInfo( - source=source, - destination=node_name, - destination_ip=ip_address, - type=link_type, - interface=raw_data["olsrInterface"], - quality=raw_data["linkQuality"], - neighbor_quality=raw_data["neighborLinkQuality"], - signal=raw_data.get("signal"), - noise=raw_data.get("noise"), - tx_rate=raw_data.get("tx_rate"), - rx_rate=raw_data.get("rx_rate"), - olsr_cost=link_cost, - ) - @attrs.define(kw_only=True) class SystemInfo: @@ -280,7 +227,7 @@ class SystemInfo: Data that is directly retrieved from the node is stored in this class and "derived" data is then determined at runtime via property attributes - (e.g. the wireless adaptor and band information). + (e.g. band information). The network interfaces are represented by a dictionary, indexed by the interface name. @@ -289,7 +236,7 @@ class SystemInfo: particularly if an empty string would not be a valid value (e.g. SSID). If there is a situation in which missing/unknown values need to be distinguished from empty strings then `None` would be appropriate. - In a case like node description it is an optional value + In a case like node description it is an optional value, so I see no need for "Unknown"/`None`. """ @@ -334,7 +281,9 @@ def __attrs_post_init__(self) -> None: self.mac_address = iface.mac_address break else: - logger.warning("Unable to identify wireless interface") + logger.warning( + "Unable to identify wireless interface", interfaces=self.interfaces + ) @property def lan_ip_address(self) -> str: @@ -416,20 +365,30 @@ def load_system_info(json_data: dict[str, Any], *, ip_address: str = "") -> Syst Args: json_data: Python dictionary loaded from the JSON data. + ip_address: IP address used to connect to this node + (used in case we cannot identify the primary interface). Returns: Data class with information about the node. """ - api_version = tuple(int(value) for value in json_data["api_version"].split(".")) - if api_version < (1, 5): - # pre-1.5 the JSON was much flatter, so treat separately - logger.debug("Parsing legacy sysinfo.json", version=api_version) - return _load_legacy_system_info(json_data) + # The vast majority of nodes at this time are on the newer API + # (and some "custom" software doesn't report its API version correctly), + # so we're just going to try the "modern" parser, falling back in case of errors. + try: + node_info = _load_system_info(json_data) + except Exception as exc: + logger.warning("JSON parse error, falling back to legacy version", exc=exc) + node_info = _load_legacy_system_info(json_data) + + # handle issue of failing to identify the main wireless interface + if not node_info.ip_address: + node_info.ip_address = ip_address + return node_info - # since 1.5, stuff has been added but the format was consistent, - # so handling here (at least until there is a 2.x API) +def _load_system_info(json_data: dict[str, Any]) -> SystemInfo: + """Load "modern" `sysinfo.json` (aka API >= 1.5).""" rf_info = json_data["meshrf"] details = json_data["node_details"] link_info = json_data.get("link_info") or {} @@ -457,20 +416,18 @@ def load_system_info(json_data: dict[str, Any], *, ip_address: str = "") -> Syst board_id=details["board_id"], active_tunnel_count=int(json_data["tunnels"]["active_tunnel_count"]), links=[ - LinkInfo.from_json( - link_info, source=json_data["node"].lower(), ip_address=ip_address + _load_link_info( + link_info, source=json_data["node"], destination_ip=ip_address ) for ip_address, link_info in link_info.items() ], source_json=json_data, ) - # handle issue of failing to identify the main wireless interface - if not node_info.ip_address: - node_info.ip_address = ip_address return node_info def _load_legacy_system_info(json_data: dict[str, Any]) -> SystemInfo: + """Load `sysinfo.json` in older format (API version < 1.5).""" node_info = SystemInfo( node_name=json_data["node"], display_name=json_data["node"], @@ -574,3 +531,48 @@ def _load_interfaces(values: list[dict]) -> dict[str, Interface]: for obj in values ) return {iface.name: iface for iface in interfaces} + + +def _load_link_info( + json_data: dict[str, Any], *, source: str, destination_ip: str +) -> LinkInfo: + """Construct the `LinkInfo` dataclass from the AREDN JSON information. + + Needs the name of the source node and I passed in as well. + + Args: + json_data: JSON link information from `sysinfo.json` + source: Hostname of source node (no domain) + destination_ip: IP address of link destination + + """ + # fix example of a DTD link that wasn't properly identified as such + missing_dtd = ( + json_data["linkType"] == "" and json_data["olsrInterface"] == "br-dtdlink" + ) + type_ = "DTD" if missing_dtd else json_data["linkType"] + try: + link_type = getattr(LinkType, type_) + except AttributeError as exc: + logger.warning("Unknown link type", error=str(exc)) + link_type = LinkType.UNKNOWN + + # ensure consistent node names + node_name = json_data["hostname"].replace(".local.mesh", "").lstrip(".").lower() + if (link_cost := json_data.get("linkCost")) is not None and link_cost > 99.99: + link_cost = 99.99 + + return LinkInfo( + source=source, + destination=node_name, + destination_ip=destination_ip, + type=link_type, + interface=json_data["olsrInterface"], + quality=json_data["linkQuality"], + neighbor_quality=json_data["neighborLinkQuality"], + signal=json_data.get("signal"), + noise=json_data.get("noise"), + tx_rate=json_data.get("tx_rate"), + rx_rate=json_data.get("rx_rate"), + olsr_cost=link_cost, + ) diff --git a/tests/test_aredn.py b/tests/test_aredn.py index c3265c2..cc621fb 100644 --- a/tests/test_aredn.py +++ b/tests/test_aredn.py @@ -336,8 +336,8 @@ def test_invalid_link_json(): "olsrInterface": "eth.0", "linkType": "foobar", } - link_info = aredn.LinkInfo.from_json( - link_json, source="n0call-nsm1", ip_address="10.1.1.1" + link_info = aredn._load_link_info( + link_json, source="n0call-nsm1", destination_ip="10.1.1.1" ) expected = aredn.LinkInfo( source="n0call-nsm1", @@ -381,8 +381,8 @@ def test_link_cost_cap(): "lastHelloTime": 0, "signal": -83, } - link_info = aredn.LinkInfo.from_json( - link_json, source="N0CALL-hAP-AC-4", ip_address="10.84.160.54" + link_info = aredn._load_link_info( + link_json, source="N0CALL-hAP-AC-4", destination_ip="10.84.160.54" ) expected = aredn.LinkInfo( source="N0CALL-hAP-AC-4",