mirror of
https://github.com/pdxlocations/contact.git
synced 2026-10-31 08:11:57 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f375d788f1 | ||
|
|
4c4b8a42c5 | ||
|
|
80667abb26 | ||
|
|
5210c54d2b | ||
|
|
6eaa567f48 | ||
|
|
76a5a1a496 | ||
|
|
2013b23812 | ||
|
|
22d8901115 | ||
|
|
94a976527d | ||
|
|
3fd811d4c2 | ||
|
|
acac61a060 | ||
|
|
fffc3e3e97 | ||
|
|
c956a11672 | ||
|
|
950266637f | ||
|
|
368f464649 | ||
|
|
f854d887b6 | ||
|
|
6c4bb0da11 | ||
|
|
642c4289f8 |
No files matched your search
@@ -13,6 +13,14 @@ _sound_timer_lock = threading.Lock()
|
||||
_last_sound_request = 0.0
|
||||
|
||||
|
||||
def chat_is_selected() -> bool:
|
||||
return (
|
||||
ui_state.current_window == 1
|
||||
and not ui_state.display_log
|
||||
and not ui_state.log_viewer_open
|
||||
)
|
||||
|
||||
|
||||
def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
|
||||
"""Schedule a notification sound after a short quiet period.
|
||||
|
||||
@@ -38,7 +46,8 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
|
||||
with _sound_timer_lock:
|
||||
if expected_request_time != _last_sound_request:
|
||||
return
|
||||
play_sound()
|
||||
if not chat_is_selected():
|
||||
play_sound()
|
||||
|
||||
_sound_timer = threading.Timer(delay, _fire, args=(now,))
|
||||
_sound_timer.daemon = True
|
||||
@@ -89,7 +98,12 @@ def play_sound():
|
||||
elif player == "cvlc":
|
||||
cmd = [executable, "--play-and-exit", sound_path]
|
||||
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if platform.system() == "Windows":
|
||||
su = subprocess.STARTUPINFO()
|
||||
su.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=0x08000000, startupinfo=su)
|
||||
else:
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
return
|
||||
logging.warning(
|
||||
"No suitable sound player found for notification sound; install ffmpeg (ffplay), mpg123, mpv, or VLC."
|
||||
@@ -145,7 +159,10 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
||||
hops = hop_start - hop_limit
|
||||
|
||||
|
||||
if str(config.notification_sound).casefold() not in {"none", "false", ""}:
|
||||
if (
|
||||
not chat_is_selected()
|
||||
and str(config.notification_sound).casefold() not in {"none", "false", ""}
|
||||
):
|
||||
schedule_notification_sound()
|
||||
|
||||
message_bytes = packet["decoded"]["payload"]
|
||||
|
||||
+75
-40
@@ -589,7 +589,7 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
elif char in (curses.KEY_F1, curses.KEY_F2, curses.KEY_F3):
|
||||
handle_function_keys(char)
|
||||
|
||||
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
elif char in (curses.KEY_ENTER, chr(10), chr(13)):
|
||||
input_text = handle_enter(input_text)
|
||||
ui_state.input_cursor = min(ui_state.input_cursor, len(input_text))
|
||||
|
||||
@@ -675,11 +675,17 @@ def insert_input_text(draft: str, text: str) -> str:
|
||||
|
||||
def draw_message_input(draft: str) -> None:
|
||||
"""Redraw the whole input row and scroll it to keep the draft cursor visible."""
|
||||
width = entry_win.getmaxyx()[1]
|
||||
# Получаем сразу высоту и ширину для правильного позиционирования счетчика
|
||||
height, width = entry_win.getmaxyx()
|
||||
prompt = slice_to_width("Message: ", max(0, width - 3))
|
||||
available = max(1, width - 2 - text_width(prompt))
|
||||
ui_state.input_cursor = max(0, min(ui_state.input_cursor, len(draft)))
|
||||
content = ui_state.reply_context + draft
|
||||
|
||||
# Считаем именно байты в UTF-8 (важно для кириллицы)
|
||||
byte_count = len(content.encode('utf-8'))
|
||||
counter_str = f" {byte_count}/220b "
|
||||
|
||||
cursor = len(ui_state.reply_context) + ui_state.input_cursor
|
||||
start = cursor
|
||||
used = 0
|
||||
@@ -689,10 +695,26 @@ def draw_message_input(draft: str) -> None:
|
||||
visible = slice_to_width(content[start:], available)
|
||||
draw_text_field(entry_win, prompt + visible, get_color("input"))
|
||||
paint_frame(entry_win, selected=ui_state.input_focused, refresh=False)
|
||||
|
||||
try:
|
||||
hint = " Ctrl+K Help "
|
||||
if width >= len(hint) + 4:
|
||||
entry_win.addstr(0, width - len(hint) - 2, hint, get_color("commands"))
|
||||
|
||||
# меняем цвет, если превышен лимит
|
||||
if byte_count > 220:
|
||||
try:
|
||||
# Используем settings_warning с добавлением жирности шрифта
|
||||
counter_color = get_color("settings_warning", bold=True)
|
||||
except:
|
||||
counter_color = get_color("commands", bold=True, reverse=True)
|
||||
else:
|
||||
counter_color = get_color("commands")
|
||||
|
||||
# Отрисовка счетчика внизу с защитой от краша (height > 2)
|
||||
if width >= len(counter_str) + 4 and height > 2:
|
||||
entry_win.addstr(height - 1, width - len(counter_str) - 2, counter_str, counter_color)
|
||||
|
||||
curses.curs_set(1 if ui_state.input_focused else 0)
|
||||
entry_win.move(1, min(width - 2, 1 + text_width(prompt) + used))
|
||||
except curses.error:
|
||||
@@ -982,12 +1004,12 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
snr = node["snr"]
|
||||
if isinstance(snr, Real):
|
||||
snr_status = (
|
||||
"🟢 Excellent"
|
||||
"Excellent"
|
||||
if snr > 10
|
||||
else (
|
||||
"🟡 Good"
|
||||
"Good"
|
||||
if snr > 3
|
||||
else "🟠 Fair" if snr > -10 else "🔴 Poor" if snr > -20 else "💀 Very Poor"
|
||||
else "Fair" if snr > -10 else "Poor" if snr > -20 else "Very Poor"
|
||||
)
|
||||
)
|
||||
message_parts.append(f"• SNR: {snr}dB {snr_status}")
|
||||
@@ -996,11 +1018,10 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
|
||||
if node.get("hopsAway") is not None:
|
||||
hops = node["hopsAway"]
|
||||
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩"
|
||||
message_parts.append(f"• Hops away: {hop_emoji} {hops}")
|
||||
message_parts.append(f"• Hops away: {hops}")
|
||||
|
||||
if node.get("lastHeard"):
|
||||
message_parts.append(f"• Last heard: 🕐 {get_time_ago(node['lastHeard'])}")
|
||||
message_parts.append(f"• Last heard: {get_time_ago(node['lastHeard'])}")
|
||||
|
||||
if node.get("deviceMetrics"):
|
||||
metrics = node["deviceMetrics"]
|
||||
@@ -1009,28 +1030,23 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
|
||||
if metrics.get("batteryLevel") is not None:
|
||||
battery = metrics["batteryLevel"]
|
||||
battery_emoji = "🔴"
|
||||
if isinstance(battery, Real):
|
||||
battery_emoji = "🟢" if battery > 50 else "🟡" if battery > 20 else "🔴"
|
||||
voltage_info = f" ({metrics['voltage']}v)" if "voltage" in metrics else ""
|
||||
message_parts.append(f"• Battery: {battery_emoji} {battery}%{voltage_info}")
|
||||
message_parts.append(f"• Battery: {battery}%{voltage_info}")
|
||||
|
||||
if metrics.get("uptimeSeconds") is not None:
|
||||
message_parts.append(f"• Uptime: ⏱️ {get_readable_duration(metrics['uptimeSeconds'])}")
|
||||
message_parts.append(f"• Uptime: {get_readable_duration(metrics['uptimeSeconds'])}")
|
||||
|
||||
if metrics.get("channelUtilization") is not None:
|
||||
util = metrics["channelUtilization"]
|
||||
if isinstance(util, Real):
|
||||
util_emoji = "🔴" if util > 80 else "🟡" if util > 50 else "🟢"
|
||||
message_parts.append(f"• Channel utilization: {util_emoji} {util:.2f}%")
|
||||
message_parts.append(f"• Channel utilization: {util:.2f}%")
|
||||
else:
|
||||
message_parts.append(f"• Channel utilization: {util}%")
|
||||
|
||||
if metrics.get("airUtilTx") is not None:
|
||||
air_util = metrics["airUtilTx"]
|
||||
if isinstance(air_util, Real):
|
||||
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
|
||||
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
|
||||
message_parts.append(f"• Air utilization TX: {air_util:.2f}%")
|
||||
else:
|
||||
message_parts.append(f"• Air utilization TX: {air_util}%")
|
||||
|
||||
@@ -1067,6 +1083,15 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
|
||||
layout = (dialog_height, dialog_width, y, x, ui_state.selected_node, height, width)
|
||||
if previous_layout is not None and layout != previous_layout:
|
||||
if (height, width) != previous_layout[-2:]:
|
||||
# Rebuild the background before staging pads whose old
|
||||
# screen coordinates may now lie outside the terminal.
|
||||
ui_state.current_window = previous_window
|
||||
try:
|
||||
handle_resize(stdscr, False)
|
||||
finally:
|
||||
ui_state.current_window = 4
|
||||
curses.curs_set(0)
|
||||
stage_node_details_background(stdscr, previous_window)
|
||||
previous_layout = layout
|
||||
|
||||
@@ -1629,14 +1654,17 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
|
||||
return
|
||||
|
||||
messages_pad.erase()
|
||||
# Erasing the pad also removes the old highlight. Its row range may no
|
||||
# longer exist after switching conversations or rewrapping messages.
|
||||
ui_state.highlighted_message_range = ()
|
||||
|
||||
channel = ui_state.channel_list[ui_state.selected_channel]
|
||||
|
||||
msg_line_count = 0
|
||||
message_ranges = []
|
||||
rendered_lines = []
|
||||
if channel in ui_state.all_messages:
|
||||
messages = ui_state.all_messages[channel]
|
||||
rendered_lines = []
|
||||
for prefix, message in messages:
|
||||
start_line = len(rendered_lines)
|
||||
full_message = normalize_message_text(f"{prefix}{message}")
|
||||
@@ -1655,10 +1683,10 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
|
||||
message_ranges.append((start_line, len(rendered_lines), color))
|
||||
|
||||
msg_line_count = len(rendered_lines)
|
||||
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
|
||||
for row, (line, color) in enumerate(rendered_lines):
|
||||
messages_pad.addstr(row, 1, line, color)
|
||||
ui_state.message_line_ranges[channel] = message_ranges
|
||||
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
|
||||
for row, (line, color) in enumerate(rendered_lines):
|
||||
messages_pad.addstr(row, 1, line, color)
|
||||
ui_state.message_line_ranges[channel] = message_ranges
|
||||
|
||||
paint_frame(messages_win, selected=(ui_state.current_window == 1), refresh=False)
|
||||
|
||||
@@ -1955,7 +1983,7 @@ def search(win: int) -> None:
|
||||
except curses.error:
|
||||
break
|
||||
|
||||
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
if char in (chr(27), curses.KEY_ENTER, chr(10), chr(13)):
|
||||
break
|
||||
elif char == "\t":
|
||||
start_idx = ui_state.selected_node + 1 if win == 2 else ui_state.selected_channel + 1
|
||||
@@ -2037,34 +2065,41 @@ def refresh_pad(window: int, commit: bool = True) -> None:
|
||||
|
||||
# Clamp start_index within the pad's height
|
||||
pad_h, pad_w = pad.getmaxyx()
|
||||
if pad_h <= 0:
|
||||
if pad_h <= 0 or pad_w <= 0:
|
||||
return
|
||||
start_index = max(0, min(start_index, max(0, pad_h - 1)))
|
||||
|
||||
top = box_y + 1
|
||||
left = box_x + 1
|
||||
bottom = box_y + min(inner_h, lines) # inclusive
|
||||
right = box_x + min(inner_w, box_w - 2)
|
||||
# A resize can leave the frame larger than its pad or the screen.
|
||||
# noutrefresh requires both source and destination rectangles to fit.
|
||||
bottom = min(top + lines - 1, top + pad_h - start_index - 1, curses.LINES - 1)
|
||||
right = min(left + inner_w - 1, left + pad_w - 1, curses.COLS - 1)
|
||||
|
||||
if bottom < top or right < left:
|
||||
return
|
||||
|
||||
draw_frame_title(box, get_window_title(window))
|
||||
box.noutrefresh()
|
||||
try:
|
||||
box.noutrefresh()
|
||||
|
||||
# The frame overlaps the pad's viewport. Recompose even unchanged pad
|
||||
# rows before committing, so its text cannot flash blank between draws.
|
||||
pad.touchwin()
|
||||
pad.noutrefresh(
|
||||
start_index,
|
||||
0,
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
right,
|
||||
)
|
||||
if commit:
|
||||
curses.doupdate()
|
||||
# The frame overlaps the pad's viewport. Recompose even unchanged pad
|
||||
# rows before committing, so its text cannot flash blank between draws.
|
||||
pad.touchwin()
|
||||
pad.noutrefresh(
|
||||
start_index,
|
||||
0,
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
right,
|
||||
)
|
||||
if commit:
|
||||
curses.doupdate()
|
||||
except curses.error:
|
||||
# The terminal can resize again between checking bounds and drawing.
|
||||
# Let the next resize/redraw rebuild the windows instead of exiting.
|
||||
ui_state.redraw_full_ui = True
|
||||
|
||||
|
||||
def add_notification(channel_number: int) -> None:
|
||||
|
||||
@@ -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.4"
|
||||
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"}
|
||||
|
||||
@@ -12,6 +12,85 @@ from tests.test_support import reset_shared_state, restore_config, snapshot_conf
|
||||
|
||||
|
||||
class ContactUiTests(unittest.TestCase):
|
||||
def test_refresh_pad_clips_to_pad_and_resized_screen(self):
|
||||
for pad_size, screen_size, expected in (
|
||||
((2, 5), (24, 80), (1, 0, 1, 1, 1, 5)),
|
||||
((100, 100), (6, 8), (1, 0, 1, 1, 5, 7)),
|
||||
):
|
||||
with self.subTest(pad_size=pad_size, screen_size=screen_size), ExitStack() as stack:
|
||||
box = mock.Mock()
|
||||
box.getmaxyx.return_value = (40, 100)
|
||||
box.getbegyx.return_value = (0, 0)
|
||||
pad = mock.Mock()
|
||||
pad.getmaxyx.return_value = pad_size
|
||||
ui_state.selected_channel = 38
|
||||
stack.enter_context(mock.patch.object(contact_ui, "channel_win", box, create=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "channel_pad", pad, create=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", screen_size[0], create=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", screen_size[1], create=True))
|
||||
contact_ui.refresh_pad(0, commit=False)
|
||||
pad.noutrefresh.assert_called_once_with(*expected)
|
||||
pad.noutrefresh.side_effect = contact_ui.curses.error("resize during refresh")
|
||||
contact_ui.refresh_pad(0, commit=False)
|
||||
self.assertTrue(ui_state.redraw_full_ui)
|
||||
|
||||
def test_f5_rebuilds_background_before_staging_after_terminal_resize(self):
|
||||
for size in ((24, 80), (50, 140)):
|
||||
with self.subTest(size=size), ExitStack() as stack:
|
||||
ui_state.node_list = [101]
|
||||
ui_state.current_window = 2
|
||||
interface = SimpleNamespace(nodesByNum={101: {"user": {"shortName": "NODE"}}})
|
||||
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", 40, create=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", 100, create=True))
|
||||
win = mock.Mock()
|
||||
def read_key():
|
||||
if (contact_ui.curses.LINES, contact_ui.curses.COLS) == (40, 100):
|
||||
contact_ui.curses.LINES, contact_ui.curses.COLS = size
|
||||
return contact_ui.curses.KEY_RESIZE
|
||||
return 27
|
||||
win.getch.side_effect = read_key
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
|
||||
for name in ("curs_set", "update_lines_cols", "doupdate"):
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, name))
|
||||
for name in ("get_color", "refresh_node_selection"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
|
||||
events = []
|
||||
def resize(*args):
|
||||
events.append(("resize", ui_state.current_window))
|
||||
def stage(*args):
|
||||
events.append(("stage", ui_state.current_window))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "handle_resize", side_effect=resize))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background", side_effect=stage))
|
||||
contact_ui.handle_f5_key(mock.Mock())
|
||||
self.assertEqual(events, [("resize", 2), ("stage", 4), ("resize", 2)])
|
||||
self.assertEqual(ui_state.current_window, 2)
|
||||
|
||||
def test_main_ui_accepts_keypad_and_regular_enter(self):
|
||||
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
|
||||
with self.subTest(key=key), ExitStack() as stack:
|
||||
entry = mock.Mock()
|
||||
entry.get_wch.side_effect = [key, "\x1b"]
|
||||
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True))
|
||||
for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name))
|
||||
enter = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value=""))
|
||||
contact_ui.main_ui(mock.Mock())
|
||||
enter.assert_called_once_with("")
|
||||
|
||||
def test_search_closes_on_keypad_and_regular_enter(self):
|
||||
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
|
||||
with self.subTest(key=key), ExitStack() as stack:
|
||||
entry = mock.Mock()
|
||||
entry.get_wch.side_effect = [key]
|
||||
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "get_color", return_value=0))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "draw_centered_text_field"))
|
||||
contact_ui.search(2)
|
||||
entry.get_wch.assert_called_once_with()
|
||||
self.assertEqual(entry.timeout.call_args_list, [mock.call(-1), mock.call(200)])
|
||||
|
||||
def test_f5_restores_background_when_switching_between_different_size_nodes(self):
|
||||
ui_state.node_list = [101, 102]
|
||||
ui_state.current_window = 2
|
||||
@@ -214,6 +293,58 @@ class ContactUiTests(unittest.TestCase):
|
||||
|
||||
contact_ui.messages_pad.resize.assert_called_once_with(2, 40)
|
||||
|
||||
def test_single_pane_typing_after_switching_to_shorter_conversation(self) -> None:
|
||||
for messages in ([("RX: ", "hello")], [], None):
|
||||
with self.subTest(messages=messages):
|
||||
reset_shared_state()
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 0
|
||||
ui_state.channel_list = [123]
|
||||
ui_state.all_messages = {123: messages} if messages is not None else {}
|
||||
ui_state.highlighted_message_range = (10, 12, 20)
|
||||
ui_state.message_line_ranges = {123: [(10, 12, 20)]}
|
||||
pad = mock.Mock()
|
||||
pad.getmaxyx.return_value = (12, 40)
|
||||
|
||||
def resize_pad(height, width):
|
||||
pad.getmaxyx.return_value = (height, width)
|
||||
|
||||
def change_attributes(row, column, width, color):
|
||||
height, columns = pad.getmaxyx()
|
||||
if not (0 <= row < height and 0 <= column < columns and column + width <= columns):
|
||||
raise contact_ui.curses.error("chgat() returned ERR")
|
||||
|
||||
pad.resize.side_effect = resize_pad
|
||||
pad.chgat.side_effect = change_attributes
|
||||
win = mock.Mock()
|
||||
win.getmaxyx.return_value = (10, 40)
|
||||
packetlog = mock.Mock()
|
||||
packetlog.getmaxyx.return_value = (1, 40)
|
||||
with ExitStack() as stack:
|
||||
for name, value in (("messages_pad", pad), ("messages_win", win), ("packetlog_win", packetlog)):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name, value, create=True))
|
||||
for name in ("paint_frame", "refresh_pad", "draw_packetlog_win", "draw_window_arrows", "refresh_main_window"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "get_color", return_value=10))
|
||||
stack.enter_context(mock.patch.object(
|
||||
contact_ui, "handle_resize", side_effect=lambda *args: contact_ui.draw_messages_window(True)
|
||||
))
|
||||
self.assertEqual(contact_ui.insert_input_text("", "a"), "a")
|
||||
|
||||
self.assertEqual(ui_state.current_window, 1)
|
||||
self.assertTrue(ui_state.input_focused)
|
||||
self.assertEqual(ui_state.highlighted_message_range, ())
|
||||
pad.resize.assert_called_once_with(1, 40)
|
||||
if messages:
|
||||
self.assertEqual(ui_state.message_line_ranges[123], [(0, 1, 10)])
|
||||
self.assertEqual(pad.chgat.call_args_list, [
|
||||
mock.call(0, 1, 38, 10 | contact_ui.curses.A_REVERSE),
|
||||
mock.call(0, 1, 38, 10),
|
||||
])
|
||||
else:
|
||||
self.assertEqual(ui_state.message_line_ranges[123], [])
|
||||
pad.chgat.assert_not_called()
|
||||
|
||||
def test_build_reply_prefix_includes_sender_and_five_character_excerpt(self) -> None:
|
||||
reply = contact_ui.build_reply_prefix(
|
||||
"[06:27:25] >> [6] B1G1: ",
|
||||
@@ -485,6 +616,8 @@ class ContactUiTests(unittest.TestCase):
|
||||
|
||||
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(contact_ui.curses, "LINES", 24, create=True).start()
|
||||
mock.patch.object(contact_ui.curses, "COLS", 80, create=True).start()
|
||||
mock.patch.object(contact_ui, "packetlog_win", mock.Mock(), create=True).start()
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 1
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -19,6 +19,51 @@ class RxHandlerTests(unittest.TestCase):
|
||||
restore_config(self.saved_config)
|
||||
reset_shared_state()
|
||||
|
||||
def test_message_sound_depends_on_selected_view(self):
|
||||
config.notification_sound = "alert.mp3"
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary", "Other"]
|
||||
for window, packet_log, log_viewer, channel, expected in (
|
||||
(1, False, False, 0, False),
|
||||
(1, False, False, 1, False),
|
||||
(0, False, False, 0, True),
|
||||
(2, False, False, 0, True),
|
||||
(4, False, False, 0, True),
|
||||
(1, True, False, 0, True),
|
||||
(1, False, True, 0, True),
|
||||
):
|
||||
with self.subTest(window=window, packet_log=packet_log, log_viewer=log_viewer, channel=channel):
|
||||
ui_state.current_window = window
|
||||
ui_state.display_log = packet_log
|
||||
ui_state.log_viewer_open = log_viewer
|
||||
ui_state.all_messages = {"Primary": [], "Other": []}
|
||||
packet = {
|
||||
"from": 222, "to": 999, "channel": channel,
|
||||
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
|
||||
}
|
||||
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False), \
|
||||
mock.patch.object(rx_handler, "request_ui_redraw"), \
|
||||
mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"), \
|
||||
mock.patch.object(rx_handler, "save_message_to_db"), \
|
||||
mock.patch.object(rx_handler, "schedule_notification_sound") as sound:
|
||||
rx_handler.on_receive(packet, interface=None)
|
||||
self.assertEqual(sound.call_count, int(expected))
|
||||
|
||||
def test_pending_sound_is_suppressed_if_chat_selected_before_playback(self):
|
||||
with mock.patch.object(rx_handler, "_sound_timer", None), \
|
||||
mock.patch.object(rx_handler, "_last_sound_request", 0.0), \
|
||||
mock.patch.object(rx_handler.threading, "Timer") as timer, \
|
||||
mock.patch.object(rx_handler, "play_sound") as play:
|
||||
rx_handler.schedule_notification_sound()
|
||||
callback = timer.call_args.args[1]
|
||||
callback_args = timer.call_args.kwargs["args"]
|
||||
ui_state.current_window = 1
|
||||
callback(*callback_args)
|
||||
play.assert_not_called()
|
||||
ui_state.current_window = 0
|
||||
callback(*callback_args)
|
||||
play.assert_called_once_with()
|
||||
|
||||
def test_on_receive_text_message_refreshes_selected_channel(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
|
||||
@@ -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