Compare commits

...
3 Commits
Author SHA1 Message Date
pdxlocations f854d887b6 bump version to 1.7.1 in pyproject.toml 2026-09-18 15:49:22 -07:00
pdxlocations 6c4bb0da11 maybe fix json permission error 2026-09-18 15:48:24 -07:00
pdxlocations 642c4289f8 Temp fix status message 2026-09-18 09:47:26 -07:00
7 changed files with 138 additions and 21 deletions

No files matched your search

+4 -12
View File
@@ -1,5 +1,4 @@
import os
import json
import curses
from typing import Any, List, Dict, Optional
@@ -429,22 +428,15 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> bool:
menu_state.selected_index = 0 # Track the selected option
made_changes = False # Track if any changes were made
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
file_path = os.path.join(parent_dir, "config.json")
file_path = config.json_file_path
menu_state.show_save_option = True # Always show the Save button
menu_state.help_win = None
menu_state.help_text = {}
# Ensure the file exists
if not os.path.exists(file_path):
with open(file_path, "w") as f:
json.dump({}, f)
# Load JSON data
with open(file_path, "r", encoding="utf-8") as f:
original_data = json.load(f)
# Use the same path and defaults as startup, including the writable
# fallback for shared installations and CONTACT_CONFIG_ROOT overrides.
original_data = config.initialize_config()
data = original_data # Reference to the original data
menu_state.current_menu = data # Track the current level of the menu
+28 -4
View File
@@ -3,6 +3,30 @@ import contextlib
import io
import time
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
from meshtastic.protobuf import mesh_pb2
class _StatusMessageConfigMixin:
"""Read status-message config omitted by Meshtastic 2.7.11's receiver."""
def _handleFromRadio(self, fromRadioBytes):
packet = mesh_pb2.FromRadio()
packet.ParseFromString(fromRadioBytes)
if packet.HasField("moduleConfig") and packet.moduleConfig.HasField("statusmessage"):
self.localNode.moduleConfig.statusmessage.CopyFrom(packet.moduleConfig.statusmessage)
return super()._handleFromRadio(fromRadioBytes)
class SerialInterface(_StatusMessageConfigMixin, meshtastic.serial_interface.SerialInterface):
pass
class TCPInterface(_StatusMessageConfigMixin, meshtastic.tcp_interface.TCPInterface):
pass
class BLEInterface(_StatusMessageConfigMixin, meshtastic.ble_interface.BLEInterface):
pass
def interface_is_connected(interface) -> bool:
@@ -31,7 +55,7 @@ def initialize_interface(args, status_callback=None):
try:
if args.ble:
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
return BLEInterface(args.ble if args.ble != "any" else None)
elif args.host:
try:
@@ -40,7 +64,7 @@ def initialize_interface(args, status_callback=None):
else:
tcp_hostname = args.host
tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT
return meshtastic.tcp_interface.TCPInterface(tcp_hostname, portNumber=tcp_port)
return TCPInterface(tcp_hostname, portNumber=tcp_port)
except Exception as ex:
logging.error(f"Error connecting to {args.host}. {ex}")
else:
@@ -48,7 +72,7 @@ def initialize_interface(args, status_callback=None):
# The library prints its fallback notice directly to stdout,
# which would overwrite the curses splash screen.
with contextlib.redirect_stdout(io.StringIO()):
client = meshtastic.serial_interface.SerialInterface(args.port)
client = SerialInterface(args.port)
except FileNotFoundError as ex:
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
except PermissionError as ex:
@@ -63,7 +87,7 @@ def initialize_interface(args, status_callback=None):
try:
if status_callback:
status_callback("No serial device found.\nConnecting via TCP to localhost…")
client = meshtastic.tcp_interface.TCPInterface("localhost")
client = TCPInterface("localhost")
except Exception as ex:
logging.error(f"Error connecting to localhost:{ex}")
+9 -1
View File
@@ -248,7 +248,15 @@ def save_changes(interface, modified_settings, menu_state, node=None):
# Write the configuration changes to the node
try:
node.writeConfig(config_category)
if config_category == "statusmessage":
# Meshtastic 2.7.11 includes this protobuf but omits it from
# writeConfig's dispatch, which exits for unknown categories.
message = admin_pb2.AdminMessage()
message.set_module_config.statusmessage.CopyFrom(node.moduleConfig.statusmessage)
callback = None if node is interface.localNode else node.onAckNak
node._sendAdmin(message, onResponse=callback)
else:
node.writeConfig(config_category)
logging.info(f"Changes written to config category: {config_category}")
if admin_key_backup is not None:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.7.0"
version = "1.7.1"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
+32 -1
View File
@@ -3,10 +3,41 @@ from types import SimpleNamespace
import unittest
from unittest import mock
from contact.utilities.interfaces import reconnect_interface
from meshtastic.protobuf import localonly_pb2, mesh_pb2
from contact.ui.menus import generate_menu_from_protobuf
from contact.utilities.interfaces import BLEInterface, SerialInterface, TCPInterface, reconnect_interface
class InterfacesTests(unittest.TestCase):
def test_received_status_message_appears_in_settings_after_reconnect(self):
for transport in (SerialInterface, TCPInterface, BLEInterface):
with self.subTest(transport=transport.__name__):
# A fresh interface has no cached value from the previous save.
interface = transport.__new__(transport)
interface.configId = 123
interface.localNode = SimpleNamespace(
localConfig=localonly_pb2.LocalConfig(),
moduleConfig=localonly_pb2.LocalModuleConfig(),
getChannelByChannelIndex=lambda _: None,
)
interface.getMyNodeInfo = lambda: {"position": {}}
for status in ("Available", ""):
packet = mesh_pb2.FromRadio()
packet.moduleConfig.statusmessage.node_status = status
interface._handleFromRadio(packet.SerializeToString())
# Other module packets must still pass through normally,
# without clearing the status message already received.
packet = mesh_pb2.FromRadio()
packet.moduleConfig.mqtt.enabled = True
interface._handleFromRadio(packet.SerializeToString())
self.assertTrue(interface.localNode.moduleConfig.mqtt.enabled)
menu = generate_menu_from_protobuf(interface)
value = menu["Main Menu"]["Module Settings"]["statusmessage"]["node_status"][1]
self.assertEqual(value, status)
def test_reconnect_interface_retries_until_connection_succeeds(self) -> None:
args = Namespace()
+30 -1
View File
@@ -1,12 +1,41 @@
from types import SimpleNamespace
import unittest
from unittest import mock
from meshtastic.protobuf import channel_pb2
from meshtastic.protobuf import channel_pb2, localonly_pb2, module_config_pb2
from contact.utilities.save_to_radio import save_changes
class SaveToRadioTests(unittest.TestCase):
def test_status_message_saves_for_local_and_remote_nodes(self):
for remote in (False, True):
with self.subTest(remote=remote):
interface, node = self.build_interface()
node.localConfig = localonly_pb2.LocalConfig()
node.moduleConfig = module_config_pb2.ModuleConfig()
node.moduleConfig.statusmessage.node_status = "Old status"
interface.localNode = mock.Mock() if remote else node
node.writeConfig.side_effect = SystemExit(1)
menu_state = SimpleNamespace(
menu_path=["Main Menu", "Module Settings", "Statusmessage"]
)
reconnect = save_changes(
interface, {"node_status": "Available"}, menu_state, node=node
)
self.assertTrue(reconnect)
node.writeConfig.assert_not_called()
node._sendAdmin.assert_called_once()
message = node._sendAdmin.call_args.args[0]
self.assertEqual(message.WhichOneof("payload_variant"), "set_module_config")
self.assertEqual(message.set_module_config.WhichOneof("payload_variant"), "statusmessage")
self.assertEqual(message.set_module_config.statusmessage.node_status, "Available")
self.assertEqual(
node._sendAdmin.call_args.kwargs["onResponse"],
node.onAckNak if remote else None,
)
def test_channel_enable_disable_and_unrelated_edits_preserve_role(self):
for index in (0, 1):
interface, node = self.build_interface()
+34 -1
View File
@@ -1,5 +1,9 @@
import unittest
from tempfile import NamedTemporaryFile
import json
from contextlib import ExitStack
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from types import SimpleNamespace
from unittest import mock
import contact.ui.default_config # Initialize config before the color helpers.
@@ -7,6 +11,35 @@ from contact.ui import user_config
class UserConfigTests(unittest.TestCase):
def test_editor_reads_and_saves_active_config_outside_installation(self):
for existing in (False, True):
with self.subTest(existing=existing), TemporaryDirectory() as directory, ExitStack() as stack:
root = Path(directory)
config_path = root / "config.json"
package = root / "site-packages" / "contact"
(package / "ui").mkdir(parents=True)
if existing:
config_path.write_text(json.dumps({"message_prefix": "Custom"}), encoding="utf-8")
stack.enter_context(mock.patch.object(user_config, "__file__", str(package / "ui" / "user_config.py")))
stack.enter_context(mock.patch.object(user_config.config, "json_file_path", str(config_path)))
reload_config = stack.enter_context(mock.patch.object(user_config.config, "reload_config"))
stack.enter_context(mock.patch.object(user_config, "reload_translations"))
stack.enter_context(mock.patch.object(user_config, "update_app_settings_help"))
window = mock.Mock()
state = SimpleNamespace()
stack.enter_context(mock.patch.object(user_config, "display_menu", return_value=(window, mock.Mock(), [])))
def save_from_editor():
self.assertEqual(state.current_menu["message_prefix"], "Custom" if existing else ">>")
state.current_menu["message_prefix"] = "Edited"
return 10 # Save button.
window.getch.side_effect = save_from_editor
self.assertFalse(user_config.json_editor(mock.Mock(), state))
self.assertEqual(json.loads(config_path.read_text())["message_prefix"], "Edited")
self.assertFalse((package / "config.json").exists())
reload_config.assert_called_once_with()
def test_ping_bot_enabled_uses_boolean_picker(self) -> None:
with mock.patch.object(user_config, "get_list_input", return_value="True") as picker:
result = user_config.edit_value("enabled", "Enabled", "False")