mirror of
https://github.com/pdxlocations/contact.git
synced 2026-10-31 08:11:57 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa2dbccf44 | ||
|
|
66256f0a4c | ||
|
|
e570ce4e48 | ||
|
|
d0d9c1dd55 | ||
|
|
043418bfde | ||
|
|
a8a1527a84 | ||
|
|
7d2092e25f | ||
|
|
ac23c2bbbb | ||
|
|
e378aba526 | ||
|
|
ec35cbd3e7 | ||
|
|
7f6fa49385 | ||
|
|
4ed3cbf370 | ||
|
|
7afc025b9d | ||
|
|
ece37f2a36 | ||
|
|
0cee9777bc | ||
|
|
72956321da | ||
|
|
6a61f7dcd5 | ||
|
|
b1b9f22203 | ||
|
|
f63c27e9ad | ||
|
|
8ea5646f6c | ||
|
|
41d703ad9b | ||
|
|
f4bd0c8614 | ||
|
|
68b5d81c1f | ||
|
|
61bbc24d29 | ||
|
|
0143d9bacc | ||
|
|
424e10af1e | ||
|
|
986cf817f3 | ||
|
|
c699845c81 | ||
|
|
7273e80639 | ||
|
|
954bd6ee82 | ||
|
|
2cebd6b659 | ||
|
|
72d959a3c1 | ||
|
|
73b31a5124 | ||
|
|
d403d2cea5 | ||
|
|
59a7b5b488 | ||
|
|
5baef73c3c | ||
|
|
46a73fbc65 | ||
|
|
0145d84f3b | ||
|
|
ff656f20ad | ||
|
|
eef321cac7 | ||
|
|
cc33716222 | ||
|
|
7efc1a640d | ||
|
|
b54e467ef0 | ||
|
|
8c01f8fc17 | ||
|
|
c6164af582 | ||
|
|
52f42591d2 |
No files matched your search
Vendored
+8
@@ -17,6 +17,14 @@
|
||||
"cwd": "${workspaceFolder}",
|
||||
"module": "contact.__main__",
|
||||
"args": ["--host","192.168.86.69"]
|
||||
},
|
||||
{
|
||||
"name": "Python Debugger: tcp-remote",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"module": "contact.__main__",
|
||||
"args": ["--host","100.89.187.29"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -45,6 +45,14 @@ All messages will saved in a SQLite DB and restored upon relaunch of the app. Y
|
||||
|
||||
By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more!
|
||||
|
||||
### Notification sounds
|
||||
|
||||
Add audio files to Contact's `contact/sounds/` folder, then choose one in Settings → App Settings → Notification sound. `alert.mp3` is the default selection; choose `None` to disable notification audio. Supported file types are MP3, WAV, OGG, AIFF, and FLAC.
|
||||
|
||||
### Remote administration
|
||||
|
||||
With the Nodes pane selected, press `` ` `` or `F12` and choose **Remote admin** for the highlighted node. Contact requests the node's settings through Meshtastic Remote Admin and displays an error if the node does not authorize your public key. Remote App Settings are intentionally unavailable because they apply only to the local Contact client.
|
||||
|
||||
For smaller displays you may wish to enable `single_pane_mode`:
|
||||
|
||||
<img width="486" height="194" alt="Screenshot 2025-08-22 at 11 15 54 PM" src="https://github.com/user-attachments/assets/447c5d30-0850-4a4f-b0d4-976e4c5e329d" />
|
||||
@@ -60,7 +68,6 @@ For smaller displays you may wish to enable `single_pane_mode`:
|
||||
- `CTRL` + `t` or `F4` = With the Node List highlighted, send a traceroute to the selected node
|
||||
- `F5` = Display a node's info
|
||||
- `CTRL` + `f` = With the Node List highlighted, favorite the selected node
|
||||
- `CTRL` + `b` = Enable/Disable Autoresponder Bot (ping / pong)
|
||||
- `CTRL` + `g` = With the Node List highlighted, ignore the selected node
|
||||
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
|
||||
- `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb.
|
||||
|
||||
@@ -67,6 +67,8 @@ dialog.traceroute_not_sent_body, "Please wait {seconds} seconds before sending a
|
||||
dialog.traceroute_sent_title, "Traceroute Sent To: {name}", ""
|
||||
dialog.traceroute_sent_body, "Results will appear in messages window.", ""
|
||||
dialog.help_title, "Help - Shortcut Keys", ""
|
||||
dialog.reply_unavailable_title, "Reply unavailable", ""
|
||||
dialog.reply_unavailable_body, "This message has no packet ID, so Contact cannot send a native Meshtastic reply.", ""
|
||||
help.scroll, "Up/Down = Scroll", ""
|
||||
help.switch_window, "Left/Right = Switch window", ""
|
||||
help.jump_windows, "F1/F2/F3 = Jump to Channel/Messages/Nodes", ""
|
||||
@@ -74,11 +76,11 @@ help.enter, "ENTER = Send / Select", ""
|
||||
help.settings, "` or F12 = Settings", ""
|
||||
help.quit, "ESC = Quit", ""
|
||||
help.packet_log, "Ctrl+P = Toggle Packet Log", ""
|
||||
help.reply, "Ctrl+R = Reply to message at cursor", ""
|
||||
help.traceroute, "Ctrl+T or F4 = Traceroute", ""
|
||||
help.node_info, "F5 = Full node info", ""
|
||||
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
|
||||
help.favorite, "Ctrl+F = Favorite", ""
|
||||
help.bot_responder, "Ctrl+B = Toggle Bot Responder", ""
|
||||
help.ignore, "Ctrl+G = Ignore", ""
|
||||
help.search, "Ctrl+/ or / = Search", ""
|
||||
help.help, "Ctrl+K = Help", ""
|
||||
@@ -91,17 +93,13 @@ 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.", ""
|
||||
bot.status.enabled, "Enabled", ""
|
||||
bot.status.disabled, "Disabled", ""
|
||||
bot.dialog.title, "Bot Responder", ""
|
||||
bot.dialog.body, "Bot responder is now {status}.", ""
|
||||
bot.status.message, "Bot responder is now {status}.", ""
|
||||
|
||||
[User Settings]
|
||||
user, "User"
|
||||
longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign."
|
||||
shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis."
|
||||
isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling."
|
||||
isUnmessagable, "Disable incoming messages", "When enabled, this node advertises that it cannot receive messages."
|
||||
|
||||
[app_settings]
|
||||
title, "App Settings", ""
|
||||
@@ -115,7 +113,7 @@ language, "Language", "UI language for labels and help text."
|
||||
message_prefix, "Message prefix", ""
|
||||
sent_message_prefix, "Sent message prefix", ""
|
||||
notification_symbol, "Notification symbol", ""
|
||||
notification_sound, "Notification sound", ""
|
||||
notification_sound, "Notification sound", "Select a sound file from Contact's sounds folder, or None to disable notification audio."
|
||||
ack_implicit_str, "ACK (implicit)", ""
|
||||
ack_str, "ACK", ""
|
||||
nak_str, "NAK", ""
|
||||
@@ -129,6 +127,7 @@ COLOR_CONFIG_GREEN, "Theme colors (green)", ""
|
||||
|
||||
[app_settings.ping_bot]
|
||||
title, "Ping Bot", ""
|
||||
enabled, "Enabled", "Enable automatic Ping Bot responses."
|
||||
catch_words, "Catch words", "Semicolon-separated bot trigger words."
|
||||
response_word, "Response word", "Bot response word."
|
||||
|
||||
@@ -254,6 +253,8 @@ heading_bold, "Heading bold", "The heading can be hard to read when 'INVERTED' o
|
||||
wake_on_tap_or_motion, "Wake on tap or motion", "This option enables the ability to wake the device screen when motion, such as a tap on the device, is detected via an attached accelerometer, or a capacitive touch button."
|
||||
compass_orientation, "Compass orientation", "Whether to rotate the compass."
|
||||
use_12h_clock, "Use 12 hour clock"
|
||||
use_long_node_name, "Use long node name", "Show each node's long name on the device display when space permits."
|
||||
enable_message_bubbles, "Enable message bubbles", "Display messages in chat-style bubbles on supported device displays."
|
||||
|
||||
[config.device_ui]
|
||||
title, "Device UI"
|
||||
@@ -272,6 +273,10 @@ node_filter, "Node Filter", ""
|
||||
node_highlight, "Node Highlight", ""
|
||||
calibration_data, "Calibration Data", ""
|
||||
map_data, "Map Data", ""
|
||||
compass_mode, "Compass mode", "Controls whether the compass is dynamic, fixed, or freezes its heading."
|
||||
screen_rgb_color, "Screen RGB color", "Sets the display color on supported RGB screens."
|
||||
is_clockface_analog, "Analog clock face", "Use an analog clock face on supported device displays."
|
||||
gps_format, "GPS coordinate format", "Select the coordinate format used by the device UI."
|
||||
|
||||
[config.device_ui.node_filter]
|
||||
title, "Node Filter"
|
||||
@@ -297,6 +302,12 @@ home, "Home", ""
|
||||
style, "Style", ""
|
||||
follow_gps, "Follow GPS", ""
|
||||
|
||||
[config.device_ui.map_data.home]
|
||||
title, "Home location", ""
|
||||
zoom, "Zoom", "Map zoom level for the home location."
|
||||
latitude, "Latitude", "Latitude of the home location."
|
||||
longitude, "Longitude", "Longitude of the home location."
|
||||
|
||||
[config.lora]
|
||||
title, "LoRa"
|
||||
use_preset, "Use modem preset", "Presets are pre-defined modem settings (Bandwidth, Spread Factor, and Coding Rate) which influence both message speed and range. The vast majority of users use a preset."
|
||||
@@ -316,6 +327,8 @@ override_frequency, "Override frequency in MHz", "Overrides frequency slot. May
|
||||
pa_fan_disabled, "PA Fan Disabled", "If true, disable the build-in PA FAN using pin define in RF95_FAN_EN"
|
||||
ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device."
|
||||
config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers."
|
||||
fem_lna_mode, "FEM LNA mode", "Configures the front-end module low-noise amplifier mode on supported hardware."
|
||||
serial_hal_only, "Serial HAL only", "Use only the serial hardware abstraction layer on supported hardware."
|
||||
|
||||
[config.bluetooth]
|
||||
title, "Bluetooth"
|
||||
@@ -332,6 +345,7 @@ serial_enabled, "Enable serial console", ""
|
||||
debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active."
|
||||
admin_channel_enabled, "Enable legacy admin channel", "If the node you need to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes."
|
||||
admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3."
|
||||
packet_signature_policy, "Packet signature policy", "Controls how the node validates packet signatures."
|
||||
|
||||
[module.mqtt]
|
||||
title, "MQTT"
|
||||
@@ -363,6 +377,7 @@ override_console_serial_port, "Override console serial port", "If set to true, t
|
||||
|
||||
[module.external_notification]
|
||||
title, "External Notification"
|
||||
ringtone, "Ringtone", "RTTTL ringtone played by supported external-notification hardware."
|
||||
enabled, "Enabled", "Enables the module."
|
||||
output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause."
|
||||
output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI."
|
||||
@@ -393,6 +408,7 @@ title, "Range Test"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages."
|
||||
save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]"
|
||||
clear_on_reboot, "Clear CSV on reboot", "Delete the RangeTest.csv file when the device reboots. [warning]Only available on ESP32-based devices.[/warning]"
|
||||
|
||||
[module.telemetry]
|
||||
title, "Telemetry"
|
||||
@@ -410,9 +426,11 @@ health_measurement_enabled, "Health telemetry interval", "This option is used to
|
||||
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
|
||||
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
|
||||
device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry"
|
||||
air_quality_screen_enabled, "Air quality screen enabled", "Show air-quality telemetry on the device display."
|
||||
|
||||
[module.canned_message]
|
||||
title, "Canned Message"
|
||||
messages, "Messages", "Pipe-separated canned messages, for example: Hi|On my way|Yes|No."
|
||||
rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder."
|
||||
inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A."
|
||||
inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B."
|
||||
@@ -470,5 +488,52 @@ use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. O
|
||||
title, "Paxcounter"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
|
||||
Wi-Fi_threshold, "Wi-Fi Threshold", "WiFi RSSI threshold. Defaults to -80"
|
||||
wifi_threshold, "Wi-Fi threshold", "Wi-Fi RSSI threshold. Defaults to -80."
|
||||
ble_threshold, "BLE Threshold", "BLE RSSI threshold. Defaults to -80"
|
||||
|
||||
[module.statusmessage]
|
||||
title, "Status Message"
|
||||
node_status, "Node status", ""
|
||||
|
||||
[module.traffic_management]
|
||||
title, "Traffic Management"
|
||||
position_min_interval_secs, "Position minimum interval", "Minimum seconds between position updates from the same node. Set to 0 to disable position-update suppression."
|
||||
nodeinfo_direct_response_max_hops, "NodeInfo direct-response maximum hops", "Maximum hop distance at which cached NodeInfo requests receive a direct response. Set to 0 to disable direct responses."
|
||||
rate_limit_window_secs, "Rate-limit window", "Per-node rate-limit window in seconds. Set to 0 to disable rate limiting."
|
||||
rate_limit_max_packets, "Rate-limit maximum packets", "Maximum packets allowed per node during the rate-limit window. Set to 0 to disable rate limiting."
|
||||
unknown_packet_threshold, "Unknown-packet threshold", "Maximum unknown or undecryptable packets per rate-limit window before packets from that source are dropped. Set to 0 to disable filtering."
|
||||
|
||||
[module.tak]
|
||||
title, "Tak"
|
||||
team, "Team", "Your team color."
|
||||
role, "Role", "Your role in the team."
|
||||
|
||||
[module.mesh_beacon]
|
||||
title, "Mesh Beacon", ""
|
||||
flags, "Beacon options", "Bitwise combination of the listen, broadcast, and legacy-split options."
|
||||
broadcast_send_as_node, "Broadcast as node", "Optional node ID to use as the sender of beacon packets. Leave unset to send as this node."
|
||||
broadcast_message, "Broadcast message", "Message included in each beacon broadcast. Maximum 100 bytes."
|
||||
broadcast_offer_channel, "Offered channel", "Optional channel name and key to advertise in the beacon offer."
|
||||
broadcast_offer_region, "Offered region", "Optional LoRa region to advertise in the beacon offer."
|
||||
broadcast_offer_preset, "Offered modem preset", "Optional modem preset to advertise in the beacon offer."
|
||||
broadcast_on_channel, "Broadcast channel", "Optional channel settings to use when transmitting beacons. Uses the primary channel when unset."
|
||||
broadcast_on_region, "Broadcast region", "Optional LoRa region to use when transmitting beacons."
|
||||
broadcast_on_preset, "Broadcast modem preset", "Optional modem preset to use when transmitting beacons."
|
||||
broadcast_interval_secs, "Broadcast interval", "How often to broadcast beacons in seconds. The minimum and default is 3600 seconds."
|
||||
broadcast_targets, "Broadcast targets", "Optional list of channel, region, and modem-preset targets for beacon broadcasts."
|
||||
|
||||
[module.mesh_beacon.broadcast_offer_channel]
|
||||
title, "Offered channel", ""
|
||||
psk, "PSK", "Encryption key for the offered channel."
|
||||
name, "Name", "Name of the offered channel."
|
||||
|
||||
[module.mesh_beacon.broadcast_on_channel]
|
||||
title, "Broadcast channel", ""
|
||||
psk, "PSK", "Encryption key for the broadcast channel."
|
||||
name, "Name", "Name of the broadcast channel."
|
||||
|
||||
[module.mesh_beacon.broadcast_targets]
|
||||
title, "Broadcast targets", ""
|
||||
preset, "Modem preset", "Optional modem preset for this beacon target."
|
||||
region, "Region", "LoRa region for this beacon target."
|
||||
channel_index, "Channel index", "Index of the configured channel to use for this beacon target."
|
||||
@@ -78,16 +78,10 @@ help.traceroute, "Ctrl+T ou F4 = Traceroute", ""
|
||||
help.node_info, "F5 = Informations complètes du nœud", ""
|
||||
help.archive_chat, "Ctrl+D = Archiver la discussion / supprimer le nœud", ""
|
||||
help.favorite, "Ctrl+F = Favori", ""
|
||||
help.bot_responder, "Ctrl+B = Activer/désactiver le bot répondeur", ""
|
||||
help.ignore, "Ctrl+G = Ignorer", ""
|
||||
help.search, "Ctrl+/ ou / = Rechercher", ""
|
||||
help.help, "Ctrl+K = Aide", ""
|
||||
help.no_help, "Aucune aide disponible.", ""
|
||||
bot.status.enabled, "Activé", ""
|
||||
bot.status.disabled, "Désactivé", ""
|
||||
bot.dialog.title, "Bot répondeur", ""
|
||||
bot.dialog.body, "Le bot répondeur est maintenant {status}.", ""
|
||||
bot.status.message, "Le bot répondeur est maintenant {status}.", ""
|
||||
|
||||
[User Settings]
|
||||
user, "Utilisateur", ""
|
||||
@@ -118,6 +112,7 @@ ping_bot, "Bot Ping", ""
|
||||
|
||||
[app_settings.ping_bot]
|
||||
title, "Bot Ping", ""
|
||||
enabled, "Activé", "Activer les réponses automatiques du bot Ping."
|
||||
catch_words, "Mots déclencheurs", "Mots déclencheurs du bot séparés par des points-virgules."
|
||||
response_word, "Mot de réponse", "Mot de réponse du bot."
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ help.traceroute, "Ctrl+T или F4 = Traceroute", ""
|
||||
help.node_info, "F5 = Полная информация об узле", ""
|
||||
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
|
||||
help.favorite, "Ctrl+F = Избранное", ""
|
||||
help.bot_responder, "Ctrl+B = Вкл/выкл автоответчик", ""
|
||||
help.ignore, "Ctrl+G = Игнорировать", ""
|
||||
help.search, "Ctrl+/ или / = Поиск", ""
|
||||
help.help, "Ctrl+K = Справка", ""
|
||||
@@ -91,11 +90,6 @@ confirm.remove_ignored, "Убрать {name} из игнорируемых?", ""
|
||||
confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", ""
|
||||
dialog.resize_title, "Увеличьте окно", ""
|
||||
dialog.resize_body, "Пожалуйста, увеличьте окно до {rows} строк.", ""
|
||||
bot.status.enabled, "Включен", ""
|
||||
bot.status.disabled, "Выключен", ""
|
||||
bot.dialog.title, "Автоответчик", ""
|
||||
bot.dialog.body, "Автоответчик теперь {status}.", ""
|
||||
bot.status.message, "Автоответчик теперь {status}.", ""
|
||||
|
||||
[User Settings]
|
||||
user, "Пользователь"
|
||||
@@ -129,6 +123,7 @@ COLOR_CONFIG_GREEN, "Цвета темы (зеленая)", ""
|
||||
|
||||
[app_settings.ping_bot]
|
||||
title, "Пинг-бот", ""
|
||||
enabled, "Включен", "Включить автоматические ответы пинг-бота."
|
||||
catch_words, "Слова-триггеры", "Слова для активации бота, разделенные точкой с запятой."
|
||||
response_word, "Ответное слово", "Ответное слово бота."
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import time
|
||||
from typing import Any, Dict
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.singleton import app_state, interface_state, ui_state
|
||||
from contact.utilities.singleton import app_state, interface_state
|
||||
from contact.message_handlers.tx_handler import send_message
|
||||
|
||||
BOT_RESPONSE_DELAY_SECONDS = 2.3
|
||||
@@ -24,9 +24,15 @@ def is_bot_message(message: str) -> bool:
|
||||
"""Return True when the incoming message should trigger an automatic response."""
|
||||
return message.strip().casefold() in _get_bot_catch_words()
|
||||
|
||||
|
||||
def is_bot_enabled() -> bool:
|
||||
"""Return whether automatic responses are enabled in app settings."""
|
||||
value = getattr(config, "ping_bot_enabled", False)
|
||||
return value if isinstance(value, bool) else str(value).strip().casefold() == "true"
|
||||
|
||||
def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool:
|
||||
"""Send a basic response when bot mode is enabled."""
|
||||
if not ui_state.bot_mode_enabled:
|
||||
if not is_bot_enabled():
|
||||
return False
|
||||
|
||||
if not is_bot_message(message):
|
||||
@@ -40,7 +46,8 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
|
||||
return False
|
||||
snr = packet.get('rxSnr', -128)
|
||||
rssi = packet.get('rxRssi', -128)
|
||||
replyIDset = packet.get('replyId', False)
|
||||
incoming_reply_id = (packet.get('decoded') or {}).get('replyId')
|
||||
packet_id = packet.get("id")
|
||||
hop_start = packet.get('hopStart', 0)
|
||||
hop_limit = packet.get('hopLimit', 0)
|
||||
transport_type = packet.get('transportMechanism', None)
|
||||
@@ -53,8 +60,8 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
|
||||
details.append(f"RSSI: {rssi}")
|
||||
if hops != 0:
|
||||
details.append(f"Hops: {hops}")
|
||||
if replyIDset:
|
||||
details.append(f"Relay: {replyIDset}")
|
||||
if incoming_reply_id:
|
||||
details.append(f"Relay: {incoming_reply_id}")
|
||||
transport_text = str(transport_type).upper() if transport_type is not None else ""
|
||||
for transport_name in ("UDP", "MQTT"):
|
||||
if transport_name in transport_text:
|
||||
@@ -69,10 +76,12 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
|
||||
time.sleep(BOT_RESPONSE_DELAY_SECONDS)
|
||||
|
||||
with app_state.lock:
|
||||
if not ui_state.bot_mode_enabled:
|
||||
if not is_bot_enabled():
|
||||
return
|
||||
|
||||
send_message(response_data_string,channel=send_channel)
|
||||
# Use the triggering packet's ID so Meshtastic clients render this
|
||||
# automatic response as a native reply when that ID is available.
|
||||
send_message(response_data_string, channel=send_channel, reply_id=packet_id)
|
||||
|
||||
# Import locally to avoid circular import at module import time.
|
||||
from contact.ui.contact_ui import request_ui_redraw
|
||||
|
||||
@@ -46,6 +46,7 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
|
||||
from contact.utilities.utils import (
|
||||
refresh_node_list,
|
||||
add_new_message,
|
||||
get_reply_context,
|
||||
)
|
||||
from contact.ui.contact_ui import (
|
||||
add_notification,
|
||||
@@ -65,37 +66,34 @@ from contact.message_handlers.bot_handler import bot_respond
|
||||
|
||||
def play_sound():
|
||||
try:
|
||||
system = platform.system()
|
||||
sound_path = None
|
||||
executable = None
|
||||
selected_sound = str(getattr(config, "notification_sound", "None"))
|
||||
if selected_sound.casefold() in {"none", "false", ""}:
|
||||
return
|
||||
sound_path = os.path.join(config.parent_dir, "sounds", selected_sound)
|
||||
if not os.path.isfile(sound_path):
|
||||
logging.warning("Configured notification sound is unavailable: %s", sound_path)
|
||||
return
|
||||
|
||||
if system == "Darwin": # macOS
|
||||
sound_path = "/System/Library/Sounds/Ping.aiff"
|
||||
executable = "afplay"
|
||||
|
||||
elif system == "Linux":
|
||||
ogg_path = "/usr/share/sounds/freedesktop/stereo/complete.oga"
|
||||
wav_path = "/usr/share/sounds/alsa/Front_Center.wav" # common fallback
|
||||
|
||||
if shutil.which("paplay") and os.path.exists(ogg_path):
|
||||
executable = "paplay"
|
||||
sound_path = ogg_path
|
||||
elif shutil.which("ffplay") and os.path.exists(ogg_path):
|
||||
executable = "ffplay"
|
||||
sound_path = ogg_path
|
||||
elif shutil.which("aplay") and os.path.exists(wav_path):
|
||||
executable = "aplay"
|
||||
sound_path = wav_path
|
||||
else:
|
||||
logging.warning("No suitable sound player or sound file found on Linux")
|
||||
executable = "afplay" if platform.system() == "Darwin" else next(
|
||||
(shutil.which(player) for player in ("ffplay", "mpg123", "mpv", "cvlc", "paplay") if shutil.which(player)),
|
||||
None,
|
||||
)
|
||||
|
||||
if executable and sound_path:
|
||||
player = os.path.basename(executable)
|
||||
cmd = [executable, sound_path]
|
||||
if executable == "ffplay":
|
||||
if player == "ffplay":
|
||||
cmd = [executable, "-nodisp", "-autoexit", sound_path]
|
||||
elif player == "mpv":
|
||||
cmd = [executable, "--no-video", sound_path]
|
||||
elif player == "cvlc":
|
||||
cmd = [executable, "--play-and-exit", sound_path]
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Sound playback failed: {e}")
|
||||
@@ -143,7 +141,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
||||
hops = hop_start - hop_limit
|
||||
|
||||
|
||||
if config.notification_sound == "True":
|
||||
if str(config.notification_sound).casefold() not in {"none", "false", ""}:
|
||||
schedule_notification_sound()
|
||||
|
||||
message_bytes = packet["decoded"]["payload"]
|
||||
@@ -183,14 +181,32 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
||||
message_from_id = packet["from"]
|
||||
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
|
||||
|
||||
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
|
||||
# replyId is a field of Meshtastic's decrypted Data payload.
|
||||
reply_id = packet["decoded"].get("replyId")
|
||||
reply_context = get_reply_context(reply_id) if reply_id is not None else ""
|
||||
add_new_message(
|
||||
channel_id,
|
||||
f"{config.message_prefix} [{hops}] {message_from_string} ",
|
||||
f"{reply_context}{message_string}",
|
||||
packet_id=packet.get("id"),
|
||||
)
|
||||
|
||||
if refresh_channels:
|
||||
request_ui_redraw(channels=True)
|
||||
if refresh_messages:
|
||||
request_ui_redraw(messages=True, scroll_messages_to_bottom=True)
|
||||
request_ui_redraw(
|
||||
messages=True,
|
||||
scroll_messages_to_bottom=True,
|
||||
preserve_message_selection=(ui_state.current_window == 1),
|
||||
)
|
||||
|
||||
save_message_to_db(channel_id, message_from_id, message_string)
|
||||
save_message_to_db(
|
||||
channel_id,
|
||||
message_from_id,
|
||||
message_string,
|
||||
packet_id=packet.get("id"),
|
||||
reply_id=reply_id,
|
||||
)
|
||||
|
||||
except KeyError as e:
|
||||
logging.error(f"Error processing packet: {e}")
|
||||
@@ -56,7 +56,7 @@ def onAckNak(packet: Dict[str, Any]) -> None:
|
||||
message,
|
||||
)
|
||||
|
||||
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
|
||||
update_ack_nak(acknak["channel"], acknak["timestamp"], acknak["dbMessage"], ack_type)
|
||||
|
||||
channel_number = ui_state.channel_list.index(acknak["channel"])
|
||||
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
|
||||
@@ -164,7 +164,13 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None:
|
||||
|
||||
save_message_to_db(channel_id, packet["from"], msg_str)
|
||||
|
||||
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
|
||||
def send_message(
|
||||
message: str,
|
||||
destination: int = BROADCAST_NUM,
|
||||
channel: int = 0,
|
||||
reply_id: int | None = None,
|
||||
reply_context: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
Sends a chat message using the selected channel.
|
||||
"""
|
||||
@@ -177,23 +183,32 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int =
|
||||
elif isinstance(channel_id, str):
|
||||
send_on_channel = channel
|
||||
|
||||
sent_message_data = interface_state.interface.sendText(
|
||||
text=message,
|
||||
destinationId=destination,
|
||||
wantAck=True,
|
||||
wantResponse=False,
|
||||
onResponse=onAckNak,
|
||||
channelIndex=send_on_channel,
|
||||
send_kwargs = {
|
||||
"text": message,
|
||||
"destinationId": destination,
|
||||
"wantAck": True,
|
||||
"wantResponse": False,
|
||||
"onResponse": onAckNak,
|
||||
"channelIndex": send_on_channel,
|
||||
}
|
||||
if reply_id is not None:
|
||||
send_kwargs["replyId"] = reply_id
|
||||
sent_message_data = interface_state.interface.sendText(**send_kwargs)
|
||||
|
||||
add_new_message(
|
||||
channel_id,
|
||||
config.sent_message_prefix + config.ack_unknown_str + ": ",
|
||||
f"{reply_context}{message}",
|
||||
packet_id=sent_message_data.id,
|
||||
)
|
||||
|
||||
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message)
|
||||
|
||||
timestamp = save_message_to_db(channel_id, myid, message)
|
||||
timestamp = save_message_to_db(channel_id, myid, message, packet_id=sent_message_data.id, reply_id=reply_id)
|
||||
|
||||
ack_naks[sent_message_data.id] = {
|
||||
"channel": channel_id,
|
||||
"messageIndex": len(ui_state.all_messages[channel_id]) - 1,
|
||||
"timestamp": timestamp,
|
||||
"dbMessage": message,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
+401
-82
@@ -1,17 +1,27 @@
|
||||
import curses
|
||||
import logging
|
||||
from typing import Optional
|
||||
import time
|
||||
import traceback
|
||||
from numbers import Real
|
||||
from typing import Union
|
||||
|
||||
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list, add_new_message
|
||||
from contact.utilities.utils import (
|
||||
get_channels,
|
||||
get_readable_duration,
|
||||
get_time_ago,
|
||||
refresh_node_list,
|
||||
add_new_message,
|
||||
build_reply_prefix,
|
||||
)
|
||||
from contact.settings import settings_menu
|
||||
from contact.ui.control_ui import RemoteAdminCancelled
|
||||
from contact.message_handlers.tx_handler import send_message, send_traceroute
|
||||
from contact.utilities.utils import parse_protobuf
|
||||
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.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived, load_older_messages
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.interfaces import interface_is_connected
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.emoji_utils import normalize_message_text
|
||||
import contact.ui.default_config as config
|
||||
@@ -41,6 +51,7 @@ def request_ui_redraw(
|
||||
packetlog: bool = False,
|
||||
full: bool = False,
|
||||
scroll_messages_to_bottom: bool = False,
|
||||
preserve_message_selection: bool = False,
|
||||
) -> None:
|
||||
ui_state.redraw_channels = ui_state.redraw_channels or channels
|
||||
ui_state.redraw_messages = ui_state.redraw_messages or messages
|
||||
@@ -48,6 +59,7 @@ def request_ui_redraw(
|
||||
ui_state.redraw_packetlog = ui_state.redraw_packetlog or packetlog
|
||||
ui_state.redraw_full_ui = ui_state.redraw_full_ui or full
|
||||
ui_state.scroll_messages_to_bottom = ui_state.scroll_messages_to_bottom or scroll_messages_to_bottom
|
||||
ui_state.preserve_message_selection = ui_state.preserve_message_selection or preserve_message_selection
|
||||
|
||||
|
||||
def process_pending_ui_updates(stdscr: curses.window) -> None:
|
||||
@@ -58,6 +70,7 @@ def process_pending_ui_updates(stdscr: curses.window) -> None:
|
||||
ui_state.redraw_nodes = False
|
||||
ui_state.redraw_packetlog = False
|
||||
ui_state.scroll_messages_to_bottom = False
|
||||
ui_state.preserve_message_selection = False
|
||||
handle_resize(stdscr, False)
|
||||
return
|
||||
|
||||
@@ -71,9 +84,14 @@ def process_pending_ui_updates(stdscr: curses.window) -> None:
|
||||
|
||||
if ui_state.redraw_messages:
|
||||
scroll_to_bottom = ui_state.scroll_messages_to_bottom
|
||||
preserve_selection = ui_state.preserve_message_selection
|
||||
ui_state.redraw_messages = False
|
||||
ui_state.scroll_messages_to_bottom = False
|
||||
draw_messages_window(scroll_to_bottom)
|
||||
ui_state.preserve_message_selection = False
|
||||
if preserve_selection:
|
||||
draw_messages_window(scroll_to_bottom, preserve_selection=True)
|
||||
else:
|
||||
draw_messages_window(scroll_to_bottom)
|
||||
|
||||
if ui_state.redraw_packetlog:
|
||||
ui_state.redraw_packetlog = False
|
||||
@@ -368,9 +386,41 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
handle_resize(stdscr, True)
|
||||
|
||||
while True:
|
||||
interface = interface_state.interface
|
||||
if not interface_is_connected(interface) and not ui_state.reconnect_attempted:
|
||||
ui_state.reconnect_attempted = True
|
||||
status_win = show_connection_status(stdscr, "Disconnected", "Trying to reconnect…")
|
||||
try:
|
||||
from contact.ui.control_ui import reconnect_interface_with_splash
|
||||
|
||||
reconnect_interface_with_splash(stdscr, interface)
|
||||
if status_win is not None:
|
||||
status_win.erase()
|
||||
status_win.refresh()
|
||||
ui_state.reconnect_attempted = False
|
||||
handle_resize(stdscr, False)
|
||||
except Exception:
|
||||
if status_win is not None:
|
||||
status_win.erase()
|
||||
status_win.refresh()
|
||||
handle_resize(stdscr, False)
|
||||
logging.exception("Automatic reconnect after transport disconnect failed")
|
||||
retry = get_list_input(
|
||||
"Contact could not reconnect after 20 seconds.",
|
||||
"Retry",
|
||||
["Retry", "Cancel"],
|
||||
mandatory=True,
|
||||
)
|
||||
if retry == "Retry":
|
||||
ui_state.reconnect_attempted = False
|
||||
handle_resize(stdscr, False)
|
||||
continue
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
with app_state.lock:
|
||||
process_pending_ui_updates(stdscr)
|
||||
draw_text_field(entry_win, f"Message: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
||||
entry_display = f"{ui_state.reply_context}{input_text or ''}"
|
||||
draw_text_field(entry_win, f"Message: {entry_display[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
||||
|
||||
# Get user input from entry window
|
||||
try:
|
||||
@@ -426,6 +476,12 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
elif char == chr(16): # Ctrl + P for Packet Log
|
||||
handle_ctrl_p()
|
||||
|
||||
elif char == chr(18): # Ctrl + R for Reply
|
||||
cancelling_reply = bool(ui_state.reply_context)
|
||||
input_text = handle_ctrl_r(input_text)
|
||||
if cancelling_reply:
|
||||
entry_win.erase()
|
||||
|
||||
elif char == curses.KEY_RESIZE:
|
||||
input_text = ""
|
||||
queued_char = drain_resize_events(entry_win)
|
||||
@@ -446,9 +502,6 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
elif char == chr(6): # Ctrl + F to toggle favorite
|
||||
handle_ctrl_f(stdscr)
|
||||
|
||||
elif char == chr(2): # Ctrl + B to toggle bot responder
|
||||
handle_ctrl_b(stdscr)
|
||||
|
||||
elif char == chr(7): # Ctrl + G to toggle ignored
|
||||
handle_ctlr_g(stdscr)
|
||||
|
||||
@@ -488,7 +541,8 @@ def handle_home() -> None:
|
||||
if ui_state.current_window == 0:
|
||||
select_channel(0)
|
||||
elif ui_state.current_window == 1:
|
||||
ui_state.selected_message = 0
|
||||
set_message_selection(0)
|
||||
refresh_message_highlight()
|
||||
refresh_pad(1)
|
||||
elif ui_state.current_window == 2:
|
||||
select_node(0)
|
||||
@@ -501,8 +555,8 @@ def handle_end() -> None:
|
||||
if ui_state.current_window == 0:
|
||||
select_channel(len(ui_state.channel_list) - 1)
|
||||
elif ui_state.current_window == 1:
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0)
|
||||
set_message_selection(messages_pad.getmaxyx()[0] - 1)
|
||||
refresh_message_highlight()
|
||||
refresh_pad(1)
|
||||
elif ui_state.current_window == 2:
|
||||
select_node(len(ui_state.node_list) - 1)
|
||||
@@ -514,9 +568,8 @@ def handle_pageup() -> None:
|
||||
if ui_state.current_window == 0:
|
||||
select_channel(ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2))
|
||||
elif ui_state.current_window == 1:
|
||||
ui_state.selected_message = max(
|
||||
ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0
|
||||
)
|
||||
set_message_selection(ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win))
|
||||
refresh_message_highlight()
|
||||
refresh_pad(1)
|
||||
elif ui_state.current_window == 2:
|
||||
select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2))
|
||||
@@ -528,11 +581,8 @@ def handle_pagedown() -> None:
|
||||
if ui_state.current_window == 0:
|
||||
select_channel(ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2))
|
||||
elif ui_state.current_window == 1:
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
ui_state.selected_message = min(
|
||||
ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win),
|
||||
msg_line_count - get_msg_window_lines(messages_win, packetlog_win),
|
||||
)
|
||||
set_message_selection(ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win))
|
||||
refresh_message_highlight()
|
||||
refresh_pad(1)
|
||||
elif ui_state.current_window == 2:
|
||||
select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2))
|
||||
@@ -555,6 +605,9 @@ def handle_leftright(char: int) -> None:
|
||||
|
||||
refresh_main_window(ui_state.current_window, selected=True)
|
||||
draw_window_arrows(ui_state.current_window)
|
||||
if ui_state.current_window == 1:
|
||||
draw_messages_window(True)
|
||||
refresh_message_highlight()
|
||||
|
||||
|
||||
def handle_function_keys(char: int) -> None:
|
||||
@@ -585,6 +638,9 @@ def handle_function_keys(char: int) -> None:
|
||||
|
||||
refresh_main_window(ui_state.current_window, selected=True)
|
||||
draw_window_arrows(ui_state.current_window)
|
||||
if ui_state.current_window == 1:
|
||||
draw_messages_window(True)
|
||||
refresh_message_highlight()
|
||||
|
||||
|
||||
def handle_enter(input_text: str) -> str:
|
||||
@@ -612,6 +668,15 @@ def handle_enter(input_text: str) -> str:
|
||||
return input_text
|
||||
|
||||
elif len(input_text) > 0:
|
||||
if ui_state.reply_context and ui_state.reply_id is None:
|
||||
contact.ui.dialog.dialog(
|
||||
t("ui.dialog.reply_unavailable_title", default="Reply unavailable"),
|
||||
t(
|
||||
"ui.dialog.reply_unavailable_body",
|
||||
default="This message has no packet ID, so Contact cannot send a native Meshtastic reply.",
|
||||
),
|
||||
)
|
||||
return input_text
|
||||
# TODO: This is a hack to prevent sending messages too quickly. Let's get errors from the node.
|
||||
now = time.monotonic()
|
||||
if now - ui_state.last_sent_time < 2.5:
|
||||
@@ -621,7 +686,30 @@ def handle_enter(input_text: str) -> str:
|
||||
)
|
||||
return input_text
|
||||
# Enter key pressed, send user input as message
|
||||
send_message(input_text, channel=ui_state.selected_channel)
|
||||
try:
|
||||
send_message(
|
||||
input_text,
|
||||
channel=ui_state.selected_channel,
|
||||
reply_id=ui_state.reply_id,
|
||||
reply_context=ui_state.reply_context,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.warning("Message send failed; reconnecting interface: %s", exc)
|
||||
try:
|
||||
from contact.ui.control_ui import reconnect_interface_with_splash
|
||||
|
||||
reconnect_interface_with_splash(root_win, interface_state.interface)
|
||||
contact.ui.dialog.dialog("Reconnected", "Connection restored. Your message was not sent; press Enter to retry.")
|
||||
except Exception as reconnect_exc:
|
||||
logging.exception("Automatic reconnect failed")
|
||||
contact.ui.dialog.dialog(
|
||||
"Reconnect failed",
|
||||
"Contact could not reconnect after 20 seconds. Check the radio connection and retry your message.",
|
||||
)
|
||||
return input_text
|
||||
ui_state.reply_id = None
|
||||
ui_state.reply_context = ""
|
||||
ui_state.reply_id_unavailable = False
|
||||
draw_messages_window(True)
|
||||
ui_state.last_sent_time = now
|
||||
entry_win.erase()
|
||||
@@ -899,7 +987,78 @@ def handle_backtick(stdscr: curses.window) -> None:
|
||||
curses.curs_set(0)
|
||||
previous_window = ui_state.current_window
|
||||
ui_state.current_window = 4
|
||||
settings_menu(stdscr, interface_state.interface)
|
||||
interface = interface_state.interface
|
||||
if previous_window != 2:
|
||||
settings_menu(stdscr, interface)
|
||||
ui_state.current_window = previous_window
|
||||
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||
curses.curs_set(1)
|
||||
get_channels()
|
||||
refresh_node_list()
|
||||
handle_resize(stdscr, False)
|
||||
return
|
||||
|
||||
options = ["Local settings"]
|
||||
remote_node_num = None
|
||||
if ui_state.node_list:
|
||||
remote_node_num = ui_state.node_list[ui_state.selected_node]
|
||||
options.append(f"Remote admin: {get_name_from_database(remote_node_num)}")
|
||||
choice = get_list_input("Open settings for", None, options)
|
||||
if choice == "Local settings":
|
||||
settings_menu(stdscr, interface)
|
||||
elif remote_node_num is not None:
|
||||
wait_win = None
|
||||
|
||||
def remote_status(message: Optional[str]) -> None:
|
||||
nonlocal wait_win
|
||||
if message is None:
|
||||
if wait_win is not None:
|
||||
wait_win.erase()
|
||||
wait_win.refresh()
|
||||
wait_win = None
|
||||
return
|
||||
if wait_win is None:
|
||||
wait_win = show_remote_admin_wait(stdscr, get_name_from_database(remote_node_num))
|
||||
update_remote_admin_wait(wait_win, message)
|
||||
|
||||
def remote_cancel_requested() -> bool:
|
||||
if wait_win is None:
|
||||
return False
|
||||
try:
|
||||
wait_win.timeout(0)
|
||||
return wait_win.getch() == 27
|
||||
finally:
|
||||
wait_win.timeout(-1)
|
||||
|
||||
try:
|
||||
settings_menu(
|
||||
stdscr,
|
||||
interface,
|
||||
# Settings requests configs explicitly; avoid getNode's
|
||||
# implicit channel request racing that setup.
|
||||
node=interface.getNode(remote_node_num, False),
|
||||
remote=True,
|
||||
status_callback=remote_status,
|
||||
cancel_callback=remote_cancel_requested,
|
||||
)
|
||||
except RemoteAdminCancelled:
|
||||
logging.info("Remote admin request cancelled for %s", remote_node_num)
|
||||
remote_status(None)
|
||||
except SystemExit as exc:
|
||||
logging.warning("Remote admin was rejected for %s: %s", remote_node_num, exc)
|
||||
if wait_win is not None:
|
||||
wait_win.erase()
|
||||
wait_win.refresh()
|
||||
contact.ui.dialog.dialog(
|
||||
"Remote admin rejected",
|
||||
"The selected node did not authorize this admin request.",
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.exception("Remote admin failed for %s", remote_node_num)
|
||||
if wait_win is not None:
|
||||
wait_win.erase()
|
||||
wait_win.refresh()
|
||||
contact.ui.dialog.dialog("Remote admin failed", str(exc))
|
||||
ui_state.current_window = previous_window
|
||||
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||
curses.curs_set(1)
|
||||
@@ -908,6 +1067,55 @@ def handle_backtick(stdscr: curses.window) -> None:
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
|
||||
def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> curses.window | None:
|
||||
"""Show progress while Meshtastic retrieves remote-admin settings."""
|
||||
message = f"Attempting remote admin of {node_name}, waiting for response..."
|
||||
try:
|
||||
height, width = stdscr.getmaxyx()
|
||||
box_width = min(width - 4, max(len(message) + 4, 36))
|
||||
box_height = 5
|
||||
wait_win = curses.newwin(box_height, box_width, max(0, (height - box_height) // 2), max(0, (width - box_width) // 2))
|
||||
wait_win.bkgd(get_color("background"))
|
||||
wait_win.attrset(get_color("window_frame"))
|
||||
wait_win.border()
|
||||
wait_win.addstr(0, 2, " Remote Admin ", get_color("settings_default"))
|
||||
wait_win.addstr(2, 2, message[: box_width - 4], get_color("settings_default"))
|
||||
wait_win.refresh()
|
||||
return wait_win
|
||||
except curses.error:
|
||||
return None
|
||||
|
||||
|
||||
def show_connection_status(stdscr: curses.window, title: str, message: str) -> curses.window | None:
|
||||
"""Display a non-blocking connection-status dialog over the current UI."""
|
||||
try:
|
||||
height, width = stdscr.getmaxyx()
|
||||
box_width = min(width - 4, max(len(message) + 4, len(title) + 6, 32))
|
||||
win = curses.newwin(5, box_width, max(0, (height - 5) // 2), max(0, (width - box_width) // 2))
|
||||
win.bkgd(get_color("background"))
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.border()
|
||||
win.addstr(0, 2, f" {title} ", get_color("settings_default"))
|
||||
win.addstr(2, 2, message[: box_width - 4], get_color("settings_default"))
|
||||
win.refresh()
|
||||
return win
|
||||
except curses.error:
|
||||
return None
|
||||
|
||||
|
||||
def update_remote_admin_wait(wait_win: curses.window | None, message: str) -> None:
|
||||
"""Append the current remote-admin request to the waiting overlay."""
|
||||
if wait_win is None:
|
||||
return
|
||||
try:
|
||||
_, width = wait_win.getmaxyx()
|
||||
wait_win.addstr(3, 2, " " * (width - 4), get_color("settings_default"))
|
||||
wait_win.addstr(3, 2, message[: width - 4], get_color("settings_default"))
|
||||
wait_win.refresh()
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
def handle_ctrl_p() -> None:
|
||||
"""Handle Ctrl + P key events to toggle the packet log display."""
|
||||
# Display packet log
|
||||
@@ -920,6 +1128,49 @@ def handle_ctrl_p() -> None:
|
||||
draw_messages_window(True)
|
||||
|
||||
|
||||
def get_message_at_display_line(channel, display_line: int):
|
||||
"""Return the message containing a rendered line in a channel, if any."""
|
||||
line = 0
|
||||
for prefix, message in ui_state.all_messages.get(channel, []):
|
||||
wrapped_lines = wrap_text(
|
||||
normalize_message_text(f"{prefix}{message}"),
|
||||
messages_win.getmaxyx()[1] - 2,
|
||||
)
|
||||
if line <= display_line < line + len(wrapped_lines):
|
||||
return None if prefix.startswith("--") else (prefix, message)
|
||||
line += len(wrapped_lines)
|
||||
return None
|
||||
|
||||
|
||||
def handle_ctrl_r(input_text: str) -> str:
|
||||
"""Toggle a reply to the message at the current Messages-pane position."""
|
||||
if ui_state.reply_context:
|
||||
ui_state.reply_id = None
|
||||
ui_state.reply_context = ""
|
||||
ui_state.reply_id_unavailable = False
|
||||
return ""
|
||||
|
||||
if ui_state.current_window != 1 or not ui_state.channel_list:
|
||||
return input_text
|
||||
|
||||
channel = ui_state.channel_list[ui_state.selected_channel]
|
||||
selected = get_message_at_display_line(channel, ui_state.selected_message)
|
||||
if selected is None:
|
||||
return input_text
|
||||
|
||||
message_index = next(
|
||||
index
|
||||
for index, entry in enumerate(ui_state.all_messages[channel])
|
||||
if entry == selected
|
||||
)
|
||||
packet_ids = ui_state.message_packet_ids.get(channel, [])
|
||||
prefix, message = selected
|
||||
ui_state.reply_context = build_reply_prefix(prefix, message)
|
||||
ui_state.reply_id = packet_ids[message_index] if message_index < len(packet_ids) else None
|
||||
ui_state.reply_id_unavailable = ui_state.reply_id is None
|
||||
return input_text
|
||||
|
||||
|
||||
# --- Ctrl+K handler for Help ---
|
||||
def handle_ctrl_k(stdscr: curses.window) -> None:
|
||||
"""Handle Ctrl + K to show a help window with shortcut keys."""
|
||||
@@ -933,11 +1184,11 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
|
||||
t("ui.help.settings", default="` or F12 = Settings"),
|
||||
t("ui.help.quit", default="ESC = Quit"),
|
||||
t("ui.help.packet_log", default="Ctrl+P = Toggle Packet Log"),
|
||||
t("ui.help.reply", default="Ctrl+R = Reply to message at cursor"),
|
||||
t("ui.help.traceroute", default="Ctrl+T or F4 = Traceroute"),
|
||||
t("ui.help.node_info", default="F5 = Full node info"),
|
||||
t("ui.help.archive_chat", default="Ctrl+D = Archive chat / remove node"),
|
||||
t("ui.help.favorite", default="Ctrl+F = Favorite"),
|
||||
t("ui.help.bot_responder", default="Ctrl+B = Toggle Bot Responder"),
|
||||
t("ui.help.ignore", default="Ctrl+G = Ignore"),
|
||||
t("ui.help.search", default="Ctrl+/ = Search"),
|
||||
t("ui.help.help", default="Ctrl+K = Help"),
|
||||
@@ -949,32 +1200,6 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
|
||||
def handle_ctrl_b(stdscr: curses.window) -> None:
|
||||
"""Handle Ctrl + B key events to toggle automatic bot responses."""
|
||||
ui_state.bot_mode_enabled = not ui_state.bot_mode_enabled
|
||||
status = t("ui.bot.status.enabled", default="Enabled") if ui_state.bot_mode_enabled else t(
|
||||
"ui.bot.status.disabled", default="Disabled"
|
||||
)
|
||||
|
||||
curses.curs_set(0)
|
||||
contact.ui.dialog.dialog(
|
||||
t("ui.bot.dialog.title", default="Bot Responder"),
|
||||
t("ui.bot.dialog.body", default="Bot responder is now {status}.", status=status),
|
||||
)
|
||||
|
||||
if ui_state.channel_list:
|
||||
channel_id = ui_state.channel_list[ui_state.selected_channel]
|
||||
add_new_message(
|
||||
channel_id,
|
||||
f"{config.message_prefix} Info: ",
|
||||
t("ui.bot.status.message", default="Bot responder is now {status}.", status=status.lower()),
|
||||
)
|
||||
draw_messages_window(True)
|
||||
|
||||
curses.curs_set(1)
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
|
||||
def handle_ctrl_d() -> None:
|
||||
if ui_state.current_window == 0:
|
||||
if isinstance(ui_state.channel_list[ui_state.selected_channel], int):
|
||||
@@ -1152,7 +1377,7 @@ def draw_channel_list() -> None:
|
||||
channel_win.refresh()
|
||||
|
||||
|
||||
def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
||||
def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: bool = False) -> None:
|
||||
"""Update the messages window based on the selected channel and scroll position."""
|
||||
|
||||
if ui_state.current_window != 1 and ui_state.single_pane_mode:
|
||||
@@ -1162,18 +1387,15 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
||||
|
||||
channel = ui_state.channel_list[ui_state.selected_channel]
|
||||
|
||||
msg_line_count = 0
|
||||
message_ranges = []
|
||||
if channel in ui_state.all_messages:
|
||||
messages = ui_state.all_messages[channel]
|
||||
|
||||
msg_line_count = 0
|
||||
|
||||
row = 0
|
||||
rendered_lines = []
|
||||
for prefix, message in messages:
|
||||
start_line = len(rendered_lines)
|
||||
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])
|
||||
|
||||
for line in wrapped_lines:
|
||||
if prefix.startswith("--"):
|
||||
color = get_color("timestamps")
|
||||
@@ -1182,18 +1404,31 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
||||
else:
|
||||
color = get_color("rx_messages")
|
||||
|
||||
messages_pad.addstr(row, 1, line, color)
|
||||
row += 1
|
||||
rendered_lines.append((line, color))
|
||||
|
||||
if not prefix.startswith("--"):
|
||||
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
|
||||
|
||||
paint_frame(messages_win, selected=(ui_state.current_window == 1))
|
||||
|
||||
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
|
||||
|
||||
if scroll_to_bottom:
|
||||
ui_state.selected_message = max(msg_line_count - visible_lines, 0)
|
||||
ui_state.start_index[1] = max(msg_line_count - visible_lines, 0)
|
||||
if preserve_selection:
|
||||
set_message_selection(ui_state.selected_message)
|
||||
ui_state.start_index[1] = max(0, msg_line_count - visible_lines)
|
||||
else:
|
||||
set_message_selection(msg_line_count - 1)
|
||||
else:
|
||||
ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0)
|
||||
set_message_selection(ui_state.selected_message)
|
||||
|
||||
refresh_message_highlight()
|
||||
|
||||
messages_win.refresh()
|
||||
refresh_pad(1)
|
||||
@@ -1204,6 +1439,39 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
||||
menu_state.need_redraw = True
|
||||
|
||||
|
||||
def refresh_message_highlight() -> None:
|
||||
"""Apply a reverse-video highlight to the message at the current scroll position."""
|
||||
previous_range = ui_state.highlighted_message_range
|
||||
width = max(0, messages_win.getmaxyx()[1] - 2)
|
||||
|
||||
if not ui_state.channel_list or ui_state.current_window != 1:
|
||||
if previous_range:
|
||||
start, end, color = previous_range
|
||||
for row in range(start, end):
|
||||
messages_pad.chgat(row, 1, width, color)
|
||||
ui_state.highlighted_message_range = ()
|
||||
return
|
||||
|
||||
channel = ui_state.channel_list[ui_state.selected_channel]
|
||||
ranges = ui_state.message_line_ranges.get(channel, [])
|
||||
selected_range = next(
|
||||
(line_range for line_range in ranges if line_range[0] <= ui_state.selected_message < line_range[1]),
|
||||
None,
|
||||
)
|
||||
|
||||
if previous_range and previous_range != selected_range:
|
||||
start, end, color = previous_range
|
||||
for row in range(start, end):
|
||||
messages_pad.chgat(row, 1, width, color)
|
||||
|
||||
if selected_range:
|
||||
start, end, color = selected_range
|
||||
for row in range(start, end):
|
||||
messages_pad.chgat(row, 1, width, color | curses.A_REVERSE)
|
||||
|
||||
ui_state.highlighted_message_range = selected_range or ()
|
||||
|
||||
|
||||
def draw_node_list() -> None:
|
||||
"""Update the nodes list window and pad based on the current state."""
|
||||
global nodes_pad
|
||||
@@ -1283,31 +1551,78 @@ def scroll_channels(direction: int) -> None:
|
||||
|
||||
def scroll_messages(direction: int) -> None:
|
||||
"""Scroll through the messages in the current channel by a given direction."""
|
||||
ui_state.selected_message += direction
|
||||
if direction < 0 and ui_state.selected_message == 0 and ui_state.channel_list:
|
||||
channel = ui_state.channel_list[ui_state.selected_channel]
|
||||
previous_height = messages_pad.getmaxyx()[0]
|
||||
if load_older_messages(channel):
|
||||
draw_messages_window()
|
||||
added_height = max(0, messages_pad.getmaxyx()[0] - previous_height)
|
||||
ui_state.selected_message = added_height
|
||||
ui_state.start_index[1] = added_height
|
||||
refresh_pad(1)
|
||||
return
|
||||
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
ui_state.selected_message = max(
|
||||
0, min(ui_state.selected_message, msg_line_count - get_msg_window_lines(messages_win, packetlog_win))
|
||||
)
|
||||
|
||||
max_index = msg_line_count - 1
|
||||
visible_height = get_msg_window_lines(messages_win, packetlog_win)
|
||||
|
||||
if ui_state.selected_message < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
|
||||
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
|
||||
elif ui_state.selected_message >= ui_state.start_index[ui_state.current_window]: # Moving below the visible area
|
||||
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
|
||||
|
||||
# Ensure start_index is within bounds
|
||||
ui_state.start_index[ui_state.current_window] = max(
|
||||
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
|
||||
)
|
||||
move_message_selection(direction)
|
||||
|
||||
refresh_message_highlight()
|
||||
messages_win.refresh()
|
||||
refresh_pad(1)
|
||||
draw_window_arrows(ui_state.current_window)
|
||||
|
||||
|
||||
def set_message_selection(line: int) -> None:
|
||||
"""Select a rendered message line and keep it visible without limiting selection to the viewport."""
|
||||
msg_line_count = messages_pad.getmaxyx()[0]
|
||||
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
|
||||
ui_state.selected_message = max(0, min(line, max(msg_line_count - 1, 0)))
|
||||
|
||||
max_start = max(msg_line_count - visible_lines, 0)
|
||||
start = ui_state.start_index[1]
|
||||
if ui_state.selected_message < start:
|
||||
start = ui_state.selected_message
|
||||
elif ui_state.selected_message >= start + visible_lines:
|
||||
start = ui_state.selected_message - visible_lines + 1
|
||||
ui_state.start_index[1] = max(0, min(start, max_start))
|
||||
|
||||
|
||||
def move_message_selection(direction: int) -> None:
|
||||
"""Move between messages, keeping wrapped messages as one selectable item."""
|
||||
if not ui_state.channel_list:
|
||||
return
|
||||
|
||||
channel = ui_state.channel_list[ui_state.selected_channel]
|
||||
ranges = ui_state.message_line_ranges.get(channel, [])
|
||||
if not ranges:
|
||||
set_message_selection(ui_state.selected_message + direction)
|
||||
return
|
||||
|
||||
current_index = next(
|
||||
(index for index, line_range in enumerate(ranges) if line_range[0] <= ui_state.selected_message < line_range[1]),
|
||||
None,
|
||||
)
|
||||
if current_index is None:
|
||||
current_index = 0 if direction > 0 else len(ranges) - 1
|
||||
else:
|
||||
current_index = max(0, min(current_index + direction, len(ranges) - 1))
|
||||
|
||||
start, end, _color = ranges[current_index]
|
||||
set_message_selection(end - 1 if current_index == len(ranges) - 1 else start)
|
||||
|
||||
|
||||
def select_last_message() -> None:
|
||||
"""Select and reveal the newest message in the active channel."""
|
||||
if not ui_state.channel_list:
|
||||
return
|
||||
|
||||
channel = ui_state.channel_list[ui_state.selected_channel]
|
||||
ranges = ui_state.message_line_ranges.get(channel, [])
|
||||
if ranges:
|
||||
_start, end, _color = ranges[-1]
|
||||
set_message_selection(end - 1)
|
||||
else:
|
||||
set_message_selection(0)
|
||||
|
||||
|
||||
def select_node(idx: int) -> None:
|
||||
"""Select a node by index and update the UI state accordingly."""
|
||||
old_selected_node = ui_state.selected_node
|
||||
@@ -1529,21 +1844,25 @@ def remove_notification(channel_number: int) -> None:
|
||||
|
||||
def draw_text_field(win: curses.window, text: str, color: int) -> None:
|
||||
win.border()
|
||||
height, width = win.getmaxyx()
|
||||
|
||||
# Put a small hint in the border of the message entry field.
|
||||
# We key off the "Message:" prompt to avoid affecting other bordered fields.
|
||||
if isinstance(text, str) and text.startswith("Message:"):
|
||||
hint = " Ctrl+K Help "
|
||||
h, w = win.getmaxyx()
|
||||
x = max(2, w - len(hint) - 2)
|
||||
x = max(2, width - len(hint) - 2)
|
||||
try:
|
||||
win.addstr(0, x, hint, get_color("commands"))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Draw the actual field text
|
||||
# Clear the row before redrawing. Reply context can be longer than the
|
||||
# next value (or empty after Ctrl+R cancels it), so simply overwriting the
|
||||
# new text leaves trailing characters from the old context on screen.
|
||||
try:
|
||||
win.addstr(1, 1, text, color)
|
||||
if height > 2 and width > 2:
|
||||
win.addstr(1, 1, " " * (width - 2), color)
|
||||
win.addstr(1, 1, text[: max(0, width - 2)], color)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
+151
-22
@@ -4,6 +4,8 @@ import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
from meshtastic.protobuf import admin_pb2
|
||||
|
||||
@@ -25,7 +27,6 @@ from contact.ui.colors import get_color
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.ui.menus import generate_menu_from_protobuf
|
||||
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
|
||||
from contact.ui.splash import draw_splash
|
||||
from contact.ui.user_config import json_editor
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.singleton import interface_state, menu_state
|
||||
@@ -34,10 +35,16 @@ from contact.utilities.singleton import interface_state, menu_state
|
||||
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
|
||||
save_option = "Save Changes"
|
||||
max_help_lines = 0
|
||||
REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS = 15
|
||||
admin_target_label = ""
|
||||
help_win = None
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
|
||||
|
||||
|
||||
class RemoteAdminCancelled(Exception):
|
||||
"""Raised when the user dismisses an in-progress remote-admin request."""
|
||||
|
||||
|
||||
# Compute the effective menu width for the current terminal
|
||||
def get_menu_width() -> int:
|
||||
# Leave at least 2 columns for borders; clamp to >= 20 for usability
|
||||
@@ -86,7 +93,8 @@ def get_translated_header(menu_path: List[str]) -> str:
|
||||
continue
|
||||
full_key = ".".join(transformed_path[:idx])
|
||||
translated_parts.append(field_mapping.get(full_key, part))
|
||||
return " > ".join(translated_parts)
|
||||
breadcrumb = " > ".join(translated_parts)
|
||||
return f"{admin_target_label} | {breadcrumb}" if admin_target_label else breadcrumb
|
||||
|
||||
|
||||
def display_menu() -> tuple[object, object]:
|
||||
@@ -232,9 +240,6 @@ def reconnect_interface_with_splash(stdscr: object, interface: object) -> object
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
draw_splash(stdscr)
|
||||
new_interface = reconnect_interface(setup_parser().parse_args())
|
||||
interface_state.interface = new_interface
|
||||
redraw_main_ui_after_reconnect(stdscr)
|
||||
@@ -279,10 +284,93 @@ def redraw_main_ui_after_reconnect(stdscr: object) -> None:
|
||||
logging.debug("Skipping main UI redraw after reconnect", exc_info=True)
|
||||
|
||||
|
||||
def settings_menu(stdscr: object, interface: object) -> None:
|
||||
curses.update_lines_cols()
|
||||
def _request_remote_with_timeout(request, *args, cancel_callback=None) -> None:
|
||||
"""Run a Meshtastic remote request without allowing its ACK wait to hang the UI."""
|
||||
failure = []
|
||||
|
||||
menu = generate_menu_from_protobuf(interface)
|
||||
def run_request() -> None:
|
||||
try:
|
||||
request(*args)
|
||||
except BaseException as exc:
|
||||
failure.append(exc)
|
||||
|
||||
worker = threading.Thread(target=run_request, name="remote-admin-request", daemon=True)
|
||||
worker.start()
|
||||
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
|
||||
while worker.is_alive() and time.monotonic() < deadline:
|
||||
worker.join(0.1)
|
||||
if cancel_callback and cancel_callback():
|
||||
raise RemoteAdminCancelled()
|
||||
if worker.is_alive():
|
||||
raise TimeoutError("Timed out waiting for the remote node to answer the admin request.")
|
||||
if failure:
|
||||
raise failure[0]
|
||||
|
||||
|
||||
def _request_remote_channels_with_timeout(node: object, cancel_callback=None) -> None:
|
||||
"""Retrieve a remote node's channel list without leaving the UI waiting forever."""
|
||||
node.requestChannels()
|
||||
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
|
||||
while getattr(node, "channels", None) is None:
|
||||
if cancel_callback and cancel_callback():
|
||||
raise RemoteAdminCancelled()
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("Timed out waiting for the remote node's channel list.")
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _request_remote_section(
|
||||
node: object, menu_path: List[str], selected_option: str, status_callback=None, cancel_callback=None
|
||||
) -> bool:
|
||||
"""Fetch exactly the remote-admin section the user is opening.
|
||||
|
||||
Meshtastic returns radio and module settings one protobuf section at a
|
||||
time. Asking for all of them on menu entry is both slow and unreliable on
|
||||
long paths, so leave the top-level menu immediately available.
|
||||
"""
|
||||
if menu_path == ["Main Menu"] and selected_option == "Channels":
|
||||
if status_callback:
|
||||
status_callback("Requesting channel list…")
|
||||
_request_remote_channels_with_timeout(node, cancel_callback=cancel_callback)
|
||||
return True
|
||||
|
||||
if len(menu_path) != 2:
|
||||
return False
|
||||
|
||||
category = menu_path[-1]
|
||||
if category == "Radio Settings":
|
||||
config = node.localConfig
|
||||
elif category == "Module Settings":
|
||||
config = node.moduleConfig
|
||||
else:
|
||||
return False
|
||||
|
||||
field = config.DESCRIPTOR.fields_by_name.get(selected_option)
|
||||
if field is None or field.name in {"version", "sessionkey"}:
|
||||
return False
|
||||
if status_callback:
|
||||
status_callback(f"Requesting {selected_option} config…")
|
||||
_request_remote_with_timeout(node.requestConfig, field, cancel_callback=cancel_callback)
|
||||
return True
|
||||
|
||||
|
||||
def settings_menu(
|
||||
stdscr: object,
|
||||
interface: object,
|
||||
node: object = None,
|
||||
remote: bool = False,
|
||||
status_callback=None,
|
||||
cancel_callback=None,
|
||||
) -> None:
|
||||
curses.update_lines_cols()
|
||||
global admin_target_label
|
||||
node = node or interface.localNode
|
||||
node_num = getattr(node, "nodeNum", 0)
|
||||
node_info = interface.getMyNodeInfo() if not remote else getattr(interface, "nodesByNum", {}).get(node_num, {})
|
||||
node_name = node_info.get("user", {}).get("longName") if isinstance(node_info, dict) else None
|
||||
admin_target_label = node_name or f"!{node_num:08x}"
|
||||
|
||||
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote)
|
||||
menu_state.current_menu = menu["Main Menu"]
|
||||
menu_state.menu_path = ["Main Menu"]
|
||||
|
||||
@@ -387,12 +475,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
help_win.refresh()
|
||||
|
||||
if menu_state.show_save_option and menu_state.selected_index == len(options):
|
||||
reconnect_required = save_changes(interface, modified_settings, menu_state)
|
||||
reconnect_required = save_changes(interface, modified_settings, menu_state, node=node)
|
||||
modified_settings.clear()
|
||||
logging.info("Changes Saved")
|
||||
if remote:
|
||||
break
|
||||
if reconnect_required:
|
||||
interface = reconnect_interface_with_splash(stdscr, interface)
|
||||
menu = generate_menu_from_protobuf(interface)
|
||||
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote)
|
||||
|
||||
if len(menu_state.menu_path) > 1:
|
||||
menu_state.menu_path.pop()
|
||||
@@ -422,7 +512,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
filename += ".yaml"
|
||||
|
||||
try:
|
||||
config_text = config_export(interface)
|
||||
config_text = config_export(interface, node=node)
|
||||
yaml_file_path = os.path.join(config_folder, filename)
|
||||
|
||||
if os.path.exists(yaml_file_path):
|
||||
@@ -487,12 +577,12 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
["Yes", "No"],
|
||||
)
|
||||
if overwrite == "Yes":
|
||||
config_import(interface, file_path)
|
||||
config_import(interface, file_path, node=node)
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Config URL":
|
||||
current_value = interface.localNode.getURL()
|
||||
current_value = node.getURL()
|
||||
new_value = get_text_input(
|
||||
t(
|
||||
"ui.prompt.config_url_current",
|
||||
@@ -510,7 +600,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
["Yes", "No"],
|
||||
)
|
||||
if overwrite == "Yes":
|
||||
interface.localNode.setURL(new_value)
|
||||
node.setURL(new_value)
|
||||
logging.info(f"New Config URL sent to node")
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
@@ -521,7 +611,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr, interface, interface.localNode.reboot, "Node Reboot Requested by menu"
|
||||
stdscr, interface, node.reboot, "Node Reboot Requested by menu"
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
@@ -535,7 +625,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr, interface, interface.localNode.resetNodeDb, "Node DB Reset Requested by menu"
|
||||
stdscr, interface, node.resetNodeDb, "Node DB Reset Requested by menu"
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
@@ -546,7 +636,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
t("ui.confirm.shutdown", default="Are you sure you want to Shutdown?"), None, ["Yes", "No"]
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.shutdown()
|
||||
node.shutdown()
|
||||
logging.info(f"Node Shutdown Requested by menu")
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
@@ -561,7 +651,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr,
|
||||
interface,
|
||||
lambda: request_factory_reset(interface.localNode, full=True),
|
||||
lambda: request_factory_reset(node, full=True),
|
||||
"Factory Reset Requested by menu",
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
@@ -578,7 +668,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr,
|
||||
interface,
|
||||
lambda: request_factory_reset(interface.localNode, full=False),
|
||||
lambda: request_factory_reset(node, full=False),
|
||||
"Factory Reset Config Requested by menu",
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
@@ -609,7 +699,25 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
# Fetch human-readable name from field_mapping
|
||||
human_readable_name = field_mapping.get(full_key, selected_option)
|
||||
|
||||
if selected_option in ["longName", "shortName", "isLicensed"]:
|
||||
if field is None and selected_option in ("ringtone", "messages"):
|
||||
is_ringtone = selected_option == "ringtone"
|
||||
getter_name = "get_ringtone" if is_ringtone else "get_canned_message"
|
||||
setter_name = "set_ringtone" if is_ringtone else "set_canned_message"
|
||||
getter = getattr(node, getter_name, None)
|
||||
setter = getattr(node, setter_name, None)
|
||||
fetched_value = getter() if callable(getter) else None
|
||||
current_value = fetched_value if fetched_value is not None else current_value
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, None
|
||||
)
|
||||
if new_value is not None and new_value != current_value and callable(setter):
|
||||
setter(new_value)
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
menu_state.start_index.pop()
|
||||
menu_state.need_redraw = True
|
||||
continue
|
||||
|
||||
if selected_option in ["longName", "shortName", "isLicensed", "isUnmessagable"]:
|
||||
if selected_option in ["longName", "shortName"]:
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, None
|
||||
@@ -617,7 +725,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
new_value = current_value if new_value is None else new_value
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
|
||||
elif selected_option == "isLicensed":
|
||||
elif selected_option in ["isLicensed", "isUnmessagable"]:
|
||||
new_value = get_list_input(
|
||||
f"{human_readable_name} is currently: {current_value}",
|
||||
str(current_value),
|
||||
@@ -717,7 +825,28 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
else:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
requested_remote_section = False
|
||||
if remote:
|
||||
try:
|
||||
requested_remote_section = _request_remote_section(
|
||||
node, menu_state.menu_path, selected_option, status_callback, cancel_callback
|
||||
)
|
||||
finally:
|
||||
if status_callback:
|
||||
status_callback(None)
|
||||
if requested_remote_section:
|
||||
# The response updates the node's protobufs. Rebuild
|
||||
# the menu before entering the requested section so
|
||||
# the newly received values are what the user sees.
|
||||
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=False)
|
||||
refreshed_menu = menu["Main Menu"]
|
||||
for path_step in menu_state.menu_path[1:]:
|
||||
refreshed_menu = refreshed_menu[path_step]
|
||||
menu_state.current_menu = refreshed_menu[selected_option]
|
||||
else:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
else:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
menu_state.menu_path.append(selected_option)
|
||||
menu_state.menu_index.append(menu_state.selected_index)
|
||||
menu_state.selected_index = 0
|
||||
|
||||
@@ -235,7 +235,7 @@ def initialize_config() -> Dict[str, object]:
|
||||
"message_prefix": ">>",
|
||||
"sent_message_prefix": ">> Sent",
|
||||
"notification_symbol": "*",
|
||||
"notification_sound": "True",
|
||||
"notification_sound": "alert.mp3",
|
||||
"ack_implicit_str": "[◌]",
|
||||
"ack_str": "[✓]",
|
||||
"nak_str": "[x]",
|
||||
@@ -243,6 +243,7 @@ def initialize_config() -> Dict[str, object]:
|
||||
"node_sort": "lastHeard",
|
||||
"theme": "dark",
|
||||
"ping_bot": {
|
||||
"enabled": "False",
|
||||
"catch_words": "ping; test",
|
||||
"response_word": "Pong!",
|
||||
},
|
||||
@@ -263,6 +264,15 @@ def initialize_config() -> Dict[str, object]:
|
||||
# Check and add missing variables
|
||||
updated = update_dict(default_config_variables, loaded_config)
|
||||
|
||||
# Migrate the legacy boolean notification preference to the file-based
|
||||
# sound selection introduced in 1.5.12.
|
||||
if loaded_config.get("notification_sound") == "True":
|
||||
loaded_config["notification_sound"] = "alert.mp3"
|
||||
updated = True
|
||||
elif loaded_config.get("notification_sound") == "False":
|
||||
loaded_config["notification_sound"] = "None"
|
||||
updated = True
|
||||
|
||||
# Update the JSON file if any variables were missing
|
||||
if updated:
|
||||
formatted_json = format_json_single_line_arrays(loaded_config)
|
||||
@@ -280,7 +290,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
||||
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
|
||||
global node_list_16ths, channel_list_16ths, single_pane_mode
|
||||
global theme, COLOR_CONFIG, language
|
||||
global node_sort, notification_sound, ping_bot_catch_words, ping_bot_response_word
|
||||
global node_sort, notification_sound, ping_bot_enabled, ping_bot_catch_words, ping_bot_response_word
|
||||
|
||||
channel_list_16ths = loaded_config["channel_list_16ths"]
|
||||
node_list_16ths = loaded_config["node_list_16ths"]
|
||||
@@ -300,6 +310,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
||||
node_sort = loaded_config["node_sort"]
|
||||
theme = loaded_config["theme"]
|
||||
ping_bot = loaded_config.get("ping_bot", {})
|
||||
ping_bot_enabled = ping_bot.get("enabled", "False")
|
||||
ping_bot_catch_words = ping_bot.get("catch_words", "ping; test")
|
||||
ping_bot_response_word = ping_bot.get("response_word", "Pong!")
|
||||
if theme == "dark":
|
||||
|
||||
+29
-12
@@ -57,14 +57,17 @@ def extract_fields(
|
||||
return menu
|
||||
|
||||
|
||||
def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
def generate_menu_from_protobuf(interface: object, node: Any = None, include_app_settings: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Builds the full settings menu structure from the protobuf definitions.
|
||||
"""
|
||||
menu_structure = {"Main Menu": {}}
|
||||
|
||||
# Add User Settings
|
||||
current_node_info = interface.getMyNodeInfo() if interface else None
|
||||
node = node or (interface.localNode if interface else None)
|
||||
current_node_info = interface.getMyNodeInfo() if interface and node is interface.localNode else None
|
||||
if current_node_info is None and interface and node is not None:
|
||||
current_node_info = getattr(interface, "nodesByNum", {}).get(getattr(node, "nodeNum", None))
|
||||
|
||||
if current_node_info:
|
||||
current_user_config = current_node_info.get("user", None)
|
||||
@@ -73,34 +76,35 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
"longName": (None, current_user_config.get("longName", "Not Set")),
|
||||
"shortName": (None, current_user_config.get("shortName", "Not Set")),
|
||||
"isLicensed": (None, current_user_config.get("isLicensed", "False")),
|
||||
"isUnmessagable": (None, current_user_config.get("isUnmessagable", "False")),
|
||||
}
|
||||
else:
|
||||
logging.info("User settings not found in Node Info")
|
||||
menu_structure["Main Menu"]["User Settings"] = "No user settings available"
|
||||
else:
|
||||
logging.info("Node Info not available")
|
||||
menu_structure["Main Menu"]["User Settings"] = "Node Info not available"
|
||||
menu_structure["Main Menu"]["User Settings"] = {}
|
||||
|
||||
# Add Channels
|
||||
channel = channel_pb2.ChannelSettings()
|
||||
menu_structure["Main Menu"]["Channels"] = {}
|
||||
if interface:
|
||||
if node:
|
||||
for i in range(8):
|
||||
current_channel = interface.localNode.getChannelByChannelIndex(i)
|
||||
current_channel = node.getChannelByChannelIndex(i)
|
||||
if current_channel:
|
||||
channel_config = extract_fields(channel, current_channel.settings)
|
||||
menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config
|
||||
|
||||
# Add Radio Settings
|
||||
radio = config_pb2.Config()
|
||||
current_radio_config = interface.localNode.localConfig if interface else None
|
||||
current_radio_config = node.localConfig if node else None
|
||||
menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config)
|
||||
|
||||
# Add Lat/Lon/Alt
|
||||
position_data = {
|
||||
"latitude": (None, current_node_info["position"].get("latitude", 0.0)),
|
||||
"longitude": (None, current_node_info["position"].get("longitude", 0.0)),
|
||||
"altitude": (None, current_node_info["position"].get("altitude", 0)),
|
||||
"latitude": (None, (current_node_info or {}).get("position", {}).get("latitude", 0.0)),
|
||||
"longitude": (None, (current_node_info or {}).get("position", {}).get("longitude", 0.0)),
|
||||
"altitude": (None, (current_node_info or {}).get("position", {}).get("altitude", 0)),
|
||||
}
|
||||
|
||||
existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {})
|
||||
@@ -117,11 +121,24 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
|
||||
# Add Module Settings
|
||||
module = module_config_pb2.ModuleConfig()
|
||||
current_module_config = interface.localNode.moduleConfig if interface else None
|
||||
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
|
||||
current_module_config = node.moduleConfig if node else None
|
||||
module_settings = extract_fields(module, current_module_config)
|
||||
if node:
|
||||
# These values use dedicated admin requests rather than ModuleConfig
|
||||
# fields, so expose them alongside their related module settings.
|
||||
module_settings.setdefault("external_notification", {})["ringtone"] = (
|
||||
None,
|
||||
getattr(node, "ringtone", "") or "",
|
||||
)
|
||||
module_settings.setdefault("canned_message", {})["messages"] = (
|
||||
None,
|
||||
getattr(node, "cannedPluginMessage", "") or "",
|
||||
)
|
||||
menu_structure["Main Menu"]["Module Settings"] = module_settings
|
||||
|
||||
# Add App Settings
|
||||
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
|
||||
if include_app_settings:
|
||||
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
|
||||
|
||||
# Additional settings options
|
||||
menu_structure["Main Menu"].update(
|
||||
|
||||
+10
-2
@@ -27,8 +27,6 @@ class ChatUIState:
|
||||
current_window: int = 0
|
||||
last_sent_time: float = 0.0
|
||||
last_traceroute_time: float = 0.0
|
||||
bot_mode_enabled: bool = False
|
||||
|
||||
selected_index: int = 0
|
||||
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
|
||||
show_save_option: bool = False
|
||||
@@ -40,6 +38,16 @@ class ChatUIState:
|
||||
redraw_packetlog: bool = False
|
||||
redraw_full_ui: bool = False
|
||||
scroll_messages_to_bottom: bool = False
|
||||
preserve_message_selection: bool = False
|
||||
reconnect_attempted: bool = False
|
||||
oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict)
|
||||
has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict)
|
||||
message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict)
|
||||
highlighted_message_range: tuple = field(default_factory=tuple)
|
||||
message_packet_ids: Dict[Union[str, int], List[Any]] = field(default_factory=dict)
|
||||
reply_id: Any = None
|
||||
reply_context: str = ""
|
||||
reply_id_unavailable: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -115,6 +115,23 @@ def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> L
|
||||
|
||||
def edit_value(key: str, display_label: str, current_value: str) -> str:
|
||||
|
||||
if key in ("single_pane_mode", "enabled"):
|
||||
return get_list_input(display_label, current_value, ["True", "False"])
|
||||
|
||||
if key == "notification_sound":
|
||||
sounds_dir = os.path.join(config.parent_dir, "sounds")
|
||||
try:
|
||||
sound_options = sorted(
|
||||
filename for filename in os.listdir(sounds_dir)
|
||||
if filename.lower().endswith((".mp3", ".wav", ".ogg", ".aiff", ".flac"))
|
||||
)
|
||||
except OSError:
|
||||
sound_options = []
|
||||
sound_options = ["None"] + sound_options
|
||||
if current_value not in sound_options:
|
||||
sound_options.append(current_value)
|
||||
return get_list_input(display_label, current_value, sound_options)
|
||||
|
||||
w = get_effective_width()
|
||||
height = 10
|
||||
input_width = w - 16 # Allow space for "New Value: "
|
||||
@@ -170,14 +187,6 @@ def edit_value(key: str, display_label: str, current_value: str) -> str:
|
||||
sort_options = ["lastHeard", "name", "hops"]
|
||||
return get_list_input(display_label, current_value, sort_options)
|
||||
|
||||
elif key == "notification_sound":
|
||||
sound_options = ["True", "False"]
|
||||
return get_list_input(display_label, current_value, sound_options)
|
||||
|
||||
elif key == "single_pane_mode":
|
||||
sound_options = ["True", "False"]
|
||||
return get_list_input(display_label, current_value, sound_options)
|
||||
|
||||
# Standard Input Mode (Scrollable)
|
||||
edit_win.addstr(7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default"))
|
||||
curses.curs_set(1)
|
||||
|
||||
@@ -134,47 +134,47 @@ def setPref(config, comp_name, raw_val) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def config_import(interface, filename):
|
||||
def config_import(interface, filename, node=None):
|
||||
"""Apply a configuration file to the selected local or remote node."""
|
||||
node = node or interface.localNode
|
||||
with open(filename, encoding="utf8") as file:
|
||||
configuration = yaml.safe_load(file)
|
||||
closeNow = True
|
||||
|
||||
interface.getNode("^local", False).beginSettingsTransaction()
|
||||
node.beginSettingsTransaction()
|
||||
|
||||
if "owner" in configuration:
|
||||
logging.info(f"Setting device owner to {configuration['owner']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(configuration["owner"])
|
||||
node.setOwner(configuration["owner"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "owner_short" in configuration:
|
||||
logging.info(f"Setting device owner short to {configuration['owner_short']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
|
||||
node.setOwner(long_name=None, short_name=configuration["owner_short"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "ownerShort" in configuration:
|
||||
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
|
||||
node.setOwner(long_name=None, short_name=configuration["ownerShort"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "channel_url" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channel_url']}")
|
||||
interface.getNode("^local").setURL(configuration["channel_url"])
|
||||
node.setURL(configuration["channel_url"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "channelUrl" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channelUrl']}")
|
||||
interface.getNode("^local").setURL(configuration["channelUrl"])
|
||||
node.setURL(configuration["channelUrl"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "location" in configuration:
|
||||
alt = 0
|
||||
lat = 0.0
|
||||
lon = 0.0
|
||||
localConfig = interface.localNode.localConfig
|
||||
|
||||
if "alt" in configuration["location"]:
|
||||
alt = int(configuration["location"]["alt"] or 0)
|
||||
logging.info(f"Fixing altitude at {alt} meters")
|
||||
@@ -185,39 +185,44 @@ def config_import(interface, filename):
|
||||
lon = float(configuration["location"]["lon"] or 0)
|
||||
logging.info(f"Fixing longitude at {lon} degrees")
|
||||
logging.info("Setting device position")
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
node.setFixedPosition(lat, lon, alt)
|
||||
time.sleep(0.5)
|
||||
|
||||
if "config" in configuration:
|
||||
localConfig = interface.getNode("^local").localConfig
|
||||
localConfig = node.localConfig
|
||||
for section in configuration["config"]:
|
||||
traverseConfig(section, configuration["config"][section], localConfig)
|
||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||
node.writeConfig(camel_to_snake(section))
|
||||
time.sleep(0.5)
|
||||
|
||||
if "module_config" in configuration:
|
||||
moduleConfig = interface.getNode("^local").moduleConfig
|
||||
moduleConfig = node.moduleConfig
|
||||
for section in configuration["module_config"]:
|
||||
traverseConfig(
|
||||
section,
|
||||
configuration["module_config"][section],
|
||||
moduleConfig,
|
||||
)
|
||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||
node.writeConfig(camel_to_snake(section))
|
||||
time.sleep(0.5)
|
||||
|
||||
interface.getNode("^local", False).commitSettingsTransaction()
|
||||
node.commitSettingsTransaction()
|
||||
logging.info("Writing modified configuration to device")
|
||||
|
||||
|
||||
def config_export(interface) -> str:
|
||||
"""used in --export-config"""
|
||||
def config_export(interface, node=None) -> str:
|
||||
"""Export the selected node's configuration as Meshtastic configure YAML."""
|
||||
configObj = {}
|
||||
node = node or interface.localNode
|
||||
|
||||
owner = interface.getLongName()
|
||||
owner_short = interface.getShortName()
|
||||
channel_url = interface.localNode.getURL()
|
||||
myinfo = interface.getMyNodeInfo()
|
||||
if node is interface.localNode:
|
||||
myinfo = interface.getMyNodeInfo()
|
||||
else:
|
||||
myinfo = getattr(interface, "nodesByNum", {}).get(getattr(node, "nodeNum", None), {})
|
||||
user = myinfo.get("user", {}) if isinstance(myinfo, dict) else {}
|
||||
owner = user.get("longName")
|
||||
owner_short = user.get("shortName")
|
||||
channel_url = node.getURL()
|
||||
pos = myinfo.get("position")
|
||||
lat = None
|
||||
lon = None
|
||||
@@ -242,7 +247,7 @@ def config_export(interface) -> str:
|
||||
if alt:
|
||||
configObj["location"]["alt"] = alt
|
||||
|
||||
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below
|
||||
config = MessageToDict(node.localConfig) # checkme - Used as a dictionary here and a string below
|
||||
if config:
|
||||
# Convert inner keys to correct snake/camelCase
|
||||
prefs = {}
|
||||
@@ -265,7 +270,7 @@ def config_export(interface) -> str:
|
||||
else:
|
||||
configObj["config"] = config
|
||||
|
||||
module_config = MessageToDict(interface.localNode.moduleConfig)
|
||||
module_config = MessageToDict(node.moduleConfig)
|
||||
if module_config:
|
||||
# Convert inner keys to correct snake/camelCase
|
||||
prefs = {}
|
||||
|
||||
+148
-63
@@ -4,13 +4,16 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Union, Dict
|
||||
|
||||
from contact.utilities.utils import decimal_to_hex
|
||||
from contact.utilities.utils import build_reply_prefix, decimal_to_hex
|
||||
import contact.ui.default_config as config
|
||||
|
||||
|
||||
from contact.utilities.singleton import ui_state, interface_state
|
||||
|
||||
|
||||
MESSAGE_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def get_table_name(channel: str) -> str:
|
||||
# Construct the table name
|
||||
table_name = f"{str(interface_state.myNodeNum)}_{channel}_messages"
|
||||
@@ -18,7 +21,21 @@ def get_table_name(channel: str) -> str:
|
||||
return quoted_table_name
|
||||
|
||||
|
||||
def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]:
|
||||
def _ensure_message_columns(db_cursor, quoted_table_name: str) -> None:
|
||||
"""Add fields introduced after the original message-history schema."""
|
||||
table_columns = {row[1] for row in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")}
|
||||
for column, definition in (("ack_type", "TEXT"), ("packet_id", "INTEGER"), ("reply_id", "INTEGER")):
|
||||
if column not in table_columns:
|
||||
db_cursor.execute(f"ALTER TABLE {quoted_table_name} ADD COLUMN {column} {definition}")
|
||||
|
||||
|
||||
def save_message_to_db(
|
||||
channel: str,
|
||||
user_id: str,
|
||||
message_text: str,
|
||||
packet_id: Optional[int] = None,
|
||||
reply_id: Optional[int] = None,
|
||||
) -> Optional[int]:
|
||||
"""Save messages to the database, ensuring the table exists."""
|
||||
try:
|
||||
quoted_table_name = get_table_name(channel)
|
||||
@@ -27,21 +44,25 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
|
||||
user_id TEXT,
|
||||
message_text TEXT,
|
||||
timestamp INTEGER,
|
||||
ack_type TEXT
|
||||
ack_type TEXT,
|
||||
packet_id INTEGER,
|
||||
reply_id INTEGER
|
||||
"""
|
||||
ensure_table_exists(quoted_table_name, schema)
|
||||
|
||||
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
|
||||
db_connection.execute("PRAGMA busy_timeout=10000")
|
||||
db_cursor = db_connection.cursor()
|
||||
_ensure_message_columns(db_cursor, quoted_table_name)
|
||||
timestamp = int(time.time())
|
||||
|
||||
# Insert the message
|
||||
insert_query = f"""
|
||||
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO {quoted_table_name}
|
||||
(user_id, message_text, timestamp, ack_type, packet_id, reply_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
|
||||
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None, packet_id, reply_id))
|
||||
db_connection.commit()
|
||||
|
||||
return timestamp
|
||||
@@ -75,7 +96,66 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
|
||||
logging.error(f"Unexpected error in update_ack_nak: {e}")
|
||||
|
||||
|
||||
def load_messages_from_db() -> None:
|
||||
def _format_db_messages(db_messages, node_names):
|
||||
"""Format database rows and packet IDs, adding one timestamp separator per hour."""
|
||||
hourly_messages = {}
|
||||
known_messages = {}
|
||||
for _rowid, user_id, message, timestamp, ack_type, packet_id, reply_id in db_messages:
|
||||
if user_id is None or message is None or timestamp is None:
|
||||
logging.warning(f"Skipping row with NULL required field(s): {(user_id, message, timestamp, ack_type)}")
|
||||
continue
|
||||
|
||||
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
|
||||
ack_str = config.ack_unknown_str
|
||||
if ack_type == "Implicit":
|
||||
ack_str = config.ack_implicit_str
|
||||
elif ack_type == "Ack":
|
||||
ack_str = config.ack_str
|
||||
elif ack_type == "Nak":
|
||||
ack_str = config.nak_str
|
||||
|
||||
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
|
||||
sanitized_message = message.replace("\x00", "")
|
||||
if user_id == str(interface_state.myNodeNum):
|
||||
formatted_message = (f"{ts_str} {config.sent_message_prefix}{ack_str}: ", sanitized_message)
|
||||
else:
|
||||
try:
|
||||
fallback_name = decimal_to_hex(int(user_id))
|
||||
except (TypeError, ValueError):
|
||||
fallback_name = str(user_id)
|
||||
formatted_message = (
|
||||
f"{ts_str} {config.message_prefix} {node_names.get(str(user_id), fallback_name)}: ",
|
||||
sanitized_message,
|
||||
)
|
||||
|
||||
if reply_id is not None and reply_id in known_messages:
|
||||
referenced_prefix, referenced_message = known_messages[reply_id]
|
||||
formatted_message = (formatted_message[0], build_reply_prefix(referenced_prefix, referenced_message) + sanitized_message)
|
||||
|
||||
hourly_messages.setdefault(hour, []).append((formatted_message, packet_id))
|
||||
if packet_id is not None:
|
||||
known_messages[packet_id] = formatted_message
|
||||
|
||||
formatted = []
|
||||
packet_ids = []
|
||||
for hour, messages in sorted(hourly_messages.items()):
|
||||
formatted.append((f"-- {hour} --", ""))
|
||||
packet_ids.append(None)
|
||||
for formatted_message, packet_id in messages:
|
||||
formatted.append(formatted_message)
|
||||
packet_ids.append(packet_id)
|
||||
return formatted, packet_ids
|
||||
|
||||
|
||||
def _load_node_names(db_cursor):
|
||||
table_name = f'"{interface_state.myNodeNum}_nodedb"'
|
||||
try:
|
||||
return {str(row[0]): row[1] for row in db_cursor.execute(f"SELECT user_id, short_name FROM {table_name}")}
|
||||
except sqlite3.Error:
|
||||
return {}
|
||||
|
||||
|
||||
def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None:
|
||||
"""Load messages from the database for all channels and update ui_state.all_messages and ui_state.channel_list."""
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
|
||||
@@ -85,27 +165,31 @@ def load_messages_from_db() -> None:
|
||||
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
|
||||
db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",))
|
||||
tables = [row[0] for row in db_cursor.fetchall()]
|
||||
node_names = _load_node_names(db_cursor)
|
||||
|
||||
# Iterate through each table and fetch its messages
|
||||
for table_name in tables:
|
||||
quoted_table_name = (
|
||||
f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
|
||||
)
|
||||
table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")]
|
||||
if "ack_type" not in table_columns:
|
||||
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
|
||||
db_cursor.execute(update_table_query)
|
||||
db_connection.commit()
|
||||
_ensure_message_columns(db_cursor, quoted_table_name)
|
||||
|
||||
query = f"SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}"
|
||||
query = f"""
|
||||
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
|
||||
FROM {quoted_table_name}
|
||||
ORDER BY rowid DESC LIMIT ?
|
||||
"""
|
||||
|
||||
try:
|
||||
# Fetch all messages from the table
|
||||
db_cursor.execute(query)
|
||||
db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
|
||||
db_cursor.execute(query, (page_size + 1,))
|
||||
rows = db_cursor.fetchall()
|
||||
has_older = len(rows) > page_size
|
||||
db_messages = list(reversed(rows[:page_size]))
|
||||
|
||||
# Extract the channel name from the table name
|
||||
channel = table_name.split("_")[1]
|
||||
prefix = f"{interface_state.myNodeNum}_"
|
||||
channel = table_name[len(prefix) : -len("_messages")]
|
||||
|
||||
# Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name)
|
||||
channel = int(channel) if channel.isdigit() else channel
|
||||
@@ -114,53 +198,13 @@ def load_messages_from_db() -> None:
|
||||
if channel not in ui_state.channel_list and not is_chat_archived(channel):
|
||||
ui_state.channel_list.append(channel)
|
||||
|
||||
# Ensure the channel exists in ui_state.all_messages
|
||||
if channel not in ui_state.all_messages:
|
||||
ui_state.all_messages[channel] = []
|
||||
|
||||
# Add messages to ui_state.all_messages grouped by hourly timestamp
|
||||
hourly_messages = {}
|
||||
for row in db_messages:
|
||||
user_id, message, timestamp, ack_type = row
|
||||
|
||||
# Only ack_type is allowed to be None
|
||||
if user_id is None or message is None or timestamp is None:
|
||||
logging.warning(f"Skipping row with NULL required field(s): {row}")
|
||||
continue
|
||||
|
||||
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
|
||||
if hour not in hourly_messages:
|
||||
hourly_messages[hour] = []
|
||||
|
||||
ack_str = config.ack_unknown_str
|
||||
if ack_type == "Implicit":
|
||||
ack_str = config.ack_implicit_str
|
||||
elif ack_type == "Ack":
|
||||
ack_str = config.ack_str
|
||||
elif ack_type == "Nak":
|
||||
ack_str = config.nak_str
|
||||
|
||||
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
|
||||
|
||||
if user_id == str(interface_state.myNodeNum):
|
||||
sanitized_message = message.replace("\x00", "")
|
||||
formatted_message = (
|
||||
f"{ts_str} {config.sent_message_prefix}{ack_str}: ",
|
||||
sanitized_message,
|
||||
)
|
||||
else:
|
||||
sanitized_message = message.replace("\x00", "")
|
||||
formatted_message = (
|
||||
f"{ts_str} {config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
|
||||
sanitized_message,
|
||||
)
|
||||
|
||||
hourly_messages[hour].append(formatted_message)
|
||||
|
||||
# Flatten the hourly messages into ui_state.all_messages[channel]
|
||||
for hour, messages in sorted(hourly_messages.items()):
|
||||
ui_state.all_messages[channel].append((f"-- {hour} --", ""))
|
||||
ui_state.all_messages[channel].extend(messages)
|
||||
# Replace the channel's messages with the freshly loaded page to avoid duplicates
|
||||
formatted_messages, packet_ids = _format_db_messages(db_messages, node_names)
|
||||
ui_state.all_messages[channel] = formatted_messages
|
||||
ui_state.message_packet_ids[channel] = packet_ids
|
||||
if db_messages:
|
||||
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
|
||||
ui_state.has_older_messages[channel] = has_older
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
|
||||
@@ -169,6 +213,47 @@ def load_messages_from_db() -> None:
|
||||
logging.error(f"SQLite error in load_messages_from_db: {e}")
|
||||
|
||||
|
||||
def load_older_messages(channel, page_size: int = MESSAGE_PAGE_SIZE) -> int:
|
||||
"""Prepend one older page for a channel and return the number of messages loaded."""
|
||||
before_rowid = ui_state.oldest_message_rowid.get(channel)
|
||||
if before_rowid is None or not ui_state.has_older_messages.get(channel, False):
|
||||
return 0
|
||||
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
|
||||
db_connection.execute("PRAGMA busy_timeout=10000")
|
||||
db_cursor = db_connection.cursor()
|
||||
query = f"""
|
||||
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
|
||||
FROM {get_table_name(channel)}
|
||||
WHERE rowid < ? ORDER BY rowid DESC LIMIT ?
|
||||
"""
|
||||
db_cursor.execute(query, (before_rowid, page_size + 1))
|
||||
rows = db_cursor.fetchall()
|
||||
has_older = len(rows) > page_size
|
||||
db_messages = list(reversed(rows[:page_size]))
|
||||
if not db_messages:
|
||||
ui_state.has_older_messages[channel] = False
|
||||
return 0
|
||||
|
||||
older, older_packet_ids = _format_db_messages(db_messages, _load_node_names(db_cursor))
|
||||
current = ui_state.all_messages.setdefault(channel, [])
|
||||
current_packet_ids = ui_state.message_packet_ids.setdefault(channel, [])
|
||||
while len(current_packet_ids) < len(current):
|
||||
current_packet_ids.append(None)
|
||||
# Keep the existing page's leading separator even when the older page
|
||||
# falls in the same hour. Removing it makes a timestamp that the user
|
||||
# is looking at jump out of view as soon as another page is loaded.
|
||||
ui_state.all_messages[channel] = older + current
|
||||
ui_state.message_packet_ids[channel] = older_packet_ids + current_packet_ids
|
||||
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
|
||||
ui_state.has_older_messages[channel] = has_older
|
||||
return len(db_messages)
|
||||
except sqlite3.Error as e:
|
||||
logging.error(f"SQLite error loading older messages for channel '{channel}': {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def init_nodedb() -> None:
|
||||
"""Initialize the node database and update it with nodes from the interface."""
|
||||
|
||||
|
||||
@@ -3,6 +3,28 @@ import time
|
||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||
|
||||
|
||||
def interface_is_connected(interface) -> bool:
|
||||
"""Return the transport-independent connection state for an interface.
|
||||
|
||||
TCPInterface deliberately leaves ``stream`` set to ``None`` and uses its
|
||||
``socket`` instead. Checking only ``stream`` therefore treats every TCP
|
||||
connection (including meshtasticd) as disconnected.
|
||||
"""
|
||||
if interface is None:
|
||||
return False
|
||||
|
||||
connected = getattr(interface, "isConnected", None)
|
||||
if hasattr(connected, "is_set"):
|
||||
return connected.is_set()
|
||||
if isinstance(connected, bool):
|
||||
return connected
|
||||
if hasattr(interface, "socket"):
|
||||
return getattr(interface, "socket", None) is not None
|
||||
if hasattr(interface, "stream"):
|
||||
return getattr(interface, "stream", None) is not None
|
||||
return getattr(interface, "localNode", None) is not None
|
||||
|
||||
|
||||
def initialize_interface(args):
|
||||
try:
|
||||
|
||||
@@ -44,15 +66,21 @@ def initialize_interface(args):
|
||||
logging.critical(f"Fatal error initializing interface: {ex}")
|
||||
|
||||
|
||||
def reconnect_interface(args, attempts: int = 15, delay_seconds: float = 1.0):
|
||||
def reconnect_interface(args, attempts: int = 20, delay_seconds: float = 1.0):
|
||||
last_error = None
|
||||
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
interface = initialize_interface(args)
|
||||
if interface is not None:
|
||||
if interface_is_connected(interface) and getattr(interface, "localNode", None) is not None and getattr(
|
||||
interface.localNode, "localConfig", None
|
||||
) is not None:
|
||||
return interface
|
||||
last_error = RuntimeError("initialize_interface returned None")
|
||||
last_error = RuntimeError("interface did not complete connection setup")
|
||||
try:
|
||||
interface.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as ex:
|
||||
last_error = ex
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ LORA_REBOOT_KEYS = {
|
||||
"sx126x_rx_boosted_gain",
|
||||
}
|
||||
SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"}
|
||||
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed"}
|
||||
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed", "isUnmessagable", "is_unmessagable"}
|
||||
|
||||
|
||||
def _collect_changed_keys(modified_settings):
|
||||
@@ -78,7 +78,7 @@ def _requires_reconnect(menu_state, modified_settings) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def save_changes(interface, modified_settings, menu_state):
|
||||
def save_changes(interface, modified_settings, menu_state, node=None):
|
||||
"""
|
||||
Save changes to the device based on modified settings.
|
||||
:param interface: Meshtastic interface instance
|
||||
@@ -90,7 +90,7 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
logging.info("No changes to save. modified_settings is empty.")
|
||||
return False
|
||||
|
||||
node = interface.getNode("^local")
|
||||
node = node or interface.getNode("^local")
|
||||
admin_key_backup = None
|
||||
if "admin_key" in modified_settings:
|
||||
# Get reference to security config
|
||||
@@ -134,7 +134,7 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
lon = float(modified_settings.get("longitude", 0.0))
|
||||
alt = int(modified_settings.get("altitude", 0))
|
||||
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
node.setFixedPosition(lat, lon, alt)
|
||||
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
|
||||
return False
|
||||
|
||||
@@ -143,12 +143,16 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
long_name = modified_settings.get("longName")
|
||||
short_name = modified_settings.get("shortName")
|
||||
is_licensed = modified_settings.get("isLicensed")
|
||||
is_unmessagable = modified_settings.get("isUnmessagable")
|
||||
is_licensed = is_licensed == "True" or is_licensed is True # Normalize boolean
|
||||
if is_unmessagable is not None:
|
||||
is_unmessagable = is_unmessagable == "True" or is_unmessagable is True
|
||||
|
||||
node.setOwner(long_name, short_name, is_licensed)
|
||||
node.setOwner(long_name, short_name, is_licensed, is_unmessagable)
|
||||
|
||||
logging.info(
|
||||
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
|
||||
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, "
|
||||
f"Licensed Mode: {is_licensed}, Unmessageable: {is_unmessagable}"
|
||||
)
|
||||
|
||||
return _requires_reconnect(menu_state, modified_settings)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, Union
|
||||
from google.protobuf.message import DecodeError
|
||||
@@ -156,10 +157,52 @@ def get_time_ago(timestamp):
|
||||
return "now"
|
||||
|
||||
|
||||
def add_new_message(channel_id, prefix, message):
|
||||
REPLY_EXCERPT_LENGTH = 5
|
||||
|
||||
|
||||
def _local_short_name() -> str:
|
||||
"""Return this node's short name without depending on the message database."""
|
||||
nodes = getattr(interface_state.interface, "nodes", {}) or {}
|
||||
for node in nodes.values():
|
||||
if node.get("num") == interface_state.myNodeNum:
|
||||
short_name = node.get("user", {}).get("shortName")
|
||||
if short_name:
|
||||
return str(short_name)
|
||||
return ""
|
||||
|
||||
|
||||
def build_reply_prefix(prefix: str, message: str) -> str:
|
||||
"""Create Contact's local display marker for a native Meshtastic reply."""
|
||||
sender_text = re.sub(r"^\[[^]]+\]\s*", "", prefix).strip()
|
||||
if sender_text.startswith(config.sent_message_prefix.strip()):
|
||||
sender = _local_short_name()
|
||||
else:
|
||||
sender_text = re.sub(r"^(?:>>|<<)\s*(?:\[[^]]+\]\s*)?", "", sender_text)
|
||||
sender_match = re.search(r"(.+?)\s*:\s*$", sender_text)
|
||||
sender = sender_match.group(1).strip() if sender_match else ""
|
||||
message_without_reply_marker = re.sub(r"^<Re:\s*[^>]*>\s*", "", message.replace("\x00", ""))
|
||||
excerpt = " ".join(message_without_reply_marker.split())[:REPLY_EXCERPT_LENGTH]
|
||||
return f"<Re: {sender}: {excerpt}> "
|
||||
|
||||
|
||||
def get_reply_context(reply_id):
|
||||
"""Find the locally displayed message referred to by a Meshtastic reply ID."""
|
||||
for channel, packet_ids in ui_state.message_packet_ids.items():
|
||||
for index, packet_id in enumerate(packet_ids):
|
||||
if packet_id == reply_id and index < len(ui_state.all_messages.get(channel, [])):
|
||||
prefix, message = ui_state.all_messages[channel][index]
|
||||
return build_reply_prefix(prefix, message)
|
||||
return ""
|
||||
|
||||
|
||||
def add_new_message(channel_id, prefix, message, packet_id=None):
|
||||
if channel_id not in ui_state.all_messages:
|
||||
ui_state.all_messages[channel_id] = []
|
||||
|
||||
packet_ids = ui_state.message_packet_ids.setdefault(channel_id, [])
|
||||
while len(packet_ids) < len(ui_state.all_messages[channel_id]):
|
||||
packet_ids.append(None)
|
||||
|
||||
# Timestamp handling
|
||||
current_timestamp = time.time()
|
||||
current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
|
||||
@@ -180,10 +223,12 @@ def add_new_message(channel_id, prefix, message):
|
||||
# Add a new timestamp if it's a new hour
|
||||
if last_hour != current_hour:
|
||||
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
|
||||
packet_ids.append(None)
|
||||
|
||||
# Add the message
|
||||
ts_str = time.strftime("[%H:%M:%S] ")
|
||||
ui_state.all_messages[channel_id].append((f"{ts_str}{prefix}", message))
|
||||
packet_ids.append(packet_id)
|
||||
|
||||
|
||||
def parse_protobuf(packet: dict) -> Union[str, dict]:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.5.8"
|
||||
version = "1.6.3"
|
||||
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"}
|
||||
|
||||
@@ -5,6 +5,8 @@ import types
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.singleton import interface_state
|
||||
from tests.test_support import reset_singletons
|
||||
|
||||
|
||||
class BotHandlerTests(unittest.TestCase):
|
||||
@@ -16,6 +18,13 @@ class BotHandlerTests(unittest.TestCase):
|
||||
)
|
||||
cls.bot_handler = importlib.import_module("contact.message_handlers.bot_handler")
|
||||
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
self.bot_handler.send_message.reset_mock()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_singletons()
|
||||
|
||||
def test_is_bot_message_uses_configured_catch_words(self) -> None:
|
||||
with mock.patch.object(config, "ping_bot_catch_words", "ping; test; pong"):
|
||||
self.assertTrue(self.bot_handler.is_bot_message("PING"))
|
||||
@@ -26,6 +35,29 @@ class BotHandlerTests(unittest.TestCase):
|
||||
with mock.patch.object(config, "ping_bot_catch_words", " ; ; "):
|
||||
self.assertTrue(self.bot_handler.is_bot_message("ping"))
|
||||
|
||||
def test_bot_response_replies_to_triggering_packet(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
packet = {"id": 900, "from": 222, "decoded": {}}
|
||||
|
||||
with mock.patch.object(config, "ping_bot_enabled", "True"):
|
||||
with mock.patch.object(self.bot_handler.threading, "Thread") as thread:
|
||||
self.assertTrue(self.bot_handler.bot_respond(packet, "ping", 0))
|
||||
send_response = thread.call_args.kwargs["target"]
|
||||
with mock.patch.object(self.bot_handler.time, "sleep"):
|
||||
with mock.patch.dict(
|
||||
sys.modules,
|
||||
{"contact.ui.contact_ui": types.SimpleNamespace(request_ui_redraw=mock.Mock())},
|
||||
):
|
||||
send_response()
|
||||
|
||||
self.bot_handler.send_message.assert_called_once_with(
|
||||
"Pong!", channel=0, reply_id=900
|
||||
)
|
||||
|
||||
def test_bot_response_requires_enabled_app_setting(self) -> None:
|
||||
with mock.patch.object(config, "ping_bot_enabled", "False"):
|
||||
self.assertFalse(self.bot_handler.bot_respond({"from": 222}, "ping", 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+46
-1
@@ -1,6 +1,12 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from tempfile import NamedTemporaryFile
|
||||
from unittest.mock import Mock
|
||||
|
||||
from contact.utilities.config_io import _is_repeated_field, splitCompoundName
|
||||
import yaml
|
||||
from meshtastic.protobuf import config_pb2, module_config_pb2
|
||||
|
||||
from contact.utilities.config_io import _is_repeated_field, config_export, config_import, splitCompoundName
|
||||
|
||||
|
||||
class ConfigIoTests(unittest.TestCase):
|
||||
@@ -19,3 +25,42 @@ class ConfigIoTests(unittest.TestCase):
|
||||
field_type = type("Field", (), {"label": 3, "LABEL_REPEATED": 3})
|
||||
|
||||
self.assertTrue(_is_repeated_field(field_type()))
|
||||
|
||||
def test_config_export_uses_selected_remote_node(self) -> None:
|
||||
local_node = SimpleNamespace(nodeNum=1)
|
||||
remote_node = SimpleNamespace(
|
||||
nodeNum=2,
|
||||
localConfig=config_pb2.Config(),
|
||||
moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getURL=lambda: "remote-channel-url",
|
||||
)
|
||||
interface = SimpleNamespace(
|
||||
localNode=local_node,
|
||||
nodesByNum={
|
||||
2: {
|
||||
"user": {"longName": "Remote Node", "shortName": "REM"},
|
||||
"position": {"latitude": 1.25, "longitude": 2.5, "altitude": 12},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
exported = yaml.safe_load(config_export(interface, node=remote_node))
|
||||
|
||||
self.assertEqual(exported["owner"], "Remote Node")
|
||||
self.assertEqual(exported["owner_short"], "REM")
|
||||
self.assertEqual(exported["channel_url"], "remote-channel-url")
|
||||
self.assertEqual(exported["location"], {"lat": 1.25, "lon": 2.5, "alt": 12})
|
||||
|
||||
def test_config_import_uses_selected_remote_node(self) -> None:
|
||||
remote_node = Mock()
|
||||
interface = SimpleNamespace(localNode=Mock())
|
||||
with NamedTemporaryFile("w", suffix=".yaml") as config_file:
|
||||
yaml.safe_dump({"owner": "Remote Node"}, config_file)
|
||||
config_file.flush()
|
||||
|
||||
config_import(interface, config_file.name, node=remote_node)
|
||||
|
||||
remote_node.beginSettingsTransaction.assert_called_once_with()
|
||||
remote_node.setOwner.assert_called_once_with("Remote Node")
|
||||
remote_node.commitSettingsTransaction.assert_called_once_with()
|
||||
interface.localNode.setOwner.assert_not_called()
|
||||
@@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
@@ -38,6 +39,49 @@ class ContactUiTests(unittest.TestCase):
|
||||
self.assertEqual(curs_set.call_args_list[-1].args, (1,))
|
||||
self.assertEqual(ui_state.current_window, 1)
|
||||
|
||||
def test_handle_backtick_shows_error_when_remote_admin_is_rejected(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
ui_state.current_window = 2
|
||||
ui_state.node_list = [123]
|
||||
ui_state.selected_node = 0
|
||||
contact_ui.interface_state.interface = mock.Mock()
|
||||
contact_ui.interface_state.interface.getNode.return_value = mock.Mock()
|
||||
|
||||
with mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
with mock.patch.object(contact_ui, "get_channels"):
|
||||
with mock.patch.object(contact_ui, "refresh_node_list"):
|
||||
with mock.patch.object(contact_ui, "handle_resize"):
|
||||
with mock.patch.object(contact_ui, "get_list_input", return_value="Remote admin: node"):
|
||||
with mock.patch.object(contact_ui, "get_name_from_database", return_value="node"):
|
||||
with mock.patch.object(contact_ui, "settings_menu", side_effect=SystemExit(1)):
|
||||
with mock.patch("contact.ui.dialog.dialog") as dialog:
|
||||
contact_ui.handle_backtick(stdscr)
|
||||
|
||||
dialog.assert_called_once_with(
|
||||
"Remote admin rejected",
|
||||
"The selected node did not authorize this admin request.",
|
||||
)
|
||||
|
||||
def test_show_remote_admin_wait_draws_status(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
stdscr.getmaxyx.return_value = (24, 100)
|
||||
wait_win = mock.Mock()
|
||||
|
||||
with mock.patch.object(contact_ui.curses, "newwin", return_value=wait_win):
|
||||
result = contact_ui.show_remote_admin_wait(stdscr, "Remote")
|
||||
|
||||
self.assertIs(result, wait_win)
|
||||
self.assertIn("Attempting remote admin of Remote", wait_win.addstr.call_args_list[-1].args[2])
|
||||
wait_win.refresh.assert_called_once()
|
||||
|
||||
def test_update_remote_admin_wait_replaces_status_line(self) -> None:
|
||||
wait_win = mock.Mock()
|
||||
wait_win.getmaxyx.return_value = (5, 40)
|
||||
|
||||
contact_ui.update_remote_admin_wait(wait_win, "Requesting lora config…")
|
||||
|
||||
self.assertIn("Requesting lora config", wait_win.addstr.call_args_list[-1].args[2])
|
||||
|
||||
def test_process_pending_ui_updates_draws_requested_windows(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
ui_state.redraw_channels = True
|
||||
@@ -70,6 +114,197 @@ class ContactUiTests(unittest.TestCase):
|
||||
self.assertFalse(ui_state.redraw_channels)
|
||||
self.assertFalse(ui_state.redraw_messages)
|
||||
|
||||
def test_process_pending_ui_updates_preserves_selection_when_scrolling_to_new_messages(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
ui_state.redraw_messages = True
|
||||
ui_state.scroll_messages_to_bottom = True
|
||||
ui_state.preserve_message_selection = True
|
||||
|
||||
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
|
||||
contact_ui.process_pending_ui_updates(stdscr)
|
||||
|
||||
draw_messages_window.assert_called_once_with(True, preserve_selection=True)
|
||||
self.assertFalse(ui_state.preserve_message_selection)
|
||||
|
||||
def test_draw_messages_resizes_pad_once(self) -> None:
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.all_messages = {"Primary": [("[10:00] RX: ", "one"), ("[10:01] RX: ", "two")]}
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.messages_pad.getmaxyx.return_value = (2, 40)
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
|
||||
contact_ui.packetlog_win = mock.Mock()
|
||||
contact_ui.packetlog_win.getmaxyx.return_value = (1, 40)
|
||||
contact_ui.messages_win.getbegyx.return_value = (0, 0)
|
||||
|
||||
with mock.patch.object(contact_ui, "paint_frame"):
|
||||
with mock.patch.object(contact_ui, "get_color", return_value=0):
|
||||
with mock.patch.object(contact_ui, "refresh_pad"):
|
||||
with mock.patch.object(contact_ui, "draw_packetlog_win"):
|
||||
with mock.patch.object(contact_ui, "draw_window_arrows"):
|
||||
contact_ui.draw_messages_window()
|
||||
|
||||
contact_ui.messages_pad.resize.assert_called_once_with(2, 40)
|
||||
|
||||
def test_build_reply_prefix_includes_sender_and_five_character_excerpt(self) -> None:
|
||||
reply = contact_ui.build_reply_prefix(
|
||||
"[06:27:25] >> [6] B1G1: ",
|
||||
"This is a message long enough to be shortened for the reply marker.",
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "<Re: B1G1: This > ")
|
||||
|
||||
def test_build_reply_prefix_uses_me_for_an_outgoing_message(self) -> None:
|
||||
contact_ui.interface_state.myNodeNum = 123
|
||||
contact_ui.interface_state.interface = SimpleNamespace(
|
||||
nodes={"!0000007b": {"num": 123, "user": {"shortName": "LRY"}}}
|
||||
)
|
||||
reply = contact_ui.build_reply_prefix("[06:27:25] >> Sent[☼]: ", "Thanks Lairy")
|
||||
|
||||
self.assertEqual(reply, "<Re: LRY: Thank> ")
|
||||
|
||||
def test_build_reply_prefix_does_not_nest_an_existing_reply_marker(self) -> None:
|
||||
reply = contact_ui.build_reply_prefix(
|
||||
"[06:27:25] >> xT1e: ",
|
||||
"<Re: CATS: Woot!> Woot indeed",
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "<Re: xT1e: Woot > ")
|
||||
|
||||
def test_handle_ctrl_r_prefills_reply_for_message_at_cursor(self) -> None:
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
|
||||
ui_state.message_packet_ids = {"Primary": [1234]}
|
||||
ui_state.selected_message = 0
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
|
||||
|
||||
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "Same to you!")
|
||||
self.assertEqual(ui_state.reply_id, 1234)
|
||||
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
|
||||
|
||||
def test_handle_ctrl_r_clears_an_existing_reply(self) -> None:
|
||||
ui_state.current_window = 1
|
||||
ui_state.reply_id = 1234
|
||||
ui_state.reply_context = "<Re: B1G1: Good > "
|
||||
ui_state.reply_id_unavailable = False
|
||||
|
||||
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "")
|
||||
self.assertIsNone(ui_state.reply_id)
|
||||
self.assertEqual(ui_state.reply_context, "")
|
||||
self.assertFalse(ui_state.reply_id_unavailable)
|
||||
|
||||
def test_draw_text_field_clears_stale_reply_context(self) -> None:
|
||||
win = mock.Mock()
|
||||
win.getmaxyx.return_value = (3, 30)
|
||||
|
||||
contact_ui.draw_text_field(win, "Message: ", 1)
|
||||
|
||||
self.assertIn(mock.call(1, 1, " " * 28, 1), win.addstr.call_args_list)
|
||||
self.assertIn(mock.call(1, 1, "Message: ", 1), win.addstr.call_args_list)
|
||||
|
||||
def test_handle_ctrl_r_shows_context_when_message_id_is_unavailable(self) -> None:
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
|
||||
ui_state.selected_message = 0
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
|
||||
|
||||
self.assertEqual(contact_ui.handle_ctrl_r(""), "")
|
||||
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
|
||||
self.assertTrue(ui_state.reply_id_unavailable)
|
||||
|
||||
def test_refresh_message_highlight_marks_selected_message(self) -> None:
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.message_line_ranges = {"Primary": [(0, 2, 10), (2, 3, 20)]}
|
||||
ui_state.selected_message = 2
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
|
||||
|
||||
contact_ui.refresh_message_highlight()
|
||||
|
||||
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20 | contact_ui.curses.A_REVERSE)
|
||||
|
||||
def test_refresh_message_highlight_clears_when_messages_pane_is_not_active(self) -> None:
|
||||
ui_state.current_window = 0
|
||||
ui_state.highlighted_message_range = (2, 3, 20)
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
|
||||
|
||||
contact_ui.refresh_message_highlight()
|
||||
|
||||
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20)
|
||||
self.assertEqual(ui_state.highlighted_message_range, ())
|
||||
|
||||
def test_handle_end_moves_messages_viewport_to_bottom(self) -> None:
|
||||
ui_state.current_window = 1
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
|
||||
contact_ui.packetlog_win = mock.Mock()
|
||||
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
|
||||
|
||||
with mock.patch.object(contact_ui, "refresh_message_highlight"):
|
||||
with mock.patch.object(contact_ui, "refresh_pad"):
|
||||
with mock.patch.object(contact_ui, "draw_window_arrows"):
|
||||
contact_ui.handle_end()
|
||||
|
||||
self.assertEqual(ui_state.selected_message, 99)
|
||||
self.assertGreater(ui_state.start_index[1], 0)
|
||||
|
||||
def test_message_selection_can_reach_last_rendered_line(self) -> None:
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
|
||||
contact_ui.packetlog_win = mock.Mock()
|
||||
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
|
||||
|
||||
contact_ui.set_message_selection(99)
|
||||
|
||||
self.assertEqual(ui_state.selected_message, 99)
|
||||
self.assertEqual(ui_state.start_index[1], 90)
|
||||
|
||||
def test_switching_to_messages_redraws_at_bottom(self) -> None:
|
||||
ui_state.current_window = 0
|
||||
ui_state.single_pane_mode = False
|
||||
with mock.patch.object(contact_ui, "refresh_main_window"):
|
||||
with mock.patch.object(contact_ui, "draw_window_arrows"):
|
||||
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
|
||||
with mock.patch.object(contact_ui, "refresh_message_highlight"):
|
||||
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
|
||||
|
||||
draw_messages_window.assert_called_once_with(True)
|
||||
|
||||
def test_move_message_selection_skips_all_lines_of_wrapped_message(self) -> None:
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.message_line_ranges = {"Primary": [(0, 3, 10), (3, 4, 20), (4, 6, 30)]}
|
||||
ui_state.selected_message = 1
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.messages_pad.getmaxyx.return_value = (6, 80)
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
|
||||
contact_ui.packetlog_win = mock.Mock()
|
||||
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
|
||||
|
||||
contact_ui.move_message_selection(1)
|
||||
self.assertEqual(ui_state.selected_message, 3)
|
||||
|
||||
contact_ui.move_message_selection(1)
|
||||
self.assertEqual(ui_state.selected_message, 5)
|
||||
|
||||
contact_ui.move_message_selection(-1)
|
||||
self.assertEqual(ui_state.selected_message, 3)
|
||||
|
||||
def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None:
|
||||
ui_state.node_list = [101, 202]
|
||||
ui_state.selected_node = 1
|
||||
|
||||
@@ -45,6 +45,38 @@ class DbHandlerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(row, ("123", "hello", "Ack"))
|
||||
|
||||
def test_message_ids_are_migrated_and_reloaded_with_history(self) -> None:
|
||||
db_handler.ensure_table_exists(
|
||||
'"123_Primary_messages"',
|
||||
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
|
||||
)
|
||||
with sqlite3.connect(config.db_file_path) as conn:
|
||||
conn.execute(
|
||||
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
|
||||
("456", "original message", 1700000000, None),
|
||||
)
|
||||
|
||||
db_handler.save_message_to_db("Primary", "456", "reply", packet_id=902, reply_id=901)
|
||||
with sqlite3.connect(config.db_file_path) as conn:
|
||||
columns = {row[1] for row in conn.execute('PRAGMA table_info("123_Primary_messages")')}
|
||||
stored_ids = conn.execute(
|
||||
'SELECT packet_id, reply_id FROM "123_Primary_messages" WHERE message_text = ?', ("reply",)
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
'UPDATE "123_Primary_messages" SET packet_id = ? WHERE message_text = ?', (901, "original message")
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
db_handler.load_messages_from_db()
|
||||
|
||||
self.assertTrue({"packet_id", "reply_id"}.issubset(columns))
|
||||
self.assertEqual(stored_ids, (902, 901))
|
||||
self.assertEqual([packet_id for packet_id in ui_state.message_packet_ids["Primary"] if packet_id], [901, 902])
|
||||
self.assertEqual(
|
||||
ui_state.all_messages["Primary"][-1][1],
|
||||
f"<Re: {decimal_to_hex(456)}: origi> reply",
|
||||
)
|
||||
|
||||
def test_update_node_info_in_db_fills_defaults_and_preserves_existing_values(self) -> None:
|
||||
db_handler.update_node_info_in_db(999, short_name="ABCD")
|
||||
|
||||
@@ -112,6 +144,59 @@ class DbHandlerTests(unittest.TestCase):
|
||||
self.assertTrue(any("RM:" in prefix for prefix, _ in messages))
|
||||
self.assertEqual(ui_state.all_messages[789][-1][1], "hidden")
|
||||
|
||||
def test_message_history_is_loaded_in_bounded_pages(self) -> None:
|
||||
db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM")
|
||||
db_handler.ensure_table_exists(
|
||||
'"123_Primary_messages"',
|
||||
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
|
||||
)
|
||||
with sqlite3.connect(config.db_file_path) as conn:
|
||||
conn.executemany(
|
||||
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
|
||||
[("456", f"message-{i}", 1700000000 + i, None) for i in range(7)],
|
||||
)
|
||||
|
||||
db_handler.load_messages_from_db(page_size=3)
|
||||
|
||||
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
|
||||
self.assertEqual(loaded_text, ["message-4", "message-5", "message-6"])
|
||||
self.assertTrue(ui_state.has_older_messages["Primary"])
|
||||
|
||||
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 3)
|
||||
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
|
||||
self.assertEqual(
|
||||
loaded_text,
|
||||
["message-1", "message-2", "message-3", "message-4", "message-5", "message-6"],
|
||||
)
|
||||
self.assertTrue(ui_state.has_older_messages["Primary"])
|
||||
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
|
||||
self.assertEqual(len(separators), 2)
|
||||
self.assertEqual(separators[0], separators[1])
|
||||
|
||||
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 1)
|
||||
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
|
||||
self.assertEqual(loaded_text, [f"message-{i}" for i in range(7)])
|
||||
self.assertFalse(ui_state.has_older_messages["Primary"])
|
||||
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
|
||||
self.assertEqual(len(separators), 3)
|
||||
|
||||
def test_message_table_channel_name_may_contain_underscores(self) -> None:
|
||||
db_handler.ensure_node_table_exists()
|
||||
db_handler.ensure_table_exists(
|
||||
'"123_My_Channel_messages"',
|
||||
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
|
||||
)
|
||||
with sqlite3.connect(config.db_file_path) as conn:
|
||||
conn.execute(
|
||||
'INSERT INTO "123_My_Channel_messages" VALUES (?, ?, ?, ?)',
|
||||
("123", "hello", 1700000000, None),
|
||||
)
|
||||
|
||||
db_handler.load_messages_from_db()
|
||||
|
||||
self.assertIn("My_Channel", ui_state.channel_list)
|
||||
self.assertEqual(ui_state.all_messages["My_Channel"][-1][1], "hello")
|
||||
|
||||
def test_init_nodedb_inserts_nodes_from_interface(self) -> None:
|
||||
interface_state.interface = build_demo_interface()
|
||||
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
|
||||
|
||||
+2
-7
@@ -57,15 +57,10 @@ class I18nTests(unittest.TestCase):
|
||||
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
|
||||
self.assertEqual(parse_ini_file.call_count, 2)
|
||||
|
||||
def test_bot_ui_translation_keys_exist_in_all_locales(self) -> None:
|
||||
def test_ping_bot_settings_translation_keys_exist_in_all_locales(self) -> None:
|
||||
required_keys = {
|
||||
"ui.help.bot_responder",
|
||||
"ui.bot.status.enabled",
|
||||
"ui.bot.status.disabled",
|
||||
"ui.bot.dialog.title",
|
||||
"ui.bot.dialog.body",
|
||||
"ui.bot.status.message",
|
||||
"app_settings.ping_bot",
|
||||
"app_settings.ping_bot.enabled",
|
||||
"app_settings.ping_bot.catch_words",
|
||||
"app_settings.ping_bot.response_word",
|
||||
}
|
||||
|
||||
@@ -26,3 +26,38 @@ class MenusTests(unittest.TestCase):
|
||||
|
||||
self.assertLess(keys.index("Factory Reset"), keys.index("factory_reset_config"))
|
||||
self.assertEqual(keys[keys.index("Factory Reset") + 1], "factory_reset_config")
|
||||
|
||||
def test_module_settings_include_ringtone_and_canned_messages(self) -> None:
|
||||
local_node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(),
|
||||
moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
ringtone="tone",
|
||||
cannedPluginMessage="Hi|Bye",
|
||||
getChannelByChannelIndex=lambda _: None,
|
||||
)
|
||||
interface = SimpleNamespace(
|
||||
localNode=local_node,
|
||||
getMyNodeInfo=lambda: {"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0}},
|
||||
)
|
||||
|
||||
module_settings = generate_menu_from_protobuf(interface)["Main Menu"]["Module Settings"]
|
||||
|
||||
self.assertEqual(module_settings["external_notification"]["ringtone"], (None, "tone"))
|
||||
self.assertEqual(module_settings["canned_message"]["messages"], (None, "Hi|Bye"))
|
||||
|
||||
def test_user_settings_include_unmessageable_toggle(self) -> None:
|
||||
local_node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(),
|
||||
moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getChannelByChannelIndex=lambda _: None,
|
||||
)
|
||||
interface = SimpleNamespace(
|
||||
localNode=local_node,
|
||||
getMyNodeInfo=lambda: {
|
||||
"user": {"longName": "Test User", "shortName": "TU", "isUnmessagable": True},
|
||||
"position": {},
|
||||
},
|
||||
)
|
||||
|
||||
user_settings = generate_menu_from_protobuf(interface)["Main Menu"]["User Settings"]
|
||||
self.assertEqual(user_settings["isUnmessagable"], (None, True))
|
||||
@@ -40,13 +40,50 @@ class RxHandlerTests(unittest.TestCase):
|
||||
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
|
||||
rx_handler.on_receive(packet, interface=None)
|
||||
|
||||
self.assertEqual(request_ui_redraw.call_args_list, [mock.call(nodes=True), mock.call(messages=True, scroll_messages_to_bottom=True)])
|
||||
self.assertEqual(
|
||||
request_ui_redraw.call_args_list,
|
||||
[
|
||||
mock.call(nodes=True),
|
||||
mock.call(
|
||||
messages=True,
|
||||
scroll_messages_to_bottom=True,
|
||||
preserve_message_selection=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
add_notification.assert_not_called()
|
||||
save_message_to_db.assert_called_once_with("Primary", 222, "hello")
|
||||
save_message_to_db.assert_called_once_with("Primary", 222, "hello", packet_id=None, reply_id=None)
|
||||
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
|
||||
self.assertIn("SAT2:", ui_state.all_messages["Primary"][-1][0])
|
||||
self.assertIn("[2]", ui_state.all_messages["Primary"][-1][0])
|
||||
|
||||
def test_on_receive_preserves_message_selection_while_showing_new_message(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.all_messages = {"Primary": []}
|
||||
ui_state.selected_channel = 0
|
||||
packet = {
|
||||
"from": 222,
|
||||
"to": 999,
|
||||
"channel": 0,
|
||||
"hopStart": 1,
|
||||
"hopLimit": 1,
|
||||
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
|
||||
}
|
||||
|
||||
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
|
||||
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
|
||||
with mock.patch.object(rx_handler, "save_message_to_db"):
|
||||
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
|
||||
rx_handler.on_receive(packet, interface=None)
|
||||
|
||||
request_ui_redraw.assert_called_once_with(
|
||||
messages=True,
|
||||
scroll_messages_to_bottom=True,
|
||||
preserve_message_selection=True,
|
||||
)
|
||||
|
||||
def test_on_receive_direct_message_adds_channel_and_notification(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
@@ -74,7 +111,33 @@ class RxHandlerTests(unittest.TestCase):
|
||||
request_ui_redraw.assert_called_once_with(channels=True)
|
||||
add_notification.assert_called_once_with(1)
|
||||
update_node_info_in_db.assert_called_once_with(222, chat_archived=False)
|
||||
save_message_to_db.assert_called_once_with(222, 222, "dm")
|
||||
save_message_to_db.assert_called_once_with(222, 222, "dm", packet_id=None, reply_id=None)
|
||||
|
||||
def test_on_receive_displays_context_for_native_reply_id(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.all_messages = {"Primary": [("[06:00:00] >> SAT2: ", "hello world")]}
|
||||
ui_state.message_packet_ids = {"Primary": [900]}
|
||||
ui_state.selected_channel = 0
|
||||
packet = {
|
||||
"id": 901,
|
||||
"from": 222,
|
||||
"to": 999,
|
||||
"channel": 0,
|
||||
"hopStart": 1,
|
||||
"hopLimit": 1,
|
||||
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi", "replyId": 900},
|
||||
}
|
||||
|
||||
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
|
||||
with mock.patch.object(rx_handler, "request_ui_redraw"):
|
||||
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
|
||||
with mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"):
|
||||
rx_handler.on_receive(packet, interface=None)
|
||||
|
||||
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "<Re: SAT2: hello> hi")
|
||||
self.assertEqual(ui_state.message_packet_ids["Primary"][-1], 901)
|
||||
save_message_to_db.assert_called_once_with("Primary", 222, "hi", packet_id=901, reply_id=900)
|
||||
|
||||
def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(self) -> None:
|
||||
ui_state.packet_buffer = list(range(25))
|
||||
|
||||
@@ -112,3 +112,12 @@ class SaveToRadioTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
node.setOwner.assert_called_once()
|
||||
|
||||
def test_save_changes_sends_user_unmessageable_setting(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"isUnmessagable": True}, menu_state)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
self.assertEqual(node.setOwner.call_args.args[3], True)
|
||||
@@ -42,7 +42,7 @@ class TxHandlerTests(unittest.TestCase):
|
||||
onResponse=tx_handler.onAckNak,
|
||||
channelIndex=0,
|
||||
)
|
||||
save_message_to_db.assert_called_once_with("Primary", 111, "hello")
|
||||
save_message_to_db.assert_called_once_with("Primary", 111, "hello", packet_id="req-1", reply_id=None)
|
||||
self.assertEqual(tx_handler.ack_naks["req-1"]["channel"], "Primary")
|
||||
self.assertEqual(tx_handler.ack_naks["req-1"]["messageIndex"], 1)
|
||||
self.assertEqual(tx_handler.ack_naks["req-1"]["timestamp"], 999)
|
||||
@@ -70,12 +70,27 @@ class TxHandlerTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(tx_handler.ack_naks["req-2"]["channel"], 222)
|
||||
|
||||
def test_send_message_uses_native_reply_id_but_only_displays_reply_context_locally(self) -> None:
|
||||
interface = mock.Mock()
|
||||
interface.sendText.return_value = SimpleNamespace(id="req-3")
|
||||
interface_state.interface = interface
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.all_messages = {"Primary": []}
|
||||
|
||||
with mock.patch.object(tx_handler, "save_message_to_db", return_value=123):
|
||||
tx_handler.send_message("hello", channel=0, reply_id=77, reply_context="<Re: NODE: hello> ")
|
||||
|
||||
self.assertEqual(interface.sendText.call_args.kwargs["text"], "hello")
|
||||
self.assertEqual(interface.sendText.call_args.kwargs["replyId"], 77)
|
||||
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "<Re: NODE: hello> hello")
|
||||
|
||||
def test_on_ack_nak_updates_message_for_explicit_ack(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.all_messages = {"Primary": [("pending", "hello")]}
|
||||
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55}
|
||||
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"}
|
||||
|
||||
packet = {"from": 222, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
|
||||
|
||||
@@ -94,7 +109,7 @@ class TxHandlerTests(unittest.TestCase):
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.all_messages = {"Primary": [("pending", "hello")]}
|
||||
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55}
|
||||
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"}
|
||||
|
||||
packet = {"from": 111, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config # Initialize config before the color helpers.
|
||||
from contact.ui import user_config
|
||||
|
||||
|
||||
class UserConfigTests(unittest.TestCase):
|
||||
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")
|
||||
|
||||
self.assertEqual(result, "True")
|
||||
picker.assert_called_once_with("Enabled", "False", ["True", "False"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user