Compare commits

..
Author SHA1 Message Date
pdxlocations 0143d9bacc Clear stale reply context in draw_text_field function and add corresponding test 2026-07-24 15:25:07 -07:00
pdxlocations 424e10af1e Bump version to 1.5.13 in pyproject.toml 2026-07-24 10:17:55 -07:00
pdxlocations 986cf817f3 Merge pull request #286 from pdxlocations:canned-messages-and-ringtone
Add Canned Messages and Ringone
2026-07-24 10:17:18 -07:00
pdxlocations c699845c81 Improve error handling in API responses
Enhance the error handling mechanism in API responses to provide clearer and more informative messages to users. This change aims to improve user experience and debugging capabilities.
2026-07-24 10:14:49 -07:00
pdxlocations 954bd6ee82 Bump version to 1.5.12 in pyproject.toml 2026-07-24 09:35:48 -07:00
pdxlocations 2cebd6b659 Merge pull request #283 from pdxlocations:bot-enable-config
Move bot responder enable to app config
2026-07-24 09:31:51 -07:00
7 changed files with 69 additions and 6 deletions

No files matched your search

+2
View File
@@ -361,6 +361,7 @@ override_console_serial_port, "Override console serial port", "If set to true, t
[module.external_notification]
title, "External Notification"
ringtone, "Ringtone", "RTTTL ringtone played by supported external-notification hardware."
enabled, "Enabled", "Enables the module."
output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause."
output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI."
@@ -411,6 +412,7 @@ device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemet
[module.canned_message]
title, "Canned Message"
messages, "Messages", "Pipe-separated canned messages, for example: Hi|On my way|Yes|No."
rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder."
inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A."
inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B."
+8 -4
View File
@@ -1675,21 +1675,25 @@ def remove_notification(channel_number: int) -> None:
def draw_text_field(win: curses.window, text: str, color: int) -> None:
win.border()
height, width = win.getmaxyx()
# Put a small hint in the border of the message entry field.
# We key off the "Message:" prompt to avoid affecting other bordered fields.
if isinstance(text, str) and text.startswith("Message:"):
hint = " Ctrl+K Help "
h, w = win.getmaxyx()
x = max(2, w - len(hint) - 2)
x = max(2, width - len(hint) - 2)
try:
win.addstr(0, x, hint, get_color("commands"))
except curses.error:
pass
# Draw the actual field text
# Clear the row before redrawing. Reply context can be longer than the
# next value (or empty after Ctrl+R cancels it), so simply overwriting the
# new text leaves trailing characters from the old context on screen.
try:
win.addstr(1, 1, text, color)
if height > 2 and width > 2:
win.addstr(1, 1, " " * (width - 2), color)
win.addstr(1, 1, text[: max(0, width - 2)], color)
except curses.error:
pass
+18
View File
@@ -609,6 +609,24 @@ def settings_menu(stdscr: object, interface: object) -> None:
# Fetch human-readable name from field_mapping
human_readable_name = field_mapping.get(full_key, selected_option)
if field is None and selected_option in ("ringtone", "messages"):
is_ringtone = selected_option == "ringtone"
getter_name = "get_ringtone" if is_ringtone else "get_canned_message"
setter_name = "set_ringtone" if is_ringtone else "set_canned_message"
getter = getattr(interface.localNode, getter_name, None)
setter = getattr(interface.localNode, setter_name, None)
fetched_value = getter() if callable(getter) else None
current_value = fetched_value if fetched_value is not None else current_value
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, None
)
if new_value is not None and new_value != current_value and callable(setter):
setter(new_value)
menu_state.current_menu[selected_option] = (field, new_value)
menu_state.start_index.pop()
menu_state.need_redraw = True
continue
if selected_option in ["longName", "shortName", "isLicensed"]:
if selected_option in ["longName", "shortName"]:
new_value = get_text_input(
+13 -1
View File
@@ -118,7 +118,19 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
# Add Module Settings
module = module_config_pb2.ModuleConfig()
current_module_config = interface.localNode.moduleConfig if interface else None
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
module_settings = extract_fields(module, current_module_config)
if interface and interface.localNode:
# These values use dedicated admin requests rather than ModuleConfig
# fields, so expose them alongside their related module settings.
module_settings.setdefault("external_notification", {})["ringtone"] = (
None,
getattr(interface.localNode, "ringtone", "") or "",
)
module_settings.setdefault("canned_message", {})["messages"] = (
None,
getattr(interface.localNode, "cannedPluginMessage", "") or "",
)
menu_structure["Main Menu"]["Module Settings"] = module_settings
# Add App Settings
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.5.11"
version = "1.5.13"
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"}
+9
View File
@@ -152,6 +152,15 @@ class ContactUiTests(unittest.TestCase):
self.assertEqual(ui_state.reply_context, "")
self.assertFalse(ui_state.reply_id_unavailable)
def test_draw_text_field_clears_stale_reply_context(self) -> None:
win = mock.Mock()
win.getmaxyx.return_value = (3, 30)
contact_ui.draw_text_field(win, "Message: ", 1)
self.assertIn(mock.call(1, 1, " " * 28, 1), win.addstr.call_args_list)
self.assertIn(mock.call(1, 1, "Message: ", 1), win.addstr.call_args_list)
def test_handle_ctrl_r_shows_context_when_message_id_is_unavailable(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
+18
View File
@@ -26,3 +26,21 @@ class MenusTests(unittest.TestCase):
self.assertLess(keys.index("Factory Reset"), keys.index("factory_reset_config"))
self.assertEqual(keys[keys.index("Factory Reset") + 1], "factory_reset_config")
def test_module_settings_include_ringtone_and_canned_messages(self) -> None:
local_node = SimpleNamespace(
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
ringtone="tone",
cannedPluginMessage="Hi|Bye",
getChannelByChannelIndex=lambda _: None,
)
interface = SimpleNamespace(
localNode=local_node,
getMyNodeInfo=lambda: {"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0}},
)
module_settings = generate_menu_from_protobuf(interface)["Main Menu"]["Module Settings"]
self.assertEqual(module_settings["external_notification"]["ringtone"], (None, "tone"))
self.assertEqual(module_settings["canned_message"]["messages"], (None, "Hi|Bye"))