Compare commits

...
10 Commits
5 changed files with 185 additions and 37 deletions

No files matched your search

+14 -2
View File
@@ -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
@@ -150,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"]
+44 -34
View File
@@ -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))
@@ -1004,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}")
@@ -1018,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"]
@@ -1031,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}%")
@@ -1089,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
@@ -1980,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
@@ -2062,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 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.7.2"
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"}
+81
View File
@@ -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
@@ -537,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
+45
View File
@@ -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"]