├── .github └── ISSUE_TEMPLATE │ ├── PULL_REQUEST_TEMPLATE.md │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── Dockerfile ├── LICENSE ├── README.md ├── meshtastic_visualizer.py ├── meshtastic_visualizer ├── __init__.py ├── datastore.py ├── devices.py ├── manager.py ├── mapper.py ├── mqtt.py ├── node_actions_widget.py ├── resources.py └── visualizer.py ├── pyproject.toml └── resources └── app.ui /.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the changes and the related issue(s). Explain the motivation behind these changes and what problem they are solving. 4 | 5 | Fixes # (issue number) 6 | 7 | ## Type of Change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change that fixes an issue) 12 | - [ ] New feature (non-breaking change that adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] Documentation update (if none of the other options apply) 15 | - [ ] Other (please describe): 16 | 17 | ## Checklist 18 | 19 | - [ ] My code follows the style guidelines of this project 20 | - [ ] I have performed a self-review of my own code 21 | - [ ] I have commented my code, particularly in hard-to-understand areas 22 | - [ ] I have made corresponding changes to the documentation 23 | - [ ] My changes generate no new warnings 24 | - [ ] I have added tests that prove my fix is effective or that my feature works 25 | - [ ] New and existing unit tests pass locally with my changes 26 | - [ ] Any dependent changes have been merged and published in downstream modules 27 | 28 | ## Screenshots (if applicable) 29 | 30 | If your changes include visual updates, please include screenshots to demonstrate the changes. 31 | 32 | ## Additional Information 33 | 34 | Add any other context or information about the pull request here. This might include links to relevant discussions, references to related issues, or any other important details. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug or unexpected behavior 4 | title: "[Bug]: " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '...' 17 | 3. Scroll down to '...' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment:** 27 | - OS: [e.g. Windows, Mac, Linux] 28 | - Browser [e.g. Chrome, Safari] 29 | - Version [e.g. 22] 30 | - Other relevant details [e.g. network settings, proxies, etc.] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea or enhancement 4 | title: "[Feature Request]: " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about the project 4 | title: "[Question]: " 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What's your question?** 11 | A clear and concise question you have about the project. 12 | 13 | **Additional context** 14 | Add any other context, code snippets, or examples here that may help answer your question. 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-bookworm 2 | 3 | ENV UV_INSTALL_DIR="/usr/local/bin/" 4 | 5 | RUN apt-get update \ 6 | && apt-get install -y sudo libgl1-mesa-glx libxkbcommon0 libegl1 libxcb-cursor0 libdbus-1-dev libnss3 libxcomposite-dev libxdamage-dev libxrandr-dev libxtst-dev libxkbfile1 libx11-dev libasound2 libxcb-cursor0 libxcb-xinerama0 qt6-base-dev fonts-recommended \ 7 | && apt-get clean \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | RUN useradd -ms /bin/bash runner 11 | RUN usermod -aG sudo runner 12 | RUN usermod -aG dialout runner 13 | 14 | RUN curl -LsSf https://astral.sh/uv/install.sh -o install.sh 15 | RUN sh install.sh 16 | 17 | WORKDIR /home/runner 18 | USER runner 19 | 20 | 21 | COPY meshtastic_visualizer meshtastic_visualizer 22 | COPY meshtastic_visualizer.py meshtastic_visualizer.py 23 | COPY resources resources 24 | COPY pyproject.toml pyproject.toml 25 | 26 | RUN uv python install 3.11 27 | RUN uv python pin 3.11 28 | RUN uv run true 29 | ENTRYPOINT ["uv", "run", "meshtastic_visualizer.py"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 antlas0 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Meshtastic Visualizer 2 | Python PyQt graphical desktop app to interface with a Meshtastic node using a TCP, Bluetooth or serial connection. Possibility to subscribe to MQTT servers and retrieve nodes, messages,... 3 | Objective is to use an already configured Meshtastic device, and be able to inspect messages, packets, metrics,... 4 | 5 | Initial work based on original repository of "Meshtastic Chat Desktop" 6 | 7 | Main framework used is PyQt6. 8 | Linux compatible, debian based tested (should work on Windows, compatibility not ensured). 9 | 10 | ## Features 11 | | Connection | Serial | TCP | Bluetooth | 12 | |---|---|---|---| 13 | |Availability|✅|✅|✅| 14 | 15 | 16 | | Feature | Using local device | Using MQTT | 17 | |---|---|---| 18 | | Display nodes configuration (PK, hopsaway,...)|✅|✅| 19 | | Display map of nodes |✅|✅| 20 | | Display messages |✅|✅| 21 | | Display packets |✅|✅| 22 | | Send messages with acknowledgment|✅|❌| 23 | | Perform traceroute (with SNR)|✅|❌| 24 | | Export nodes (json) |✅|✅| 25 | | Export messages and packets (json) |✅|✅| 26 | | Export telemetry metrics (json) |✅|✅| 27 | | Export mqtt logs |-|✅| 28 | | Export radio serial console |✅|-| 29 | | Nodes telemetry metrics plotting (CHutil, power,...) |✅|✅| 30 | | Packets RF metrics plotting (RSSI, SNR,...) |✅|✅| 31 | | Custom tiles server |✅|✅| 32 | 33 | ## How to download 34 | 35 | ```bash 36 | $ git clone https://github.com/antlas0/meshtastic_visualizer.git 37 | $ git checkout v1.8 38 | ``` 39 | 40 | ## How to install and run 41 | [uv](https://github.com/astral-sh/uv) is used to manage dependencies, venv and packaging. 42 | To install dependencies and run on your computer: 43 | ```bash 44 | $ uv run meshtastic_visualizer.py 45 | ``` 46 | 47 | Note: If you rely on Wayland, you may experience Qt event not properly managed. To fall back on a `X11` session, provide the following environment variable when launching the application: `XDG_SESSION_TYPE=x11`. 48 | Otherwise, you can try `QT_QPA_PLATFORM=xcb`, by having previously installed `libxcb-cursor0` package. 49 | 50 | 51 | ## How to run with Docker 52 | 53 | Based on X11, build the dockerfile and run the docker container. This example assumes your node is accessible at `/dev/ttyACM0`. 54 | ```bash 55 | $ export DISPLAY=:0.0 56 | $ xhost +local:docker 57 | $ docker build . -t meshtastic_visualizer:latest 58 | $ docker run -it \ 59 | --env="DISPLAY=$DISPLAY" \ 60 | --privileged \ 61 | --volume="/var/run/dbus/:/var/run/dbus/" \ 62 | --volume="/tmp/.X11-unix:/tmp/.X11-unix:rw" \ 63 | --device=/dev/ttyACM0 \ 64 | meshtastic_visualizer:latest 65 | ``` 66 | 67 | ## How to use a local tiles server 68 | To setup a local tiles server, one solution is to use Docker to run a server and point to it from the application. 69 | 70 | To import and setup the server, we can rely on [openstreetmap-tile-server](https://github.com/Overv/openstreetmap-tile-server). Globally we will: 71 | * Download the needed tiles in `.pbf` format along with the polylines in `.poly` format, from [https://download.geofabrik.de](https://download.geofabrik.de). Here is an example for all [France](https://download.geofabrik.de/europe/france.html). 72 | * Import them in a Docker volume 73 | ```bash 74 | $ docker volume create osm-data 75 | $ docker run \ 76 | -v /absolute/path/to/france.osm.pbf:/data/region.osm.pbf \ 77 | -v /absolute/path/to/france.poly:/data/region.poly \ 78 | -v osm-data:/data/database/ \ 79 | overv/openstreetmap-tile-server \ 80 | import 81 | ``` 82 | * Run the tiles server with this volume, in this case it will be bound to `0.0.0.0:8080` 83 | ```bash 84 | $ docker run \ 85 | -p 8080:80 \ 86 | -v osm-data:/data/database/ \ 87 | -d overv/openstreetmap-tile-server \ 88 | run 89 | ``` 90 | * Give to the application the tiles request `http://127.0.0.1:8080/tile/{z}/{x}/{y}.png`. 91 | ![Capture d’écran du 2025-06-05 15-46-54](https://github.com/user-attachments/assets/8ee0e3a1-730e-4d4c-8ad5-bbb55a1d771b) 92 | 93 | 94 | ## Todo 95 | A lot ! Please fill an issue to add ideas or raise bugs. 96 | 97 | Here is a list of things it could be intetesting to work on: 98 | 99 | #### App features 100 | 101 | - [ ] Code factorisation 102 | - [x] Custom tile server 103 | - [ ] Offline map 104 | - [ ] Theming 105 | - [ ] Quick node actions (shutdown,... TBD) 106 | - [ ] Traceroute results: review graphical display as not optimal 107 | - [ ] Map: add layer for only "online" nodes 108 | - [ ] Map: review "relay node" layer as not easily scalable 109 | 110 | #### Packaging 111 | 112 | - [ ] Automate non-regression build 113 | - [ ] Add backend unitary testing 114 | - [ ] Make Docker image available without having to build it 115 | 116 | ## Contributing 117 | Please open a Pull Request. 118 | 119 | ## Overview 120 | ![Capture d’écran du 2025-05-30 09-43-22](https://github.com/user-attachments/assets/2df80a1d-7895-498c-9389-684419566568) 121 | ![Capture d’écran du 2025-03-24 15-55-21](https://github.com/user-attachments/assets/dd2f10ae-442e-4958-ac51-50eafd4b5df1) 122 | ![Capture d’écran du 2025-03-24 15-55-30](https://github.com/user-attachments/assets/96f44374-dfa3-4e69-9d7f-a91b8038052b) 123 | ![Capture d’écran du 2025-03-24 15-55-36](https://github.com/user-attachments/assets/e16b66d1-79f4-4fc9-8d55-f1000f05ae13) 124 | ![Capture d’écran du 2025-05-30 09-43-36](https://github.com/user-attachments/assets/e0958a6a-ff74-4c32-b970-0664dcecf1c1) 125 | 126 | 127 | -------------------------------------------------------------------------------- /meshtastic_visualizer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | from PyQt6 import QtWidgets 5 | 6 | from meshtastic_visualizer import MeshtasticQtApp 7 | 8 | 9 | def main(): 10 | app = QtWidgets.QApplication(sys.argv) 11 | window = MeshtasticQtApp() 12 | window.show() 13 | app.exec() 14 | 15 | 16 | if __name__ == "__main__": 17 | main() 18 | -------------------------------------------------------------------------------- /meshtastic_visualizer/__init__.py: -------------------------------------------------------------------------------- 1 | from .visualizer import MeshtasticQtApp 2 | 3 | __all__ = ["MeshtasticQtApp"] -------------------------------------------------------------------------------- /meshtastic_visualizer/datastore.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import queue 4 | import copy 5 | import datetime 6 | from dataclasses import dataclass, fields, field 7 | from typing import List, Optional, Any, Dict 8 | from threading import Lock, Thread 9 | 10 | from .resources import Channel, \ 11 | MeshtasticNode, \ 12 | MeshtasticMessage, \ 13 | NodeMetrics, \ 14 | MQTTPacket, \ 15 | RadioPacket 16 | 17 | 18 | @dataclass 19 | class MeshtasticDataStore(Thread): 20 | channels: List[Channel] = field(default_factory=list) 21 | local_node_config: Optional[MeshtasticNode] = None 22 | nodes: Dict[str, MeshtasticNode] = field( 23 | default_factory=dict) # Dict[node_id, Node object] 24 | messages: Dict[str, MeshtasticMessage] = field( 25 | default_factory=dict) # Dict[message_id, Message object] 26 | metrics: Dict[str, List[NodeMetrics]] = field( 27 | default_factory=dict) # Dict[node_id, Dict[metric_name, List[value]]] 28 | mqttpackets: Dict[str, MQTTPacket] = field( 29 | default_factory=dict) 30 | radiopackets: Dict[str, RadioPacket] = field( 31 | default_factory=dict) 32 | 33 | def __post_init__(self) -> None: 34 | self._lock = Lock() 35 | self._metrics_maxlength = 120 36 | self.task_queue = queue.Queue() 37 | 38 | def get_local_node_config(self) -> MeshtasticNode: 39 | self._lock.acquire() 40 | res = copy.copy(self.local_node_config) 41 | self._lock.release() 42 | return res 43 | 44 | def set_local_node_config(self, config: MeshtasticNode) -> None: 45 | self._lock.acquire() 46 | self.local_node_config = config 47 | self._lock.release() 48 | 49 | def set_local_node_config_field(self, field: str, value: Any) -> None: 50 | self._lock.acquire() 51 | setattr(self.local_node_config, field, value) 52 | self._lock.release() 53 | 54 | def get_channels(self) -> Optional[List[Channel]]: 55 | self._lock.acquire() 56 | res = copy.copy(self.channels) 57 | self._lock.release() 58 | return res 59 | 60 | def store_mqtt_packet(self, packet: MQTTPacket) -> None: 61 | self._lock.acquire() 62 | key = str(packet.date) 63 | self.mqttpackets[key] = packet 64 | self._lock.release() 65 | 66 | def get_mqtt_packets(self) -> List: 67 | self._lock.acquire() 68 | packets = copy.deepcopy(list(self.mqttpackets.values())) 69 | self._lock.release() 70 | return packets 71 | 72 | def store_radiopacket(self, packet: RadioPacket) -> None: 73 | self._lock.acquire() 74 | key = str(packet.date) 75 | self.radiopackets[key] = packet 76 | self._lock.release() 77 | 78 | def get_radio_packets(self) -> List: 79 | self._lock.acquire() 80 | packets = copy.deepcopy(list(self.radiopackets.values())) 81 | self._lock.release() 82 | return packets 83 | 84 | def get_radio_packet(self, pid: int) -> Optional[RadioPacket]: 85 | self._lock.acquire() 86 | packet = list( 87 | filter( 88 | lambda x: x.pid == pid, 89 | self.radiopackets.values())) 90 | if len(packet) == 1: 91 | packet = copy.deepcopy(packet[0]) 92 | else: 93 | packet = None 94 | self._lock.release() 95 | return packet 96 | 97 | def get_mqtt_packet(self, pid: int) -> Optional[MQTTPacket]: 98 | self._lock.acquire() 99 | packet = None 100 | if pid in self.mqttpackets: 101 | packet = copy.deepcopy(self.mqttpackets[pid]) 102 | self._lock.release() 103 | return packet 104 | 105 | def clear_radio_packets(self) -> None: 106 | self._lock.acquire() 107 | del self.radiopackets 108 | self.radiopackets = {} 109 | self._lock.release() 110 | 111 | def clear_mqtt_packets(self) -> None: 112 | self._lock.acquire() 113 | del self.mqttpackets 114 | self.mqttpackets = {} 115 | self._lock.release() 116 | 117 | def get_channel_index_from_name(self, name: str) -> int: 118 | self._lock.acquire() 119 | res: int = -1 120 | if self.channels is None: 121 | pass 122 | else: 123 | channel = list(filter(lambda x: x.name == name, self.channels)) 124 | if len(channel) != 1: 125 | pass 126 | else: 127 | res = channel[0].index 128 | self._lock.release() 129 | return res 130 | 131 | def has_seen_node_id(self, node_id:str) -> bool: 132 | res = False 133 | self._lock.acquire() 134 | res = (node_id in self.nodes.keys()) and (self.nodes[node_id].rx_counter > 0) 135 | self._lock.release() 136 | return res 137 | 138 | def has_node_id(self, node_id:str) -> bool: 139 | res = False 140 | self._lock.acquire() 141 | res = (node_id in self.nodes.keys()) 142 | self._lock.release() 143 | return res 144 | 145 | def get_id_from_long_name(self, long_name_or_id: str) -> str: 146 | self._lock.acquire() 147 | res: str = "" 148 | nodes = self.nodes.values() 149 | if nodes is None: 150 | res = "" 151 | elif long_name_or_id == "All": 152 | res = "^all" 153 | else: 154 | node = list( 155 | filter( 156 | lambda x: x.long_name == long_name_or_id, 157 | nodes)) 158 | if len(node) != 1: 159 | res = long_name_or_id 160 | else: 161 | res = node[0].id 162 | self._lock.release() 163 | return res 164 | 165 | def get_id_from_short_name(self, short_name_or_id: str) -> str: 166 | self._lock.acquire() 167 | res: str = "" 168 | nodes = self.nodes.values() 169 | if nodes is None: 170 | res = "" 171 | elif short_name_or_id == "All": 172 | res = "^all" 173 | else: 174 | node = list( 175 | filter( 176 | lambda x: x.short_name == short_name_or_id, 177 | nodes)) 178 | if len(node) != 1: 179 | res = short_name_or_id 180 | else: 181 | res = node[0].id 182 | self._lock.release() 183 | return res 184 | 185 | def get_long_name_from_id(self, id: str) -> str: 186 | self._lock.acquire() 187 | res: str = id 188 | nodes = self.nodes.values() 189 | if not nodes: 190 | res = id 191 | node = list(filter(lambda x: x.id == id, nodes)) 192 | if len(node) != 1: 193 | res = id 194 | else: 195 | res = node[0].long_name if node[0].long_name else node[0].id 196 | 197 | if id == "!ffffffff": 198 | res = "All" 199 | 200 | self._lock.release() 201 | return res 202 | 203 | def get_short_name_from_id(self, id: str) -> str: 204 | self._lock.acquire() 205 | res: str = id 206 | nodes = self.nodes.values() 207 | if not nodes: 208 | res = id 209 | node = list(filter(lambda x: x.id == id, nodes)) 210 | if len(node) != 1: 211 | res = id 212 | else: 213 | res = node[0].short_name if node[0].short_name else node[0].id 214 | 215 | if id == "!ffffffff": 216 | res = "All" 217 | 218 | self._lock.release() 219 | return res 220 | 221 | def get_node_from_id(self, node_id: str) -> Optional[MeshtasticNode]: 222 | self._lock.acquire() 223 | res = None 224 | try: 225 | res = copy.deepcopy(self.nodes[node_id]) 226 | except Exception: 227 | pass 228 | self._lock.release() 229 | return res 230 | 231 | def add_neighbor(self, me: str, my_neighbor: str) -> None: 232 | if me == my_neighbor: 233 | return 234 | self._lock.acquire() 235 | for k, v in {me: my_neighbor, my_neighbor: me}.items(): 236 | if k in self.nodes.keys() and self.nodes[k].neighbors is None: 237 | self.nodes[k].neighbors = [] 238 | 239 | if k in self.nodes.keys() and v not in self.nodes[k].neighbors: 240 | self.nodes[k].neighbors.append(v) 241 | self._lock.release() 242 | 243 | def get_nodes(self) -> dict: 244 | self._lock.acquire() 245 | res = copy.deepcopy(self.nodes) 246 | self._lock.release() 247 | return res 248 | 249 | def clear_nodes(self) -> None: 250 | self._lock.acquire() 251 | del self.nodes 252 | self.nodes = {} 253 | self._lock.release() 254 | 255 | def get_messages(self) -> List: 256 | self._lock.acquire() 257 | messages = list(self.messages.values()) 258 | self._lock.release() 259 | return messages 260 | 261 | def clear_messages(self) -> None: 262 | self._lock.acquire() 263 | del self.messages 264 | self.messages = {} 265 | self._lock.release() 266 | 267 | def clear_nodes_metrics(self) -> None: 268 | self._lock.acquire() 269 | del self.metrics 270 | self.metrics = {} 271 | self._lock.release() 272 | 273 | def update_node_rx_counter(self, node: MeshtasticNode) -> None: 274 | rx_counter = getattr(self.nodes[str(node.id)], "rx_counter") + 1 275 | # update the received packet counter 276 | setattr(self.nodes[str(node.id)], "rx_counter", rx_counter) 277 | 278 | def store_or_update_node( 279 | self, 280 | node: MeshtasticNode, 281 | init: bool = False) -> None: 282 | self._lock.acquire() 283 | if init: 284 | self.nodes[str(node.id)] = node 285 | self._lock.release() 286 | return 287 | 288 | if not str(node.id) in self.nodes.keys(): 289 | # not previously in nodedb and discovering at runtime 290 | # meaning we got a packet from this node 291 | self.nodes[str(node.id)] = node 292 | node.firstseen = datetime.datetime.now() 293 | node.lastseen = node.firstseen 294 | node.rx_counter = 0 295 | else: 296 | # update already known node 297 | # either it was in nodedb and it is the first packet we get 298 | # either this is not the first packet we get 299 | def __get_nodes_fields(): 300 | return [field for field in fields( 301 | MeshtasticNode) if not field.name.startswith('_')] 302 | 303 | # if in nodedb previously but unseen so far (rx_counter == 0) 304 | if getattr(self.nodes[str(node.id)], "rx_counter") == 0: 305 | node.firstseen = datetime.datetime.now() 306 | node.lastseen = node.firstseen 307 | 308 | for f in __get_nodes_fields(): 309 | if getattr(self.nodes[str(node.id)], 310 | f.name) != getattr(node, f.name): 311 | if getattr( 312 | node, 313 | f.name) is not None: 314 | setattr(self.nodes[str(node.id)], f.name, 315 | getattr(node, f.name)) 316 | self._lock.release() 317 | 318 | def store_or_update_messages( 319 | self, 320 | message: MeshtasticMessage, 321 | only_update: bool = False) -> None: 322 | self._lock.acquire() 323 | key = list( 324 | filter( 325 | lambda x: self.messages[x].mid == message.mid, 326 | self.messages.keys())) 327 | if len(key) == 0: 328 | if not only_update: 329 | self.messages[message.mid] = message 330 | else: 331 | key = key[0] 332 | for field in fields(MeshtasticMessage): 333 | if getattr(message, field.name) is not None: 334 | setattr( 335 | self.messages[key], 336 | field.name, 337 | getattr( 338 | message, 339 | field.name)) 340 | self._lock.release() 341 | 342 | def get_node_metrics_fields(self) -> list: 343 | return [ 344 | "uptime", 345 | "voltage", 346 | "air_util_tx", 347 | "num_packets_tx", 348 | "num_tx_relay", 349 | "num_tx_relay_canceled", 350 | "channel_utilization", 351 | "battery_level", 352 | ] 353 | 354 | def get_packet_metrics_fields(self) -> list: 355 | return [ 356 | "snr", 357 | "rssi", 358 | "hopsaway", 359 | ] 360 | 361 | def store_or_update_node_metrics(self, new_metric: NodeMetrics) -> None: 362 | self._lock.acquire() 363 | if new_metric.node_id not in self.metrics.keys(): 364 | self.metrics[new_metric.node_id] = [] 365 | self.metrics[new_metric.node_id].append(new_metric) 366 | if len(self.metrics[new_metric.node_id]) > self._metrics_maxlength: 367 | self.metrics[new_metric.node_id][0] 368 | self._lock.release() 369 | 370 | def get_node_metrics(self, node_id: str, metric: str) -> Dict: 371 | self._lock.acquire() 372 | res: Dict[str, List[Any]] = {} 373 | if node_id not in self.metrics.keys(): 374 | res = {} 375 | else: 376 | if metric not in self.get_node_metrics_fields(): 377 | res = {} 378 | else: 379 | timestamp = [x.timestamp for x in self.metrics[node_id]] 380 | values = [getattr(x, metric) for x in self.metrics[node_id]] 381 | res["timestamp"] = timestamp 382 | res["value"] = values 383 | self._lock.release() 384 | return res.copy() 385 | 386 | def get_packet_metrics(self, node_id: str, metric: str, port_num:str="all") -> Dict: 387 | self._lock.acquire() 388 | res: Dict[str, List[Any]] = {} 389 | 390 | packets: list = list(copy.deepcopy(self.radiopackets).values( 391 | )) + list(copy.deepcopy(self.mqttpackets).values()) 392 | filtered = list(filter(lambda x: x.from_id == node_id, packets)) 393 | if port_num.lower() != "all": 394 | filtered = list(filter(lambda x: x.port_num == port_num, filtered)) 395 | 396 | if len(filtered) == 0: 397 | res = {} 398 | else: 399 | if metric not in self.get_packet_metrics_fields(): 400 | res = {} 401 | else: 402 | timestamp = [x.date.timestamp() for x in filtered] 403 | values = [getattr(x, metric) for x in filtered] 404 | res["timestamp"] = timestamp 405 | res["value"] = values 406 | self._lock.release() 407 | return res.copy() 408 | 409 | def enqueue_task(self, task, *args, **kwargs): 410 | self.task_queue.put((task, args, kwargs)) 411 | 412 | def quit(self): 413 | # Signal the thread to exit 414 | self.task_queue.put((None, [], {})) 415 | 416 | def run(self): 417 | while True: 418 | task, args, kwargs = self.task_queue.get() 419 | if task is None: 420 | break 421 | task(*args, **kwargs) 422 | self.task_queue.task_done() 423 | -------------------------------------------------------------------------------- /meshtastic_visualizer/devices.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin.env python3 2 | 3 | from typing import List 4 | import serial.tools.list_ports 5 | 6 | 7 | def list_serial_ports() -> List[str]: 8 | """ 9 | Return a list of all available serial devices 10 | """ 11 | ports = serial.tools.list_ports.comports() 12 | return [port.device for port in ports] 13 | -------------------------------------------------------------------------------- /meshtastic_visualizer/manager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import queue 4 | import base64 5 | import logging 6 | import datetime 7 | from pubsub import pub 8 | from typing import Union, Optional 9 | import google.protobuf.json_format 10 | from typing import List, Optional 11 | import threading 12 | from threading import Lock 13 | import meshtastic 14 | import meshtastic.serial_interface 15 | import meshtastic.tcp_interface 16 | from meshtastic.ble_interface import BLEInterface 17 | from meshtastic import channel_pb2, portnums_pb2, mesh_pb2, config_pb2, telemetry_pb2 18 | from PyQt6.QtCore import pyqtSignal, QObject 19 | 20 | 21 | from .devices import list_serial_ports 22 | from .resources import run_in_thread, \ 23 | MessageLevel, \ 24 | Channel, \ 25 | MeshtasticNode, \ 26 | MeshtasticMessage, \ 27 | PacketInfoType, \ 28 | NodeMetrics, \ 29 | RadioPacket, \ 30 | Packet, \ 31 | ConnectionKind, \ 32 | BROADCAST_NAME, \ 33 | sneaky_to_camel 34 | 35 | 36 | from .datastore import MeshtasticDataStore 37 | 38 | 39 | # Enable logging but set to ERROR level to suppress debug/info messages 40 | logging.basicConfig(level=logging.ERROR) 41 | 42 | FILE_IDENTIFIER = b'FILEDATA:' 43 | ANNOUNCE_IDENTIFIER = b'FILEINFO:' 44 | CHUNK_SIZE = 100 # Chunk size in bytes 45 | 46 | 47 | class MeshtasticManager(QObject, threading.Thread): 48 | 49 | notify_frontend_signal = pyqtSignal(MessageLevel, str) 50 | refresh_ui_signal = pyqtSignal() 51 | notify_local_device_configuration_signal = pyqtSignal(str) 52 | notify_log_line = pyqtSignal(str) 53 | notify_new_packet = pyqtSignal(Packet) 54 | notify_message_signal = pyqtSignal() 55 | notify_ble_devices_signal = pyqtSignal(list) 56 | notify_serial_devices_signal = pyqtSignal(list) 57 | notify_traceroute_signal = pyqtSignal(list, list, list) 58 | notify_channels_signal = pyqtSignal() 59 | notify_nodes_update = pyqtSignal(MeshtasticNode) 60 | notify_nodes_metrics_signal = pyqtSignal() 61 | 62 | def __init__(self): 63 | super().__init__() 64 | self._data = None 65 | self._interface = None 66 | self._local_board_id: str = "" 67 | self.task_queue = queue.Queue() 68 | self.daemon = True 69 | self._store_lock = Lock() 70 | self._interface: Optional[meshtastic.serial_interface.SerialInterface] = None 71 | self._is_serial_connected = False 72 | self._is_tcp_connected = False 73 | self._is_ble_connected = False 74 | 75 | def is_connected(self) -> bool: 76 | return self._is_serial_connected or self._is_tcp_connected or self._is_ble_connected 77 | 78 | def is_serial_connected(self) -> bool: 79 | return self._is_serial_connected 80 | 81 | def is_tcp_connected(self) -> bool: 82 | return self._is_tcp_connected 83 | 84 | def is_ble_connected(self) -> bool: 85 | return self._is_ble_connected 86 | 87 | def set_store(self, store: MeshtasticDataStore) -> None: 88 | self._data = store 89 | 90 | def get_data_store(self) -> MeshtasticDataStore: 91 | return self._data 92 | 93 | @run_in_thread 94 | def serial_scan_devices(self) -> List[str]: 95 | devices = list_serial_ports() 96 | self.notify_serial_devices_signal.emit(devices) 97 | 98 | @run_in_thread 99 | def ble_scan_devices(self) -> None: 100 | try: 101 | devices = BLEInterface.scan() 102 | except Exception: 103 | self.notify_frontend_signal.emit(MessageLevel.ERROR, "Not able to scan devices") 104 | devices = [] 105 | 106 | self.notify_ble_devices_signal.emit(devices) 107 | 108 | @run_in_thread 109 | def connect_device(self, connection_kind:ConnectionKind, target:str, load_db: bool = True) -> bool: 110 | res = False 111 | if self._interface is not None: 112 | self.refresh_ui_signal.emit() 113 | return res 114 | try: 115 | if connection_kind == ConnectionKind.SERIAL: 116 | self._interface = meshtastic.serial_interface.SerialInterface(devPath=target) 117 | if connection_kind == ConnectionKind.TCP: 118 | if target.startswith("http://"): 119 | target = target.replace("http://", "") 120 | self._interface = meshtastic.tcp_interface.TCPInterface(hostname=target) 121 | if connection_kind == ConnectionKind.BLE: 122 | self._interface = meshtastic.ble_interface.BLEInterface(target) 123 | except Exception as e: 124 | trace = f"Failed to connect to Meshtastic device {target}: {str(e)}" 125 | self.notify_frontend_signal.emit(MessageLevel.ERROR, trace) 126 | self.refresh_ui_signal.emit() 127 | else: 128 | # Subscribe to received message events 129 | pub.subscribe(self.on_log, "meshtastic.log.line") 130 | pub.subscribe(self.on_receive, "meshtastic.receive") 131 | trace = f"Successfully connected to Meshtastic device {target}" 132 | if connection_kind == ConnectionKind.SERIAL: 133 | self._is_serial_connected = True 134 | if connection_kind == ConnectionKind.TCP: 135 | self._is_tcp_connected = True 136 | if connection_kind == ConnectionKind.BLE: 137 | self._is_ble_connected = True 138 | self.retrieve_channels() 139 | 140 | node = self._interface.getMyNodeInfo() 141 | self._local_board_id = node["user"]["id"] 142 | self.load_local_nodedb(only_self=(not load_db)) 143 | self.load_local_node_configuration() 144 | self.get_local_node_details() 145 | self.notify_frontend_signal.emit(MessageLevel.INFO, trace) 146 | res = True 147 | finally: 148 | pass 149 | self.refresh_ui_signal.emit() 150 | return res 151 | 152 | def get_local_node_infos(self, dummy) -> None: 153 | self.load_local_node_configuration() 154 | self.get_local_node_details() 155 | self.retrieve_channels() 156 | self.refresh_ui_signal.emit() 157 | 158 | @run_in_thread 159 | def disconnect_device(self) -> bool: 160 | res = False 161 | if self._interface is None: 162 | self.refresh_ui_signal.emit() 163 | return False 164 | 165 | try: 166 | self._interface.close() 167 | del self._interface 168 | self._interface = None 169 | except Exception as e: 170 | trace = f"Failed to disconnect from Meshtastic device: {str(e)}" 171 | self.notify_frontend_signal.emit(MessageLevel.ERROR, trace) 172 | else: 173 | trace = f"Meshtastic device disconnected." 174 | self._is_serial_connected = False 175 | self._is_tcp_connected = False 176 | self._is_ble_connected = False 177 | self.notify_frontend_signal.emit(MessageLevel.INFO, trace) 178 | res = True 179 | finally: 180 | pass 181 | 182 | self.refresh_ui_signal.emit() 183 | return res 184 | 185 | def get_local_node_details(self) -> None: 186 | conf = [] 187 | try: 188 | conf.append(str(self._interface.getNode("^local").localConfig)) 189 | conf.append(str(self._interface.getNode("^local").moduleConfig)) 190 | conf.extend([ str(x) for x in self._interface.getNode("^local").channels]) 191 | except Exception as e: 192 | pass 193 | else: 194 | self.notify_local_device_configuration_signal.emit("\n".join(conf)) 195 | 196 | def load_local_node_configuration(self) -> None: 197 | if self._interface is None: 198 | return 199 | 200 | node = self._interface.getMyNodeInfo() 201 | batlevel = node["deviceMetrics"]["batteryLevel"] if "deviceMetrics" in node else 0 202 | if batlevel > 100: 203 | batlevel = 100 204 | 205 | n = MeshtasticNode( 206 | id=node["user"]["id"], 207 | long_name=node["user"]["longName"], 208 | short_name=node["user"]["shortName"], 209 | hardware=node["user"]["hwModel"], 210 | role=node["user"]["role"] if "role" in node["user"] else None, 211 | lat=str( 212 | node["position"]["latitude"]) if "position" in node and "latitude" in node["position"] else None, 213 | lon=str( 214 | node["position"]["longitude"] if "position" in node and "longitude" in node["position"] else None), 215 | lastseen=datetime.datetime.fromtimestamp( 216 | node["lastHeard"]) if "lastHeard" in node and node["lastHeard"] is not None else None, 217 | battery_level=batlevel, 218 | hopsaway=int( 219 | node["hopsAway"]) if "hopsAway" in node else None, 220 | snr=round( 221 | node["snr"], 222 | 2) if "snr" in node else None, 223 | txairutil=round( 224 | node["deviceMetrics"]["airUtilTx"], 225 | 2) if "deviceMetrics" in node and "airUtilTx" in node["deviceMetrics"] else None, 226 | chutil=round( 227 | node["deviceMetrics"]["channelUtilization"], 228 | 2) if "deviceMetrics" in node and "channelUtilization" in node["deviceMetrics"] else None, 229 | uptime=node["deviceMetrics"]["uptimeSeconds"] if "deviceMetrics" in node and "uptimeSeconds" in node["deviceMetrics"] else None, 230 | is_local=True, 231 | public_key=node["user"]["publicKey"]) 232 | 233 | self._local_board_id = node["user"]["id"] 234 | 235 | self._data.set_local_node_config(n) 236 | 237 | def on_log(self, interface, line:str) -> None: 238 | self.notify_log_line.emit(line) 239 | 240 | def on_receive(self, packet, interface): 241 | try: 242 | decoded = packet['decoded'] 243 | except KeyError: 244 | # encrypted packet 245 | decoded = {} 246 | decoded["payload"] = "" 247 | decoded["portnum"] = "UNKNOWN_APP" 248 | 249 | nodes_to_update: list = [] 250 | 251 | node_from = MeshtasticNode( 252 | id=self._node_id_from_num( 253 | packet["from"]) 254 | ) 255 | node_from.is_local = node_from.id == self._local_board_id 256 | 257 | received_packet = RadioPacket( 258 | date=datetime.datetime.now(), 259 | pid=packet["id"], 260 | from_id=self._node_id_from_num(packet['from']), 261 | to_id=self._node_id_from_num(packet['to']), 262 | is_encrypted=True if "encrypted" in packet else False, 263 | payload=decoded['payload'], 264 | decoded=str(decoded).replace("\n", " "), 265 | port_num=decoded["portnum"], 266 | snr=packet["rxSnr"] if "rxSnr" in packet else None, 267 | rssi=packet["rxRssi"] if "rxRssi" in packet else None, 268 | hop_limit=packet["hopLimit"] if "hopLimit" in packet else None, 269 | hop_start=packet["hopStart"] if "hopStart" in packet else None, 270 | priority=packet["priority"] if "priority" in packet else None, 271 | ) 272 | if received_packet.hop_limit is not None and received_packet.hop_start is not None: 273 | received_packet.hopsaway = received_packet.hop_start - received_packet.hop_limit 274 | 275 | for f in ["relay_node", "next_hop"]: 276 | if sneaky_to_camel(f) in packet and packet[sneaky_to_camel(f)] != 0: 277 | setattr(received_packet, f, f"{packet[sneaky_to_camel(f)]:0x}") 278 | setattr(node_from, f, f"{packet[sneaky_to_camel(f)]:0x}") 279 | 280 | self._data.store_radiopacket(received_packet) 281 | 282 | node_from.rssi = round( 283 | packet["rxRssi"], 284 | 2) if "rxRssi" in packet else None 285 | node_from.snr = round( 286 | packet["rxSnr"], 287 | 2) if "rxSnr" in packet else None 288 | if "hopsAway" in packet: 289 | node_from.hopsaway = int(packet["hopsAway"]) 290 | elif ("hopLimit" in packet and "hopStart" in packet): 291 | node_from.hopsaway = ( 292 | int(packet["hopStart"]) - int(packet["hopLimit"])) 293 | if node_from.hopsaway is not None and node_from.hopsaway == 0: 294 | self._data.add_neighbor(self._local_board_id, node_from.id) 295 | 296 | node_from.lastseen = datetime.datetime.now() 297 | 298 | if decoded["portnum"] == PacketInfoType.PCK_TELEMETRY_APP.value: 299 | env = telemetry_pb2.Telemetry() 300 | try: 301 | env.ParseFromString(decoded["payload"]) 302 | except Exception as e: 303 | pass 304 | else: 305 | node_from.lastseen = datetime.datetime.now() 306 | nm = NodeMetrics( 307 | node_id=node_from.id, 308 | timestamp=int(round(datetime.datetime.now().timestamp())), 309 | ) 310 | if env.HasField("device_metrics"): 311 | node_from.txairutil = round(env.device_metrics.air_util_tx, 2) 312 | node_from.battery_level = round(env.device_metrics.battery_level) 313 | node_from.chutil = round(env.device_metrics.channel_utilization, 2) 314 | node_from.voltage = round(env.device_metrics.voltage, 2) 315 | node_from.uptime = env.device_metrics.uptime_seconds 316 | nm.air_util_tx = round(env.device_metrics.air_util_tx, 2) 317 | nm.battery_level = round(env.device_metrics.battery_level) 318 | nm.channel_utilization = round(env.device_metrics.channel_utilization, 2) 319 | nm.voltage = round(env.device_metrics.voltage, 2) 320 | nm.uptime = env.device_metrics.uptime_seconds 321 | 322 | if env.HasField("local_stats"): 323 | nm.num_packets_tx = env.local_stats.num_packets_tx 324 | nm.num_tx_relay = env.local_stats.num_tx_relay 325 | nm.num_tx_relay_canceled = env.local_stats.num_tx_relay_canceled 326 | node_from.tx_counter = (env.local_stats.num_packets_tx + env.local_stats.num_tx_relay) 327 | 328 | self._data.store_or_update_node_metrics(nm) 329 | self.notify_nodes_metrics_signal.emit() 330 | 331 | if decoded["portnum"] == PacketInfoType.PCK_POSITION_APP.value: 332 | position = mesh_pb2.Position() 333 | try: 334 | position.ParseFromString(decoded["payload"]) 335 | 336 | except Exception as e: 337 | pass 338 | else: 339 | if position.latitude_i != 0 and position.longitude_i != 0: 340 | node_from.lat = str( 341 | round(position.latitude_i * 1e-7, 7)) 342 | node_from.lon = str( 343 | round(position.longitude_i * 1e-7, 7)) 344 | node_from.alt = str(position.altitude) 345 | 346 | if decoded["portnum"] == PacketInfoType.PCK_ROUTING_APP.value: 347 | ack_label = decoded["routing"]["errorReason"] 348 | acked_message_id = decoded["requestId"] 349 | 350 | ack_status = { 351 | "MAX_RETRANSMIT": False, 352 | "NONE": True, 353 | "NO_RESPONSE": False, 354 | "PKI_FAILED": False, 355 | } 356 | 357 | p = self._data.get_radio_packet(acked_message_id) 358 | if p and p.port_num == PacketInfoType.PCK_TEXT_MESSAGE_APP.value: 359 | m = MeshtasticMessage( 360 | mid=acked_message_id, 361 | ack_status=ack_status[ack_label] if ack_label in ack_status else None, 362 | ack_by=packet['fromId']) 363 | self._data.store_or_update_messages(m, only_update=True) 364 | self.notify_message_signal.emit() 365 | 366 | if decoded["portnum"] == PacketInfoType.PCK_TRACEROUTE_APP.value: 367 | route = self._extract_route_discovery(packet) 368 | neighbors = self._extract_route_neighbors(route) 369 | 370 | for k, v in neighbors.items(): 371 | self._data.add_neighbor(k, v) 372 | 373 | snr_towards: list = [] 374 | snr_back: list = [] 375 | 376 | # https://js.meshtastic.org/types/Protobuf.Mesh.RouteDiscovery.html 377 | # values scaled by 4 378 | SCALING_FACTOR = 4.0 379 | try: 380 | snr_towards = [str(float(x) / SCALING_FACTOR) 381 | for x in decoded["traceroute"]["snrTowards"]] 382 | except Exception: 383 | pass 384 | try: 385 | snr_back = [str(float(x) / SCALING_FACTOR) 386 | for x in decoded["traceroute"]["snrBack"]] 387 | except Exception: 388 | pass 389 | 390 | l = list(route) 391 | l.pop(0) 392 | for hop, node_id in enumerate(l): 393 | nodes_to_update.append( 394 | MeshtasticNode( 395 | id=node_id, 396 | hopsaway=hop, 397 | ) 398 | ) 399 | 400 | self.notify_traceroute_signal.emit(route, snr_towards, snr_back) 401 | if decoded["portnum"] == PacketInfoType.PCK_NODEINFO_APP.value: 402 | info = mesh_pb2.User() 403 | try: 404 | info.ParseFromString(decoded["payload"]) 405 | except Exception as e: 406 | pass 407 | else: 408 | node_from.long_name = info.long_name 409 | node_from.short_name = info.short_name 410 | node_from.hardware = mesh_pb2.HardwareModel.Name(info.hw_model) 411 | node_from.role = config_pb2.Config.DeviceConfig.Role.Name( 412 | info.role) 413 | node_from.public_key = str(info.public_key) 414 | 415 | if decoded["portnum"] == PacketInfoType.PCK_NEIGHBORINFO_APP.value: 416 | if "neighbors" in decoded["neighborinfo"]: 417 | for x in decoded["neighborinfo"]["neighbors"]: 418 | self._data.add_neighbor( 419 | node_from.id, self._node_id_from_num( 420 | x["nodeId"])) 421 | 422 | if decoded["portnum"] == PacketInfoType.PCK_TEXT_MESSAGE_APP.value: 423 | data = decoded['payload'] 424 | try: 425 | current_message = data.decode('utf-8').strip() 426 | except UnicodeDecodeError: 427 | print(f"Received non-text payload: {decoded['payload']}") 428 | return 429 | else: 430 | if len(current_message) == 0: 431 | return 432 | 433 | m = MeshtasticMessage( 434 | mid=packet["id"], 435 | date=datetime.datetime.now(), 436 | content=current_message, 437 | rx_rssi=packet['rxRssi'] if 'rxRssi' in packet else None, 438 | rx_snr=packet['rxSnr'] if 'rxSnr' in packet else None, 439 | from_id=self._node_id_from_num( 440 | packet['from']) if "from" in packet else None, 441 | to_id=self._node_id_from_num( 442 | packet['to']), 443 | channel_index=packet["channel"] if "channel" in packet else 0, 444 | hop_limit=packet['hopLimit'] if 'hopLimit' in packet else None, 445 | hop_start=packet['hopStart'] if 'hopStart' in packet else None, 446 | want_ack=packet['wantAck'] if 'wantAck' in packet else None, 447 | public_key=packet["publicKey"] if "publicKey" in packet else "", 448 | pki_encrypted=packet["pkiEncrypted"] if "pkiEncrypted" in packet else False, 449 | ) 450 | 451 | if m.to_id == self._local_board_id: 452 | m.ack_status = True 453 | m.ack_by = self._local_board_id 454 | 455 | self._data.store_or_update_messages(m) 456 | self.notify_message_signal.emit() 457 | 458 | # update node whose packet was received 459 | self._data.store_or_update_node(node_from) 460 | self._data.update_node_rx_counter(node_from) 461 | 462 | # update nodes consequent to received info 463 | self.update_nodes_info(nodes_to_update) 464 | self.notify_nodes_update.emit(node_from) 465 | self.notify_new_packet.emit(received_packet) 466 | 467 | def _extract_route_discovery(self, packet) -> list: 468 | route: list = [] 469 | routeDiscovery = mesh_pb2.RouteDiscovery() 470 | try: 471 | routeDiscovery.ParseFromString(packet["decoded"]["payload"]) 472 | asDict = google.protobuf.json_format.MessageToDict(routeDiscovery) 473 | route: list = [self._node_id_from_num(packet["to"])] 474 | if "route" in asDict: 475 | for nodeNum in asDict["route"]: 476 | route.append(self._node_id_from_num(nodeNum)) 477 | route.append(self._node_id_from_num(packet["from"])) 478 | except Exception as e: 479 | logging.warning(f"Could not extract route discovery {e}") 480 | 481 | return route 482 | 483 | def _extract_route_neighbors(self, route: list) -> dict: 484 | neighbors = {} 485 | if not route or len(route) <= 1: 486 | return {} 487 | for i in range(len(route) - 1): 488 | neighbors[route[i]] = route[i + 1] 489 | return neighbors 490 | 491 | @run_in_thread 492 | def send_text_message(self, message: MeshtasticMessage): 493 | if self._interface is None: 494 | return 495 | 496 | message.pki_encrypted = False 497 | if message.to_id != BROADCAST_NAME: 498 | message.pki_encrypted = True 499 | if not self._data.has_node_id(self._data.get_id_from_short_name(message.to_id)): 500 | self.notify_frontend_signal.emit(MessageLevel.ERROR, f"Node id unknown {message.to_id}") 501 | return 502 | 503 | try: 504 | sent_packet = self._interface.sendData( 505 | data=message.content.encode("utf8"), 506 | destinationId=message.to_id, 507 | portNum=portnums_pb2.PortNum.TEXT_MESSAGE_APP, 508 | wantAck=message.want_ack, 509 | channelIndex=message.channel_index, 510 | onResponseAckPermitted=False, 511 | pkiEncrypted=message.pki_encrypted, 512 | ) 513 | except Exception as e: 514 | self.notify_frontend_signal.emit(MessageLevel.ERROR, f"Could not send message to {message.to_id}") 515 | return 516 | 517 | sent_packet = RadioPacket( 518 | date=datetime.datetime.now(), 519 | pid=sent_packet.id, 520 | from_id=self._local_board_id, 521 | to_id=message.to_id, 522 | is_encrypted=message.pki_encrypted, 523 | payload=message.content.encode("utf8"), 524 | port_num=PacketInfoType.PCK_TEXT_MESSAGE_APP.value, 525 | snr=None, 526 | rssi=None, 527 | hop_limit=sent_packet.hop_limit, 528 | priority=sent_packet.priority, 529 | ) 530 | 531 | self._data.store_radiopacket(sent_packet) 532 | message.mid = sent_packet.pid 533 | self.notify_new_packet.emit(sent_packet) 534 | self._data.store_or_update_messages(message) 535 | self.notify_message_signal.emit() 536 | 537 | @run_in_thread 538 | def update_nodes_info(self, nodes: List[MeshtasticNode]) -> None: 539 | for n in nodes: 540 | self._data.store_or_update_node(n) 541 | 542 | @run_in_thread 543 | def load_local_nodedb(self, only_self: bool = False) -> list: 544 | """Return a list of nodes in the mesh""" 545 | if self._interface is None: 546 | return [] 547 | 548 | if self._interface.nodesByNum: 549 | logging.debug( 550 | f"self._interface.nodes:{self._interface.nodes}") 551 | for node in self._interface.nodesByNum.values(): 552 | if only_self and node["num"] != self._interface.localNode.nodeNum: 553 | continue 554 | 555 | batlevel = node["deviceMetrics"]["batteryLevel"] if "deviceMetrics" in node else 0 556 | if batlevel > 100: 557 | batlevel = 100 558 | 559 | n = MeshtasticNode( 560 | long_name=node["user"]["longName"], 561 | public_key=node["user"]["publicKey"] if "publicKey" in node["user"] else "", 562 | short_name=node["user"]["shortName"], 563 | hardware=node["user"]["hwModel"], 564 | role=node["user"]["role"] if "role" in node["user"] else None, 565 | lat=str( 566 | node["position"]["latitude"]) if "position" in node and "latitude" in node["position"] else None, 567 | lon=str( 568 | node["position"]["longitude"] if "position" in node and "longitude" in node["position"] else None), 569 | lastseen=datetime.datetime.fromtimestamp( 570 | node["lastHeard"]) if "lastHeard" in node and node["lastHeard"] is not None else None, 571 | id=node["user"]["id"], 572 | battery_level=batlevel, 573 | hopsaway=int( 574 | node["hopsAway"]) if "hopsAway" in node else None, 575 | snr=round( 576 | node["snr"], 577 | 2) if "snr" in node else None, 578 | txairutil=round( 579 | node["deviceMetrics"]["airUtilTx"], 580 | 2) if "deviceMetrics" in node and "airUtilTx" in node["deviceMetrics"] else None, 581 | chutil=round( 582 | node["deviceMetrics"]["channelUtilization"], 583 | 2) if "deviceMetrics" in node and "channelUtilization" in node["deviceMetrics"] else None, 584 | uptime=node["deviceMetrics"]["uptimeSeconds"] if "deviceMetrics" in node and "uptimeSeconds" in node["deviceMetrics"] else None, 585 | rx_counter=0, 586 | ) 587 | 588 | self._data.store_or_update_node(n, init=True) 589 | self.notify_nodes_update.emit(n) 590 | 591 | @run_in_thread 592 | def retrieve_channels(self) -> list: 593 | """Get the current channel settings from the node.""" 594 | if self._interface is None: 595 | return [] 596 | 597 | self._data.channels = [] 598 | try: 599 | for channel in self._interface.localNode.channels: 600 | if channel.role != channel_pb2.Channel.Role.DISABLED: 601 | self._data.channels.append( 602 | Channel( 603 | index=channel.index, 604 | role=channel_pb2.Channel.Role.Name(channel.role), 605 | name=channel.settings.name, 606 | psk=base64.b64encode( 607 | channel.settings.psk).decode('utf-8'), # Encode to base64 608 | ) 609 | ) 610 | except Exception as e: 611 | trace = f"Failed to get channels: {str(e)}" 612 | self.notify_frontend_signal.emit(MessageLevel.ERROR, trace) 613 | self.notify_channels_signal.emit() 614 | else: 615 | self.notify_channels_signal.emit() 616 | 617 | @run_in_thread 618 | def send_telemetry(self) -> None: 619 | try: 620 | self._interface.sendTelemetry() 621 | except Exception as e: 622 | self.notify_frontend_signal.emit(MessageLevel.ERROR, f"Could not send telemetry") 623 | else: 624 | self.notify_frontend_signal.emit(MessageLevel.INFO, f"Telemetry broadcasted.") 625 | 626 | sent_packet = RadioPacket( 627 | date=datetime.datetime.now(), 628 | pid=str(-1), 629 | from_id=self._local_board_id, 630 | to_id=BROADCAST_NAME, 631 | is_encrypted=False, 632 | payload=None, 633 | port_num=PacketInfoType.PCK_TELEMETRY_APP.value, 634 | snr=None, 635 | rssi=None, 636 | ) 637 | self._data.store_radiopacket(sent_packet) 638 | self.notify_new_packet.emit(sent_packet) 639 | 640 | @run_in_thread 641 | def send_position(self) -> None: 642 | try: 643 | node_infos = self._interface.getMyNodeInfo() 644 | if not "position" in node_infos or not node_infos["position"]: 645 | self.notify_frontend_signal.emit(MessageLevel.ERROR, f"Could not send position: not found.") 646 | else: 647 | self._interface.sendPosition(latitude=node_infos["position"]["latitude"], longitude=node_infos["position"]["longitude"]) 648 | except Exception as e: 649 | self.notify_frontend_signal.emit(MessageLevel.ERROR, f"Could not send position: {e}") 650 | else: 651 | self.notify_frontend_signal.emit(MessageLevel.INFO, f"Position broadcasted.") 652 | 653 | sent_packet = RadioPacket( 654 | date=datetime.datetime.now(), 655 | pid=str(-1), 656 | from_id=self._local_board_id, 657 | to_id=BROADCAST_NAME, 658 | is_encrypted=False, 659 | payload=None, 660 | port_num=PacketInfoType.PCK_POSITION_APP.value, 661 | snr=None, 662 | rssi=None, 663 | ) 664 | self._data.store_radiopacket(sent_packet) 665 | self.notify_new_packet.emit(sent_packet) 666 | 667 | @run_in_thread 668 | def send_traceroute(self, 669 | dest: Union[int, 670 | str], 671 | channelIndex: int = 0, 672 | hopLimit: int = 5): 673 | """Send the trace route""" 674 | if self._interface is None or not dest: 675 | return 676 | 677 | r = mesh_pb2.RouteDiscovery() 678 | try: 679 | self._interface.sendData( 680 | r.SerializeToString(), 681 | destinationId=dest, 682 | portNum=portnums_pb2.PortNum.TRACEROUTE_APP, 683 | wantResponse=True, 684 | channelIndex=channelIndex, 685 | ) 686 | except Exception as e: 687 | print(f"Could not send traceroute: {e}") 688 | else: 689 | self.notify_frontend_signal.emit( 690 | MessageLevel.INFO, 691 | f"Traceroute sent to {dest}") 692 | 693 | sent_packet = RadioPacket( 694 | date=datetime.datetime.now(), 695 | pid=str(-1), 696 | from_id=self._local_board_id, 697 | to_id=dest, 698 | is_encrypted=False, 699 | payload=None, 700 | port_num=PacketInfoType.PCK_TRACEROUTE_APP.value, 701 | snr=None, 702 | rssi=None, 703 | hop_limit=hopLimit, 704 | ) 705 | 706 | self._data.store_radiopacket(sent_packet) 707 | self.notify_new_packet.emit(sent_packet) 708 | 709 | def _node_id_from_num(self, nodeNum): 710 | """Convert node number to node ID""" 711 | if self._interface is None: 712 | return "" 713 | 714 | for node in self._interface.nodesByNum.values(): 715 | if node["num"] == nodeNum: 716 | return node["user"]["id"] 717 | return f"!{nodeNum:08x}" 718 | 719 | def enqueue_task(self, task, *args, **kwargs): 720 | self.task_queue.put((task, args, kwargs)) 721 | 722 | def quit(self): 723 | # Signal the thread to exit 724 | pub.unsubAll() 725 | self.task_queue.put((None, [], {})) 726 | 727 | def run(self): 728 | while True: 729 | task, args, kwargs = self.task_queue.get() 730 | if task is None: 731 | break 732 | task(*args, **kwargs) 733 | self.task_queue.task_done() 734 | -------------------------------------------------------------------------------- /meshtastic_visualizer/mapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | import hashlib 5 | import io 6 | import folium 7 | from typing import Optional 8 | from folium.plugins import MousePosition, MeasureControl 9 | 10 | 11 | from .resources import CHARGING_TRESHOLD 12 | 13 | class Mapper: 14 | """class that manages the map 15 | """ 16 | def __init__(self, custom_tiles_uri:Optional[str]=None) -> None: 17 | self._map = None 18 | 19 | self.create_map(custom_tiles_uri) 20 | 21 | def create_map(self, custom_tiles_uri:Optional[str]=None) -> None: 22 | """create a map with initial parameters 23 | """ 24 | if self._map: 25 | del self._map 26 | 27 | params = {} 28 | if custom_tiles_uri: 29 | params["tiles"] = custom_tiles_uri 30 | params["attr"] = "Custom Tiles" 31 | 32 | self._map = folium.Map(zoom_start=7, control_scale=True, no_touch=True, **params) 33 | MousePosition().add_to(self._map) 34 | MeasureControl().add_to(self._map) 35 | 36 | def convert2html(self) -> str: 37 | """convert map to html code 38 | 39 | Returns: 40 | str: html code to be integrated in other component 41 | """ 42 | data = io.BytesIO() 43 | self._map.save(data, close_file=False) 44 | data.seek(0) 45 | html = data.getvalue().decode() 46 | data.close() 47 | del data 48 | return html 49 | 50 | def _link_color(self, node_id: str) -> str: 51 | """returns a color from node condition 52 | 53 | Args: 54 | node_id (str): node id 55 | 56 | Returns: 57 | str: color code 58 | """ 59 | hash_object = hashlib.md5(node_id.encode()) 60 | color = '#' + hash_object.hexdigest()[:6] 61 | return color 62 | 63 | def update(self, nodes: list, custom_tiles_uri:Optional[str]=None) -> None: 64 | """update map with nodes 65 | 66 | Args: 67 | nodes (list): nodes 68 | """ 69 | self.create_map(custom_tiles_uri) 70 | 71 | if nodes is None or not nodes: 72 | return 73 | 74 | markers_group = folium.FeatureGroup(name="Stations") 75 | links_group = folium.FeatureGroup(name="Links") 76 | markers: list = [] 77 | links: list = [] 78 | relays: list = [] 79 | 80 | # remove any node that does not have full coordinates 81 | nodes_filtered = {} 82 | for node_id, details in nodes.items(): 83 | if details.lat is not None and details.lat != "None" \ 84 | and details.lon is not None and details.lon != "None": 85 | nodes_filtered[node_id] = details 86 | 87 | # in case of links tracing, pre-create a dict(node_id, [lat, lon]) 88 | nodes_coords = { 89 | x.id: [ 90 | float( 91 | x.lat), float( 92 | x.lon)] for __, x in nodes_filtered.items()} 93 | 94 | # prepare a dict with relays of node 95 | nodes_relays = {} 96 | for __, node in nodes_filtered.items(): 97 | if node.relay_node == node.id[-2:]: 98 | continue 99 | if node.relay_node is not None and node.relay_node != 0: 100 | potential_relays = list(filter(lambda x:node.relay_node == x.id[-2:], nodes_filtered.values())) 101 | if len(potential_relays) > 0: 102 | nodes_relays[node.id] = potential_relays 103 | 104 | for node_id, node_relays in nodes_relays.items(): 105 | rgroup = folium.FeatureGroup(name=f"Relay: {node_id}", show=False) 106 | 107 | strl = [] 108 | node = nodes_filtered[node_id] 109 | if node.long_name: 110 | strl.append(f"👤 Name: {node.long_name}
") 111 | strl.append(f"🆔 id: {node.id}
") 112 | if node.short_name: 113 | strl.append(f"AKA: {node.short_name}
") 114 | popup_content = "".join(strl) 115 | popup = folium.Popup(popup_content, max_width=300, min_width=250) 116 | relay_marker = folium.Marker( 117 | location=[ 118 | node.lat, 119 | node.lon], 120 | tooltip=popup_content, 121 | popup=popup, 122 | icon=folium.Icon(color="blue"), 123 | ) 124 | relay_marker.add_to(rgroup) 125 | for relay in node_relays: 126 | if relay.id == node_id: 127 | continue 128 | strl = [] 129 | if relay.long_name: 130 | strl.append(f"👤 Name: {relay.long_name}
") 131 | strl.append(f"🆔 id: {relay.id}
") 132 | if relay.short_name: 133 | strl.append(f"AKA: {relay.short_name}
") 134 | popup_content = "".join(strl) 135 | popup = folium.Popup(popup_content, max_width=300, min_width=250) 136 | relay_marker = folium.Marker( 137 | location=[ 138 | relay.lat, 139 | relay.lon], 140 | tooltip=popup_content, 141 | popup=popup, 142 | icon=folium.Icon(color="orange", icon="tower-observation", prefix="fa"), 143 | ) 144 | relay_marker.add_to(rgroup) 145 | rgroup.add_to(self._map) 146 | relays.append(rgroup) 147 | 148 | for node_id, node in nodes_filtered.items(): 149 | if node.lat is None or node.lon is None: 150 | continue 151 | icon_name:str = "tower-cell" 152 | strl = [] 153 | if node.long_name: 154 | strl.append(f"👤 Name: {node.long_name}
") 155 | strl.append(f"🆔 id: {node.id}
") 156 | if node.short_name: 157 | strl.append(f"AKA: {node.short_name}
") 158 | if node.hardware: 159 | strl.append(f"🚲 Hardware: {node.hardware}
") 160 | if node.battery_level: 161 | icon = "⚡" 162 | if node.voltage and node.voltage > CHARGING_TRESHOLD: 163 | icon = "🔌" 164 | strl.append( 165 | f"{icon} Battery Level: {node.battery_level} %
") 166 | if node.role: 167 | strl.append(f"⚙️ Role: {node.role}
") 168 | if node.hopsaway: 169 | strl.append(f"📍 Hops Away: {node.hopsaway}
") 170 | if node.txairutil: 171 | strl.append(f"🔊 Air Util. Tx: {node.txairutil} %
") 172 | if node.lastseen: 173 | strl.append(f"⌛ Last seen: {node.lastseen}
") 174 | if node.relay_node: 175 | strl.append(f"📡 Relay node: {node.relay_node}
") 176 | if node.next_hop: 177 | strl.append(f"➡️ Next hop: {node.next_hop}
") 178 | popup_content = "".join(strl) 179 | popup = folium.Popup( 180 | popup_content, max_width=300, min_width=250) 181 | color = "blue" 182 | if node.rx_counter > 0: 183 | color = "green" 184 | if node.is_local: 185 | color = "orange" 186 | icon_name = "walkie-talkie" 187 | if node.is_mqtt_gateway: 188 | icon_name = "network-wired" 189 | 190 | marker = folium.Marker( 191 | location=[ 192 | node.lat, 193 | node.lon], 194 | tooltip=popup_content, 195 | popup=popup, 196 | icon=folium.Icon(color=color, icon=icon_name, prefix="fa"), 197 | ) 198 | marker.add_to(markers_group) 199 | markers.append(marker) 200 | 201 | # neighbors 202 | if node.neighbors is not None: 203 | for neighbor in node.neighbors: 204 | # we can trace a link 205 | if neighbor in nodes_coords.keys(): 206 | link_coords = [ 207 | nodes_coords[node.id], 208 | nodes_coords[neighbor], 209 | ] 210 | if link_coords[0][0] is not None \ 211 | and link_coords[0][1] is not None \ 212 | and link_coords[1][0] is not None\ 213 | and link_coords[1][1] is not None: 214 | link = folium.PolyLine( 215 | link_coords, color=self._link_color(node.id)) 216 | link.add_to(links_group) 217 | links.append(link) 218 | if markers: 219 | markers_group.add_to(self._map) 220 | markers_lat = [x.location[0] for x in markers] 221 | markers_lon = [x.location[1] for x in markers] 222 | self._map.fit_bounds([[min(markers_lat), min(markers_lon)], [ 223 | max(markers_lat), max(markers_lon)]]) 224 | if links: 225 | links_group.add_to(self._map) 226 | if relays: 227 | for g in relays: 228 | g.add_to(self._map) 229 | 230 | if links or relays: 231 | folium.LayerControl().add_to(self._map) 232 | del nodes_filtered 233 | -------------------------------------------------------------------------------- /meshtastic_visualizer/mqtt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time 4 | import queue 5 | import ssl 6 | import datetime 7 | import base64 8 | import logging 9 | import google.protobuf.json_format 10 | import threading 11 | from PyQt6.QtCore import pyqtSignal, QObject 12 | from meshtastic.protobuf import mesh_pb2, mqtt_pb2, portnums_pb2, telemetry_pb2, config_pb2 13 | import paho.mqtt.client as mqtt 14 | from paho.mqtt.client import MessageState 15 | 16 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 17 | from cryptography.hazmat.backends import default_backend 18 | 19 | from .resources import run_in_thread, \ 20 | MessageLevel, \ 21 | MeshtasticNode, \ 22 | MeshtasticMessage, \ 23 | NodeMetrics, \ 24 | MeshtasticMQTTClientSettings, \ 25 | Packet, \ 26 | MQTTPacket, \ 27 | TIME_FORMAT 28 | 29 | from .datastore import MeshtasticDataStore 30 | 31 | 32 | # Enable logging but set to ERROR level to suppress debug/info messages 33 | logging.basicConfig(level=logging.ERROR) 34 | 35 | 36 | class MeshtasticMQTT(QObject, threading.Thread): 37 | 38 | notify_frontend_signal = pyqtSignal(MessageLevel, str) 39 | refresh_ui_signal = pyqtSignal() 40 | notify_nodes_update = pyqtSignal(MeshtasticNode) 41 | notify_nodes_metrics_signal = pyqtSignal() 42 | notify_message_signal = pyqtSignal() 43 | notify_new_packet = pyqtSignal(Packet) 44 | notify_mqtt_logs = pyqtSignal(str) 45 | 46 | def __init__(self) -> None: 47 | super().__init__() 48 | self.daemon = True 49 | self._client = None 50 | self._subscribe_topic = None 51 | self._client = mqtt.Client( 52 | mqtt.CallbackAPIVersion.VERSION2, 53 | client_id="", 54 | clean_session=True, 55 | userdata=None) 56 | self._mqtt_settings = MeshtasticMQTTClientSettings() 57 | self._client.on_connect = self.on_connect 58 | self._client.on_disconnect = self.on_disconnect 59 | self._client.on_message = self.on_message 60 | self.task_queue = queue.Queue() 61 | self._store = None 62 | self._mqtt_thread = None 63 | 64 | def set_store(self, store: MeshtasticDataStore) -> None: 65 | self._store = store 66 | 67 | def setup(self) -> bool: 68 | return True 69 | 70 | @run_in_thread 71 | def configure_and_start( 72 | self, 73 | settings: MeshtasticMQTTClientSettings) -> None: 74 | self._mqtt_settings = settings 75 | self.connect_mqtt() 76 | 77 | def disconnect_mqtt(self) -> bool: 78 | if self._client.is_connected(): 79 | self._client.disconnect() 80 | self._mqtt_thread.join() 81 | return True 82 | 83 | def is_connected(self) -> bool: 84 | return self._client.is_connected() 85 | 86 | def connect_mqtt(self) -> None: 87 | if not self._client.is_connected(): 88 | key = self._mqtt_settings.key 89 | if self._mqtt_settings.key == "AQ==": 90 | key = "1PG7OiApB1nwvP+rz05pAQ==" 91 | 92 | padded_key = key.ljust(len(key) + ((4 - (len(key) % 4)) % 4), '=') 93 | replaced_key = padded_key.replace('-', '+').replace('_', '/') 94 | key = replaced_key 95 | self._client.username_pw_set( 96 | self._mqtt_settings.username, 97 | self._mqtt_settings.password) 98 | if self._mqtt_settings.port == 8883 and self._mqtt_settings.tls is False: 99 | self._client.tls_set( 100 | ca_certs="cacert.pem", 101 | tls_version=ssl.PROTOCOL_TLSv1_2) 102 | self._client.tls_insecure_set(False) 103 | self._mqtt_settings.tls = True 104 | try: 105 | self._client.connect( 106 | self._mqtt_settings.host, self._mqtt_settings.port, 60) 107 | except Exception as e: 108 | self.notify_frontend_signal.emit( 109 | MessageLevel.ERROR, 110 | f"Could not connect to MQTT server {self._mqtt_settings.host}:{self._mqtt_settings.port}") 111 | else: 112 | self.notify_frontend_signal.emit( 113 | MessageLevel.INFO, 114 | f"Succesfully connected to MQTT server {self._mqtt_settings.host}:{self._mqtt_settings.port}") 115 | self._mqtt_thread = threading.Thread( 116 | target=self._client.loop_start) 117 | self._mqtt_thread.start() 118 | 119 | def on_connect(self, client, userdata, flags, reason_code, properties): 120 | if not reason_code.is_failure: 121 | if self._client.is_connected(): 122 | self.notify_frontend_signal.emit( 123 | MessageLevel.INFO, 124 | f"Connected to {self._mqtt_settings.host}:{self._mqtt_settings.port}") 125 | try: 126 | self._client.subscribe(self._mqtt_settings.topic) 127 | except Exception as e: 128 | self.notify_frontend_signal.emit( 129 | MessageLevel.ERROR, 130 | f"Could not subscribe to topic {self._mqtt_settings.topic}") 131 | else: 132 | self.notify_frontend_signal.emit( 133 | MessageLevel.INFO, f"Subscribed to root topic {self._mqtt_settings.topic}") 134 | else: 135 | self.notify_frontend_signal.emit( 136 | MessageLevel.ERROR, 137 | f"Failed to connect to {self._mqtt_settings.host}:{self._mqtt_settings.port}") 138 | else: 139 | self.notify_frontend_signal.emit( 140 | MessageLevel.ERROR, 141 | f"Failed to connect to {self._mqtt_settings.host}:{self._mqtt_settings.port}: {reason_code.names[reason_code.value]}") 142 | time.sleep(2) 143 | self.refresh_ui_signal.emit() 144 | 145 | def on_disconnect(self, client, userdata, flags, reason_code, properties): 146 | self.notify_frontend_signal.emit( 147 | MessageLevel.INFO, 148 | f"Disconnected from {self._mqtt_settings.host}:{self._mqtt_settings.port}") 149 | self.refresh_ui_signal.emit() 150 | 151 | def xor_hash(data: bytes) -> int: 152 | """Return XOR hash of all bytes in the provided string.""" 153 | result = 0 154 | for char in data: 155 | result ^= char 156 | return result 157 | 158 | def decode_encrypted(self, mp) -> bool: 159 | """Decrypt a meshtastic message.""" 160 | try: 161 | # Convert key to bytes 162 | key_bytes = base64.b64decode( 163 | self._mqtt_settings.key.encode('ascii')) 164 | 165 | nonce_packet_id = getattr(mp, "id").to_bytes(8, "little") 166 | nonce_from_node = getattr(mp, "from").to_bytes(8, "little") 167 | 168 | # Put both parts into a single byte array. 169 | nonce = nonce_packet_id + nonce_from_node 170 | 171 | cipher = Cipher( 172 | algorithms.AES(key_bytes), 173 | modes.CTR(nonce), 174 | backend=default_backend()) 175 | decryptor = cipher.decryptor() 176 | decrypted_bytes = decryptor.update( 177 | getattr(mp, "encrypted")) + decryptor.finalize() 178 | 179 | data = mesh_pb2.Data() 180 | data.ParseFromString(decrypted_bytes) 181 | mp.decoded.CopyFrom(data) 182 | 183 | except Exception as e: 184 | return False 185 | else: 186 | return True 187 | 188 | def node_number_to_id(self, node_number): 189 | return f"!{hex(node_number)[2:]}" 190 | 191 | def on_message(self, client, userdata, msg): 192 | se = mqtt_pb2.ServiceEnvelope() 193 | is_encrypted: bool = False 194 | if msg.state == MessageState.MQTT_MS_INVALID: 195 | pass 196 | try: 197 | se.ParseFromString(msg.payload) 198 | mp = se.packet 199 | except Exception as e: 200 | pass 201 | else: 202 | if len(msg.payload) > self._mqtt_settings.max_msg_len: 203 | print(MessageLevel.ERROR, 'Message too long: ' + 204 | str(len(msg.payload)) + ' bytes long, skipping.') 205 | return 206 | 207 | decrypted: bool = False 208 | if mp.HasField("encrypted") and not mp.HasField("decoded"): 209 | is_encrypted = True 210 | if self.decode_encrypted(mp): 211 | decrypted = True 212 | 213 | if is_encrypted and not decrypted: 214 | return 215 | 216 | if decrypted and mp.decoded.portnum == portnums_pb2.UNKNOWN_APP: 217 | return 218 | 219 | strl = [] 220 | strl.append( 221 | f"{self.node_number_to_id(getattr(se.packet, 'from'))}") 222 | strl.append( 223 | f"->{self.node_number_to_id(getattr(se.packet, 'to'))}") 224 | strl.append(f"[{se.channel_id}]") 225 | strl.append("{" + f"{se.gateway_id}" + "}") 226 | strl.append(f"pid:{se.packet.id}") 227 | if is_encrypted: 228 | strl.append("|e|") 229 | if decrypted: 230 | strl.append( 231 | f"pn:{portnums_pb2.PortNum.Name(se.packet.decoded.portnum)}") 232 | else: 233 | strl.append("|!e|") 234 | strl.append( 235 | f"pn:{portnums_pb2.PortNum.Name(se.packet.decoded.portnum)}") 236 | 237 | received_packet = MQTTPacket( 238 | date=datetime.datetime.now(), 239 | pid=se.packet.id, 240 | from_id=self.node_number_to_id( 241 | getattr( 242 | se.packet, 243 | 'from')), 244 | to_id=self.node_number_to_id( 245 | getattr( 246 | se.packet, 247 | 'to')), 248 | channel_id=se.channel_id, 249 | is_encrypted=is_encrypted, 250 | is_decrypted=decrypted, 251 | gateway_id=se.gateway_id, 252 | payload=mp.decoded.payload, 253 | port_num=portnums_pb2.PortNum.Name( 254 | se.packet.decoded.portnum), 255 | rssi=mp.rx_rssi, 256 | snr=mp.rx_snr, 257 | hop_limit=se.packet.hop_limit, 258 | hop_start=se.packet.hop_start, 259 | ) 260 | node_from = MeshtasticNode( 261 | id=self.node_number_to_id(getattr(se.packet, 'from')), 262 | lastseen=datetime.datetime.now(), 263 | rssi=mp.rx_rssi, 264 | snr=mp.rx_snr, 265 | ) 266 | 267 | if received_packet.hop_limit is not None and received_packet.hop_start is not None: 268 | received_packet.hopsaway = max(0, received_packet.hop_start - received_packet.hop_limit) 269 | for f in ["relay_node", "next_hop"]: 270 | if getattr(se.packet, f) != 0: 271 | setattr(received_packet, f, f"{getattr(se.packet, f):0x}") 272 | setattr(node_from, f, f"{getattr(se.packet, f):0x}") 273 | 274 | self._store.store_mqtt_packet(received_packet) 275 | self.notify_new_packet.emit(received_packet) 276 | self.notify_mqtt_logs.emit(" ".join(strl)) 277 | 278 | self._store.store_or_update_node(MeshtasticNode( 279 | id=se.gateway_id, 280 | is_mqtt_gateway=True, 281 | )) 282 | 283 | if mp.decoded.portnum == portnums_pb2.TEXT_MESSAGE_APP: 284 | text_payload = "" 285 | try: 286 | text_payload = mp.decoded.payload.decode("utf-8") 287 | except Exception as e: 288 | pass 289 | else: 290 | m = MeshtasticMessage( 291 | from_id=self.node_number_to_id( 292 | getattr( 293 | mp, 294 | "from")), 295 | to_id=self.node_number_to_id( 296 | getattr( 297 | mp, 298 | "to")), 299 | content=text_payload, 300 | mid=mp.id, 301 | hop_limit=mp.hop_limit, 302 | hop_start=mp.hop_start, 303 | channel_index=mp.channel, 304 | date=datetime.datetime.fromtimestamp( 305 | mp.rx_time), 306 | ) 307 | self._store.store_or_update_messages(m) 308 | self.notify_message_signal.emit() 309 | 310 | elif mp.decoded.portnum == portnums_pb2.NEIGHBORINFO_APP: 311 | neigh = mesh_pb2.NeighborInfo() 312 | try: 313 | neigh.ParseFromString(mp.decoded.payload) 314 | except Exception as e: 315 | pass 316 | else: 317 | if getattr(mp, "from") != neigh.last_sent_by_id: 318 | node_from.neighbors = [ 319 | self.node_number_to_id( 320 | neigh.last_sent_by_id)] 321 | 322 | elif mp.decoded.portnum == portnums_pb2.NODEINFO_APP: 323 | info = mesh_pb2.User() 324 | try: 325 | info.ParseFromString(mp.decoded.payload) 326 | except Exception as e: 327 | pass 328 | else: 329 | node_from.long_name = info.long_name 330 | node_from.short_name = info.short_name 331 | node_from.hardware = mesh_pb2.HardwareModel.Name( 332 | info.hw_model) 333 | node_from.snr = mp.rx_snr 334 | node_from.rssi = mp.rx_rssi 335 | node_from.role = config_pb2.Config.DeviceConfig.Role.Name( 336 | info.role) 337 | node_from.public_key = str(info.public_key) 338 | 339 | elif mp.decoded.portnum == portnums_pb2.MAP_REPORT_APP: 340 | mapreport = mqtt_pb2.MapReport() 341 | try: 342 | mapreport.ParseFromString(mp.decoded.payload) 343 | except Exception as e: 344 | pass 345 | else: 346 | node_from.long_name = mapreport.long_name 347 | node_from.lat = str(round(mapreport.latitude_i * 1e-7, 7)) 348 | node_from.lon = str(round(mapreport.longitude_i * 1e-7, 7)) 349 | node_from.alt = mapreport.altitude 350 | node_from.hardware = mesh_pb2.HardwareModel.Name( 351 | mapreport.hw_model) 352 | node_from.role = config_pb2.Config.DeviceConfig.Role.Name( 353 | mapreport.role) 354 | 355 | elif mp.decoded.portnum == portnums_pb2.POSITION_APP: 356 | position = mesh_pb2.Position() 357 | try: 358 | position.ParseFromString(mp.decoded.payload) 359 | 360 | except Exception as e: 361 | pass 362 | else: 363 | if position.latitude_i != 0 and position.longitude_i != 0: 364 | node_from.lat = str( 365 | round(position.latitude_i * 1e-7, 7)) 366 | node_from.lon = str( 367 | round(position.longitude_i * 1e-7, 7)) 368 | node_from.alt = str(position.altitude) 369 | node_from.rssi = mp.rx_rssi 370 | node_from.snr = mp.rx_snr 371 | 372 | elif mp.decoded.portnum == portnums_pb2.TELEMETRY_APP: 373 | env = telemetry_pb2.Telemetry() 374 | try: 375 | env.ParseFromString(mp.decoded.payload) 376 | except Exception as e: 377 | pass 378 | else: 379 | node_from.lastseen = datetime.datetime.now() 380 | nm = NodeMetrics( 381 | node_id=node_from.id, 382 | timestamp=int(round(datetime.datetime.now().timestamp())), 383 | ) 384 | if env.HasField("device_metrics"): 385 | node_from.txairutil = round(env.device_metrics.air_util_tx, 2) 386 | node_from.battery_level = round(env.device_metrics.battery_level) 387 | node_from.chutil = round(env.device_metrics.channel_utilization, 2) 388 | node_from.voltage = round(env.device_metrics.voltage, 2) 389 | node_from.uptime = env.device_metrics.uptime_seconds 390 | nm.air_util_tx = round(env.device_metrics.air_util_tx, 2) 391 | nm.battery_level = round(env.device_metrics.battery_level) 392 | nm.channel_utilization = round(env.device_metrics.channel_utilization, 2) 393 | nm.voltage = round(env.device_metrics.voltage, 2) 394 | nm.uptime = env.device_metrics.uptime_seconds 395 | if env.HasField("local_stats"): 396 | nm.num_packets_tx = env.local_stats.num_packets_tx 397 | nm.num_tx_relay = env.local_stats.num_tx_relay 398 | nm.num_tx_relay_canceled = env.local_stats.num_tx_relay_canceled 399 | node_from.tx_counter = (env.local_stats.num_packets_tx + env.local_stats.num_tx_relay) 400 | 401 | self._store.store_or_update_node_metrics(nm) 402 | self.notify_nodes_metrics_signal.emit() 403 | 404 | elif mp.decoded.portnum == portnums_pb2.TRACEROUTE_APP: 405 | if mp.decoded.payload: 406 | routeDiscovery = mesh_pb2.RouteDiscovery() 407 | routeDiscovery.ParseFromString(mp.decoded.payload) 408 | 409 | asDict = google.protobuf.json_format.MessageToDict( 410 | routeDiscovery) 411 | print(asDict) 412 | 413 | self._store.store_or_update_node(node_from) 414 | self._store.update_node_rx_counter(node_from) 415 | node_from.hopsaway = max(0, int(se.packet.hop_start) - int(se.packet.hop_limit)) 416 | if node_from.hopsaway is not None and node_from.hopsaway == 0: 417 | self._store.add_neighbor(node_from.id, se.gateway_id) 418 | self.notify_nodes_update.emit(node_from) 419 | 420 | def enqueue_task(self, task, *args, **kwargs): 421 | self.task_queue.put((task, args, kwargs)) 422 | 423 | def quit(self): 424 | self.task_queue.put((None, [], {})) 425 | 426 | def run(self): 427 | while True: 428 | task, args, kwargs = self.task_queue.get() 429 | if task is None: 430 | break 431 | task(*args, **kwargs) 432 | self.task_queue.task_done() 433 | -------------------------------------------------------------------------------- /meshtastic_visualizer/node_actions_widget.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from PyQt6.QtWidgets import QWidget, QPushButton, QHBoxLayout 4 | 5 | class NodeActionsWidget(QWidget): 6 | def __init__(self, parent, callback_traceroute, callback_telemetry, callback_position, is_local:bool=False, node_id:str=""): 7 | super(NodeActionsWidget,self).__init__(parent) 8 | self._node_id = node_id 9 | layout = QHBoxLayout() 10 | layout.setContentsMargins(0,0,0,0) 11 | layout.setSpacing(0) 12 | 13 | if not is_local: 14 | btn = QPushButton("Traceroute") 15 | btn.setStyleSheet("QPushButton{font-size: 9pt;}") 16 | btn.setEnabled(True) 17 | btn.clicked.connect(lambda: callback_traceroute(self._node_id)) 18 | layout.addWidget(btn) 19 | 20 | # only add these button if this is the local node 21 | if is_local: 22 | btn = QPushButton("Send position") 23 | btn.setStyleSheet("QPushButton{font-size: 9pt;}") 24 | btn.setEnabled(True) 25 | btn.clicked.connect(callback_position) 26 | layout.addWidget(btn) 27 | 28 | btn = QPushButton("Send telemetry") 29 | btn.setStyleSheet("QPushButton{font-size: 9pt;}") 30 | btn.setEnabled(True) 31 | btn.clicked.connect(callback_telemetry) 32 | layout.addWidget(btn) 33 | 34 | self.setLayout(layout) 35 | -------------------------------------------------------------------------------- /meshtastic_visualizer/resources.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import re 4 | import enum 5 | import datetime 6 | from dataclasses import dataclass, fields 7 | from typing import List, Optional 8 | 9 | BROADCAST_NAME = "^all" 10 | BROADCAST_ADDR = "!ffffffff" 11 | TEXT_MESSAGE_MAX_CHARS = 237 12 | TIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f" 13 | CHARGING_TRESHOLD = 4.2 14 | DEFAULT_TRACEROUTE_CHANNEL = 0 15 | 16 | def sneaky_to_camel(s:str) -> str: 17 | res = re.sub(r'_([a-z])', lambda match: match.group(1).upper(),s) 18 | return res 19 | 20 | class MessageLevel(enum.Enum): 21 | """ 22 | Message criticality level 23 | displayed in the window 24 | """ 25 | ERROR = 2 26 | INFO = 1 27 | UNKNOWN = 0 28 | 29 | 30 | class PacketInfoType(enum.Enum): 31 | """ 32 | Meshtastic packet type 33 | """ 34 | PCK_NEIGHBORINFO_APP = "NEIGHBORINFO_APP" 35 | PCK_TELEMETRY_APP = "TELEMETRY_APP" 36 | PCK_POSITION_APP = "POSITION_APP" 37 | PCK_TEXT_MESSAGE_APP = "TEXT_MESSAGE_APP" 38 | PCK_ROUTING_APP = "ROUTING_APP" 39 | PCK_TRACEROUTE_APP = "TRACEROUTE_APP" 40 | PCK_STORE_FORWARD_APP = "STORE_FORWARD_APP" 41 | PCK_NODEINFO_APP = "NODEINFO_APP" 42 | PCK_ADMIN_APP = "ADMIN_APP" 43 | PCK_RANGE_TEST_APP = "RANGE_TEST_APP" 44 | PCK_MAP_REPORT_APP = "MAP_REPORT_APP" 45 | PCK_UNKNOWN = "" 46 | 47 | 48 | class ConnectionKind(enum.Enum): 49 | UNKNOWN=0 50 | SERIAL=1 51 | TCP=2 52 | BLE=3 53 | 54 | def create_getter(field_name): 55 | def getter(self): 56 | with self._lock: 57 | return getattr(self, f"{field_name}") 58 | getter.__name__ = f"get_{field_name}" 59 | return getter 60 | 61 | 62 | def create_setter(field_name): 63 | def setter(self, value): 64 | with self._lock: 65 | setattr(self, f"{field_name}", value) 66 | setter.__name__ = f"set_{field_name}" 67 | return setter 68 | 69 | 70 | def run_in_thread(method): 71 | def wrapper(self, *args, **kwargs): 72 | self.enqueue_task(method, self, *args, **kwargs) 73 | 74 | wrapper.__name__ = method.__name__ 75 | wrapper.__doc__ = method.__doc__ 76 | wrapper.__module__ = method.__module__ 77 | 78 | return wrapper 79 | 80 | 81 | @dataclass 82 | class JsonExporter: 83 | def date2str(self, time_format: str = TIME_FORMAT) -> None: 84 | """convert all fields of class to json-convertible format 85 | 86 | Returns: 87 | None 88 | """ 89 | for f in fields(self): 90 | if isinstance(getattr(self, f.name), datetime.datetime): 91 | setattr( 92 | self, f.name, getattr( 93 | self, f.name).strftime(time_format)) 94 | 95 | 96 | @dataclass 97 | class MeshtasticMessage(JsonExporter): 98 | mid: int 99 | date: Optional[datetime.datetime] = None 100 | from_id: Optional[str] = None 101 | to_id: Optional[str] = None 102 | content: Optional[str] = None 103 | rx_snr: Optional[float] = None 104 | hop_limit: Optional[int] = None 105 | want_ack: Optional[bool] = None 106 | rx_rssi: Optional[int] = None 107 | hop_start: Optional[int] = None 108 | channel_index: Optional[int] = None 109 | ack_status: Optional[bool] = None 110 | ack_by: Optional[str] = None 111 | public_key: str = "" 112 | pki_encrypted: Optional[bool] = None 113 | 114 | 115 | @dataclass 116 | class MeshtasticNode(JsonExporter): 117 | long_name: Optional[str] = None 118 | short_name: Optional[str] = None 119 | id: Optional[str] = None 120 | role: Optional[str] = None 121 | hardware: Optional[str] = None 122 | lat: Optional[str] = None 123 | lon: Optional[str] = None 124 | alt: Optional[str] = None 125 | battery_level: Optional[int] = None 126 | voltage: Optional[float] = None 127 | chutil: Optional[float] = None 128 | txairutil: Optional[float] = None 129 | rssi: Optional[float] = None 130 | snr: Optional[float] = None 131 | neighbors: Optional[List[str]] = None 132 | hopsaway: Optional[int] = None 133 | firstseen: Optional[datetime.datetime] = None 134 | lastseen: Optional[datetime.datetime] = None 135 | uptime: Optional[int] = None 136 | is_local: Optional[bool] = None 137 | public_key: Optional[str] = None 138 | rx_counter: Optional[int] = None 139 | tx_counter: Optional[int] = None 140 | is_mqtt_gateway: Optional[bool] = None 141 | relay_node: Optional[str] = None 142 | next_hop: Optional[str] = None 143 | 144 | def has_location(self) -> bool: 145 | return (self.lat is not None and self.lon is not None) 146 | 147 | 148 | @dataclass 149 | class Channel: 150 | index: Optional[int] = None 151 | name: Optional[str] = None 152 | role: Optional[str] = None 153 | psk: Optional[str] = None 154 | 155 | 156 | @dataclass 157 | class NodeMetrics: 158 | node_id: str 159 | timestamp: int 160 | uptime: Optional[int] = None 161 | voltage: Optional[float] = None 162 | air_util_tx: Optional[float] = None 163 | channel_utilization: Optional[float] = None 164 | battery_level: Optional[float] = None 165 | num_packets_tx: Optional[float] = None 166 | num_tx_relay: Optional[float] = None 167 | num_tx_relay_canceled: Optional[float] = None 168 | 169 | 170 | @dataclass 171 | class MeshtasticMQTTClientSettings: 172 | host: Optional[str] = None 173 | port: Optional[int] = None 174 | username: Optional[str] = None 175 | password: Optional[str] = None 176 | topic: Optional[str] = None 177 | channel: Optional[str] = None 178 | key: Optional[str] = None 179 | tls: bool = True 180 | max_msg_len: int = 255 181 | 182 | 183 | @dataclass 184 | class Packet(JsonExporter): 185 | date: datetime.datetime 186 | pid: str 187 | from_id: str 188 | to_id: str 189 | port_num: str 190 | is_encrypted: bool 191 | payload: bytes 192 | source: str = "unknown" 193 | snr: Optional[float] = None 194 | rssi: Optional[float] = None 195 | hop_limit: Optional[int] = None 196 | hop_start: Optional[int] = None 197 | hopsaway: Optional[int] = None 198 | relay_node: Optional[str] = None 199 | next_hop: Optional[str] = None 200 | 201 | 202 | @dataclass 203 | class MQTTPacket(Packet): 204 | channel_id: Optional[str] = None 205 | gateway_id: str = "" 206 | is_decrypted: bool = False 207 | source: str = "mqtt" 208 | 209 | 210 | @dataclass 211 | class RadioPacket(Packet): 212 | decoded: Optional[dict] = None 213 | source: str = "radio" 214 | priority: Optional[str] = None 215 | 216 | 217 | # 67ea94 218 | MAINWINDOW_STYLESHEET = """ 219 | 220 | """ 221 | -------------------------------------------------------------------------------- /meshtastic_visualizer/visualizer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | import os 5 | import re 6 | import json 7 | from threading import Lock 8 | from datetime import datetime 9 | from typing import List, Optional 10 | from PyQt6 import QtCore 11 | from PyQt6 import QtWidgets, uic 12 | from PyQt6.QtGui import QTextCursor 13 | from PyQt6.QtWidgets import QTableWidgetItem, QTreeWidgetItem, QPushButton, QFileDialog 14 | from PyQt6.QtCore import pyqtSignal, QSettings 15 | import pyqtgraph as pg 16 | from pyqtgraph import DateAxisItem 17 | from dataclasses import asdict 18 | 19 | from .manager import MeshtasticManager 20 | from .resources import MessageLevel, \ 21 | MeshtasticMessage, \ 22 | MeshtasticNode, \ 23 | Packet, \ 24 | TEXT_MESSAGE_MAX_CHARS, \ 25 | MeshtasticMQTTClientSettings, \ 26 | TIME_FORMAT, \ 27 | DEFAULT_TRACEROUTE_CHANNEL, \ 28 | ConnectionKind, \ 29 | PacketInfoType, \ 30 | BROADCAST_ADDR, \ 31 | BROADCAST_NAME 32 | from .node_actions_widget import NodeActionsWidget 33 | 34 | from .mqtt import MeshtasticMQTT 35 | from .datastore import MeshtasticDataStore 36 | from .mapper import Mapper 37 | 38 | 39 | class MeshtasticQtApp(QtWidgets.QMainWindow): 40 | connect_device_signal = pyqtSignal(ConnectionKind, str, bool) 41 | disconnect_device_signal = pyqtSignal() 42 | scan_serial_devices_signal = pyqtSignal() 43 | scan_ble_devices_signal = pyqtSignal() 44 | get_nodes_signal = pyqtSignal() 45 | send_message_signal = pyqtSignal(MeshtasticMessage) 46 | retrieve_channels_signal = pyqtSignal() 47 | traceroute_signal = pyqtSignal(str, int, int) 48 | send_telemetry_signal = pyqtSignal() 49 | send_position_signal = pyqtSignal() 50 | mqtt_connect_signal = pyqtSignal(MeshtasticMQTTClientSettings) 51 | 52 | def __init__(self): 53 | self._lock = Lock() 54 | super(MeshtasticQtApp, self).__init__() 55 | uic.loadUi('resources/app.ui', self) 56 | self.setWindowFlags(QtCore.Qt.WindowType.CustomizeWindowHint | 57 | QtCore.Qt.WindowType.WindowCloseButtonHint | QtCore.Qt.WindowType.WindowMinimizeButtonHint) 58 | self.setFixedSize(self.size()) 59 | self.show() 60 | self._settings = QSettings("antlas0", "meshtastic_visualizer") 61 | 62 | self._map = None 63 | self._map_custom_tiles_uri = self._settings.value("map_custom_tiles_uri", "") 64 | self._local_board_ln = "" 65 | self._telemetry_plot_widget = pg.PlotWidget() 66 | self._telemetry_plot_widget.plotItem.getViewBox().setMouseMode(pg.ViewBox.RectMode) 67 | self._packets_plot_widget = pg.PlotWidget() 68 | self._packets_plot_widget.plotItem.getViewBox().setMouseMode(pg.ViewBox.RectMode) 69 | self._telemetry_plot_item = pg.ScatterPlotItem( 70 | pen=pg.mkPen( 71 | '#007aff', 72 | width=1), 73 | symbol='o', 74 | symbolPen='b', 75 | symbolSize=8, 76 | hoverable=True) 77 | self._telemetry_plot_widget.addItem(self._telemetry_plot_item) 78 | self._packets_plot_item = pg.ScatterPlotItem( 79 | pen=pg.mkPen( 80 | '#007aff', 81 | width=1), 82 | symbol='o', 83 | symbolPen='b', 84 | symbolSize=8, 85 | hoverable=True) 86 | self._packets_plot_widget.addItem(self._packets_plot_item) 87 | 88 | # Variables 89 | self.status_var: str = "" 90 | self._local_board_id: str = "" 91 | self._action_buttons = [] 92 | self._current_output_folder = self._settings.value("output_folder", os.getcwd()) 93 | self._store = MeshtasticDataStore() 94 | self._manager = MeshtasticManager() 95 | self.setup_ui() 96 | 97 | self._manager.set_store(self._store) 98 | self._manager.start() 99 | 100 | self._mqtt_manager = MeshtasticMQTT() 101 | self._mqtt_manager.set_store(self._store) 102 | self._mqtt_manager.start() 103 | 104 | self._manager.refresh_ui_signal.connect(self.refresh_ui) 105 | self._mqtt_manager.refresh_ui_signal.connect(self.refresh_ui) 106 | self._manager.notify_frontend_signal.connect( 107 | self.refresh_status_header) 108 | self._mqtt_manager.notify_frontend_signal.connect( 109 | self.refresh_status_header) 110 | self._manager.notify_nodes_metrics_signal.connect( 111 | self.update_nodes_metrics) 112 | self._mqtt_manager.notify_nodes_metrics_signal.connect( 113 | self.update_nodes_metrics) 114 | self._manager.notify_local_device_configuration_signal.connect( 115 | self.update_device_details) 116 | self._manager.notify_new_packet.connect( 117 | self.update_packet_received) 118 | self._manager.notify_message_signal.connect( 119 | self.update_received_message) 120 | self._mqtt_manager.notify_message_signal.connect( 121 | self.update_received_message) 122 | self._mqtt_manager.notify_new_packet.connect( 123 | self.update_packet_received) 124 | self._manager.notify_traceroute_signal.connect(self.update_traceroute) 125 | self._manager.notify_channels_signal.connect( 126 | self.update_channels_list) 127 | self._manager.notify_channels_signal.connect( 128 | self.update_channels_table) 129 | self._manager.notify_nodes_update.connect( 130 | self.update_nodes) 131 | self._manager.notify_serial_devices_signal.connect(self._update_meshtastic_serial_devices) 132 | self._manager.notify_ble_devices_signal.connect(self._update_meshtastic_ble_devices) 133 | self._manager.notify_log_line.connect(self._update_device_logs) 134 | self._mqtt_manager.notify_nodes_update.connect( 135 | self.update_nodes) 136 | self._mqtt_manager.notify_mqtt_logs.connect( 137 | self.update_received_mqtt_log) 138 | 139 | self.connect_device_signal.connect(self._manager.connect_device) 140 | self.connect_device_signal.connect(self.clear_messages_table) 141 | self.disconnect_device_signal.connect(self._manager.disconnect_device) 142 | self.scan_ble_devices_signal.connect(self._manager.ble_scan_devices) 143 | self.scan_serial_devices_signal.connect(self._manager.serial_scan_devices) 144 | self.send_message_signal.connect(self._manager.send_text_message) 145 | self.retrieve_channels_signal.connect(self._manager.retrieve_channels) 146 | self.get_nodes_signal.connect(self.update_nodes_map) 147 | self.traceroute_signal.connect(self._manager.send_traceroute) 148 | self.send_position_signal.connect(self._manager.send_position) 149 | self.send_telemetry_signal.connect(self._manager.send_telemetry) 150 | self.mqtt_connect_signal.connect( 151 | self._mqtt_manager.configure_and_start) 152 | self.export_chat_button.pressed.connect(self.export_chat) 153 | self.export_packets_button.pressed.connect(self.export_packets) 154 | self.export_nodes_button.pressed.connect(self.export_nodes) 155 | self.export_node_metrics_button.pressed.connect(self.export_node_metrics) 156 | self.clear_mqtt_button.pressed.connect(self.mqtt_output_textedit.clear) 157 | self.clear_console_button.pressed.connect(self.console_logs_textedit.clear) 158 | self.clear_messages_button.pressed.connect(self.clear_messages_table) 159 | self.clear_messages_button.pressed.connect(self._store.clear_messages) 160 | self.clear_nodes_button.pressed.connect(self.clear_nodes) 161 | self.clear_node_metrics_button.pressed.connect(self.clear_nodes_metrics) 162 | self.clear_packets_button.pressed.connect(self.clear_packets) 163 | self.export_mqtt_button.pressed.connect(self.export_mqtt_logs) 164 | self.export_console_button.pressed.connect(self.export_console_logs) 165 | self.start_pause_console_button.clicked.connect(self._update_console_button) 166 | for i, metric in enumerate( 167 | self._store.get_node_metrics_fields()): 168 | self.nm_metric_combobox.insertItem( 169 | i + 1, metric) 170 | for i, metric in enumerate( 171 | self._store.get_packet_metrics_fields()): 172 | self.pm_metric_combobox.insertItem( 173 | i + 1, metric) 174 | self.nodes_filter_linedit.textChanged.connect(self.update_nodes) 175 | self.shortcut_filter_combobox.currentTextChanged.connect(self.update_nodes) 176 | self.mqtt_connect_button.pressed.connect(self.connect_mqtt) 177 | self.mqtt_disconnect_button.pressed.connect(self._mqtt_manager.disconnect_mqtt) 178 | 179 | def set_status(self, loglevel: MessageLevel, message: str) -> None: 180 | if loglevel.value == MessageLevel.ERROR.value: 181 | self.notification_bar.setText(message) 182 | 183 | if loglevel.value == MessageLevel.INFO.value or loglevel.value == MessageLevel.UNKNOWN.value: 184 | self.notification_bar.setText(message) 185 | 186 | def _update_device_logs(self, line:str) -> None: 187 | if not self.start_pause_console_button.isChecked(): 188 | try: 189 | ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') 190 | result = ansi_escape.sub('', line) 191 | except Exception as e: 192 | pass 193 | else: 194 | self.console_logs_textedit.append(result) 195 | 196 | def _request_meshtastic_serial_devices(self) -> None: 197 | self.set_status(MessageLevel.INFO, "Scanning serial devices.") 198 | self.serial_scan_button.setText("⌛ Serial Scan") 199 | self.serial_devices_combobox.clear() 200 | self.serial_connect_button.setEnabled(False) 201 | self.serial_scan_button.setEnabled(False) 202 | self.scan_serial_devices_signal.emit() 203 | 204 | def _update_meshtastic_serial_devices(self, devices:list) -> None: 205 | if len(devices) == 1: 206 | self.serial_devices_combobox.insertItem(0, devices[0]) 207 | self.serial_devices_combobox.setCurrentText(devices[0]) 208 | else: 209 | for i, device in enumerate(devices): 210 | self.serial_devices_combobox.insertItem(i, device) 211 | self.set_status(MessageLevel.INFO, f"Found {len(devices)} serial device(s).") 212 | self.serial_scan_button.setText("🔍 Serial Scan") 213 | self.serial_connect_button.setEnabled(True) 214 | self.serial_scan_button.setEnabled(True) 215 | 216 | def _request_meshtastic_ble_devices(self) -> None: 217 | self.set_status(MessageLevel.INFO, "Scanning bluetooth devices.") 218 | self.ble_scan_button.setText("⌛ BLE Scan") 219 | self.ble_address_combobox.clear() 220 | self.ble_connect_button.setEnabled(False) 221 | self.ble_scan_button.setEnabled(False) 222 | self.scan_ble_devices_signal.emit() 223 | 224 | def _update_meshtastic_ble_devices(self, devices:list) -> None: 225 | if len(devices) == 1: 226 | self.ble_address_combobox.insertItem(0, devices[0].address) 227 | self.ble_address_combobox.setCurrentText(devices[0].address) 228 | else: 229 | for i, device in enumerate(devices): 230 | self.ble_address_combobox.insertItem(i, device.address) 231 | self.set_status(MessageLevel.INFO, f"Found {len(devices)} bluetooth device(s).") 232 | self.ble_scan_button.setText("🔍 BLE Scan") 233 | self.ble_connect_button.setEnabled(True) 234 | self.ble_scan_button.setEnabled(True) 235 | 236 | def setup_ui(self) -> None: 237 | self.mynodeinfo_refresh_button.clicked.connect(self._manager.get_local_node_infos) 238 | if self._settings.value("serial_port", ""): 239 | self.serial_devices_combobox.insertItem(0, self._settings.value("serial_port", "")) 240 | self.serial_devices_combobox.setCurrentText(self._settings.value("serial_port", "")) 241 | if self._settings.value("ble_address", ""): 242 | self.ble_address_combobox.insertItem(0, self._settings.value("ble_address", "")) 243 | self.ble_address_combobox.setCurrentText(self._settings.value("ble_address", "")) 244 | self.tabWidget.currentChanged.connect(self.remove_notification_badge) 245 | self.notification_bar.setOpenExternalLinks(True) 246 | self.serial_connect_button.clicked.connect(self.connect_device_serial) 247 | self.tcp_connect_button.clicked.connect(self.connect_device_tcp) 248 | self.ble_connect_button.clicked.connect(self.connect_device_ble) 249 | self.output_folder_button.clicked.connect(self.choose_output_folder) 250 | self.output_folder_label.setReadOnly(True) 251 | self.output_folder_label.setText(os.path.basename(self._current_output_folder)) 252 | self.serial_scan_button.clicked.connect(self._request_meshtastic_serial_devices) 253 | self.ble_scan_button.clicked.connect(self._request_meshtastic_ble_devices) 254 | self.serial_disconnect_button.clicked.connect(self.disconnect_device) 255 | self.tcp_disconnect_button.clicked.connect(self.disconnect_device) 256 | self.ble_disconnect_button.clicked.connect(self.disconnect_device) 257 | self.load_nodedb_checkbox.stateChanged.connect(self.load_nodedb_checkbox_bis.setChecked) 258 | self.load_nodedb_checkbox.stateChanged.connect(self.load_nodedb_checkbox_ter.setChecked) 259 | self.load_nodedb_checkbox_bis.stateChanged.connect(self.load_nodedb_checkbox.setChecked) 260 | self.load_nodedb_checkbox_bis.stateChanged.connect(self.load_nodedb_checkbox_ter.setChecked) 261 | self.load_nodedb_checkbox_ter.stateChanged.connect(self.load_nodedb_checkbox.setChecked) 262 | self.load_nodedb_checkbox_ter.stateChanged.connect(self.load_nodedb_checkbox_bis.setChecked) 263 | self.refresh_map_button.clicked.connect(self.get_nodes) 264 | self.send_button.clicked.connect(self.send_message) 265 | self.nm_update_button.setEnabled(False) 266 | self.nm_update_button.pressed.connect(self.update_nodes_metrics) 267 | self.nm_metric_combobox.currentTextChanged.connect( 268 | self.update_node_metrics_buttons) 269 | self.pm_update_button.pressed.connect(self.update_packets_metrics) 270 | self.messagechannel_combobox.textActivated.connect( 271 | self.update_received_message 272 | ) 273 | self.mesh_table.cellClicked.connect(self.mesh_table_is_clicked) 274 | self.message_textedit.textChanged.connect( 275 | self.update_text_message_length) 276 | self.remaining_chars_label.setText( 277 | f"{TEXT_MESSAGE_MAX_CHARS}/{TEXT_MESSAGE_MAX_CHARS}") 278 | self.init_map() 279 | self.messages_table.setTextElideMode(QtCore.Qt.TextElideMode.ElideNone) 280 | self.messages_table.setColumnCount( 281 | len(self._get_meshtastic_message_header_fields().keys())) 282 | self.messages_table.setHorizontalHeaderLabels( 283 | list(self._get_meshtastic_message_header_fields().values())) 284 | self.traceroute_table.setColumnCount(3) 285 | self.traceroute_table.setHorizontalHeaderLabels( 286 | ["Id", "SNR To", "SNR Back"]) 287 | self.batterylevel_progressbar.hide() 288 | self.serial_connect_button.setEnabled(True) 289 | self.serial_disconnect_button.setEnabled(False) 290 | self.tcp_connect_button.setEnabled(True) 291 | self.ble_connect_button.setEnabled(True) 292 | self.tcp_disconnect_button.setEnabled(False) 293 | self.ble_disconnect_button.setEnabled(False) 294 | self._action_buttons = [ 295 | self.send_button, 296 | self.message_textedit, 297 | self.mynodeinfo_refresh_button, 298 | ] 299 | for button in self._action_buttons: 300 | button.setEnabled(False) 301 | 302 | for i, f in enumerate(["All", "Recently seen", "Positioned", "Neighbors", "1-hop", 303 | "2-hops", "3-hops", "4-hops", "5-hops", "6-hops", "7-hops"]): 304 | self.shortcut_filter_combobox.insertItem(i, f) 305 | 306 | for p in ["telemetry", "packets"]: 307 | widget = { 308 | "telemetry": self._telemetry_plot_widget, 309 | "packets": self._packets_plot_widget, 310 | } 311 | layout = { 312 | "telemetry": self.telemetry_plot_layout, 313 | "packets": self.packets_plot_layout, 314 | } 315 | plot_item = { 316 | "telemetry": self._telemetry_plot_item, 317 | "packets": self._packets_plot_item, 318 | } 319 | widget[p].setBackground('w') 320 | widget[p].getPlotItem().getAxis('left').setPen(pg.mkPen(color='k')) 321 | widget[p].getPlotItem().getAxis('bottom').setPen(pg.mkPen(color='k')) 322 | widget[p].getPlotItem().getAxis('left').setTextPen(pg.mkPen(color='k')) 323 | widget[p].getPlotItem().getAxis('bottom').setTextPen(pg.mkPen(color='k')) 324 | widget[p].addLegend() 325 | widget[p].setMouseEnabled(x=False, y=False) 326 | widget[p].setAxisItems({'bottom': DateAxisItem()}) 327 | layout[p].addWidget(widget[p]) 328 | plot_item[p] = widget[p].plot( 329 | pen=pg.mkPen( 330 | '#007aff', 331 | width=1), 332 | symbol='o', 333 | symbolPen='b', 334 | symbolSize=8) 335 | 336 | self.mqtt_disconnect_button.setEnabled(False) 337 | self.nodes_total_lcd.setDecMode() 338 | self.nodes_gps_lcd.setDecMode() 339 | self.node_packets_number.setDecMode() 340 | self.nodes_recently_lcd.setDecMode() 341 | self.ipaddress_textedit.setText(self._settings.value("tcp", "http://192.168.1.1")) 342 | self.mqtt_host_linedit.setText(self._settings.value("mqtt_host", "")) 343 | self.mqtt_port_spinbox.setValue( 344 | int(self._settings.value("mqtt_port", 1883))) 345 | self.mqtt_username_linedit.setText( 346 | self._settings.value("mqtt_username", "")) 347 | self.mqtt_password_linedit.setText( 348 | self._settings.value("mqtt_password", "")) 349 | self.mqtt_topic_linedit.setText(self._settings.value("mqtt_topic", "")) 350 | self.mqtt_key_linedit.setText(self._settings.value("mqtt_key", "AQ==")) 351 | self.packets_treewidget.itemClicked.connect(self.adjust_packets_treeview) 352 | self.packets_treewidget.setWordWrap(True) 353 | self.packets_treewidget.setTextElideMode(QtCore.Qt.TextElideMode.ElideNone) 354 | self.packets_treewidget.setHeaderLabels(["Packet", "Details"]) 355 | self.packettype_combobox.insertItem(0, "All") 356 | self.packettype_combobox.currentIndexChanged.connect( 357 | self.update_packets_filtered) 358 | self.packetsource_combobox.insertItem(0, "All") 359 | self.packetsource_combobox.currentTextChanged.connect( 360 | self.update_packets_filtered) 361 | self.packetmedium_combobox.insertItem(0, "All") 362 | self.packetmedium_combobox.insertItem(1, "Radio") 363 | self.packetmedium_combobox.insertItem(2, "MQTT") 364 | self.packetmedium_combobox.currentIndexChanged.connect(self.update_packets_filtered) 365 | 366 | self.activate_custom_tiles_checkbox.setChecked(False) 367 | self.custom_tiles_uri_linedit.setVisible(False) 368 | self.activate_custom_tiles_checkbox.stateChanged.connect(self.activate_custom_tiles) 369 | self.custom_tiles_uri_linedit.textChanged.connect(self.update_custom_tiles) 370 | 371 | def choose_output_folder(self): 372 | dialog = QFileDialog(self) 373 | dialog.setDirectory(self._current_output_folder if self._current_output_folder else os.getcwd()) 374 | dialog.setFileMode(QFileDialog.FileMode.Directory) 375 | dialog.setViewMode(QFileDialog.ViewMode.List) 376 | if dialog.exec(): 377 | if len(dialog.selectedFiles()) == 1: 378 | self._current_output_folder = dialog.selectedFiles()[0] 379 | self._settings.setValue("output_folder", self._current_output_folder) 380 | self.refresh_status_header(MessageLevel.INFO, f"Output directory is set to: {self._current_output_folder}") 381 | self.output_folder_label.setText(os.path.basename(self._current_output_folder)) 382 | 383 | def clear_messages_table(self) -> None: 384 | self.messages_table.setRowCount(0) 385 | 386 | def clear_nodes(self) -> None: 387 | self._store.clear_nodes() 388 | self._store.clear_nodes_metrics() 389 | self.update_channels_list() 390 | self.mesh_table.setRowCount(0) 391 | self.nm_node_label.clear() 392 | self.nodes_total_lcd.display(0) 393 | self.nodes_gps_lcd.display(0) 394 | self.nodes_recently_lcd.display(0) 395 | self._telemetry_plot_item.setData( 396 | x=None, 397 | y=None) 398 | self._telemetry_plot_widget.setTitle("No data") 399 | 400 | def clear_nodes_metrics(self) -> None: 401 | self._store.clear_nodes_metrics() 402 | self.nm_node_label.clear() 403 | self._telemetry_plot_item.setData( 404 | x=None, 405 | y=None) 406 | self._telemetry_plot_widget.setTitle("No data") 407 | 408 | def clear_packets(self) -> None: 409 | self._store.clear_radio_packets() 410 | self._store.clear_mqtt_packets() 411 | self.packets_treewidget.clear() 412 | self.packettype_combobox.clear() 413 | self.packettype_combobox.insertItem(0, "All") 414 | self.packetsource_combobox.clear() 415 | self.packetsource_combobox.insertItem(0, "All") 416 | self._packets_plot_item.setData( 417 | x=None, 418 | y=None) 419 | self._packets_plot_widget.setTitle("No data") 420 | self.reset_node_packets_counters() 421 | 422 | def _get_meshtastic_message_header_fields(self) -> dict: 423 | return { 424 | "date": "Date", 425 | "ack": "Ack", 426 | "pki_encrypted": "Encrypted", 427 | "from_id": "From", 428 | "content": "Message", 429 | } 430 | 431 | def remove_notification_badge(self, index): 432 | if index == 3: 433 | self.tabWidget.setTabText(3, "Messages") 434 | 435 | def refresh_ui(self) -> None: 436 | self._lock.acquire() 437 | self.serial_connect_button.setEnabled(True) 438 | self.serial_disconnect_button.setEnabled(False) 439 | self.serial_scan_button.setEnabled(True) 440 | self.tcp_connect_button.setEnabled(True) 441 | self.tcp_disconnect_button.setEnabled(False) 442 | self.ble_connect_button.setEnabled(True) 443 | self.ble_disconnect_button.setEnabled(False) 444 | self.ble_scan_button.setEnabled(True) 445 | for button in self._action_buttons: 446 | button.setEnabled(False) 447 | self.connection_tabs.setTabEnabled(0, True); 448 | self.connection_tabs.setTabEnabled(1, True); 449 | self.connection_tabs.setTabEnabled(2, True); 450 | 451 | if self._manager.is_serial_connected(): 452 | self.serial_scan_button.setEnabled(False) 453 | self.serial_connect_button.setEnabled(False) 454 | self.serial_disconnect_button.setEnabled(True) 455 | for button in self._action_buttons: 456 | button.setEnabled(True) 457 | self.connection_tabs.setTabEnabled(0, True); 458 | self.connection_tabs.setTabEnabled(1, False); 459 | self.connection_tabs.setTabEnabled(2, False); 460 | 461 | if self._manager.is_tcp_connected(): 462 | self.tcp_connect_button.setEnabled(False) 463 | self.tcp_disconnect_button.setEnabled(True) 464 | for button in self._action_buttons: 465 | button.setEnabled(True) 466 | self.connection_tabs.setTabEnabled(0, False); 467 | self.connection_tabs.setTabEnabled(1, True); 468 | self.connection_tabs.setTabEnabled(2, False); 469 | 470 | if self._manager.is_ble_connected(): 471 | self.ble_scan_button.setEnabled(False) 472 | self.ble_connect_button.setEnabled(False) 473 | self.ble_disconnect_button.setEnabled(True) 474 | for button in self._action_buttons: 475 | button.setEnabled(True) 476 | self.connection_tabs.setTabEnabled(0, False); 477 | self.connection_tabs.setTabEnabled(1, False); 478 | self.connection_tabs.setTabEnabled(2, True); 479 | 480 | if self._mqtt_manager.is_connected(): 481 | self.mqtt_connect_button.setEnabled(False) 482 | self.mqtt_disconnect_button.setEnabled(True) 483 | self.mqtt_host_linedit.setEnabled(False) 484 | self.mqtt_port_spinbox.setEnabled(False) 485 | self.mqtt_username_linedit.setEnabled(False) 486 | self.mqtt_password_linedit.setEnabled(False) 487 | self.mqtt_topic_linedit.setEnabled(False) 488 | self.mqtt_key_linedit.setEnabled(False) 489 | else: 490 | self.mqtt_connect_button.setEnabled(True) 491 | self.mqtt_disconnect_button.setEnabled(False) 492 | self.mqtt_host_linedit.setEnabled(True) 493 | self.mqtt_port_spinbox.setEnabled(True) 494 | self.mqtt_username_linedit.setEnabled(True) 495 | self.mqtt_password_linedit.setEnabled(True) 496 | self.mqtt_topic_linedit.setEnabled(True) 497 | self.mqtt_key_linedit.setEnabled(True) 498 | self._lock.release() 499 | 500 | def refresh_status_header( 501 | self, 502 | status: MessageLevel = MessageLevel.UNKNOWN, 503 | message=None) -> None: 504 | """ 505 | Update header status bar 506 | """ 507 | self._lock.acquire() 508 | if message is not None: 509 | self.set_status(status, message) 510 | self._lock.release() 511 | 512 | def connect_device_serial(self): 513 | self.connection_tabs.setTabEnabled(0, True); 514 | self.connection_tabs.setTabEnabled(1, False); 515 | self.connection_tabs.setTabEnabled(2, False); 516 | self.serial_scan_button.setEnabled(False) 517 | self.serial_connect_button.setEnabled(False) 518 | self.serial_disconnect_button.setEnabled(False) 519 | device_path = self.serial_devices_combobox.currentText() 520 | if device_path: 521 | self.set_status(MessageLevel.INFO, f"Connecting to {device_path}.") 522 | self.connect_device_signal.emit(ConnectionKind.SERIAL, device_path, self.load_nodedb_checkbox.isChecked()) 523 | self._settings.setValue("serial_port", self.serial_devices_combobox.currentText()) 524 | else: 525 | self.set_status(MessageLevel.ERROR, f"Cannot connect. Please specify a device path.") 526 | self.serial_connect_button.setEnabled(True) 527 | self.serial_scan_button.setEnabled(True) 528 | 529 | def connect_device_tcp(self): 530 | self.connection_tabs.setTabEnabled(0, False); 531 | self.connection_tabs.setTabEnabled(1, True); 532 | self.connection_tabs.setTabEnabled(2, False); 533 | self.tcp_connect_button.setEnabled(False) 534 | self.tcp_disconnect_button.setEnabled(False) 535 | ip = self.ipaddress_textedit.text() 536 | if ip: 537 | if "https" in ip: 538 | self.set_status(MessageLevel.INFO, "Cannot connect through https, only http.") 539 | return 540 | self.set_status(MessageLevel.INFO, f"Connecting to {ip}.") 541 | self._settings.setValue("tcp", ip) 542 | self.connect_device_signal.emit(ConnectionKind.TCP, ip, self.load_nodedb_checkbox_bis.isChecked()) 543 | else: 544 | self.set_status(MessageLevel.ERROR, f"Cannot connect. Please specify an accessible ip address.") 545 | 546 | def connect_device_ble(self): 547 | self.connection_tabs.setTabEnabled(0, False); 548 | self.connection_tabs.setTabEnabled(1, False); 549 | self.connection_tabs.setTabEnabled(2, True); 550 | self.ble_scan_button.setEnabled(False) 551 | self.ble_connect_button.setEnabled(False) 552 | self.ble_disconnect_button.setEnabled(False) 553 | ble_address = self.ble_address_combobox.currentText() 554 | if ble_address: 555 | self.set_status(MessageLevel.INFO, f"Connecting to {ble_address}.") 556 | self._settings.setValue("ble_address", self.ble_address_combobox.currentText()) 557 | else: 558 | self.set_status(MessageLevel.INFO,f"Connecting to first detected device.") 559 | self.connect_device_signal.emit(ConnectionKind.BLE, ble_address, self.load_nodedb_checkbox_ter.isChecked()) 560 | 561 | def disconnect_device(self) -> None: 562 | self.disconnect_device_signal.emit() 563 | 564 | def update_traceroute( 565 | self, 566 | route: list, 567 | snr_towards: list, 568 | snr_back: list) -> None: 569 | self.traceroute_table.clear() 570 | self.traceroute_table.setRowCount(0) 571 | self.traceroute_table.setColumnCount(3) 572 | self.traceroute_table.setHorizontalHeaderLabels( 573 | ["Id", "SNR To", "SNR Back"]) 574 | for hop in route: 575 | device = self._store.get_long_name_from_id(hop) 576 | row_position = self.traceroute_table.rowCount() 577 | self.traceroute_table.insertRow(row_position) 578 | self.traceroute_table.setItem( 579 | row_position, 0, QTableWidgetItem(device)) 580 | 581 | for i in range(len(snr_towards)): 582 | self.traceroute_table.setItem( 583 | i, 1, QTableWidgetItem("↓" + str(snr_towards[i]))) 584 | for i in range(len(snr_back)): 585 | self.traceroute_table.setItem( 586 | i, 2, QTableWidgetItem("↑" + str(snr_back[i]))) 587 | self.traceroute_table.resizeColumnsToContents() 588 | self.traceroute_table.resizeRowsToContents() 589 | 590 | def update_text_message_length(self): 591 | current_text = self.message_textedit.toPlainText() 592 | 593 | if len(current_text) > TEXT_MESSAGE_MAX_CHARS: 594 | self.message_textedit.blockSignals(True) 595 | self.message_textedit.setPlainText( 596 | current_text[:TEXT_MESSAGE_MAX_CHARS]) 597 | cursor = self.message_textedit.textCursor() 598 | cursor.setPosition(TEXT_MESSAGE_MAX_CHARS) 599 | self.message_textedit.setTextCursor(cursor) 600 | self.message_textedit.blockSignals(False) 601 | 602 | remaining_chars = TEXT_MESSAGE_MAX_CHARS - \ 603 | len(self.message_textedit.toPlainText().encode("utf-8")) 604 | self.remaining_chars_label.setText( 605 | f"{remaining_chars}/{TEXT_MESSAGE_MAX_CHARS}") 606 | 607 | def mesh_table_is_clicked(self, row, column) -> None: 608 | node_id = self.mesh_table.item(row, 2).text() 609 | self.update_node_metrics_buttons() 610 | long_name = self._store.get_long_name_from_id(node_id) 611 | self.nm_node_label.setText(long_name) 612 | 613 | def activate_custom_tiles(self, activate:bool) -> None: 614 | self.custom_tiles_uri_linedit.setText(self._map_custom_tiles_uri) 615 | self.custom_tiles_uri_linedit.setVisible(activate) 616 | 617 | def update_custom_tiles(self) -> None: 618 | uri = self.custom_tiles_uri_linedit.text() 619 | self._settings.setValue("map_custom_tiles_uri", uri) 620 | self._map_custom_tiles_uri = uri 621 | 622 | def init_map(self): 623 | self._map = Mapper(custom_tiles_uri=self._map_custom_tiles_uri) 624 | self.update_map_in_widget() 625 | 626 | def update_map_in_widget(self): 627 | self.nodes_map.setHtml(self._map.convert2html()) 628 | 629 | def update_nodes_map(self): 630 | self._map.update(self._store.get_nodes(), self._map_custom_tiles_uri) 631 | self.update_map_in_widget() 632 | 633 | def clean_plot(self, kind: str = "") -> None: 634 | to_clean = [kind] 635 | if not kind: 636 | to_clean = ["telemetry", "packets"] 637 | for p in to_clean: 638 | widget = { 639 | "telemetry": self._telemetry_plot_widget, 640 | "packets": self._packets_plot_widget, 641 | } 642 | plot_item = { 643 | "telemetry": self._telemetry_plot_item, 644 | "packets": self._packets_plot_item, 645 | } 646 | plot_item[p].setData( 647 | x=None, 648 | y=None) 649 | widget[p].setTitle("No data") 650 | 651 | def update_node_metrics_buttons(self) -> None: 652 | self.nm_update_button.setEnabled(True) 653 | 654 | def update_nodes_metrics(self) -> str: 655 | self.nm_update_button.setEnabled(False) 656 | node_id = self._store.get_id_from_long_name(self.nm_node_label.text()) 657 | metric_name = self.nm_metric_combobox.currentText() 658 | if not node_id or not metric_name: 659 | self.clean_plot(kind="telemetry") 660 | return 661 | self.refresh_plot( 662 | node_id=node_id, 663 | metric_name=metric_name, 664 | kind="telemetry") 665 | 666 | def update_packets_metrics(self) -> str: 667 | node_id = self._store.get_id_from_long_name( 668 | self.packetsource_combobox.currentText()) 669 | metric_name = self.pm_metric_combobox.currentText() 670 | if not node_id or not metric_name: 671 | self.clean_plot(kind="packets") 672 | return 673 | self.refresh_plot( 674 | node_id=node_id, 675 | metric_name=metric_name, 676 | kind="packets") 677 | 678 | def refresh_plot(self, node_id: str, metric_name: str, kind: str) -> None: 679 | self._lock.acquire() 680 | metric = None 681 | if kind == "telemetry": 682 | metric = self._store.get_node_metrics(node_id, metric_name) 683 | elif kind == "packets": 684 | metric = self._store.get_packet_metrics(node_id, metric_name, self.packettype_combobox.currentText()) 685 | else: 686 | self.clean_plot(kind=kind) 687 | self._lock.release() 688 | return 689 | if "timestamp" not in metric or "value" not in metric: 690 | self.clean_plot(kind=kind) 691 | self._lock.release() 692 | return 693 | if len( 694 | list( 695 | filter( 696 | lambda x: x is not None, 697 | metric["value"]))) == 0: 698 | self.clean_plot(kind=kind) 699 | self._lock.release() 700 | return 701 | 702 | if len( 703 | metric["timestamp"]) == len( 704 | metric["value"]) and len( 705 | metric["value"]) > 0: 706 | 707 | target_widget = { 708 | "telemetry": self._telemetry_plot_widget, 709 | "packets": self._packets_plot_widget, 710 | } 711 | target_item = { 712 | "telemetry": self._telemetry_plot_item, 713 | "packets": self._packets_plot_item, 714 | } 715 | none_indexes = [ 716 | i for i, v in enumerate( 717 | metric["value"]) if v is None] 718 | for i in reversed(none_indexes): 719 | metric["timestamp"].pop(i) 720 | metric["value"].pop(i) 721 | 722 | target_item[kind].setData( 723 | x=metric["timestamp"], 724 | y=metric["value"]) 725 | target_widget[kind].getPlotItem().getViewBox().setRange( 726 | xRange=(min(metric["timestamp"]), max(metric["timestamp"])), 727 | yRange=(min(metric["value"]), max(metric["value"])), 728 | ) 729 | target_widget[kind].setLabel('left', "value", units='') 730 | target_widget[kind].setLabel('bottom', 'Timestamp', units='') 731 | target_widget[kind].setTitle( 732 | f'{metric_name} vs time for node {self._store.get_long_name_from_id(node_id)}') 733 | self._lock.release() 734 | 735 | def send_message(self): 736 | if not self._local_board_id: 737 | self.refresh_status_header(message="Not connected to a board, cannot send.") 738 | return 739 | 740 | message = self.message_textedit.toPlainText() 741 | recipient = self.messagechannel_combobox.currentText() # channel or DM ? 742 | if recipient in self.get_channel_names(): 743 | # channel broadcast 744 | try: 745 | channel_index = self._store.get_channel_index_from_name(recipient) 746 | recipient = BROADCAST_NAME 747 | except Exception as e: 748 | raise e 749 | else: 750 | # DM 751 | recipient = self._store.get_id_from_short_name(self.messagechannel_combobox.currentText()) 752 | channel_index = 0 # not really meaningful 753 | 754 | # Update timeout before sending 755 | if message: 756 | m = MeshtasticMessage( 757 | mid=-1, 758 | date=datetime.now(), 759 | from_id=self._local_board_id, 760 | to_id=recipient, 761 | content=message, 762 | want_ack=True, 763 | channel_index=channel_index, 764 | ) 765 | self.send_message_signal.emit(m) 766 | self.message_textedit.clear() 767 | 768 | def explore_packets(self, node_id:str) -> None: 769 | self.packetsource_combobox.blockSignals(True) 770 | self.packetmedium_combobox.blockSignals(True) 771 | self.packettype_combobox.blockSignals(True) 772 | self.packetsource_combobox.setCurrentText("All") 773 | self.packettype_combobox.setCurrentText("All") 774 | self.packetmedium_combobox.setCurrentText("All") 775 | self.clean_plot(kind="packets") 776 | if self._store.has_seen_node_id(node_id): 777 | self.tabWidget.setCurrentIndex(2) 778 | self.packetsource_combobox.setCurrentText(node_id) 779 | self.update_packets_filtered() 780 | self.packetsource_combobox.blockSignals(False) 781 | self.packetmedium_combobox.blockSignals(False) 782 | self.packettype_combobox.blockSignals(False) 783 | 784 | def reset_node_packets_counters(self) -> None: 785 | self.messages_packets_number.setRange(0, 1) 786 | self.messages_packets_number.setValue(0) 787 | self.nodeinfo_packets_number.setRange(0, 1) 788 | self.nodeinfo_packets_number.setValue(0) 789 | self.position_packets_number.setRange(0, 1) 790 | self.position_packets_number.setValue(0) 791 | self.telemetry_packets_number.setRange(0, 1) 792 | self.telemetry_packets_number.setValue(0) 793 | self.neighbor_packets_number.setRange(0, 1) 794 | self.neighbor_packets_number.setValue(0) 795 | self.routing_packets_number.setRange(0, 1) 796 | self.routing_packets_number.setValue(0) 797 | self.storeforward_packets_number.setRange(0, 1) 798 | self.storeforward_packets_number.setValue(0) 799 | self.traceroute_packets_number.setRange(0, 1) 800 | self.traceroute_packets_number.setValue(0) 801 | self.admin_packets_number.setRange(0, 1) 802 | self.admin_packets_number.setValue(0) 803 | self.rangetest_packets_number.setRange(0, 1) 804 | self.rangetest_packets_number.setValue(0) 805 | self.mapreport_packets_number.setRange(0, 1) 806 | self.mapreport_packets_number.setValue(0) 807 | self.node_packets_number.display(0) 808 | 809 | def update_nodes(self, node:MeshtasticNode) -> None: 810 | self.update_local_node_config() 811 | nodes = self._store.get_nodes() 812 | if not nodes: 813 | return 814 | self.update_message_combobox() # for DM 815 | self.update_nodes_table(nodes) 816 | 817 | def apply_nodes_filter(self, nodes: List[MeshtasticNode]) -> List[MeshtasticNode]: 818 | filtered = nodes.values() # nofilter 819 | hopfilter = { 820 | "1-hop": 1, 821 | "2-hops": 2, 822 | "3-hops": 3, 823 | "4-hops": 4, 824 | "5-hops": 5, 825 | "6-hops": 6, 826 | "7-hops": 7, 827 | } 828 | if self.shortcut_filter_combobox.currentText() == "Recently seen": 829 | recently_seen = list(filter(lambda x: x.rx_counter > 0,nodes.values())) 830 | filtered = recently_seen 831 | elif self.shortcut_filter_combobox.currentText() == "Positioned": 832 | filtered = list(filter(lambda x: x.has_location(), nodes.values())) 833 | elif self.shortcut_filter_combobox.currentText() == "Neighbors": 834 | filtered = list(filter(lambda x: x.hopsaway == 0, nodes.values())) 835 | elif self.shortcut_filter_combobox.currentText() in hopfilter.keys(): 836 | filtered = list(filter(lambda x: x.hopsaway == hopfilter[self.shortcut_filter_combobox.currentText()], nodes.values())) 837 | if len(self.nodes_filter_linedit.text()) != 0: 838 | # first search in long_name, then in id, then in aka 839 | pattern = self.nodes_filter_linedit.text() 840 | filtered = list(filter(lambda x: pattern.lower() in x.short_name.lower() if x.short_name is not None else False, nodes.values())) 841 | if not filtered: 842 | filtered = list(filter(lambda x: pattern.lower() in x.long_name.lower() if x.long_name is not None else False,nodes.values())) 843 | if not filtered: 844 | filtered = list(filter(lambda x: pattern.lower() in x.id.lower(),nodes.values())) 845 | return filtered 846 | 847 | def update_nodes_table(self, nodes: List[MeshtasticNode]) -> None: 848 | # update LCD widgets 849 | self.nodes_total_lcd.display(len(nodes.values())) 850 | positioned_nodes = list( 851 | filter( 852 | lambda x: x.lat is not None and x.lon is not None and x.lat and x.lon, 853 | nodes.values())) 854 | self.nodes_gps_lcd.display(len(positioned_nodes)) 855 | recently_seen = list( 856 | filter( 857 | lambda x: x.rx_counter > 0, 858 | nodes.values())) 859 | self.nodes_recently_lcd.display(len(recently_seen)) 860 | 861 | filtered = self.apply_nodes_filter(nodes) 862 | 863 | # update table 864 | rows: list[dict[str, any]] = [] 865 | for node in filtered: 866 | row = {"Status": "", "User": "", "ID": ""} 867 | 868 | status_line = [] 869 | 870 | if node.is_mqtt_gateway: 871 | status_line.append("🖥️") 872 | else: 873 | status_line.append("📡") 874 | 875 | row.update( 876 | { 877 | "Status": " ".join(status_line), 878 | "User": node.long_name, 879 | "AKA": node.short_name, 880 | "ID": node.id, 881 | "SNR": node.snr if node.snr is not None and node.hopsaway == 0 else "/", 882 | "RSSI": node.rssi if node.rssi is not None and node.hopsaway == 0 else "/", 883 | "Hops": f"✈️{node.hopsaway}" if node.hopsaway is not None else "/", 884 | "RX": f"⬊{node.rx_counter}" if node.rx_counter is not None and node.rx_counter > 0 else "/", 885 | "TX": f"⬈{node.tx_counter}" if node.tx_counter is not None and node.tx_counter > 0 else "/", 886 | "Details": None, 887 | "Action": None, 888 | "Relay node": f"0x{node.relay_node}" if node.relay_node else "/", 889 | "Next hop": f"0x{node.next_hop}" if node.next_hop else "/", 890 | "Role": node.role, 891 | "Hardware": node.hardware, 892 | } 893 | ) 894 | node.date2str() 895 | row.update( 896 | { 897 | "Latitude": node.lat, 898 | "Longitude": node.lon, 899 | "Public key": node.public_key, 900 | "Last seen": node.lastseen, 901 | } 902 | ) 903 | rows.append(row) 904 | 905 | rows.sort(key=lambda r: r.get("LastHeard") or "0000", reverse=True) 906 | 907 | columns = [ 908 | "Status", 909 | "User", 910 | "ID", 911 | "AKA", 912 | "SNR", 913 | "RSSI", 914 | "Hops", 915 | "RX", 916 | "TX", 917 | "Details", 918 | "Action", 919 | "Relay node", 920 | "Next hop", 921 | "Role", 922 | "Hardware", 923 | "Latitude", 924 | "Longitude", 925 | "Public key", 926 | "Last seen", 927 | ] 928 | 929 | del nodes 930 | self.mesh_table.setRowCount(0) 931 | self.mesh_table.setRowCount(len(rows)) 932 | self.mesh_table.setColumnCount(len(columns)) 933 | self.mesh_table.setHorizontalHeaderLabels(columns) 934 | 935 | for row_idx, row_data in enumerate(rows): 936 | for col_idx, value in enumerate(row_data.values()): 937 | current_item = self.mesh_table.item(row_idx, col_idx) 938 | current_widget = self.mesh_table.cellWidget(row_idx, col_idx) 939 | if current_item is None and current_widget is None: 940 | if col_idx == 10: # insert widget in cell 941 | if self._manager.is_connected(): 942 | # get id to check if node is local 943 | is_local = row_data["ID"] == self._local_board_id 944 | 945 | self.mesh_table.setCellWidget( 946 | row_idx, 947 | col_idx, 948 | NodeActionsWidget( 949 | parent=self, 950 | callback_traceroute=self.traceroute, 951 | callback_telemetry=lambda: self.send_telemetry_signal.emit(), 952 | callback_position=lambda: self.send_position_signal.emit(), 953 | is_local=is_local, 954 | node_id=row_data["ID"] 955 | ) 956 | ) 957 | else: 958 | data = str(value) 959 | if data == "None": 960 | data = "" 961 | if col_idx == 9: # insert widget in cell 962 | if self._store.has_seen_node_id(row_data["ID"]): 963 | btn = QPushButton("See packets") 964 | btn.setEnabled(True) 965 | btn.setStyleSheet("QPushButton{font-size: 9pt;}") 966 | self.mesh_table.setCellWidget(row_idx, col_idx, btn) 967 | btn.clicked.connect(lambda: self.explore_packets(self.mesh_table.item(self.mesh_table.indexAt(self.sender().pos()).row(),2).text())) 968 | else: 969 | data = str(value) 970 | if data == "None": 971 | data = "" 972 | self.mesh_table.setItem(row_idx, col_idx, QTableWidgetItem(data)) 973 | if current_item is not None: 974 | if current_item.text() != str(value): 975 | data = str(value) 976 | if data == "None": 977 | data = "" 978 | current_item.setText(data) 979 | self.mesh_table.resizeColumnsToContents() 980 | self.mesh_table.resizeRowsToContents() 981 | 982 | def get_nodes(self): 983 | self.get_nodes_signal.emit() 984 | 985 | def get_channel_names(self) -> List[str]: 986 | channels = self._store.get_channels() 987 | if not channels: 988 | return [] 989 | return [channel.name for channel in channels] 990 | 991 | def update_channels_table(self): 992 | config = self._manager.get_data_store() 993 | channels = config.get_channels() 994 | if not channels: 995 | return 996 | 997 | rows: list[dict[str, any]] = [] 998 | for channel in channels: 999 | row = { 1000 | "Index": channel.index, 1001 | "Name": channel.name, 1002 | "Role": channel.role, 1003 | "PSK": channel.psk} 1004 | rows.append(row) 1005 | 1006 | self.channels_table.clear() 1007 | self.channels_table.setRowCount(0) 1008 | columns = ["Index", "Name", "Role", "PSK"] 1009 | for i in range(self.channels_table.rowCount()): 1010 | self.channels_table.removeRow(i) 1011 | self.channels_table.setColumnCount(len(columns)) 1012 | self.channels_table.setHorizontalHeaderLabels(columns) 1013 | 1014 | for row in rows: 1015 | row_position = self.channels_table.rowCount() 1016 | self.channels_table.insertRow(row_position) 1017 | for i, elt in enumerate(columns): 1018 | self.channels_table.setItem( 1019 | row_position, i, QTableWidgetItem(str(row[elt]))) 1020 | self.channels_table.resizeColumnsToContents() 1021 | self.channels_table.resizeRowsToContents() 1022 | 1023 | def update_channels_list(self): 1024 | config = self._store 1025 | channels = config.get_channels() 1026 | if not channels: 1027 | return 1028 | for cb in ["messagechannel_combobox"]: 1029 | getattr(self, cb).clear() 1030 | for i, channel in enumerate(channels): 1031 | getattr(self, cb).insertItem(i, channel.name) 1032 | 1033 | def retrieve_channels(self): 1034 | self.retrieve_channels_signal.emit() 1035 | 1036 | def update_local_node_config(self): 1037 | cfg = self._store.get_local_node_config() 1038 | if cfg is None: 1039 | return 1040 | 1041 | self._local_board_id = cfg.id 1042 | self._local_board_ln = cfg.long_name 1043 | self.devicename_label.setText(cfg.long_name) 1044 | self.publickey_label.setText(cfg.public_key) 1045 | self.hardware_label.setText(cfg.hardware) 1046 | self.role_label.setText(cfg.role) 1047 | self.batterylevel_progressbar.setValue(cfg.battery_level) 1048 | self.batterylevel_progressbar.show() 1049 | self.id_label.setText(str(cfg.id)) 1050 | 1051 | def traceroute( 1052 | self, 1053 | dest_id: str = "", 1054 | maxhops: int = 5, 1055 | dummy: bool = False): 1056 | self.traceroute_table.setRowCount(0) 1057 | self.traceroute_table.setColumnCount(3) 1058 | self.traceroute_table.setHorizontalHeaderLabels( 1059 | ["Id", "SNR To", "SNR Back"]) 1060 | self.traceroute_signal.emit( 1061 | dest_id, DEFAULT_TRACEROUTE_CHANNEL, maxhops) 1062 | 1063 | def update_message_combobox(self) -> None: 1064 | already_present = [self.messagechannel_combobox.itemText(i) for i in range(self.messagechannel_combobox.count())] 1065 | for node in self._store.get_nodes().values(): 1066 | sn = self._store.get_short_name_from_id(node.id) 1067 | if sn and sn not in already_present: 1068 | self.messagechannel_combobox.insertItem(self.messagechannel_combobox.count(), sn) 1069 | 1070 | def update_received_message(self) -> None: 1071 | if self.tabWidget.currentIndex() != 3: 1072 | self.tabWidget.setTabText(3, "Messages 🔴") 1073 | 1074 | headers = self._get_meshtastic_message_header_fields() 1075 | columns = list(headers.keys()) 1076 | self.messages_table.setColumnCount(len(columns)) 1077 | self.messages_table.setHorizontalHeaderLabels(headers.values()) 1078 | 1079 | channels = self._store.get_channels() 1080 | messages = self._store.get_messages() 1081 | 1082 | # filter by current channel 1083 | current_channel = list(filter(lambda x: x.name == self.messagechannel_combobox.currentText(), channels)) 1084 | filtered_messages = messages 1085 | if len(current_channel) == 1: 1086 | # group message 1087 | filtered_messages = list(filter(lambda x: x.channel_index == current_channel[0].index and (x.to_id == BROADCAST_ADDR or x.to_id == BROADCAST_NAME), messages)) 1088 | else: 1089 | # DM 1090 | def __filter_dm(self, message, node_id): 1091 | if (message.to_id != BROADCAST_ADDR and message.to_id != BROADCAST_NAME) and \ 1092 | (message.from_id == self._store.get_id_from_short_name(node_id) or message.to_id == self._store.get_id_from_short_name(node_id)): 1093 | return True 1094 | return False 1095 | 1096 | filtered_messages = list(filter(lambda x: __filter_dm(self, x, self.messagechannel_combobox.currentText()), messages)) 1097 | 1098 | self.messages_table.setRowCount(len(filtered_messages)) 1099 | rows: list[dict[str, any]] = [] 1100 | 1101 | for message in filtered_messages: 1102 | message.date2str("%Y-%m-%d %H:%M:%S") 1103 | data = {} 1104 | for column in columns: 1105 | if column == "from_id" or column == "to_id": 1106 | data[headers[column]] = self._store.get_short_name_from_id( 1107 | getattr( 1108 | message, column)) 1109 | elif column == "ack": 1110 | label = "❔" 1111 | if message.from_id != self._local_board_id: 1112 | label = "/" 1113 | if getattr(message, "ack_status") is not None: 1114 | if getattr(message, "ack_status") is True: 1115 | if getattr(message, "ack_by") is not None: 1116 | if getattr( 1117 | message, 1118 | "ack_by") != getattr( 1119 | message, 1120 | "to_id"): 1121 | label = "☁️" 1122 | else: 1123 | label = "✅" 1124 | else: 1125 | label = "❌" 1126 | data[headers["ack"]] = label 1127 | elif column == "pki_encrypted": 1128 | label = "⚠️" 1129 | if getattr(message, column) is True: 1130 | label = "🔒" 1131 | data[headers[column]] = label 1132 | else: 1133 | data[headers[column]] = getattr(message, column) 1134 | rows.append(data) 1135 | 1136 | for row_idx, row_data in enumerate(rows): 1137 | for col_idx, value in enumerate(row_data.values()): 1138 | current_item = self.messages_table.item(row_idx, col_idx) 1139 | if current_item is None: 1140 | item = QTableWidgetItem(str(value)) 1141 | self.messages_table.setItem(row_idx, col_idx, item) 1142 | self.messages_table.scrollToItem(item,QtWidgets.QAbstractItemView.ScrollHint.EnsureVisible) 1143 | elif current_item.text() != value: 1144 | current_item.setText(str(value)) 1145 | self.messages_table.resizeColumnsToContents() 1146 | self.messages_table.resizeRowsToContents() 1147 | 1148 | def update_packets_filter(self, packets: List[Packet]) -> None: 1149 | inserted = [] 1150 | for i, packet in enumerate(packets): 1151 | if packet.from_id not in inserted: 1152 | inserted.append(packet.from_id) 1153 | if self.packettype_combobox.findText(packet.port_num) == -1: 1154 | self.packettype_combobox.insertItem( 1155 | 10000, packet.port_num) # insert last 1156 | 1157 | if self.packetsource_combobox.findText(packet.from_id) == -1: 1158 | self.packetsource_combobox.insertItem( 1159 | 100000, packet.from_id) # insert last 1160 | 1161 | def update_packets_filtered(self) -> None: 1162 | self.reset_node_packets_counters() 1163 | self.packets_treewidget.clear() 1164 | self.update_packet_received(MeshtasticNode()) 1165 | 1166 | def update_packet_received(self, packet:Optional[Packet]) -> None: 1167 | packets = self._store.get_radio_packets() + self._store.get_mqtt_packets() 1168 | self.update_packets_filter(packets) 1169 | self.update_packets_widgets(packets) 1170 | 1171 | def adjust_packets_treeview(self, item, column): 1172 | self.packets_treewidget.resizeColumnToContents(1) 1173 | # self.packets_treewidget.header().stretchLastSection() 1174 | # self.packets_treewidget.resizeColumnToContents(column) 1175 | 1176 | def apply_packets_filter(self, packets:List[Packet]) -> List[Packet]: 1177 | if self.packetmedium_combobox.currentText() != "All": 1178 | packets = list( 1179 | filter( 1180 | lambda x: x.source.lower() == self.packetmedium_combobox.currentText().lower(), 1181 | packets)) 1182 | if self.packetsource_combobox.currentText() != "All": 1183 | packets = list( 1184 | filter( 1185 | lambda x: x.from_id == self._store.get_id_from_long_name( 1186 | self.packetsource_combobox.currentText()), 1187 | packets)) 1188 | if self.packettype_combobox.currentText() != "All": 1189 | packets = list( 1190 | filter( 1191 | lambda x: x.port_num == self.packettype_combobox.currentText(), 1192 | packets)) 1193 | 1194 | return packets 1195 | 1196 | def update_packets_widgets(self, packets: List[Packet]) -> None: 1197 | alreading_existing_packets = [ 1198 | self.packets_treewidget.topLevelItem(i).text(0) for i in range( 1199 | self.packets_treewidget.topLevelItemCount())] 1200 | 1201 | filtered_packets = self.apply_packets_filter(packets) 1202 | 1203 | for packet in filtered_packets: 1204 | packet.date2str() 1205 | if str(packet.date) in alreading_existing_packets: 1206 | continue 1207 | category_item = QTreeWidgetItem([str(packet.date), ""]) 1208 | self.packets_treewidget.addTopLevelItem(category_item) 1209 | for sub_item, value in asdict(packet).items(): 1210 | sub_item_widget = QTreeWidgetItem([str(sub_item), str(value)]) 1211 | category_item.addChild(sub_item_widget) 1212 | self.packets_treewidget.resizeColumnToContents(0) 1213 | 1214 | filtered_packets_number = len(filtered_packets) 1215 | if filtered_packets_number > 0: 1216 | self.messages_packets_number.setRange(0, filtered_packets_number) 1217 | self.messages_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_TEXT_MESSAGE_APP.value, filtered_packets)))) 1218 | self.nodeinfo_packets_number.setRange(0, filtered_packets_number) 1219 | self.nodeinfo_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_NODEINFO_APP.value, filtered_packets)))) 1220 | self.position_packets_number.setRange(0, filtered_packets_number) 1221 | self.position_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_POSITION_APP.value, filtered_packets)))) 1222 | self.telemetry_packets_number.setRange(0, filtered_packets_number) 1223 | self.telemetry_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_TELEMETRY_APP.value, filtered_packets)))) 1224 | self.neighbor_packets_number.setRange(0, filtered_packets_number) 1225 | self.neighbor_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_NEIGHBORINFO_APP.value, filtered_packets)))) 1226 | self.routing_packets_number.setRange(0, filtered_packets_number) 1227 | self.routing_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_ROUTING_APP.value, filtered_packets)))) 1228 | self.storeforward_packets_number.setRange(0, filtered_packets_number) 1229 | self.storeforward_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_STORE_FORWARD_APP.value, filtered_packets)))) 1230 | self.traceroute_packets_number.setRange(0, filtered_packets_number) 1231 | self.traceroute_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_TRACEROUTE_APP.value, filtered_packets)))) 1232 | self.admin_packets_number.setRange(0, filtered_packets_number) 1233 | self.admin_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_ADMIN_APP.value, filtered_packets)))) 1234 | self.rangetest_packets_number.setRange(0, filtered_packets_number) 1235 | self.rangetest_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_RANGE_TEST_APP.value, filtered_packets)))) 1236 | self.mapreport_packets_number.setRange(0, filtered_packets_number) 1237 | self.mapreport_packets_number.setValue(len(list(filter(lambda x: x.port_num == PacketInfoType.PCK_MAP_REPORT_APP.value, filtered_packets)))) 1238 | self.node_packets_number.display(filtered_packets_number) 1239 | 1240 | def update_device_details(self, configuration: dict): 1241 | self.output_textedit.setText(configuration) 1242 | cursor = QTextCursor(self.output_textedit.textCursor()) 1243 | cursor.setPosition(0) 1244 | self.output_textedit.setTextCursor(cursor) 1245 | 1246 | def update_received_mqtt_log(self, log: str): 1247 | self.mqtt_output_textedit.setReadOnly(True) 1248 | tmp = [ 1249 | self.mqtt_output_textedit.toPlainText() 1250 | ] 1251 | if self.mqtt_output_textedit.toPlainText() != "": 1252 | tmp.append("\n") 1253 | nnow = datetime.now().strftime(TIME_FORMAT) 1254 | tmp.append(f"[{nnow}] {log}") 1255 | self.mqtt_output_textedit.setText("".join(tmp)) 1256 | cursor = QTextCursor(self.mqtt_output_textedit.textCursor()) 1257 | cursor.setPosition(len(self.mqtt_output_textedit.toPlainText())) 1258 | self.mqtt_output_textedit.setTextCursor(cursor) 1259 | 1260 | def connect_mqtt(self) -> None: 1261 | m = MeshtasticMQTTClientSettings() 1262 | m.host = self.mqtt_host_linedit.text() 1263 | m.port = self.mqtt_port_spinbox.value() 1264 | m.username = self.mqtt_username_linedit.text() 1265 | m.password = self.mqtt_password_linedit.text() 1266 | m.topic = self.mqtt_topic_linedit.text() 1267 | m.key = self.mqtt_key_linedit.text() 1268 | self._settings.setValue("mqtt_host", m.host) 1269 | self._settings.setValue("mqtt_port", m.port) 1270 | self._settings.setValue("mqtt_username", m.username) 1271 | self._settings.setValue("mqtt_password", m.password) 1272 | self._settings.setValue("mqtt_topic", m.topic) 1273 | self._settings.setValue("mqtt_key", m.key) 1274 | self.mqtt_connect_signal.emit(m) 1275 | 1276 | def write_to_file(self, path:str, data:str, kind:str="") -> bool: 1277 | res = False 1278 | try: 1279 | text_file = open(path, "w") 1280 | except Exception as e: 1281 | self.set_status(MessageLevel.ERROR, f"Could not write to {path}: {e}") 1282 | else: 1283 | text_file.write(data) 1284 | absp = os.path.abspath(path) 1285 | trace = f"Exported {kind} logs to file: {path}" 1286 | self.set_status(MessageLevel.INFO, trace) 1287 | res = True 1288 | text_file.close() 1289 | finally: 1290 | pass 1291 | 1292 | return res 1293 | 1294 | def export_mqtt_logs(self) -> None: 1295 | nnow = datetime.now().strftime("%Y-%m-%d__%H_%M_%S") 1296 | fpath = os.path.join(self._current_output_folder, f"mqtt_logs_{nnow}.log") 1297 | self.write_to_file(fpath, self.mqtt_output_textedit.toPlainText(), "mqtt") 1298 | 1299 | def export_console_logs(self) -> None: 1300 | nnow = datetime.now().strftime("%Y-%m-%d__%H_%M_%S") 1301 | fpath = os.path.join(self._current_output_folder, f"console_logs_{nnow}.log") 1302 | self.write_to_file(fpath, self.console_logs_textedit.toPlainText(), "console") 1303 | 1304 | def _update_console_button(self, activated:bool) -> None: 1305 | if not activated: self.start_pause_console_button.setText("⏸️") 1306 | if activated: self.start_pause_console_button.setText("▶️") 1307 | 1308 | def export_packets(self) -> None: 1309 | packets = self._store.get_radio_packets() + self._store.get_mqtt_packets() 1310 | packets = self.apply_packets_filter(packets) 1311 | 1312 | [x.date2str() for x in packets] 1313 | packets_list = [asdict(x) for x in packets] 1314 | for p in packets_list: 1315 | try: 1316 | p["payload"] = str(p["payload"]) 1317 | except Exception as e: 1318 | p["payload"] = "convertion error" 1319 | data_json = json.dumps(packets_list, indent=4) 1320 | nnow = datetime.now().strftime("%Y-%m-%d__%H_%M_%S") 1321 | fpath = os.path.join(self._current_output_folder, f"packet_{nnow}.json") 1322 | self.write_to_file(fpath, data_json, "packets") 1323 | 1324 | def export_chat(self) -> None: 1325 | messages = self._store.get_messages() 1326 | [x.date2str() for x in messages] 1327 | messages = [asdict(x) for x in messages] 1328 | data_json = json.dumps(messages, indent=4) 1329 | nnow = datetime.now().strftime("%Y-%m-%d__%H_%M_%S") 1330 | fpath = os.path.join(self._current_output_folder, f"messages_{nnow}.json") 1331 | self.write_to_file(fpath, data_json, "messages") 1332 | 1333 | def export_nodes(self) -> None: 1334 | nodes = self._store.get_nodes().values() 1335 | [x.date2str() for x in nodes] 1336 | nodes = [asdict(x) for x in nodes] 1337 | data_json = json.dumps(nodes, indent=4) 1338 | nnow = datetime.now().strftime("%Y-%m-%d__%H_%M_%S") 1339 | fpath = os.path.join(self._current_output_folder, f"nodes_{nnow}.json") 1340 | self.write_to_file(fpath, data_json, "nodes") 1341 | 1342 | def export_node_metrics(self) -> None: 1343 | metric_names = self._store.get_node_metrics_fields() 1344 | node_id = self.nm_node_label.text() 1345 | nnow = datetime.now() 1346 | metrics = { 1347 | "node_id": node_id, 1348 | "date": nnow.strftime("%Y-%m-%d %H:%M:%S"), 1349 | "metric": {}, 1350 | } 1351 | if not node_id: 1352 | return 1353 | for metric in metric_names: 1354 | metrics["metric"][metric] = self._store.get_node_metrics(self._store.get_id_from_long_name(node_id), metric) 1355 | data_json = json.dumps(metrics, indent=4) 1356 | fpath = os.path.join(self._current_output_folder, f"node_{node_id}_metrics_{nnow.strftime('%Y-%m-%d__%H_%M_%S')}.json") 1357 | self.write_to_file(fpath, data_json, f"node {node_id} metrics") 1358 | 1359 | def closeEvent(self, event) -> None: 1360 | self.quit() 1361 | 1362 | def quit(self) -> None: 1363 | self._manager.quit() 1364 | self._mqtt_manager.quit() 1365 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "meshtastic-visualizer" 3 | version = "1.8.0" 4 | description = "Qt desktop application to send messages, inspect packets, metrics from your Meshtastic node." 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "cryptography>=45.0.3", 9 | "folium==0.17.0", 10 | "meshtastic==2.6.3", 11 | "paho-mqtt>=2.1.0", 12 | "protobuf==5.27.3", 13 | "pubsub==0.1.2", 14 | "pyqt6==6.8", 15 | "pyqt6-webengine==6.7", 16 | "pyqtgraph>=0.13.7", 17 | "pyserial==3.5", 18 | ] 19 | 20 | --------------------------------------------------------------------------------