├── .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 | 
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 | 
121 | 
122 | 
123 | 
124 | 
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 |
--------------------------------------------------------------------------------