diff --git a/MeshtasticClient.exe b/MeshtasticClient.exe index 29fb0ab..9ca3488 100644 Binary files a/MeshtasticClient.exe and b/MeshtasticClient.exe differ diff --git a/meshtastic.png b/meshtastic.png new file mode 100644 index 0000000..668347f Binary files /dev/null and b/meshtastic.png differ diff --git a/meshtastic_client.py b/meshtastic_client.py index 93a6bd3..f966759 100644 --- a/meshtastic_client.py +++ b/meshtastic_client.py @@ -27,13 +27,13 @@ except Exception: BLEInterface = None try: - from meshtastic.protobuf import mesh_pb2, portnums_pb2 + from meshtastic.protobuf import mesh_pb2, portnums_pb2, telemetry_pb2 import google.protobuf.json_format as _json_format except Exception: mesh_pb2 = None portnums_pb2 = None + telemetry_pb2 = None _json_format = None - try: from serial.tools import list_ports except Exception: @@ -119,6 +119,10 @@ class MeshtasticGUI: m_view = tk.Menu(self.menubar, tearoff=False) m_view.add_command(label="Light theme", command=lambda: self.apply_theme("light")) m_view.add_command(label="Dark theme", command=lambda: self.apply_theme("dark")) + m_view.add_separator() + m_view.add_command(label="Neighbor table", command=self.show_neighbors_window) + m_view.add_command(label="Radio config (view)", command=self.show_radio_config_window) + m_view.add_command(label="Channel editor", command=self.show_channel_editor_window) self.menubar.add_cascade(label="View", menu=m_view) m_links = tk.Menu(self.menubar, tearoff=False) @@ -182,13 +186,13 @@ class MeshtasticGUI: self.cols_all = ( "shortname", "longname", "since", "hops", - "distkm", "speed", "alt", + "distkm", "speed", "alt", "battery", "voltage", "lastheard", "hwmodel", "role", "macaddr", "publickey", "isunmessagable", "id" ) self.cols_visible = ( "shortname", "longname", "since", "hops", - "distkm", "speed", "alt", "hwmodel", "role" + "distkm", "speed", "alt", "battery", "voltage", "hwmodel", "role" ) self.tv_nodes = ttk.Treeview( self.nodes_frame, @@ -206,6 +210,8 @@ class MeshtasticGUI: "distkm": "Dist (km)", "speed": "Speed", "alt": "Alt (m)", + "battery": "Batt %", + "voltage": "Volt", "hwmodel": "HW", "role": "Role", "lastheard": "", @@ -216,7 +222,6 @@ class MeshtasticGUI: } for key, text in headings.items(): self.tv_nodes.heading(key, text=text, command=lambda c=key: self.sort_by_column(c, False)) - widths = { "shortname": 90, "longname": 200, @@ -225,12 +230,14 @@ class MeshtasticGUI: "distkm": 70, "speed": 70, "alt": 70, + "battery": 70, + "voltage": 80, "hwmodel": 90, "role": 110, } for key, w in widths.items(): try: - self.tv_nodes.column(key, width=w, anchor="w", stretch=(key not in ("since", "hops", "distkm", "speed", "alt"))) + self.tv_nodes.column(key, width=w, anchor="w", stretch=(key not in ("since", "hops", "distkm", "speed", "alt", "battery", "voltage"))) except Exception: pass @@ -241,6 +248,7 @@ class MeshtasticGUI: except Exception: pass + # scrollbar for node list yscroll_nodes = ttk.Scrollbar(self.nodes_frame, orient="vertical", command=self.tv_nodes.yview) self.tv_nodes.configure(yscrollcommand=yscroll_nodes.set) yscroll_nodes.grid(row=1, column=1, sticky="ns") @@ -250,11 +258,12 @@ class MeshtasticGUI: self.node_menu.add_command(label="Node info", command=self._cm_show_node_details) self.node_menu.add_command(label="Map", command=self._cm_open_map) self.node_menu.add_command(label="Traceroute", command=self._cm_traceroute) + self.node_menu.add_command(label="Chat with node", command=self._cm_open_chat) self.node_menu.add_separator() self.node_menu.add_command(label="Delete node", command=self._cm_delete_node) self.tv_nodes.bind("", self._popup_node_menu) - self.tv_nodes.bind("", lambda e: self._toggle_send_target()) + self.tv_nodes.bind("", lambda e: self._cm_open_chat()) self.paned.add(self.msg_frame, weight=3) self.paned.add(self.nodes_frame, weight=4) @@ -265,6 +274,8 @@ class MeshtasticGUI: self._last_seen_overrides: Dict[str, float] = {} self._last_sort_col: Optional[str] = "since" self._last_sort_reverse: bool = True + self._telemetry: Dict[str, Dict[str, Any]] = {} + self._per_node_chats: Dict[str, "NodeChatWindow"] = {} if pub is not None: try: @@ -275,7 +286,7 @@ class MeshtasticGUI: except Exception as e: print("pubsub subscribe failed:", e) - self.apply_theme("light") + self.apply_theme("dark") self._append("Ready. Connection -> Connect (TCP/USB/BLE)") self._update_title_with_host() @@ -362,6 +373,24 @@ class MeshtasticGUI: decoded = packet.get("decoded", {}) if isinstance(packet, dict) else {} portnum = decoded.get("portnum") sender = packet.get("fromId") or packet.get("from") or packet.get("fromIdShort") + if portnum == "TELEMETRY_APP" and telemetry_pb2 is not None and sender: + try: + payload = decoded.get("payload") + if isinstance(payload, (bytes, bytearray)): + tmsg = telemetry_pb2.Telemetry() + tmsg.ParseFromString(payload) + if tmsg.HasField("device_metrics"): + dm = tmsg.device_metrics + entry = self._telemetry.get(str(sender), {}) + if dm.battery_level is not None: + entry["battery"] = dm.battery_level + if dm.voltage is not None: + entry["voltage"] = dm.voltage + self._telemetry[str(sender)] = entry + except Exception: + # ignore telemetry parse errors, they are non-fatal + pass + if sender: self._last_seen_overrides[str(sender)] = time.time() @@ -392,6 +421,7 @@ class MeshtasticGUI: if portnum == "TEXT_MESSAGE_APP": self._append("[MSG] %s: %s (RSSI=%s)" % (label, text, rssi)) + self._append_to_node_chat(str(sender), "%s" % (text,)) if isinstance(text, str) and text.strip().lower() == "ping": self._send_pong(sender, label) @@ -403,6 +433,7 @@ class MeshtasticGUI: try: self.iface.sendText("pong", destinationId=dest_id, wantAck=False) self._append("[auto] pong -> %s" % label) + self._append_to_node_chat(str(dest_id), "[auto] pong") except Exception as e: self._append("[auto] pong failed: %s" % e) @@ -556,10 +587,6 @@ class MeshtasticGUI: self._channel_map[label_pub] = {"mode": "broadcast", "channelIndex": 0} options.append(label_pub) - label_sel = "To selected node" - self._channel_map[label_sel] = {"mode": "selected", "channelIndex": 0} - options.append(label_sel) - if hasattr(self, "cbo_channel"): self.cbo_channel["values"] = options if hasattr(self, "channel_var"): @@ -582,7 +609,6 @@ class MeshtasticGUI: We keep: * "Public (broadcast)" -> broadcast on channel 0 - * "To selected node" -> direct message to selected node on channel 0 And then append additional channels (1..N) from the radio as: * "Ch : " -> broadcast on that channel. """ @@ -830,6 +856,12 @@ class MeshtasticGUI: speed_str = "%.1f" % speed if isinstance(speed, (int, float)) else "-" alt_str = "%.0f" % alt if isinstance(alt, (int, float)) else "-" + telem = self._telemetry.get(node_id, {}) if hasattr(self, "_telemetry") else {} + bat = telem.get("battery") + volt = telem.get("voltage") + bat_str = "%.0f" % bat if isinstance(bat, (int, float)) else "-" + volt_str = "%.2f" % volt if isinstance(volt, (int, float)) else "-" + values = ( shortname, longname, @@ -838,6 +870,8 @@ class MeshtasticGUI: dist_str, speed_str, alt_str, + bat_str, + volt_str, "%.0f" % (lastheard_epoch or 0), hwmodel, role, @@ -852,7 +886,6 @@ class MeshtasticGUI: self.tv_nodes.insert("", "end", iid=node_id, values=values) except Exception: self.tv_nodes.insert("", "end", values=values) - if self._last_sort_col: self.sort_by_column(self._last_sort_col, self._last_sort_reverse) @@ -862,7 +895,7 @@ class MeshtasticGUI: self._last_sort_col = col self._last_sort_reverse = reverse col_to_sort = "lastheard" if col == "since" else col - numeric = {"lastheard", "distkm", "hops", "speed", "alt"} + numeric = {"lastheard", "distkm", "hops", "speed", "alt", "battery", "voltage"} rows = [] for iid in self.tv_nodes.get_children(""): val = self.tv_nodes.set(iid, col_to_sort) @@ -1189,14 +1222,9 @@ class MeshtasticGUI: txt.insert("1.0", text.strip() or "No traceroute data.") txt.configure(state="disabled") def _toggle_send_target(self): - nid = self._get_selected_node_id() - if nid: - # Switch selector to "To selected node" when a node is double-clicked - self._set_channel_choice("To selected node") - self._append("[target] will send to %s" % self._node_label(nid)) - else: - # No selection -> fall back to public broadcast - self._set_channel_choice("Public (broadcast)") + # Legacy helper – kept for compatibility, but no longer used. + # Double-clicking a node now opens the per-node chat window directly. + return def _popup_node_menu(self, event): iid = self.tv_nodes.identify_row(event.y) if iid: @@ -1299,9 +1327,312 @@ class MeshtasticGUI: txt.insert("1.0", "\n".join(lines)) else: txt.insert("1.0", json.dumps(node, indent=2, default=str)) + txt.configure(state="disabled") + def show_neighbors_window(self): + if not self.iface or not getattr(self.iface, "nodes", None): + messagebox.showinfo("Neighbors", "No interface or nodes available.") + return + win = tk.Toplevel(self.root) + win.title("Neighbor table") + self._style_toplevel(win) + frm = ttk.Frame(win, padding=8) + frm.pack(expand=True, fill="both") + tree = ttk.Treeview(frm, columns=("from", "to", "snr", "lastheard"), show="headings") + for col, txt in (("from", "From node"), ("to", "To node"), ("snr", "SNR"), ("lastheard", "Last heard")): + tree.heading(col, text=txt) + tree.column(col, width=160 if col in ("from", "to") else 90, anchor="w") + tree.pack(side="left", expand=True, fill="both") + yscroll = ttk.Scrollbar(frm, orient="vertical", command=tree.yview) + tree.configure(yscrollcommand=yscroll.set) + yscroll.pack(side="right", fill="y") + try: + nodes_snapshot = dict(self.iface.nodes or {}) + except Exception: + nodes_snapshot = {} + rows = 0 + for node_id, node in nodes_snapshot.items(): + from_label = self._node_label(node_id) + neighbors = (node or {}).get("neighbors") or [] + if isinstance(neighbors, dict): + neighbors = neighbors.values() + for n in neighbors: + try: + to_id = (n or {}).get("nodeId") or (n or {}).get("id") or "" + to_label = self._node_label(str(to_id)) if to_id else str(to_id) + snr = (n or {}).get("snr") or (n or {}).get("snrDb") + last = (n or {}).get("lastHeard") + try: + last_epoch = float(last) if last is not None else None + except Exception: + last_epoch = None + last_str = _fmt_ago(last_epoch) + except Exception: + continue + tree.insert("", "end", values=(from_label, to_label, snr if snr is not None else "-", last_str)) + rows += 1 + + if rows == 0: + messagebox.showinfo("Neighbors", "No neighbor information found on nodes.") + + def show_radio_config_window(self): + if not self.iface or not getattr(self.iface, "localNode", None): + messagebox.showinfo("Radio config", "Connect to a device first.") + return + ln = self.iface.localNode + try: + if getattr(ln, "localConfig", None) is None and hasattr(ln, "waitForConfig"): + ln.waitForConfig("localConfig") + except Exception: + pass + cfg = getattr(ln, "localConfig", None) + mod = getattr(ln, "moduleConfig", None) + if cfg is None and mod is None: + messagebox.showinfo("Radio config", "No config available yet from device.") + return + win = tk.Toplevel(self.root) + win.title("Radio + module config (read‑only)") + self._style_toplevel(win) + frm = ttk.Frame(win, padding=8) + frm.pack(expand=True, fill="both") + txt = tk.Text(frm, wrap="none") + txt.pack(side="left", expand=True, fill="both") + yscroll = ttk.Scrollbar(frm, orient="vertical", command=txt.yview) + txt.configure(yscrollcommand=yscroll.set) + yscroll.pack(side="right", fill="y") + is_dark = self.current_theme == "dark" + txt.configure( + bg=("#2d2d2d" if is_dark else "#ffffff"), + fg=("#ffffff" if is_dark else "#000000"), + insertbackground=("#ffffff" if is_dark else "#000000"), + ) + lines = [] + if cfg is not None: + lines.append("localConfig:") + try: + lines.append(str(cfg)) + except Exception: + lines.append(repr(cfg)) + if mod is not None: + lines.append("\nmoduleConfig:") + try: + lines.append(str(mod)) + except Exception: + lines.append(repr(mod)) + txt.insert("1.0", "\n".join(lines)) + txt.configure(state="disabled") + + def show_channel_editor_window(self): + if not self.iface or not getattr(self.iface, "localNode", None): + messagebox.showinfo("Channels", "Connect to a device first.") + return + ln = self.iface.localNode + try: + if getattr(ln, "channels", None) in (None, {}) and hasattr(ln, "waitForConfig"): + ln.waitForConfig("channels") + except Exception: + pass + chans = getattr(ln, "channels", None) + if not chans: + messagebox.showinfo("Channels", "No channels available from device.") + return + + win = tk.Toplevel(self.root) + win.title("Channel editor") + self._style_toplevel(win) + frm = ttk.Frame(win, padding=8) + frm.grid(row=0, column=0, sticky="nsew") + win.rowconfigure(0, weight=1) + win.columnconfigure(0, weight=1) + + listbox = tk.Listbox(frm, height=10) + listbox.grid(row=0, column=0, columnspan=2, sticky="nsew") + frm.rowconfigure(0, weight=1) + frm.columnconfigure(0, weight=1) + + for idx, ch in enumerate(chans): + if ch is None: + label = f"Ch {idx} (empty)" + else: + try: + name = (getattr(ch, "settings", None).name or "").strip() + except Exception: + try: + name = (ch.settings.name or "").strip() + except Exception: + name = "" + label = f"{idx}: {name or '(no name)'}" + listbox.insert("end", label) + + ttk.Label(frm, text="Channel name:").grid(row=1, column=0, sticky="w", pady=(6, 2)) + name_var = tk.StringVar() + ent_name = ttk.Entry(frm, textvariable=name_var, width=40) + ent_name.grid(row=2, column=0, columnspan=2, sticky="ew") + + def on_select(evt=None): + sel = listbox.curselection() + if not sel: + return + i = sel[0] + ch = chans[i] + if ch is None: + name_var.set("") + return + try: + nm = (getattr(ch, "settings", None).name or "").strip() + except Exception: + try: + nm = (ch.settings.name or "").strip() + except Exception: + nm = "" + name_var.set(nm) + + listbox.bind("<>", on_select) + + def save_name(): + sel = listbox.curselection() + if not sel: + messagebox.showinfo("Channel editor", "Select a channel first.") + return + i = sel[0] + ch = chans[i] + if ch is None: + messagebox.showerror("Channel editor", "Selected channel object is empty, cannot rename.") + return + new_name = name_var.get().strip() + try: + if hasattr(ch, "settings"): + ch.settings.name = new_name + elif hasattr(ch, "name"): + ch.name = new_name + ln.writeChannel(i) + self._append(f"[admin] Renamed channel {i} to '{new_name}'") + self._update_channels_from_iface() + listbox.delete(i) + label = f"{i}: {new_name or '(no name)'}" + listbox.insert(i, label) + except Exception as e: + messagebox.showerror("Channel editor", f"Failed to write channel: {e}") + + btn_save = ttk.Button(frm, text="Save name to device", command=save_name) + btn_save.grid(row=3, column=0, sticky="w", pady=(6, 0)) + + def _cm_open_chat(self): + nid = self._get_selected_node_id() + if not nid: + messagebox.showinfo("Chat", "Select a node first.") + return + self._open_node_chat(nid) + + def _open_node_chat(self, nid: str): + key = str(nid) + if key in self._per_node_chats: + win = self._per_node_chats[key] + try: + win.top.deiconify() + win.top.lift() + except Exception: + pass + return + label = self._node_label(nid) + chat = NodeChatWindow(self, key, label) + self._per_node_chats[key] = chat + + def _append_to_node_chat(self, node_id: str, line: str): + key = str(node_id) + if not key: + return + win = self._per_node_chats.get(key) + if not win: + return + win.append(line) + + def _send_text_to_node(self, dest_id: str, msg: str) -> bool: + if not self.iface: + messagebox.showwarning("Send", "Connect first.") + return False + msg = (msg or "").strip() + if not msg: + return False + try: + choice = self.channel_var.get() if hasattr(self, "channel_var") else "" + info = (self._channel_map.get(choice) if hasattr(self, "_channel_map") else None) or { + "mode": "broadcast", + "channelIndex": 0, + } + ch_index = int(info.get("channelIndex", 0) or 0) + except Exception: + ch_index = 0 + try: + self.iface.sendText(msg, destinationId=dest_id, wantAck=False, channelIndex=ch_index) + self._append("[ME -> %s] %s" % (self._node_label(dest_id), msg)) + self._append_to_node_chat(dest_id, "[ME] " + msg) + return True + except Exception as e: + messagebox.showerror("Send failed", str(e)) + return False + + + +class NodeChatWindow: + def __init__(self, app: "MeshtasticGUI", node_id: str, label: str): + self.app = app + self.node_id = node_id + self.label = label + self.top = tk.Toplevel(app.root) + self.top.title(f"Chat: {label}") + app._style_toplevel(self.top) + self.top.protocol("WM_DELETE_WINDOW", self._on_close) + + frm = ttk.Frame(self.top, padding=8) + frm.pack(expand=True, fill="both") + frm.rowconfigure(0, weight=1) + frm.columnconfigure(0, weight=1) + + self.txt = tk.Text(frm, wrap="word") + self.txt.grid(row=0, column=0, columnspan=2, sticky="nsew") + yscroll = ttk.Scrollbar(frm, orient="vertical", command=self.txt.yview) + self.txt.configure(yscrollcommand=yscroll.set) + yscroll.grid(row=0, column=2, sticky="ns") + + is_dark = app.current_theme == "dark" + self.txt.configure( + bg=("#2d2d2d" if is_dark else "#ffffff"), + fg=("#ffffff" if is_dark else "#000000"), + insertbackground=("#ffffff" if is_dark else "#000000"), + ) + + self.entry = ttk.Entry(frm) + self.entry.grid(row=1, column=0, sticky="nsew", pady=(6, 0)) + self.entry.bind("", self._on_send) + btn = ttk.Button(frm, text="Send", command=self._on_send) + btn.grid(row=1, column=1, sticky="e", padx=(4, 0), pady=(6, 0)) + + def append(self, line: str): + try: + self.txt.insert("end", line + "\n") + self.txt.see("end") + except Exception: + pass + + def _on_send(self, *_): + msg = self.entry.get().strip() + if not msg: + return + ok = self.app._send_text_to_node(self.node_id, msg) + if ok: + self.entry.delete(0, "end") + + def _on_close(self): + key = str(self.node_id) + try: + if key in self.app._per_node_chats: + del self.app._per_node_chats[key] + except Exception: + pass + self.top.destroy() def main(): app = MeshtasticGUI() app.root.geometry("1500x820")