mirror of
https://github.com/pdxlocations/contact.git
synced 2026-10-31 16:21:57 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f375d788f1 | ||
|
|
4c4b8a42c5 | ||
|
|
80667abb26 | ||
|
|
5210c54d2b | ||
|
|
6eaa567f48 | ||
|
|
76a5a1a496 | ||
|
|
2013b23812 | ||
|
|
22d8901115 | ||
|
|
94a976527d | ||
|
|
3fd811d4c2 |
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
|
||||
@@ -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
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user