mirror of
https://github.com/pdxlocations/contact.git
synced 2026-11-02 09:00:32 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f854d887b6 | ||
|
|
6c4bb0da11 | ||
|
|
642c4289f8 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user