From c53bfce4b7e6509fcd7e9ae609fb361a10044ac5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 19 Jan 2026 09:55:31 -0800 Subject: [PATCH] feat: Enhance elapsed time handling in messages - Updated the message handling logic to format elapsed time more accurately, displaying "Nms" when the device clock is valid or "Sync Device Clock" when invalid. - Introduced a new utility function `format_elapsed_display` to centralize elapsed time formatting, improving code maintainability. - Modified configuration examples to clarify the usage of the {elapsed} placeholder. - Added translations for the "Sync Device Clock" message in multiple languages to support internationalization. --- config.ini.example | 2 +- config.ini.minimal-example | 2 +- modules/commands/base_command.py | 25 +++---------------- modules/message_handler.py | 23 ++++++++--------- modules/utils.py | 43 +++++++++++++++++++++++++++++++- translations/de.json | 3 +++ translations/en-GB.json | 3 +++ translations/en.json | 3 +++ translations/es.json | 3 +++ translations/fr-CA.json | 3 +++ translations/fr.json | 3 +++ translations/nl.json | 3 +++ translations/pt-BR.json | 3 +++ translations/pt.json | 3 +++ 14 files changed, 86 insertions(+), 36 deletions(-) diff --git a/config.ini.example b/config.ini.example index 7c3716c..fa2d668 100644 --- a/config.ini.example +++ b/config.ini.example @@ -245,7 +245,7 @@ long_jokes = false # {path} - Message routing path (e.g., "01,5f (2 hops)") # {path_distance} - Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)") # {firstlast_distance} - Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing) -# {elapsed} - Message elapsed time +# {elapsed} - Elapsed time (e.g. 1234ms) or "Sync Device Clock" when device clock is invalid (use {elapsed} only; do not append ms) # # To add newlines in responses, use \n (single backslash + n): # Example: test = "Line 1\nLine 2\nLine 3" diff --git a/config.ini.minimal-example b/config.ini.minimal-example index 94e599c..5d467e0 100644 --- a/config.ini.minimal-example +++ b/config.ini.minimal-example @@ -183,7 +183,7 @@ admin_commands = repeater,webviewer # {path} - Message routing path (e.g., "01,5f (2 hops)") # {path_distance} - Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)") # {firstlast_distance} - Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing) -# {elapsed} - Message elapsed time +# {elapsed} - Elapsed time (e.g. 1234ms) or "Sync Device Clock" when device clock is invalid (use {elapsed} only; do not append ms) test = "ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}" ping = "Pong!" diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index f8a73bd..660fdb3 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -11,6 +11,7 @@ import pytz import re from ..models import MeshMessage from ..security_utils import validate_pubkey_format +from ..utils import format_elapsed_display class BaseCommand(ABC): @@ -642,27 +643,9 @@ class BaseCommand(ABC): return "Unknown" def format_elapsed(self, message: MeshMessage) -> str: - """Format message timestamp for display""" - if message.timestamp and message.timestamp != 'unknown': - try: - from datetime import datetime,UTC - el = round((datetime.now(UTC).timestamp()-message.timestamp)*1000) - return f"{el}ms" - except: - return str(message.timestamp) - else: - return "Unknown" - def format_elapsed(self, message: MeshMessage) -> str: - """Format message timestamp for display""" - if message.timestamp and message.timestamp != 'unknown': - try: - from datetime import datetime,UTC - el = round((datetime.now(UTC).timestamp()-message.timestamp)*1000) - return f"{el}ms" - except: - return str(message.timestamp) - else: - return "Unknown" + """Format message elapsed for display. Uses 'Sync Device Clock' when device clock is invalid.""" + translator = getattr(self.bot, 'translator', None) + return format_elapsed_display(message.timestamp, translator) def format_response(self, message: MeshMessage, response_format: str) -> str: """Format a response string with message data""" diff --git a/modules/message_handler.py b/modules/message_handler.py index 13e56e4..3533717 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -14,7 +14,7 @@ from meshcore import EventType from .models import MeshMessage from .enums import PayloadType, PayloadVersion, RouteType, AdvertFlags, DeviceRole -from .utils import calculate_packet_hash +from .utils import calculate_packet_hash, format_elapsed_display from .security_utils import sanitize_input @@ -303,17 +303,11 @@ class MessageHandler: message_content = payload.get('text', '') message_content = sanitize_input(message_content, max_length=None, strip_controls=True) - # Format timestamp - if timestamp and timestamp != 'unknown': - try: - from datetime import datetime,UTC - dt = datetime.fromtimestamp(message.timestamp) - elapsed_str = round((datetime.now(UTC).timestamp()-message.timestamp)*1000) - except: - elapsed_str = "Unknown" - else: - elapsed_str = "Unknown" - + # Elapsed: "Nms" when device clock is valid, or "Sync Device Clock" when + # invalid (e.g. T-Deck before GPS sync: 0, future, or far in the past). + translator = getattr(self.bot, 'translator', None) + elapsed_str = format_elapsed_display(timestamp, translator) + # Convert to our message format message = MeshMessage( content=message_content, @@ -1611,6 +1605,10 @@ class MessageHandler: self.logger.debug(f"Found full public key for {sender_id}: {sender_pubkey[:16]}...") break + # Elapsed: "Nms" when device clock is valid, or "Sync Device Clock" when invalid. + _translator = getattr(self.bot, 'translator', None) + _elapsed = format_elapsed_display(payload.get('sender_timestamp'), _translator) + # Convert to our message format message = MeshMessage( content=message_content, # Use the extracted message content @@ -1622,6 +1620,7 @@ class MessageHandler: rssi=rssi, hops=hops, path=path_string, # Use the path information extracted from RF data + elapsed=_elapsed, is_dm=False ) diff --git a/modules/utils.py b/modules/utils.py index 991b5e9..c2e75b6 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -1698,6 +1698,42 @@ def _get_node_location_from_db(bot: Any, node_id: str) -> Optional[Tuple[float, return None +# Maximum plausible elapsed ms (5 minutes) for device clock validation. +# Values above indicate device time is far in the past (e.g. epoch); negative = in the future. +_ELAPSED_MS_MAX = 5 * 60 * 1000 # 5 minutes in milliseconds + + +def format_elapsed_display(ts: Any, translator: Any = None) -> str: + """Format elapsed time from sender timestamp for {elapsed} placeholder. + + Returns "Nms" when valid, or the i18n "Sync Device Clock" when the device + clock is invalid (e.g. T-Deck before GPS sync: 0, future, or far in the past). + + Args: + ts: Sender timestamp (int, float, None, or 'unknown'). + translator: Bot translator for i18n; uses "Sync Device Clock" if None. + + Returns: + str: e.g. "1234ms" or translated "Sync Device Clock". + """ + def _sync_str() -> str: + if translator: + return translator.translate('elapsed.sync_device_clock') + return "Sync Device Clock" + + if ts is None or ts == 'unknown': + return _sync_str() + try: + ts_f = float(ts) + except (TypeError, ValueError): + return _sync_str() + from datetime import UTC, datetime + elapsed_ms = (datetime.now(UTC).timestamp() - ts_f) * 1000 + if elapsed_ms < 0 or elapsed_ms > _ELAPSED_MS_MAX: + return _sync_str() + return f"{round(elapsed_ms)}ms" + + def format_keyword_response_with_placeholders( response_format: str, message: Any, @@ -1728,7 +1764,12 @@ def format_keyword_response_with_placeholders( replacements['path'] = message.path or "Unknown" replacements['snr'] = message.snr or "Unknown" replacements['rssi'] = message.rssi or "Unknown" - replacements['elapsed'] = message.elapsed or "Unknown" + # Compute elapsed from message.timestamp (same as TestCommand) so it's available + # for all keywords. Using message.elapsed would miss when it's unset on some paths. + _translator = getattr(bot, 'translator', None) + replacements['elapsed'] = format_elapsed_display( + getattr(message, 'timestamp', None), _translator + ) # Build connection_info routing_info = message.path or "Unknown routing" diff --git a/translations/de.json b/translations/de.json index c9d2d3b..62fe0f5 100644 --- a/translations/de.json +++ b/translations/de.json @@ -962,6 +962,9 @@ "description": "Verfügbare Befehle auflisten" } }, + "elapsed": { + "sync_device_clock": "Geräteuhr synchronisieren" + }, "errors": { "rate_limited": "Rate-limitiert. Warte {seconds:.1f} Sekunden", "cooldown": "Befehl '{command}' hat Wartezeit. Warte {seconds} Sekunden.", diff --git a/translations/en-GB.json b/translations/en-GB.json index a554b1c..10c194d 100644 --- a/translations/en-GB.json +++ b/translations/en-GB.json @@ -942,6 +942,9 @@ "description": "List available commands" } }, + "elapsed": { + "sync_device_clock": "Sync Device Clock" + }, "errors": { "rate_limited": "Rate limited. Wait {seconds:.1f} seconds", "cooldown": "Command '{command}' is on cooldown. Wait {seconds} seconds.", diff --git a/translations/en.json b/translations/en.json index c18c3da..1afc453 100644 --- a/translations/en.json +++ b/translations/en.json @@ -992,6 +992,9 @@ "description": "List available commands" } }, + "elapsed": { + "sync_device_clock": "Sync Device Clock" + }, "errors": { "rate_limited": "Rate limited. Wait {seconds:.1f} seconds", "cooldown": "Command '{command}' is on cooldown. Wait {seconds} seconds.", diff --git a/translations/es.json b/translations/es.json index e1ed399..4b11569 100644 --- a/translations/es.json +++ b/translations/es.json @@ -814,6 +814,9 @@ "description": "Listar comandos disponibles" } }, + "elapsed": { + "sync_device_clock": "Sincronizar reloj del dispositivo" + }, "errors": { "rate_limited": "Límite de solicitudes. Espera {seconds:.1f} segundos", "cooldown": "El comando '{command}' está en espera. Espera {seconds} segundos.", diff --git a/translations/fr-CA.json b/translations/fr-CA.json index 02bce0b..8689e5f 100644 --- a/translations/fr-CA.json +++ b/translations/fr-CA.json @@ -1016,6 +1016,9 @@ "description": "Lister les commandes disponibles" } }, + "elapsed": { + "sync_device_clock": "Synchroniser l'horloge du dispositif" + }, "errors": { "rate_limited": "Limite de débit atteinte. Attendez {seconds:.1f} secondes", "cooldown": "La commande '{command}' est en délai d'attente. Attendez {seconds} secondes.", diff --git a/translations/fr.json b/translations/fr.json index e0def1c..9649852 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -933,6 +933,9 @@ "description": "Lister commandes disponibles" } }, + "elapsed": { + "sync_device_clock": "Synchroniser l'horloge du dispositif" + }, "errors": { "rate_limited": "Limite atteinte. Attendez {seconds:.1f} secondes", "cooldown": "Commande '{command}' en attente. Attendez {seconds} secondes.", diff --git a/translations/nl.json b/translations/nl.json index de5d6b1..bb87f0f 100644 --- a/translations/nl.json +++ b/translations/nl.json @@ -942,6 +942,9 @@ "description": "Lijst beschikbare commando's" } }, + "elapsed": { + "sync_device_clock": "Synchroniseer apparaatklok" + }, "errors": { "rate_limited": "Rate limited. Wacht {seconds:.1f} seconden", "cooldown": "Commando '{command}' is in cooldown. Wacht {seconds} seconden.", diff --git a/translations/pt-BR.json b/translations/pt-BR.json index 3b4f737..485659f 100644 --- a/translations/pt-BR.json +++ b/translations/pt-BR.json @@ -939,6 +939,9 @@ "description": "Listar comandos disponíveis" } }, + "elapsed": { + "sync_device_clock": "Sincronizar relógio do dispositivo" + }, "errors": { "rate_limited": "Limite de taxa atingido. Aguarda {seconds:.1f} segundos", "cooldown": "Comando '{command}' em espera. Aguarda {seconds} segundos.", diff --git a/translations/pt.json b/translations/pt.json index 4cd8ab5..9b8bd19 100644 --- a/translations/pt.json +++ b/translations/pt.json @@ -938,6 +938,9 @@ "description": "Listar comandos disponíveis" } }, + "elapsed": { + "sync_device_clock": "Sincronizar relógio do dispositivo" + }, "errors": { "rate_limited": "Limitado por taxa. Aguarde {seconds:.1f} segundos", "cooldown": "Comando '{command}' em espera. Aguarde {seconds} segundos.",