Compare commits

...

24 Commits

Author SHA1 Message Date
pdxlocations
568f29ee29 single pane reload configuration on save 2026-03-18 22:21:36 -07:00
pdxlocations
87127adef1 Merge pull request #253 from pdxlocations:UI
Enhance UI color handling for channel and node rows, and refactor refresh logic
2026-03-18 22:17:27 -07:00
pdxlocations
dd0eb1473c Enhance UI color handling for channel and node rows, and refactor refresh logic 2026-03-18 22:17:08 -07:00
pdxlocations
7d9703a548 Merge pull request #252 from pdxlocations:normalize-emojis
Add emoji normalization utility and integrate into message rendering
2026-03-18 21:37:02 -07:00
pdxlocations
68b8d15181 Add emoji normalization utility and integrate into message rendering 2026-03-18 21:36:46 -07:00
pdxlocations
4ef87871df bump version 2026-03-12 19:37:21 -07:00
pdxlocations
18df7d326a Merge pull request #250 from pdxlocations:resize-faster
Add resize event handling
2026-03-12 16:21:15 -07:00
pdxlocations
c7b54caf45 Add resize event handling and update version to 1.4.18 2026-03-12 15:30:03 -07:00
pdxlocations
773f43edd8 Merge pull request #248 from pdxlocations:fix-protubuf-depend
Fix-protubuf-depend
2026-03-02 16:31:55 -08:00
pdxlocations
6af1c46bd3 bump version to 1.4.17 2026-03-02 16:31:33 -08:00
pdxlocations
7e3e44df24 Refactor repeated field handling in protobuf utilities 2026-03-02 16:31:09 -08:00
pdxlocations
45626f5e83 bump version 2026-02-28 10:32:43 -08:00
pdxlocations
e9181972b2 bump version 2026-02-28 10:32:16 -08:00
pdxlocations
795ab84ef5 Fix 3.9 compatibility 2026-02-28 10:31:48 -08:00
pdxlocations
5e108c5fe5 version bump 2026-02-12 07:45:41 -08:00
pdxlocations
edef37b116 IP bug fix 2026-02-12 07:38:43 -08:00
pdxlocations
e7e1bf7852 Merge pull request #246 from pdxlocations:display-ip
Display Human-Readable IP's and Actually Save Nested Configs
2026-02-11 21:57:54 -08:00
pdxlocations
1c2384ea8d actually save nested configs 2026-02-11 21:56:45 -08:00
pdxlocations
4cda264746 Display IPs Correctly 2026-02-11 21:40:42 -08:00
pdxlocations
0005aaf438 Bump version to 1.4.13 in pyproject.toml 2026-01-24 00:08:59 -08:00
pdxlocations
f39a09646a fix No Help Available translation 2026-01-24 00:08:33 -08:00
pdxlocations
055aaeb633 Bump version to 1.4.12 in pyproject.toml 2026-01-23 23:51:15 -08:00
pdxlocations
edd86c1d4b Add terminal resize dialog for minimum row requirement 2026-01-23 23:50:10 -08:00
pdxlocations
df4ed16bae don't hide help window on small screens 2026-01-23 23:47:27 -08:00
18 changed files with 319 additions and 150 deletions

View File

@@ -33,6 +33,8 @@ from contact.ui.splash import draw_splash
from contact.utilities.arg_parser import setup_parser
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.ui.dialog import dialog
from contact.utilities.interfaces import initialize_interface
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
from contact.utilities.singleton import ui_state, interface_state, app_state
@@ -85,6 +87,7 @@ def main(stdscr: curses.window) -> None:
output_capture = io.StringIO()
try:
setup_colors()
ensure_min_rows(stdscr)
draw_splash(stdscr)
args = setup_parser().parse_args()
@@ -120,6 +123,24 @@ def main(stdscr: curses.window) -> None:
raise
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
while True:
rows, _ = stdscr.getmaxyx()
if rows >= min_rows:
return
dialog(
t("ui.dialog.resize_title", default="Resize Terminal"),
t(
"ui.dialog.resize_body",
default="Please resize the terminal to at least {rows} rows.",
rows=min_rows,
),
)
curses.update_lines_cols()
stdscr.clear()
stdscr.refresh()
def start() -> None:
"""Entry point for the application."""

View File

@@ -86,6 +86,8 @@ confirm.remove_favorite, "Remove {name} from Favorites?", ""
confirm.set_ignored, "Set {name} as Ignored?", ""
confirm.remove_ignored, "Remove {name} from Ignored?", ""
confirm.region_unset, "Your region is UNSET. Set it now?", ""
dialog.resize_title, "Resize Terminal", ""
dialog.resize_body, "Please resize the terminal to at least {rows} rows.", ""
[User Settings]
user, "User"

View File

@@ -86,6 +86,8 @@ confirm.remove_favorite, "Удалить {name} из избранного?", ""
confirm.set_ignored, "Игнорировать {name}?", ""
confirm.remove_ignored, "Убрать {name} из игнорируемых?", ""
confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", ""
dialog.resize_title, "Увеличьте окно", ""
dialog.resize_body, "Пожалуйста, увеличьте окно до {rows} строк.", ""
[User Settings]
user, "Пользователь"

View File

@@ -5,9 +5,10 @@ import shutil
import time
import subprocess
import threading
from typing import Any, Dict, Optional
# Debounce notification sounds so a burst of queued messages only plays once.
_SOUND_DEBOUNCE_SECONDS = 0.8
_sound_timer: threading.Timer | None = None
_sound_timer: Optional[threading.Timer] = None
_sound_timer_lock = threading.Lock()
_last_sound_request = 0.0
@@ -42,8 +43,6 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
_sound_timer = threading.Timer(delay, _fire, args=(now,))
_sound_timer.daemon = True
_sound_timer.start()
from typing import Any, Dict
from contact.utilities.utils import (
refresh_node_list,
add_new_message,

View File

@@ -8,6 +8,8 @@ import traceback
import contact.ui.default_config as config
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.ui.dialog import dialog
from contact.utilities.i18n import t
from contact.ui.colors import setup_colors
from contact.ui.splash import draw_splash
from contact.ui.control_ui import set_region, settings_menu
@@ -20,6 +22,7 @@ def main(stdscr: curses.window) -> None:
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors()
ensure_min_rows(stdscr)
draw_splash(stdscr)
curses.curs_set(0)
stdscr.keypad(True)
@@ -50,6 +53,24 @@ def main(stdscr: curses.window) -> None:
raise
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
while True:
rows, _ = stdscr.getmaxyx()
if rows >= min_rows:
return
dialog(
t("ui.dialog.resize_title", default="Resize Terminal"),
t(
"ui.dialog.resize_body",
default="Please resize the terminal to at least {rows} rows.",
rows=min_rows,
),
)
curses.update_lines_cols()
stdscr.clear()
stdscr.refresh()
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
filename=config.log_file_path,
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)

View File

@@ -12,6 +12,7 @@ from contact.ui.colors import get_color
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.emoji_utils import normalize_message_text
import contact.ui.default_config as config
import contact.ui.dialog
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text
@@ -19,7 +20,9 @@ from contact.utilities.singleton import ui_state, interface_state, menu_state
MIN_COL = 1 # "effectively zero" without breaking curses
RESIZE_DEBOUNCE_MS = 250
root_win = None
nodes_pad = None
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
@@ -62,6 +65,48 @@ def paint_frame(win, selected: bool) -> None:
win.refresh()
def get_channel_row_color(index: int) -> int:
if index == ui_state.selected_channel:
if ui_state.current_window == 0:
return get_color("channel_list", reverse=True)
return get_color("channel_selected")
return get_color("channel_list")
def get_node_row_color(index: int) -> int:
node_num = ui_state.node_list[index]
node = interface_state.interface.nodesByNum.get(node_num, {})
color = "node_list"
if node.get("isFavorite"):
color = "node_favorite"
if node.get("isIgnored"):
color = "node_ignored"
return get_color(color, reverse=index == ui_state.selected_node and ui_state.current_window == 2)
def refresh_main_window(window_id: int, selected: bool) -> None:
if window_id == 0:
paint_frame(channel_win, selected=selected)
if ui_state.channel_list:
width = max(0, channel_pad.getmaxyx()[1] - 4)
channel_pad.chgat(ui_state.selected_channel, 1, width, get_channel_row_color(ui_state.selected_channel))
refresh_pad(0)
elif window_id == 1:
paint_frame(messages_win, selected=selected)
refresh_pad(1)
elif window_id == 2:
paint_frame(nodes_win, selected=selected)
if ui_state.node_list and nodes_pad is not None:
width = max(0, nodes_pad.getmaxyx()[1] - 4)
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node))
refresh_pad(2)
def get_node_display_name(node_num: int, node: dict) -> str:
user = node.get("user") or {}
return user.get("longName") or get_name_from_database(node_num, "long")
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
"""Handle terminal resize events and redraw the UI accordingly."""
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
@@ -161,6 +206,24 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
pass
def drain_resize_events(input_win: curses.window) -> Union[str, int, None]:
"""Wait for resize events to settle and preserve one queued non-resize key."""
input_win.timeout(RESIZE_DEBOUNCE_MS)
try:
while True:
try:
next_char = input_win.get_wch()
except curses.error:
return None
if next_char == curses.KEY_RESIZE:
continue
return next_char
finally:
input_win.timeout(-1)
def main_ui(stdscr: curses.window) -> None:
"""Main UI loop for the curses interface."""
global input_text
@@ -168,6 +231,7 @@ def main_ui(stdscr: curses.window) -> None:
root_win = stdscr
input_text = ""
queued_char = None
stdscr.keypad(True)
get_channels()
handle_resize(stdscr, True)
@@ -176,7 +240,11 @@ def main_ui(stdscr: curses.window) -> None:
draw_text_field(entry_win, f"Message: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
# Get user input from entry window
char = entry_win.get_wch()
if queued_char is None:
char = entry_win.get_wch()
else:
char = queued_char
queued_char = None
# draw_debug(f"Keypress: {char}")
@@ -224,7 +292,9 @@ def main_ui(stdscr: curses.window) -> None:
elif char == curses.KEY_RESIZE:
input_text = ""
queued_char = drain_resize_events(entry_win)
handle_resize(stdscr, False)
continue
elif char == chr(4): # Ctrl + D to delete current channel or node
handle_ctrl_d()
@@ -333,31 +403,16 @@ def handle_leftright(char: int) -> None:
delta = -1 if char == curses.KEY_LEFT else 1
old_window = ui_state.current_window
ui_state.current_window = (ui_state.current_window + delta) % 3
handle_resize(root_win, False)
if ui_state.single_pane_mode:
handle_resize(root_win, False)
return
if old_window == 0:
paint_frame(channel_win, selected=False)
refresh_pad(0)
if old_window == 1:
paint_frame(messages_win, selected=False)
refresh_pad(1)
elif old_window == 2:
paint_frame(nodes_win, selected=False)
refresh_pad(2)
refresh_main_window(old_window, selected=False)
if not ui_state.single_pane_mode:
draw_window_arrows(old_window)
if ui_state.current_window == 0:
paint_frame(channel_win, selected=True)
refresh_pad(0)
elif ui_state.current_window == 1:
paint_frame(messages_win, selected=True)
refresh_pad(1)
elif ui_state.current_window == 2:
paint_frame(nodes_win, selected=True)
refresh_pad(2)
refresh_main_window(ui_state.current_window, selected=True)
draw_window_arrows(ui_state.current_window)
@@ -378,31 +433,16 @@ def handle_function_keys(char: int) -> None:
return
ui_state.current_window = target
handle_resize(root_win, False)
if ui_state.single_pane_mode:
handle_resize(root_win, False)
return
if old_window == 0:
paint_frame(channel_win, selected=False)
refresh_pad(0)
elif old_window == 1:
paint_frame(messages_win, selected=False)
refresh_pad(1)
elif old_window == 2:
paint_frame(nodes_win, selected=False)
refresh_pad(2)
refresh_main_window(old_window, selected=False)
if not ui_state.single_pane_mode:
draw_window_arrows(old_window)
if ui_state.current_window == 0:
paint_frame(channel_win, selected=True)
refresh_pad(0)
elif ui_state.current_window == 1:
paint_frame(messages_win, selected=True)
refresh_pad(1)
elif ui_state.current_window == 2:
paint_frame(nodes_win, selected=True)
refresh_pad(2)
refresh_main_window(ui_state.current_window, selected=True)
draw_window_arrows(ui_state.current_window)
@@ -598,6 +638,7 @@ def handle_backtick(stdscr: curses.window) -> None:
ui_state.current_window = 4
settings_menu(stdscr, interface_state.interface)
ui_state.current_window = previous_window
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
curses.curs_set(1)
refresh_node_list()
handle_resize(stdscr, False)
@@ -839,7 +880,7 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
row = 0
for prefix, message in messages:
full_message = f"{prefix}{message}"
full_message = normalize_message_text(f"{prefix}{message}")
wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2)
msg_line_count += len(wrapped_lines)
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
@@ -881,10 +922,8 @@ def draw_node_list() -> None:
if ui_state.current_window != 2 and ui_state.single_pane_mode:
return
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time
# if nodes_pad is None:
# nodes_pad = curses.newpad(1, 1)
nodes_pad = curses.newpad(1, 1)
if nodes_pad is None:
nodes_pad = curses.newpad(1, 1)
try:
nodes_pad.erase()
@@ -898,28 +937,12 @@ def draw_node_list() -> None:
node = interface_state.interface.nodesByNum[node_num]
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
status_icon = "🔐" if secure else "🔓"
node_name = get_name_from_database(node_num, "long")
user_name = node["user"]["shortName"]
uptime_str = ""
if "deviceMetrics" in node and "uptimeSeconds" in node["deviceMetrics"]:
uptime_str = f" / Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}"
last_heard_str = f"{get_time_ago(node['lastHeard'])}" if node.get("lastHeard") else ""
hops_str = f" ■ Hops: {node['hopsAway']}" if "hopsAway" in node else ""
snr_str = f" ■ SNR: {node['snr']}dB" if node.get("hopsAway") == 0 and "snr" in node else ""
node_name = get_node_display_name(node_num, node)
# Future node name custom formatting possible
node_str = f"{status_icon} {node_name}"
node_str = node_str.ljust(box_width - 4)[: box_width - 2]
color = "node_list"
if "isFavorite" in node and node["isFavorite"]:
color = "node_favorite"
if "isIgnored" in node and node["isIgnored"]:
color = "node_ignored"
nodes_pad.addstr(
i, 1, node_str, get_color(color, reverse=ui_state.selected_node == i and ui_state.current_window == 2)
)
nodes_pad.addstr(i, 1, node_str, get_node_row_color(i))
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
nodes_win.refresh()

View File

@@ -1,5 +1,6 @@
import base64
import curses
import ipaddress
import logging
import os
import sys
@@ -8,7 +9,9 @@ from typing import List
from contact.utilities.save_to_radio import save_changes
import contact.ui.default_config as config
from contact.utilities.config_io import config_export, config_import
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
from contact.utilities.control_utils import transform_menu_path
from contact.utilities.i18n import t
from contact.utilities.ini_utils import parse_ini_file
from contact.utilities.input_handlers import (
get_repeated_input,
get_text_input,
@@ -16,7 +19,6 @@ from contact.utilities.input_handlers import (
get_list_input,
get_admin_key_input,
)
from contact.utilities.i18n import t
from contact.ui.colors import get_color
from contact.ui.dialog import dialog
from contact.ui.menus import generate_menu_from_protobuf
@@ -54,6 +56,13 @@ config_folder = os.path.abspath(config.node_configs_file_path)
# Load translations
field_mapping, help_text = parse_ini_file(translation_file)
def _is_repeated_field(field_desc) -> bool:
"""Return True if the protobuf field is repeated.
Protobuf 6.31.0 and later use an is_repeated property, while older versions compare against the label field.
"""
if hasattr(field_desc, "is_repeated"):
return bool(field_desc.is_repeated)
return field_desc.label == field_desc.LABEL_REPEATED
def reload_translations() -> None:
global translation_file, field_mapping, help_text
@@ -121,6 +130,22 @@ def display_menu() -> tuple[object, object]:
full_key = ".".join(transformed_path + [option])
display_name = field_mapping.get(full_key, option)
if full_key.startswith("config.network.ipv4_config.") and option in {"ip", "gateway", "subnet", "dns"}:
if isinstance(current_value, int):
try:
current_value = str(
ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False))
)
except ipaddress.AddressValueError:
pass
elif isinstance(current_value, str) and current_value.isdigit():
try:
current_value = str(
ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False))
)
except ipaddress.AddressValueError:
pass
display_option = f"{display_name}"[: w // 2 - 2]
display_value = f"{current_value}"[: w // 2 - 4]
@@ -546,7 +571,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
new_value = new_value == "True" or new_value is True
menu_state.start_index.pop()
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
elif _is_repeated_field(field): # Handle repeated field - Not currently used
new_value = get_repeated_input(current_value)
new_value = current_value if new_value is None else new_value.split(", ")
menu_state.start_index.pop()

View File

@@ -29,8 +29,6 @@ save_option = "Save Changes"
def get_save_option_label() -> str:
return t("ui.save_changes", default=save_option)
MIN_HEIGHT_FOR_HELP = 20
def move_highlight(
old_idx: int, options: List[str], menu_win: curses.window, menu_pad: curses.window, **kwargs: Any
@@ -181,9 +179,6 @@ def update_help_window(
) -> object: # returns a curses window
"""Handles rendering the help window consistently."""
if curses.LINES < MIN_HEIGHT_FOR_HELP:
return None
# Clamp target position and width to the current terminal size
help_x = max(0, help_x)
help_y = max(0, help_y)

View File

@@ -6,7 +6,7 @@ from typing import Any, List, Dict, Optional
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
import contact.ui.default_config as config
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.utilities.control_utils import parse_ini_file
from contact.utilities.ini_utils import parse_ini_file
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.singleton import menu_state
@@ -566,7 +566,7 @@ def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = config.format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json)
setup_colors(reinit=True)
config.reload_config()
reload_translations(data.get("language"))

View File

@@ -9,6 +9,17 @@ from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
# defs are from meshtastic/python/main
def _is_repeated_field(field_desc) -> bool:
"""Return True if the protobuf field is repeated.
Protobuf 6.31.0+ exposes `is_repeated`, while older versions require
checking `label == LABEL_REPEATED`.
"""
if hasattr(field_desc, "is_repeated"):
return bool(field_desc.is_repeated)
return field_desc.label == field_desc.LABEL_REPEATED
def traverseConfig(config_root, config, interface_config) -> bool:
"""Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference"""
snake_name = camel_to_snake(config_root)
@@ -89,7 +100,7 @@ def setPref(config, comp_name, raw_val) -> bool:
return False
# repeating fields need to be handled with append, not setattr
if pref.label != pref.LABEL_REPEATED:
if not _is_repeated_field(pref):
try:
if config_type.message_type is not None:
config_values = getattr(config_part, config_type.name)

View File

@@ -1,55 +1,7 @@
from typing import Optional, Tuple, Dict, List
from typing import List
import re
def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Parses an INI file and returns a mapping of keys to human-readable names and help text."""
field_mapping: Dict[str, str] = {}
help_text: Dict[str, str] = {}
current_section: Optional[str] = None
with open(ini_file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith(";") or line.startswith("#"):
continue
# Handle sections like [config.device]
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1]
continue
# Parse lines like: key, "Human-readable name", "helptext"
parts = [p.strip().strip('"') for p in line.split(",", 2)]
if len(parts) >= 2:
key = parts[0]
# If key is 'title', map directly to the section
if key == "title":
full_key = current_section
else:
full_key = f"{current_section}.{key}" if current_section else key
# Use the provided human-readable name or fallback to key
human_readable_name = parts[1] if parts[1] else key
field_mapping[full_key] = human_readable_name
# Handle help text or default
help = parts[2] if len(parts) == 3 and parts[2] else "No help available."
help_text[full_key] = help
else:
# Handle cases with only the key present
full_key = f"{current_section}.{key}" if current_section else key
field_mapping[full_key] = key
help_text[full_key] = "No help available."
return field_mapping, help_text
def transform_menu_path(menu_path: List[str]) -> List[str]:
"""Applies path replacements and normalizes entries in the menu path."""
path_replacements = {"Radio Settings": "config", "Module Settings": "module"}

View File

@@ -0,0 +1,54 @@
"""Helpers for normalizing emoji sequences in width-sensitive message rendering."""
# Strip zero-width and presentation modifiers that make terminal cell width inconsistent.
EMOJI_MODIFIER_REPLACEMENTS = {
"\u200d": "",
"\u20e3": "",
"\ufe0e": "",
"\ufe0f": "",
"\U0001F3FB": "",
"\U0001F3FC": "",
"\U0001F3FD": "",
"\U0001F3FE": "",
"\U0001F3FF": "",
}
_EMOJI_MODIFIER_TRANSLATION = str.maketrans(EMOJI_MODIFIER_REPLACEMENTS)
_REGIONAL_INDICATOR_START = ord("\U0001F1E6")
_REGIONAL_INDICATOR_END = ord("\U0001F1FF")
def _regional_indicator_to_letter(char: str) -> str:
return chr(ord("A") + ord(char) - _REGIONAL_INDICATOR_START)
def _normalize_flag_emoji(text: str) -> str:
"""Convert flag emoji built from regional indicators into ASCII country codes."""
normalized = []
index = 0
while index < len(text):
current = text[index]
current_ord = ord(current)
if _REGIONAL_INDICATOR_START <= current_ord <= _REGIONAL_INDICATOR_END and index + 1 < len(text):
next_char = text[index + 1]
next_ord = ord(next_char)
if _REGIONAL_INDICATOR_START <= next_ord <= _REGIONAL_INDICATOR_END:
normalized.append(_regional_indicator_to_letter(current))
normalized.append(_regional_indicator_to_letter(next_char))
index += 2
continue
normalized.append(current)
index += 1
return "".join(normalized)
def normalize_message_text(text: str) -> str:
"""Strip modifiers and rewrite flag emoji into stable terminal-friendly text."""
if not text:
return text
return _normalize_flag_emoji(text.translate(_EMOJI_MODIFIER_TRANSLATION))

View File

@@ -1,7 +1,7 @@
from typing import Optional
import contact.ui.default_config as config
from contact.utilities.control_utils import parse_ini_file
from contact.utilities.ini_utils import parse_ini_file
_translations = {}
_language = None

View File

@@ -0,0 +1,54 @@
from typing import Optional, Tuple, Dict
from contact.utilities import i18n
def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Parses an INI file and returns a mapping of keys to human-readable names and help text."""
try:
default_help = i18n.t("ui.help.no_help", default="No help available.")
except Exception:
default_help = "No help available."
field_mapping: Dict[str, str] = {}
help_text: Dict[str, str] = {}
current_section: Optional[str] = None
with open(ini_file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith(";") or line.startswith("#"):
continue
# Handle sections like [config.device]
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1]
continue
# Parse lines like: key, "Human-readable name", "helptext"
parts = [p.strip().strip('"') for p in line.split(",", 2)]
if len(parts) >= 2:
key = parts[0]
# If key is 'title', map directly to the section
if key == "title":
full_key = current_section
else:
full_key = f"{current_section}.{key}" if current_section else key
# Use the provided human-readable name or fallback to key
human_readable_name = parts[1] if parts[1] else key
field_mapping[full_key] = human_readable_name
# Handle help text or default
help = parts[2] if len(parts) == 3 and parts[2] else default_help
help_text[full_key] = help
else:
# Handle cases with only the key present
full_key = f"{current_section}.{key}" if current_section else key
field_mapping[full_key] = key
help_text[full_key] = default_help
return field_mapping, help_text

View File

@@ -462,7 +462,10 @@ from contact.utilities.singleton import menu_state # Ensure this is imported
def get_fixed32_input(current_value: int) -> int:
original_value = current_value
ip_string = str(ipaddress.IPv4Address(current_value))
try:
ip_string = str(ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False)))
except Exception:
ip_string = str(ipaddress.IPv4Address(current_value))
height = 10
width = get_dialog_width()
start_y = max(0, (curses.LINES - height) // 2)
@@ -524,7 +527,7 @@ def get_fixed32_input(current_value: int) -> int:
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
curses.noecho()
curses.curs_set(0)
return int(ipaddress.ip_address(user_input))
return int.from_bytes(ipaddress.IPv4Address(user_input).packed, "little", signed=False)
else:
fixed32_win.addstr(
7,
@@ -536,7 +539,7 @@ def get_fixed32_input(current_value: int) -> int:
curses.napms(1500)
user_input = ""
elif key in (curses.KEY_BACKSPACE, 127):
elif key in (curses.KEY_BACKSPACE, curses.KEY_DC, 127, 8, "\b", "\x7f"):
user_input = user_input[:-1]
else:

View File

@@ -112,16 +112,23 @@ def save_changes(interface, modified_settings, menu_state):
else:
config_category = None
# Resolve the target config container, including nested sub-messages (e.g., network.ipv4_config)
config_container = None
if hasattr(node.localConfig, config_category):
config_container = getattr(node.localConfig, config_category)
elif hasattr(node.moduleConfig, config_category):
config_container = getattr(node.moduleConfig, config_category)
else:
logging.warning(f"Config category '{config_category}' not found in config.")
return
if len(menu_state.menu_path) >= 4:
nested_key = menu_state.menu_path[3]
if hasattr(config_container, nested_key):
config_container = getattr(config_container, nested_key)
for config_item, new_value in modified_settings.items():
# Check if the category exists in localConfig
if hasattr(node.localConfig, config_category):
config_subcategory = getattr(node.localConfig, config_category)
# Check if the category exists in moduleConfig
elif hasattr(node.moduleConfig, config_category):
config_subcategory = getattr(node.moduleConfig, config_category)
else:
logging.warning(f"Config category '{config_category}' not found in config.")
continue
config_subcategory = config_container
# Check if the config_item exists in the subcategory
if hasattr(config_subcategory, config_item):

View File

@@ -68,19 +68,19 @@ def get_chunks(data):
# Leave it string as last resort
value = value
match key:
# Python 3.9-compatible alternative to match/case.
if key == "uptime_seconds":
# convert seconds to hours, for our sanity
case "uptime_seconds":
value = round(value / 60 / 60, 1)
value = round(value / 60 / 60, 1)
elif key in ("longitude_i", "latitude_i"):
# Convert position to degrees (humanize), as per Meshtastic protobuf comment for this telemetry
# truncate to 6th digit after floating point, which would be still accurate
case "longitude_i" | "latitude_i":
value = round(value * 1e-7, 6)
value = round(value * 1e-7, 6)
elif key == "wind_direction":
# Convert wind direction from degrees to abbreviation
case "wind_direction":
value = humanize_wind_direction(value)
case "time":
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
value = humanize_wind_direction(value)
elif key == "time":
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
if key in sensors:
parsed+= f"{sensors[key.strip()]['icon']}{value}{sensors[key]['unit']} "

View File

@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.4.11"
version = "1.4.22"
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"}