Add files via upload

This commit is contained in:
Knud Schrøder
2025-11-26 15:41:24 +01:00
committed by GitHub
parent 73d743a7c0
commit be124aeaec
3 changed files with 354 additions and 23 deletions

Binary file not shown.

BIN
meshtastic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

View File

@@ -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("<Button-3>", self._popup_node_menu)
self.tv_nodes.bind("<Double-1>", lambda e: self._toggle_send_target())
self.tv_nodes.bind("<Double-1>", 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 <idx>: <name>" -> 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 (readonly)")
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("<<ListboxSelect>>", 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("<Return>", 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")