From 5abe378a5d6cfc1cba165856b898f70742c8f34e Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Thu, 13 Mar 2025 00:29:14 -0700 Subject: [PATCH 01/37] Add to field --- paracon/paracon.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 2ec1a1a..a648cae 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -339,6 +339,7 @@ def _send(self, widget, text): if not src: src = config.get('Setup', 'callsign') dst = config.get('Unproto', 'destination') + to = config.get('Unproto', 'to') via = config.get('Unproto', 'via') port = config.get_int('Unproto', 'port') if port is not None: @@ -348,6 +349,10 @@ def _send(self, widget, text): if not self._valid_config(src, dst, via): self._mon.add_line(('unproto_error', 'Unproto config is invalid')) return + + if to != '': + text = f':{to:<9}:{text}' + vias = via.split() if via else None try: app.server.send_unproto(port, src, dst, text, vias) @@ -394,6 +399,7 @@ def _change_config(self, info): config.set('Unproto', 'source', info.src) config.set('Unproto', 'destination', info.dst) config.set('Unproto', 'via', info.via) + config.set('Unproto', 'to', info.to) config.set_int('Unproto', 'port', app.ports.port_for_index(info.port[0])) config.save_config() @@ -405,7 +411,8 @@ def _set_info(self): src = config.get('Setup', 'callsign') dst = config.get('Unproto', 'destination') via = config.get('Unproto', 'via') - text = "From: {} To: {} ".format(src, dst) + to = config.get('Unproto', 'to') + text = "From: {} Dest: {} To: {}".format(src, dst,to) if via: # Vias are saved with spaces, but displayed with commas via = ','.join(via.split()) @@ -1379,6 +1386,7 @@ class UnprotoInfo(NamedTuple): src: str dst: str via: str + to: str port: tuple def __init__(self, info=None): @@ -1390,6 +1398,7 @@ def add_fields(self): src = self._info.src dst = self._info.dst via = self._info.via + to = self._info.to port_ix = self._info.port[0] else: src = (config.get('Unproto', 'source') @@ -1397,6 +1406,7 @@ def add_fields(self): or '') dst = config.get('Unproto', 'destination') or '' via = config.get('Unproto', 'via') or '' + to = config.get('Unproto', 'to') or '' port = config.get_int('Unproto', 'port') # Ensure a valid index into list of ports @@ -1413,6 +1423,9 @@ def add_fields(self): self.add_edit_str_field( 'dst', 'Destination', group='dest', value=dst, filter=callsign_filter) + self.add_edit_str_field( + 'to', ' To', group='dest', value=to, + filter=callsign_filter) self.add_edit_str_field( 'via', ' Via', group='dest', value=via, filter=via_filter) @@ -1427,6 +1440,7 @@ def validate(self): src = self.get_edit_str_value('src') dst = self.get_edit_str_value('dst') via = self.get_edit_str_value('via') + to = self.get_edit_str_value('to') if not (src and dst): return "Both source and destination are required" if not ax25.Address.valid_call(src): @@ -1446,11 +1460,12 @@ def save(self): src = self.get_edit_str_value('src').upper() dst = self.get_edit_str_value('dst').upper() via = self.get_edit_str_value('via').upper() + to = self.get_edit_str_value('to').upper() port = self.get_dropdown_value('port') # The user may have used comma separators or something else, but we # standardize here on spaces. vias = re.findall("[A-Z0-9-]+", via) - info = self.UnprotoInfo(src, dst, ' '.join(vias), port) + info = self.UnprotoInfo(src, dst, ' '.join(vias), to, port) urwid.emit_signal(self, 'unproto_info', info) From 444edb26ee854a7a46295abbaab529b36d18741b Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 15 Mar 2025 19:23:47 -0700 Subject: [PATCH 02/37] Add more quick messge options --- paracon/paracon.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index a648cae..dc88036 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -350,8 +350,20 @@ def _send(self, widget, text): self._mon.add_line(('unproto_error', 'Unproto config is invalid')) return - if to != '': - text = f':{to:<9}:{text}' + # If line starts with double colons, fill in configure to value + if text.startswith('::') and to != '': + remStr = text[2:] + text = f':{to:<9}:{remStr}' + + # If line starts with a colon and has another colon less than 9 spaces from the first + # pad in the text between the colons to 9 spaces + else: + colPos = [m.start() for m in re.finditer(':', text)] + + if len(colPos) >= 2 and colPos[0] == 0 and colPos[1] < 10: + toStr = text[1:colPos[1]] + remStr = text[colPos[1]+1:] + text = f':{toStr:<9}:{remStr}' vias = via.split() if via else None try: @@ -412,7 +424,7 @@ def _set_info(self): dst = config.get('Unproto', 'destination') via = config.get('Unproto', 'via') to = config.get('Unproto', 'to') - text = "From: {} Dest: {} To: {}".format(src, dst,to) + text = "From: {} Dest: {} To: {} ".format(src, dst,to) if via: # Vias are saved with spaces, but displayed with commas via = ','.join(via.split()) From ec9a16eecb3ffc96b9a28aa9dd222d89b97cd7f1 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 15 Mar 2025 21:15:19 -0700 Subject: [PATCH 03/37] Fix decode error --- paracon/paracon.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index dc88036..32e50ca 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -610,7 +610,10 @@ def _update_from_queue(self, obj): def _gather_lines(self, data): if not isinstance(data, str): - data = data.decode('utf-8') + try: + data = data.decode('utf-8') + except Exception: + data = "" parts = data.split('\r') if len(self._line_remains): parts[0] = self._line_remains + parts[0] From 8c16e169d3fe9c32df54bdfc431964d497c3d9d3 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sun, 1 Feb 2026 20:44:32 -0800 Subject: [PATCH 04/37] fix binary --- paracon/paracon.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 91faf81..28c73ec 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -657,13 +657,13 @@ def _gather_lines(self, data): # safe to identify line breaks before decoding. This allows us to use # one decoder per line, and avoid having fragments of a single line # decoded with different decoders. - data = data.replace(b'\r\n', b'\r').replace(b'\n', b'\r') - parts = data.split(b'\r') + data = data.replace('\r\n', '\r').replace('\n', '\r') + parts = data.split('\r') if len(self._line_remains): parts[0] = self._line_remains + parts[0] - self._line_remains = b'' - if data[-1] != b'\r': + self._line_remains = '' + if data[-1] != '\r': self._line_remains = parts[-1] del parts[-1] for part in parts: From eb191eb0889ec97d0332a21968eb55d220452dda Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sun, 1 Feb 2026 20:46:48 -0800 Subject: [PATCH 05/37] update --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 28c73ec..f15f5cc 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.3.0' +__version__ = '1.3.1' import argparse import codecs From ae4a2edf1c6875ef583063f73330bc08df9c0a7f Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 20:33:17 -0700 Subject: [PATCH 06/37] Update --- paracon/paracon.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index f15f5cc..95cb1b0 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.3.1' +__version__ = '1.3.2' import argparse import codecs @@ -667,7 +667,8 @@ def _gather_lines(self, data): self._line_remains = parts[-1] del parts[-1] for part in parts: - self.add_line(self._decode_line(part)) + #self.add_line(self._decode_line(part)) + self.add_line(part) def add_line(self, line): text = urwid.Text(line) From 92c3cb7b8cff8247d59a1b5f827ba2a8a611baa4 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 21:19:44 -0700 Subject: [PATCH 07/37] Update --- paracon/paracon.def | 6 + paracon/paracon.py | 359 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 335 insertions(+), 30 deletions(-) diff --git a/paracon/paracon.def b/paracon/paracon.def index 58c770d..0bd6ba7 100644 --- a/paracon/paracon.def +++ b/paracon/paracon.def @@ -14,6 +14,12 @@ source = destination = ID via = +[AprsMessages] +port = 0 +source = +to = +via = + [Monitor] port = -1 color = true diff --git a/paracon/paracon.py b/paracon/paracon.py index 95cb1b0..e867772 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -85,6 +85,12 @@ # Unproto ('unproto_error', 'light red', 'black'), + # APRS Messages + ('aprs_outbound', 'light yellow', 'black'), + ('aprs_inbound', 'light cyan', 'black'), + ('aprs_ack', 'light green', 'black'), + ('aprs_error', 'light red', 'black'), + # Line entry ('entry_line', 'white', 'black') ] @@ -226,6 +232,7 @@ def __init__(self): self._list = SizeListBox(self._log) self._queue = None self._periodic_key = None + self._last_call_from = '' super().__init__(self._list) self._log.set_logfile(app.log_dir / 'monitor.log') urwid.connect_signal(app, 'server_started', self._start_monitor) @@ -252,6 +259,10 @@ def _update_from_queue(self, obj): line, kind is pserver.MonitorType.UNPROTO_OWN) if clr_line: self.add_line(clr_line) + # Track call_from for subsequent text frames + m = _INFO_LINE_PATTERN.match(line) + if m: + self._last_call_from = m['call_from'] else: logger.debug("Coloring failed: {}".format(line)) self.add_line(line) @@ -259,6 +270,12 @@ def _update_from_queue(self, obj): or kind is pserver.MonitorType.CONN_TEXT): # self.add_line(urwidx.safe_string(line.rstrip())) self.add_multi_line(line) + # Forward raw unproto text to AprsScreen for APRS msg parsing + if (kind is pserver.MonitorType.UNPROTO_TEXT + and hasattr(app, '_aprs_screen') + and app._aprs_screen is not None): + app._aprs_screen.receive_unproto_text( + self._last_call_from, line) elif (kind is pserver.MonitorType.UNPROTO_NETROM or kind is pserver.MonitorType.CONN_NETROM): if line[0] == 0xFF: # only handle routing broadcasts @@ -346,7 +363,6 @@ def _send(self, widget, text): if not src: src = config.get('Setup', 'callsign') dst = config.get('Unproto', 'destination') - to = config.get('Unproto', 'to') via = config.get('Unproto', 'via') port = config.get_int('Unproto', 'port') if port is not None: @@ -356,22 +372,6 @@ def _send(self, widget, text): if not self._valid_config(src, dst, via): self._mon.add_line(('unproto_error', 'Unproto config is invalid')) return - - # If line starts with double colons, fill in configure to value - if text.startswith('::') and to != '': - remStr = text[2:] - text = f':{to:<9}:{remStr}' - - # If line starts with a colon and has another colon less than 9 spaces from the first - # pad in the text between the colons to 9 spaces - else: - colPos = [m.start() for m in re.finditer(':', text)] - - if len(colPos) >= 2 and colPos[0] == 0 and colPos[1] < 10: - toStr = text[1:colPos[1]] - remStr = text[colPos[1]+1:] - text = f':{toStr:<9}:{remStr}' - vias = via.split() if via else None try: app.server.send_unproto(port, src, dst, text, vias) @@ -418,7 +418,6 @@ def _change_config(self, info): config.set('Unproto', 'source', info.src) config.set('Unproto', 'destination', info.dst) config.set('Unproto', 'via', info.via) - config.set('Unproto', 'to', info.to) config.set_int('Unproto', 'port', app.ports.port_for_index(info.port[0])) config.save_config() @@ -430,8 +429,7 @@ def _set_info(self): src = config.get('Setup', 'callsign') dst = config.get('Unproto', 'destination') via = config.get('Unproto', 'via') - to = config.get('Unproto', 'to') - text = "From: {} Dest: {} To: {} ".format(src, dst,to) + text = "From: {} Dest: {} ".format(src, dst) if via: # Vias are saved with spaces, but displayed with commas via = ','.join(via.split()) @@ -844,6 +842,7 @@ class Application(metaclass=urwid.MetaSignals): class MenuCommand(Enum): CONNECTIONS = 'Connections' UNPROTO = 'Unproto' + APRS_MESSAGES = 'APRS Msg' SETUP = 'Setup' HELP = 'Help' ABOUT = 'About' @@ -922,11 +921,13 @@ def _create_widgets(self): self._topbar = urwidx.MenuBar(self.MenuCommand) self._set_connected("not connected") self._topbar.menu.enable(self.MenuCommand.CONNECTIONS, False) + self._topbar.menu.enable(self.MenuCommand.APRS_MESSAGES, True) urwid.connect_signal( self._topbar.menu, 'select', self._handle_menu_command) self._monitor_panel = MonitorPanel() self._connections_screen = ConnectionsScreen(self._monitor_panel) self._unproto_screen = UnprotoScreen(self._monitor_panel) + self._aprs_screen = AprsScreen(self._monitor_panel) self._frame = urwid.Frame( self._connections_screen, header=self._topbar) return self._frame @@ -936,6 +937,8 @@ def _handle_menu_command(self, cmd): self._select_screen(self.MenuCommand.UNPROTO) elif cmd is self.MenuCommand.CONNECTIONS: self._select_screen(self.MenuCommand.CONNECTIONS) + elif cmd is self.MenuCommand.APRS_MESSAGES: + self._select_screen(self.MenuCommand.APRS_MESSAGES) elif cmd is self.MenuCommand.SETUP: self._show_setup() elif cmd is self.MenuCommand.HELP: @@ -951,11 +954,19 @@ def _select_screen(self, screen): self._frame.body = self._connections_screen self._topbar.menu.enable(self.MenuCommand.CONNECTIONS, False) self._topbar.menu.enable(self.MenuCommand.UNPROTO, True) + self._topbar.menu.enable(self.MenuCommand.APRS_MESSAGES, True) elif screen is self.MenuCommand.UNPROTO: if self._frame.body != self._unproto_screen: self._frame.body = self._unproto_screen self._topbar.menu.enable(self.MenuCommand.CONNECTIONS, True) self._topbar.menu.enable(self.MenuCommand.UNPROTO, False) + self._topbar.menu.enable(self.MenuCommand.APRS_MESSAGES, True) + elif screen is self.MenuCommand.APRS_MESSAGES: + if self._frame.body != self._aprs_screen: + self._frame.body = self._aprs_screen + self._topbar.menu.enable(self.MenuCommand.CONNECTIONS, True) + self._topbar.menu.enable(self.MenuCommand.UNPROTO, True) + self._topbar.menu.enable(self.MenuCommand.APRS_MESSAGES, False) def _show_setup(self): dlg = SetupDialog() @@ -1457,7 +1468,6 @@ class UnprotoInfo(NamedTuple): src: str dst: str via: str - to: str port: tuple def __init__(self, info=None): @@ -1469,7 +1479,6 @@ def add_fields(self): src = self._info.src dst = self._info.dst via = self._info.via - to = self._info.to port_ix = self._info.port[0] else: src = (config.get('Unproto', 'source') @@ -1477,7 +1486,6 @@ def add_fields(self): or '') dst = config.get('Unproto', 'destination') or '' via = config.get('Unproto', 'via') or '' - to = config.get('Unproto', 'to') or '' port = config.get_int('Unproto', 'port') # Ensure a valid index into list of ports @@ -1494,9 +1502,6 @@ def add_fields(self): self.add_edit_str_field( 'dst', 'Destination', group='dest', value=dst, filter=callsign_filter) - self.add_edit_str_field( - 'to', ' To', group='dest', value=to, - filter=callsign_filter) self.add_edit_str_field( 'via', ' Via', group='dest', value=via, filter=via_filter) @@ -1511,7 +1516,6 @@ def validate(self): src = self.get_edit_str_value('src') dst = self.get_edit_str_value('dst') via = self.get_edit_str_value('via') - to = self.get_edit_str_value('to') if not (src and dst): return "Both source and destination are required" if not ax25.Address.valid_call(src): @@ -1531,19 +1535,314 @@ def save(self): src = self.get_edit_str_value('src').upper() dst = self.get_edit_str_value('dst').upper() via = self.get_edit_str_value('via').upper() - to = self.get_edit_str_value('to').upper() port = self.get_dropdown_value('port') # The user may have used comma separators or something else, but we # standardize here on spaces. vias = re.findall("[A-Z0-9-]+", via) - info = self.UnprotoInfo(src, dst, ' '.join(vias), to, port) + info = self.UnprotoInfo(src, dst, ' '.join(vias), port) urwid.emit_signal(self, 'unproto_info', info) + # ============================================================================= -# Startup +# APRS Messages # ============================================================================= +# APRS message number counter (1-999, wrapping) +_aprs_msg_counter = 0 + +def _next_aprs_msg_num(): + global _aprs_msg_counter + _aprs_msg_counter = (_aprs_msg_counter % 999) + 1 + return str(_aprs_msg_counter) + + +def _format_aprs_message(to_call, text, msg_num): + """ + Format an APRS message packet payload per the APRS spec: + :CALLSIGN :message text{msgnum} + The destination callsign field is exactly 9 characters, left-justified + and padded with spaces. + """ + return ':{:<9}:{}{{{}}}'.format(to_call, text, msg_num) + + +def _parse_aprs_message(text): + """ + Parse an APRS message payload. Returns (to_call, msg_body, msg_num) or + None if the text is not a valid APRS message. + The expected format is: :CALLSIGN :message{num} + """ + if not text.startswith(':'): + return None + # Second colon must be at position 10 (9-char callsign field + leading ':') + if len(text) < 11 or text[10] != ':': + return None + to_call = text[1:10].strip() + body = text[11:] + msg_num = None + if '{' in body: + brace = body.rfind('{') + msg_num = body[brace + 1:] + body = body[:brace] + return (to_call, body, msg_num) + + +class AprsScreen(urwid.WidgetWrap): + """ + A dedicated screen for APRS direct messages. Messages are sent as unproto + UI frames addressed to APICON (the conventional APRS destination) with the + payload formatted per the APRS messaging spec. Incoming APRS messages + visible in the monitor queue are displayed here as well. + """ + + class MenuCommand(Enum): + CONFIGURE = 'Dest/Src' + + def __init__(self, mwin): + self._mon = mwin + self._menubar = urwidx.MenuBar(self.MenuCommand) + self._set_info() + urwid.connect_signal( + self._menubar.menu, 'select', self._handle_menu_command) + self._log = urwidx.LoggingDequeListWalker([]) + self._list = SizeListBox(self._log) + self._entry = urwidx.LineEntry(caption="> ", edit_text="") + urwid.connect_signal(self._entry, 'line_entry', self._send) + self._pile = urwid.Pile([ + ('weight', 1, self._list), + (1, self._menubar), + (1, urwid.AttrMap(urwid.Filler(self._entry), 'entry_line')) + ]) + super().__init__(urwid.AttrMap(urwid.LineBox( + self._pile, title="APRS Messages", title_align='center'), + 'window_norm')) + urwid.connect_signal(app, 'server_started', self._update_info) + self._log.set_logfile(app.log_dir / 'aprs_messages.log') + + # ------------------------------------------------------------------ + # Inbound message handling + # Inbound delivery is via receive_unproto_text(), called from + # MonitorPanel._update_from_queue() for every decoded unproto UI + # text frame. We do not drain the shared server queue here because + # Queue.get() is destructive and MonitorPanel must see every frame. + # ------------------------------------------------------------------ + + def receive_unproto_text(self, call_from, text): + """ + Called by MonitorPanel for every decoded unproto text frame so that + this screen can pick out APRS messages addressed to us. + """ + my_call = config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') or '' + parsed = _parse_aprs_message(text) + if parsed is None: + return + to_call, body, msg_num = parsed + # Display if addressed to us or if our callsign is not configured + if not my_call or to_call.upper() == my_call.upper(): + num_str = ' {{{}}}'.format(msg_num) if msg_num else '' + self.add_line( + ('aprs_inbound', + 'From {}: {}{}'.format(call_from, body, num_str))) + # Send an ACK if we have a message number and a configured source + if msg_num and my_call and app.server: + self._send_ack(call_from, msg_num) + + def _send_ack(self, to_call, msg_num): + src = config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') + via = config.get('AprsMessages', 'via') or '' + port = config.get_int('AprsMessages', 'port') + if port is not None: + port = app.ports.valid_port(port) + if port is None: + port = app.ports.port_for_index(0) + dst = 'APICON' + ack_text = ':{:<9}:ack{}'.format(to_call, msg_num) + vias = via.split() if via else None + try: + app.server.send_unproto(port, src, dst, ack_text, vias) + self.add_line(('aprs_ack', 'ACK sent to {}'.format(to_call))) + except BrokenPipeError: + self.add_line(('aprs_error', 'AGWPE server has disconnected')) + app.server_disappeared() + + # ------------------------------------------------------------------ + # Sending + # ------------------------------------------------------------------ + + def _send(self, widget, text): + if not app.server: + self.add_line(('aprs_error', 'Not connected to AGWPE server')) + return + src = config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') + to = config.get('AprsMessages', 'to') or '' + via = config.get('AprsMessages', 'via') or '' + port = config.get_int('AprsMessages', 'port') + if port is not None: + port = app.ports.valid_port(port) + if port is None: + port = app.ports.port_for_index(0) + if not self._valid_config(src, to): + self.add_line(('aprs_error', 'APRS config is invalid (source and to are required)')) + return + msg_num = _next_aprs_msg_num() + payload = _format_aprs_message(to, text, msg_num) + dst = 'APICON' + vias = via.split() if via else None + try: + app.server.send_unproto(port, src, dst, payload, vias) + except BrokenPipeError: + self.add_line(('aprs_error', 'AGWPE server has disconnected')) + app.server_disappeared() + return + self.add_line(('aprs_outbound', 'To {} [{}]: {}'.format(to, msg_num, text))) + + def _valid_config(self, src, to): + if not src or not ax25.Address.valid_call(src): + return False + if not to or not ax25.Address.valid_call(to): + return False + return True + + # ------------------------------------------------------------------ + # Menu / configuration + # ------------------------------------------------------------------ + + def _handle_menu_command(self, cmd): + if cmd is self.MenuCommand.CONFIGURE: + self._configure() + + def _configure(self): + dlg = AprsDialog() + urwid.connect_signal(dlg, 'aprs_info', self._change_config) + dlg.show(app._loop) + + def _change_config(self, info): + config.set('AprsMessages', 'source', info.src) + config.set('AprsMessages', 'to', info.to) + config.set('AprsMessages', 'via', info.via) + config.set_int('AprsMessages', 'port', + app.ports.port_for_index(info.port[0])) + config.save_config() + self._set_info() + + def _set_info(self, data=None): + src = config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') or '?' + to = config.get('AprsMessages', 'to') or '?' + via = config.get('AprsMessages', 'via') or '' + text = "From: {} To: {} ".format(src, to) + if via: + via = ','.join(via.split()) + text += " Via: {} ".format(via) + self._menubar.status = text + return True + + def _update_info(self, server): + self._set_info() + + # ------------------------------------------------------------------ + # Display + # ------------------------------------------------------------------ + + def add_line(self, line): + if not self._list.size: + return + line = urwidx.safe_text(line) + text = urwid.AttrMap(urwid.Text(line), 'monitor_text') + ends_visible = self._list.ends_visible(self._list.size) + self._log.append(text) + if 'bottom' in ends_visible: + self._list.set_focus(len(self._log) - 1, 'above') + + def keypress(self, size, key): + key = self._menubar.keypress(size, key) + if key: + key = super().keypress(size, key) + if key: + key = self._entry.keypress((size[0] - 2, ), key) + return key + + +class AprsDialog(urwidx.FormDialog): + signals = ['aprs_info'] + + class AprsInfo(NamedTuple): + src: str + to: str + via: str + port: tuple + + def __init__(self, info=None): + self._info = info + super().__init__("APRS Messages") + + def add_fields(self): + if self._info: + src = self._info.src + to = self._info.to + via = self._info.via + port_ix = self._info.port[0] + else: + src = (config.get('AprsMessages', 'source') + or config.get('Setup', 'callsign') + or '') + to = config.get('AprsMessages', 'to') or '' + via = config.get('AprsMessages', 'via') or '' + port = config.get_int('AprsMessages', 'port') + if port is not None: + port = app.ports.valid_port(port) + if port is not None: + port_ix = app.ports.index_for_port(port) + else: + port_ix = 0 + via = ','.join(via.split()) + avail_ports = app.ports.port_info + self.add_group('dest', "Send To") + self.add_edit_str_field( + 'to', 'To (callsign)', group='dest', value=to, + filter=callsign_filter) + self.add_edit_str_field( + 'via', ' Via', group='dest', value=via, + filter=via_filter) + self.add_group('source', "Send Using") + self.add_edit_str_field( + 'src', 'My call', group='source', value=src, + filter=callsign_filter) + self.add_dropdown_field( + 'port', ' Port', avail_ports, port_ix, group='source') + + def validate(self): + src = self.get_edit_str_value('src') + to = self.get_edit_str_value('to') + via = self.get_edit_str_value('via') + if not src: + return "My call is required" + if not ax25.Address.valid_call(src): + return "My call is invalid" + if not to: + return "To (callsign) is required" + if not ax25.Address.valid_call(to): + return "To callsign is invalid" + if via: + vias = re.findall("[A-Za-z0-9-]+", via) + if not vias: + return "Invalid via" + for v in vias: + if not ax25.Address.valid_call(v): + return "Invalid via: {}".format(v) + return None + + def save(self): + src = self.get_edit_str_value('src').upper() + to = self.get_edit_str_value('to').upper() + via = self.get_edit_str_value('via').upper() + port = self.get_dropdown_value('port') + vias = re.findall("[A-Z0-9-]+", via) + info = self.AprsInfo(src, to, ' '.join(vias), port) + urwid.emit_signal(self, 'aprs_info', info) + + + def get_args(): class ConfigFileCheckAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): From 0e832623a5c447ce541dc4d142f6434815da5e8f Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 21:22:56 -0700 Subject: [PATCH 08/37] Update --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index e867772..a19e887 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.3.2' +__version__ = '1.4.0' import argparse import codecs From edd20af3b71e281d644fb6b1dc1bd5600b3b1e01 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 21:32:56 -0700 Subject: [PATCH 09/37] Update" --- paracon/paracon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index a19e887..5616efb 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.4.0' +__version__ = '1.4.1' import argparse import codecs @@ -86,7 +86,7 @@ ('unproto_error', 'light red', 'black'), # APRS Messages - ('aprs_outbound', 'light yellow', 'black'), + ('aprs_outbound', 'yellow', 'black'), ('aprs_inbound', 'light cyan', 'black'), ('aprs_ack', 'light green', 'black'), ('aprs_error', 'light red', 'black'), From b1a11793201f9f1ece04d5f0126c93934e78a0bd Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 21:41:27 -0700 Subject: [PATCH 10/37] Update" --- paracon/paracon.def | 1 + paracon/paracon.py | 24 +++++++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/paracon/paracon.def b/paracon/paracon.def index 0bd6ba7..251b9dc 100644 --- a/paracon/paracon.def +++ b/paracon/paracon.def @@ -17,6 +17,7 @@ via = [AprsMessages] port = 0 source = +destination = APICON to = via = diff --git a/paracon/paracon.py b/paracon/paracon.py index 5616efb..294e94e 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.4.1' +__version__ = '1.3.2' import argparse import codecs @@ -1650,13 +1650,13 @@ def receive_unproto_text(self, call_from, text): def _send_ack(self, to_call, msg_num): src = config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') + dst = config.get('AprsMessages', 'destination') or 'APICON' via = config.get('AprsMessages', 'via') or '' port = config.get_int('AprsMessages', 'port') if port is not None: port = app.ports.valid_port(port) if port is None: port = app.ports.port_for_index(0) - dst = 'APICON' ack_text = ':{:<9}:ack{}'.format(to_call, msg_num) vias = via.split() if via else None try: @@ -1675,6 +1675,7 @@ def _send(self, widget, text): self.add_line(('aprs_error', 'Not connected to AGWPE server')) return src = config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') + dst = config.get('AprsMessages', 'destination') or 'APICON' to = config.get('AprsMessages', 'to') or '' via = config.get('AprsMessages', 'via') or '' port = config.get_int('AprsMessages', 'port') @@ -1687,7 +1688,6 @@ def _send(self, widget, text): return msg_num = _next_aprs_msg_num() payload = _format_aprs_message(to, text, msg_num) - dst = 'APICON' vias = via.split() if via else None try: app.server.send_unproto(port, src, dst, payload, vias) @@ -1719,6 +1719,7 @@ def _configure(self): def _change_config(self, info): config.set('AprsMessages', 'source', info.src) + config.set('AprsMessages', 'destination', info.dst) config.set('AprsMessages', 'to', info.to) config.set('AprsMessages', 'via', info.via) config.set_int('AprsMessages', 'port', @@ -1728,9 +1729,10 @@ def _change_config(self, info): def _set_info(self, data=None): src = config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') or '?' + dst = config.get('AprsMessages', 'destination') or 'APICON' to = config.get('AprsMessages', 'to') or '?' via = config.get('AprsMessages', 'via') or '' - text = "From: {} To: {} ".format(src, to) + text = "From: {} Dest: {} To: {} ".format(src, dst, to) if via: via = ','.join(via.split()) text += " Via: {} ".format(via) @@ -1768,6 +1770,7 @@ class AprsDialog(urwidx.FormDialog): class AprsInfo(NamedTuple): src: str + dst: str to: str via: str port: tuple @@ -1779,6 +1782,7 @@ def __init__(self, info=None): def add_fields(self): if self._info: src = self._info.src + dst = self._info.dst to = self._info.to via = self._info.via port_ix = self._info.port[0] @@ -1786,6 +1790,7 @@ def add_fields(self): src = (config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') or '') + dst = config.get('AprsMessages', 'destination') or 'APICON' to = config.get('AprsMessages', 'to') or '' via = config.get('AprsMessages', 'via') or '' port = config.get_int('AprsMessages', 'port') @@ -1801,6 +1806,9 @@ def add_fields(self): self.add_edit_str_field( 'to', 'To (callsign)', group='dest', value=to, filter=callsign_filter) + self.add_edit_str_field( + 'dst', ' Destination', group='dest', value=dst, + filter=callsign_filter) self.add_edit_str_field( 'via', ' Via', group='dest', value=via, filter=via_filter) @@ -1813,12 +1821,17 @@ def add_fields(self): def validate(self): src = self.get_edit_str_value('src') + dst = self.get_edit_str_value('dst') to = self.get_edit_str_value('to') via = self.get_edit_str_value('via') if not src: return "My call is required" if not ax25.Address.valid_call(src): return "My call is invalid" + if not dst: + return "Destination is required" + if not ax25.Address.valid_call(dst): + return "Destination is invalid" if not to: return "To (callsign) is required" if not ax25.Address.valid_call(to): @@ -1834,11 +1847,12 @@ def validate(self): def save(self): src = self.get_edit_str_value('src').upper() + dst = self.get_edit_str_value('dst').upper() to = self.get_edit_str_value('to').upper() via = self.get_edit_str_value('via').upper() port = self.get_dropdown_value('port') vias = re.findall("[A-Z0-9-]+", via) - info = self.AprsInfo(src, to, ' '.join(vias), port) + info = self.AprsInfo(src, dst, to, ' '.join(vias), port) urwid.emit_signal(self, 'aprs_info', info) From 633d467b0cd3829d2180510180f1757e068e9078 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 21:42:13 -0700 Subject: [PATCH 11/37] update --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 294e94e..ee93b1e 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.3.2' +__version__ = '1.4.2' import argparse import codecs From 3b0fb3b93787f25dfd82ff3d5f01143a3437b142 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 22:15:53 -0700 Subject: [PATCH 12/37] Fix number formats --- paracon/paracon.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index ee93b1e..555b28b 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1560,11 +1560,11 @@ def _next_aprs_msg_num(): def _format_aprs_message(to_call, text, msg_num): """ Format an APRS message packet payload per the APRS spec: - :CALLSIGN :message text{msgnum} + :CALLSIGN :message text{msgnum The destination callsign field is exactly 9 characters, left-justified and padded with spaces. """ - return ':{:<9}:{}{{{}}}'.format(to_call, text, msg_num) + return ':{:<9}:{}{{{}}'.format(to_call, text, msg_num) def _parse_aprs_message(text): @@ -1657,7 +1657,7 @@ def _send_ack(self, to_call, msg_num): port = app.ports.valid_port(port) if port is None: port = app.ports.port_for_index(0) - ack_text = ':{:<9}:ack{}'.format(to_call, msg_num) + ack_text = ':{:<9}:ack{}}'.format(to_call, msg_num) vias = via.split() if via else None try: app.server.send_unproto(port, src, dst, ack_text, vias) From c9508c295aebe98dc13b7f4f6727c0e9674d4fc8 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 22:16:15 -0700 Subject: [PATCH 13/37] Update --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 555b28b..3573cc9 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.4.2' +__version__ = '1.4.3' import argparse import codecs From db6ff45d4f7ff4c5eafb2636a38d82f4c3412581 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 22:23:55 -0700 Subject: [PATCH 14/37] fix ack --- paracon/paracon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 3573cc9..c94fbdf 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.4.3' +__version__ = '1.4.4' import argparse import codecs @@ -1564,7 +1564,7 @@ def _format_aprs_message(to_call, text, msg_num): The destination callsign field is exactly 9 characters, left-justified and padded with spaces. """ - return ':{:<9}:{}{{{}}'.format(to_call, text, msg_num) + return ':{:<9}:{}{{{}'.format(to_call, text, msg_num) def _parse_aprs_message(text): From 3cde07c6a0e6013f51c4b642e8dd75bdd716b555 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 22:29:56 -0700 Subject: [PATCH 15/37] Update --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index c94fbdf..4e85608 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1657,7 +1657,7 @@ def _send_ack(self, to_call, msg_num): port = app.ports.valid_port(port) if port is None: port = app.ports.port_for_index(0) - ack_text = ':{:<9}:ack{}}'.format(to_call, msg_num) + ack_text = ':{:<9}:ack{}'.format(to_call, msg_num) vias = via.split() if via else None try: app.server.send_unproto(port, src, dst, ack_text, vias) From 3c34115c3717b7664101d41f424f50a4ff9347ca Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 22:30:22 -0700 Subject: [PATCH 16/37] Update --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 4e85608..784590e 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.4.4' +__version__ = '1.4.5' import argparse import codecs From e47891e5f158ff1041a4d3d6676dd0260a1bf7ba Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 23:32:25 -0700 Subject: [PATCH 17/37] Update --- paracon/paracon.py | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 784590e..a1abcde 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -139,6 +139,46 @@ def render(self, size, focus=False): return super().render(size, focus) +class FixedMenuBar(urwidx.MenuBar): + """ + A MenuBar subclass that allocates a fixed width to the menu portion + based on its actual content, giving all remaining space to the status + text on the right. The base MenuBar splits the row 50/50 between menu + and status, which causes the status text to be clipped when it is long + (e.g. a Via path with multiple digipeaters). + """ + # Characters per menu item: name length + SPACING (3) from urwidx.Menu + _MENU_ITEM_SPACING = 3 + # Extra padding added by MenuBar's own urwid.Padding(left=1, right=1) + _BAR_PADDING = 2 + + def __init__(self, menu_items, status=""): + super().__init__(menu_items, status) + # Compute the total width consumed by all menu item labels + menu_width = sum( + len(m.value) + self._MENU_ITEM_SPACING + for m in menu_items + ) + # Rebuild the inner widget with a fixed-width left column so the + # status Text widget on the right gets whatever space remains. + widget = urwid.AttrMap( + urwid.Padding( + urwid.Columns( + [ + (menu_width, urwid.Filler(self._menu)), + urwid.Filler(self._status), + ], + box_columns=[0, 1] + ), + left=1, right=1 + ), + 'menu_text' + ) + self._wrapped_widget = widget + # urwid.WidgetWrap stores the wrapped widget in _w + self._w = widget + + class Ports: """ Per the AGWPE spec, port information comes from the server in the form @@ -339,7 +379,7 @@ class MenuCommand(Enum): def __init__(self, mwin): self._mon = mwin - self._menubar = urwidx.MenuBar(self.MenuCommand) + self._menubar = FixedMenuBar(self.MenuCommand) self._set_info() urwid.connect_signal( self._menubar.menu, 'select', self._handle_menu_command) @@ -1601,7 +1641,7 @@ class MenuCommand(Enum): def __init__(self, mwin): self._mon = mwin - self._menubar = urwidx.MenuBar(self.MenuCommand) + self._menubar = FixedMenuBar(self.MenuCommand) self._set_info() urwid.connect_signal( self._menubar.menu, 'select', self._handle_menu_command) From bd077d43810b30324747a8e093e48757f6e7994d Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Fri, 13 Mar 2026 23:48:09 -0700 Subject: [PATCH 18/37] stable state --- paracon/paracon.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index a1abcde..7355314 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -178,7 +178,6 @@ def __init__(self, menu_items, status=""): # urwid.WidgetWrap stores the wrapped widget in _w self._w = widget - class Ports: """ Per the AGWPE spec, port information comes from the server in the form @@ -379,6 +378,7 @@ class MenuCommand(Enum): def __init__(self, mwin): self._mon = mwin + self._last_sent = '' self._menubar = FixedMenuBar(self.MenuCommand) self._set_info() urwid.connect_signal( @@ -419,6 +419,8 @@ def _send(self, widget, text): self._mon.add_line( ('unproto_error', 'AGWPE server has disconnected')) app.server_disappeared() + return + self._last_sent = text def _valid_config(self, src, dst, via): if not (src and ax25.Address.valid_call(src) @@ -440,6 +442,11 @@ def keypress(self, size, key): if key: key = super().keypress(size, key) if key: + # Up arrow recalls the last sent message into the entry field. + if key == 'up' and self._last_sent and not self._entry.get_edit_text(): + self._entry.set_edit_text(self._last_sent) + self._entry.set_edit_pos(len(self._last_sent)) + return None # If the key hasn't been handled already, let the line entry # widget see if it wants it. This allows someone to type into # that widget without the focus having to be put there first. @@ -1641,6 +1648,7 @@ class MenuCommand(Enum): def __init__(self, mwin): self._mon = mwin + self._last_sent = '' self._menubar = FixedMenuBar(self.MenuCommand) self._set_info() urwid.connect_signal( @@ -1736,6 +1744,7 @@ def _send(self, widget, text): app.server_disappeared() return self.add_line(('aprs_outbound', 'To {} [{}]: {}'.format(to, msg_num, text))) + self._last_sent = text def _valid_config(self, src, to): if not src or not ax25.Address.valid_call(src): @@ -1801,6 +1810,11 @@ def keypress(self, size, key): if key: key = super().keypress(size, key) if key: + # Up arrow recalls the last sent message into the entry field. + if key == 'up' and self._last_sent and not self._entry.get_edit_text(): + self._entry.set_edit_text(self._last_sent) + self._entry.set_edit_pos(len(self._last_sent)) + return None key = self._entry.keypress((size[0] - 2, ), key) return key From a556340c0446335bc8c3160072c2aab2881aa96e Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 10:11:22 -0700 Subject: [PATCH 19/37] Handle thrid party messages --- paracon/paracon.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 7355314..e10a510 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1613,13 +1613,28 @@ def _format_aprs_message(to_call, text, msg_num): """ return ':{:<9}:{}{{{}'.format(to_call, text, msg_num) - def _parse_aprs_message(text): """ Parse an APRS message payload. Returns (to_call, msg_body, msg_num) or None if the text is not a valid APRS message. - The expected format is: :CALLSIGN :message{num} + + Handles two formats: + 1. Standard APRS message: + :CALLSIGN :message{num} + 2. Third-party traffic (prefixed with '}'): + }EMAIL>APJIE4,TCPIP,KC6SSM-5*::CALLSIGN :message{num} + The third-party header is stripped and the inner payload is parsed normally. """ + # Strip third-party traffic wrapper if present + if text.startswith('}'): + # The inner APRS payload starts at the '::' that precedes the addressee. + # Find the '::' sequence that marks the start of the message block. + inner_start = text.find('::') + if inner_start == -1: + return None + # Advance past the first ':', so text is now ':CALLSIGN :message{num}' + text = text[inner_start + 1:] + if not text.startswith(':'): return None # Second colon must be at position 10 (9-char callsign field + leading ':') @@ -1634,7 +1649,6 @@ def _parse_aprs_message(text): body = body[:brace] return (to_call, body, msg_num) - class AprsScreen(urwid.WidgetWrap): """ A dedicated screen for APRS direct messages. Messages are sent as unproto From 7a2848590e75b281b414c43796aab8758edff71f Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 10:39:07 -0700 Subject: [PATCH 20/37] Fixed status --- paracon/paracon.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index e10a510..bec1f62 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1613,31 +1613,28 @@ def _format_aprs_message(to_call, text, msg_num): """ return ':{:<9}:{}{{{}'.format(to_call, text, msg_num) -def _parse_aprs_message(text): +def _parse_aprs_message(text, from_call): """ - Parse an APRS message payload. Returns (to_call, msg_body, msg_num) or - None if the text is not a valid APRS message. - - Handles two formats: - 1. Standard APRS message: - :CALLSIGN :message{num} - 2. Third-party traffic (prefixed with '}'): - }EMAIL>APJIE4,TCPIP,KC6SSM-5*::CALLSIGN :message{num} - The third-party header is stripped and the inner payload is parsed normally. + Returns (to_call, msg_body, msg_num, ack_call) where ack_call is the + station to send the ack to. For normal messages, ack_call == to_call. + For third-party messages, ack_call is the originating station from + the third-party header. """ + ack_call = from_call + # Strip third-party traffic wrapper if present if text.startswith('}'): - # The inner APRS payload starts at the '::' that precedes the addressee. - # Find the '::' sequence that marks the start of the message block. inner_start = text.find('::') if inner_start == -1: return None - # Advance past the first ':', so text is now ':CALLSIGN :message{num}' + # Extract originator from header (between '}' and '>') + header = text[1:inner_start] # e.g. "EMAIL>APJIE4,TCPIP,KC6SSM-5*" + gt = header.find('>') + ack_call = header[:gt] if gt != -1 else None text = text[inner_start + 1:] if not text.startswith(':'): return None - # Second colon must be at position 10 (9-char callsign field + leading ':') if len(text) < 11 or text[10] != ':': return None to_call = text[1:10].strip() @@ -1647,7 +1644,8 @@ def _parse_aprs_message(text): brace = body.rfind('{') msg_num = body[brace + 1:] body = body[:brace] - return (to_call, body, msg_num) + + return (to_call, body, msg_num, ack_call) class AprsScreen(urwid.WidgetWrap): """ @@ -1696,10 +1694,10 @@ def receive_unproto_text(self, call_from, text): this screen can pick out APRS messages addressed to us. """ my_call = config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') or '' - parsed = _parse_aprs_message(text) + parsed = _parse_aprs_message(text, call_from) if parsed is None: return - to_call, body, msg_num = parsed + to_call, body, msg_num, call_from = parsed # Display if addressed to us or if our callsign is not configured if not my_call or to_call.upper() == my_call.upper(): num_str = ' {{{}}}'.format(msg_num) if msg_num else '' From ca708b228773d806024d02a5c27c11843a959dc3 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 17:42:07 -0700 Subject: [PATCH 21/37] Back out early decode --- paracon/paracon.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index bec1f62..7b37c03 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -692,16 +692,30 @@ def _decode_line(self, data): return line def _gather_lines(self, data): + # The text encodings we support all use the C0 control set, so it is + # safe to identify line breaks before decoding. This allows us to use + # one decoder per line, and avoid having fragments of a single line + # decoded with different decoders. + data = data.replace(b'\r\n', b'\r').replace(b'\n', b'\r') + parts = data.split(b'\r') + + if len(self._line_remains): + parts[0] = self._line_remains + parts[0] + self._line_remains = b'' + if data[-1] != b'\r': + self._line_remains = parts[-1] + del parts[-1] + for part in parts: + self.add_line(self._decode_line(part)) + + def _gather_lines_v2(self, data): if not isinstance(data, str): try: data = data.decode('utf-8') except Exception: data = "" - # The text encodings we support all use the C0 control set, so it is - # safe to identify line breaks before decoding. This allows us to use - # one decoder per line, and avoid having fragments of a single line - # decoded with different decoders. + data = data.replace('\r\n', '\r').replace('\n', '\r') parts = data.split('\r') @@ -712,7 +726,6 @@ def _gather_lines(self, data): self._line_remains = parts[-1] del parts[-1] for part in parts: - #self.add_line(self._decode_line(part)) self.add_line(part) def add_line(self, line): @@ -889,7 +902,7 @@ class Application(metaclass=urwid.MetaSignals): class MenuCommand(Enum): CONNECTIONS = 'Connections' UNPROTO = 'Unproto' - APRS_MESSAGES = 'APRS Msg' + APRS_MESSAGES = 'Messages' SETUP = 'Setup' HELP = 'Help' ABOUT = 'About' From 43ce251c6b6df1ff9c4f56d8a9dedc0b7985ca8f Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 17:46:45 -0700 Subject: [PATCH 22/37] Cleanup --- paracon/paracon.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 7b37c03..f83b92f 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -476,7 +476,7 @@ def _set_info(self): src = config.get('Setup', 'callsign') dst = config.get('Unproto', 'destination') via = config.get('Unproto', 'via') - text = "From: {} Dest: {} ".format(src, dst) + text = "From: {} To: {} ".format(src, dst) if via: # Vias are saved with spaces, but displayed with commas via = ','.join(via.split()) @@ -698,7 +698,6 @@ def _gather_lines(self, data): # decoded with different decoders. data = data.replace(b'\r\n', b'\r').replace(b'\n', b'\r') parts = data.split(b'\r') - if len(self._line_remains): parts[0] = self._line_remains + parts[0] self._line_remains = b'' @@ -1935,6 +1934,9 @@ def save(self): urwid.emit_signal(self, 'aprs_info', info) +# ============================================================================= +# Startup +# ============================================================================= def get_args(): class ConfigFileCheckAction(argparse.Action): From 4a6a69bcbcf24acd9bcf16ffc4cf95e29362a60b Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 17:52:50 -0700 Subject: [PATCH 23/37] code cleanup --- paracon/paracon.py | 101 +++++++++++++++++++-------------------------- 1 file changed, 43 insertions(+), 58 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index f83b92f..93bf99a 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -6,7 +6,7 @@ # ============================================================================= __author__ = 'Martin F N Cooper' -__version__ = '1.4.5' +__version__ = '1.3.0.1' import argparse import codecs @@ -1606,59 +1606,6 @@ def save(self): # ============================================================================= # APRS Messages # ============================================================================= - -# APRS message number counter (1-999, wrapping) -_aprs_msg_counter = 0 - -def _next_aprs_msg_num(): - global _aprs_msg_counter - _aprs_msg_counter = (_aprs_msg_counter % 999) + 1 - return str(_aprs_msg_counter) - - -def _format_aprs_message(to_call, text, msg_num): - """ - Format an APRS message packet payload per the APRS spec: - :CALLSIGN :message text{msgnum - The destination callsign field is exactly 9 characters, left-justified - and padded with spaces. - """ - return ':{:<9}:{}{{{}'.format(to_call, text, msg_num) - -def _parse_aprs_message(text, from_call): - """ - Returns (to_call, msg_body, msg_num, ack_call) where ack_call is the - station to send the ack to. For normal messages, ack_call == to_call. - For third-party messages, ack_call is the originating station from - the third-party header. - """ - ack_call = from_call - - # Strip third-party traffic wrapper if present - if text.startswith('}'): - inner_start = text.find('::') - if inner_start == -1: - return None - # Extract originator from header (between '}' and '>') - header = text[1:inner_start] # e.g. "EMAIL>APJIE4,TCPIP,KC6SSM-5*" - gt = header.find('>') - ack_call = header[:gt] if gt != -1 else None - text = text[inner_start + 1:] - - if not text.startswith(':'): - return None - if len(text) < 11 or text[10] != ':': - return None - to_call = text[1:10].strip() - body = text[11:] - msg_num = None - if '{' in body: - brace = body.rfind('{') - msg_num = body[brace + 1:] - body = body[:brace] - - return (to_call, body, msg_num, ack_call) - class AprsScreen(urwid.WidgetWrap): """ A dedicated screen for APRS direct messages. Messages are sent as unproto @@ -1671,6 +1618,7 @@ class MenuCommand(Enum): CONFIGURE = 'Dest/Src' def __init__(self, mwin): + self._aprs_msg_counter = 0 self._mon = mwin self._last_sent = '' self._menubar = FixedMenuBar(self.MenuCommand) @@ -1699,6 +1647,39 @@ def __init__(self, mwin): # text frame. We do not drain the shared server queue here because # Queue.get() is destructive and MonitorPanel must see every frame. # ------------------------------------------------------------------ + def _parse_aprs_message(self, text, from_call): + """ + Returns (to_call, msg_body, msg_num, ack_call) where ack_call is the + station to send the ack to. For normal messages, ack_call == to_call. + For third-party messages, ack_call is the originating station from + the third-party header. + """ + ack_call = from_call + + # Strip third-party traffic wrapper if present + if text.startswith('}'): + inner_start = text.find('::') + if inner_start == -1: + return None + # Extract originator from header (between '}' and '>') + header = text[1:inner_start] # e.g. "EMAIL>APJIE4,TCPIP,KC6SSM-5*" + gt = header.find('>') + ack_call = header[:gt] if gt != -1 else None + text = text[inner_start + 1:] + + if not text.startswith(':'): + return None + if len(text) < 11 or text[10] != ':': + return None + to_call = text[1:10].strip() + body = text[11:] + msg_num = None + if '{' in body: + brace = body.rfind('{') + msg_num = body[brace + 1:] + body = body[:brace] + + return (to_call, body, msg_num, ack_call) def receive_unproto_text(self, call_from, text): """ @@ -1706,7 +1687,7 @@ def receive_unproto_text(self, call_from, text): this screen can pick out APRS messages addressed to us. """ my_call = config.get('AprsMessages', 'source') or config.get('Setup', 'callsign') or '' - parsed = _parse_aprs_message(text, call_from) + parsed = self._parse_aprs_message(text, call_from) if parsed is None: return to_call, body, msg_num, call_from = parsed @@ -1758,8 +1739,12 @@ def _send(self, widget, text): if not self._valid_config(src, to): self.add_line(('aprs_error', 'APRS config is invalid (source and to are required)')) return - msg_num = _next_aprs_msg_num() - payload = _format_aprs_message(to, text, msg_num) + + self._aprs_msg_counter = (self._aprs_msg_counter % 999) + 1 + return str(_aprs_msg_counter) + + payload = ':{:<9}:{}{{{}'.format(to, text, self._aprs_msg_counter) + vias = via.split() if via else None try: app.server.send_unproto(port, src, dst, payload, vias) @@ -1767,7 +1752,7 @@ def _send(self, widget, text): self.add_line(('aprs_error', 'AGWPE server has disconnected')) app.server_disappeared() return - self.add_line(('aprs_outbound', 'To {} [{}]: {}'.format(to, msg_num, text))) + self.add_line(('aprs_outbound', 'To {} [{}]: {}'.format(to, self._aprs_msg_counter, text))) self._last_sent = text def _valid_config(self, src, to): From edc7d985c8fb577d7972382012f2f0a4ccaeb175 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 17:56:26 -0700 Subject: [PATCH 24/37] More cleanup --- paracon/paracon.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 93bf99a..8684f9f 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1609,11 +1609,10 @@ def save(self): class AprsScreen(urwid.WidgetWrap): """ A dedicated screen for APRS direct messages. Messages are sent as unproto - UI frames addressed to APICON (the conventional APRS destination) with the + UI frames addressed to configured APRS destination with the payload formatted per the APRS messaging spec. Incoming APRS messages visible in the monitor queue are displayed here as well. """ - class MenuCommand(Enum): CONFIGURE = 'Dest/Src' @@ -1623,8 +1622,7 @@ def __init__(self, mwin): self._last_sent = '' self._menubar = FixedMenuBar(self.MenuCommand) self._set_info() - urwid.connect_signal( - self._menubar.menu, 'select', self._handle_menu_command) + urwid.connect_signal(self._menubar.menu, 'select', self._handle_menu_command) self._log = urwidx.LoggingDequeListWalker([]) self._list = SizeListBox(self._log) self._entry = urwidx.LineEntry(caption="> ", edit_text="") @@ -1650,7 +1648,7 @@ def __init__(self, mwin): def _parse_aprs_message(self, text, from_call): """ Returns (to_call, msg_body, msg_num, ack_call) where ack_call is the - station to send the ack to. For normal messages, ack_call == to_call. + station to send the ack to. For normal messages, ack_call == from_call. For third-party messages, ack_call is the originating station from the third-party header. """ From ea702bd6d09d4878f0388d10535e6683968f5915 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 17:59:58 -0700 Subject: [PATCH 25/37] fix bug --- paracon/paracon.py | 1 - 1 file changed, 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 8684f9f..651db74 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1739,7 +1739,6 @@ def _send(self, widget, text): return self._aprs_msg_counter = (self._aprs_msg_counter % 999) + 1 - return str(_aprs_msg_counter) payload = ':{:<9}:{}{{{}'.format(to, text, self._aprs_msg_counter) From 1ec45cc6b97cbcf776f1229691cfcc1bda02dcde Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 18:05:54 -0700 Subject: [PATCH 26/37] fix bug --- paracon/paracon.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 651db74..9b71c5a 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1601,11 +1601,18 @@ def save(self): info = self.UnprotoInfo(src, dst, ' '.join(vias), port) urwid.emit_signal(self, 'unproto_info', info) - - # ============================================================================= # APRS Messages # ============================================================================= +# Some Notes: +# +# Sending an email or SMS results in multiple ack messages being sent as the +# confirmation message is sent by multiple IGATEs. It is tempting to put in +# code to block this, but it may miss a true retry in a direct message +# transactgion. +# +# Its also temping to put in a retry mechanism for message send, but instead I +# opted for allowing a simple up key to retrieve last message for manual resend. class AprsScreen(urwid.WidgetWrap): """ A dedicated screen for APRS direct messages. Messages are sent as unproto From e167eae297436bdfb5095f0ca549a5ac3d65a9a8 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 18:13:55 -0700 Subject: [PATCH 27/37] Back out update line parsing --- paracon/paracon.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 9b71c5a..ca4e374 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -707,26 +707,6 @@ def _gather_lines(self, data): for part in parts: self.add_line(self._decode_line(part)) - def _gather_lines_v2(self, data): - - if not isinstance(data, str): - try: - data = data.decode('utf-8') - except Exception: - data = "" - - data = data.replace('\r\n', '\r').replace('\n', '\r') - parts = data.split('\r') - - if len(self._line_remains): - parts[0] = self._line_remains + parts[0] - self._line_remains = '' - if data[-1] != '\r': - self._line_remains = parts[-1] - del parts[-1] - for part in parts: - self.add_line(part) - def add_line(self, line): text = urwid.Text(line) if type(line) is str: From f7e3d735f48fbef30758d5125596d385af81dee3 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 18:43:26 -0700 Subject: [PATCH 28/37] Clean up menus --- paracon/paracon.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index ca4e374..e692d82 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -443,7 +443,7 @@ def keypress(self, size, key): key = super().keypress(size, key) if key: # Up arrow recalls the last sent message into the entry field. - if key == 'up' and self._last_sent and not self._entry.get_edit_text(): + if key == 'shift up' and self._last_sent and not self._entry.get_edit_text(): self._entry.set_edit_text(self._last_sent) self._entry.set_edit_pos(len(self._last_sent)) return None @@ -1592,7 +1592,7 @@ def save(self): # transactgion. # # Its also temping to put in a retry mechanism for message send, but instead I -# opted for allowing a simple up key to retrieve last message for manual resend. +# opted for allowing a simple shift up key to retrieve last message for manual resend. class AprsScreen(urwid.WidgetWrap): """ A dedicated screen for APRS direct messages. Messages are sent as unproto @@ -1804,7 +1804,7 @@ def keypress(self, size, key): key = super().keypress(size, key) if key: # Up arrow recalls the last sent message into the entry field. - if key == 'up' and self._last_sent and not self._entry.get_edit_text(): + if key == 'shift up' and self._last_sent and not self._entry.get_edit_text(): self._entry.set_edit_text(self._last_sent) self._entry.set_edit_pos(len(self._last_sent)) return None @@ -1851,17 +1851,17 @@ def add_fields(self): avail_ports = app.ports.port_info self.add_group('dest', "Send To") self.add_edit_str_field( - 'to', 'To (callsign)', group='dest', value=to, + 'to', ' To', group='dest', value=to, filter=callsign_filter) self.add_edit_str_field( - 'dst', ' Destination', group='dest', value=dst, + 'dst', 'Destination', group='dest', value=dst, filter=callsign_filter) self.add_edit_str_field( - 'via', ' Via', group='dest', value=via, + 'via', ' Via', group='dest', value=via, filter=via_filter) self.add_group('source', "Send Using") self.add_edit_str_field( - 'src', 'My call', group='source', value=src, + 'src', 'Source', group='source', value=src, filter=callsign_filter) self.add_dropdown_field( 'port', ' Port', avail_ports, port_ix, group='source') From 6043cc5a1cb43be54fc421d599c0155e31e2c087 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 18:45:40 -0700 Subject: [PATCH 29/37] Clean up menus --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index e692d82..0563fc6 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1864,7 +1864,7 @@ def add_fields(self): 'src', 'Source', group='source', value=src, filter=callsign_filter) self.add_dropdown_field( - 'port', ' Port', avail_ports, port_ix, group='source') + 'port', ' Port', avail_ports, port_ix, group='source') def validate(self): src = self.get_edit_str_value('src') From 3728fa15fb9248b5c4a998153f4db0a013932ee2 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 18:47:56 -0700 Subject: [PATCH 30/37] add number to ack debug --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 0563fc6..d765742 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1699,7 +1699,7 @@ def _send_ack(self, to_call, msg_num): vias = via.split() if via else None try: app.server.send_unproto(port, src, dst, ack_text, vias) - self.add_line(('aprs_ack', 'ACK sent to {}'.format(to_call))) + self.add_line(('aprs_ack', 'ACK [{}] sent to {}'.format(msg_num,to_call))) except BrokenPipeError: self.add_line(('aprs_error', 'AGWPE server has disconnected')) app.server_disappeared() From f657df59651c4feddb6b96eecb8de3fb87e1c221 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 19:25:57 -0700 Subject: [PATCH 31/37] Change display messages --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index d765742..15b245c 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1681,7 +1681,7 @@ def receive_unproto_text(self, call_from, text): num_str = ' {{{}}}'.format(msg_num) if msg_num else '' self.add_line( ('aprs_inbound', - 'From {}: {}{}'.format(call_from, body, num_str))) + 'From {} [{}]: {}'.format(call_from, num_str, body))) # Send an ACK if we have a message number and a configured source if msg_num and my_call and app.server: self._send_ack(call_from, msg_num) From 59915687566f94d8281f9058c25952ed19707953 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 19:43:45 -0700 Subject: [PATCH 32/37] Fixed message number --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 15b245c..b362e38 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1681,7 +1681,7 @@ def receive_unproto_text(self, call_from, text): num_str = ' {{{}}}'.format(msg_num) if msg_num else '' self.add_line( ('aprs_inbound', - 'From {} [{}]: {}'.format(call_from, num_str, body))) + 'From {} [{}]: {}'.format(call_from, msg_num, body))) # Send an ACK if we have a message number and a configured source if msg_num and my_call and app.server: self._send_ack(call_from, msg_num) From fe7b902217671ce42a41cbd6b840bec3ce42217b Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 20:22:08 -0700 Subject: [PATCH 33/37] Change To to Dest --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index b362e38..7f906df 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -476,7 +476,7 @@ def _set_info(self): src = config.get('Setup', 'callsign') dst = config.get('Unproto', 'destination') via = config.get('Unproto', 'via') - text = "From: {} To: {} ".format(src, dst) + text = "From: {} Dest: {} ".format(src, dst) if via: # Vias are saved with spaces, but displayed with commas via = ','.join(via.split()) From 1c505147841da398e9a53e533cb0d19d977c0a44 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Sat, 14 Mar 2026 21:14:14 -0700 Subject: [PATCH 34/37] Add rstrip --- paracon/paracon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 7f906df..4ca8d60 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1657,7 +1657,7 @@ def _parse_aprs_message(self, text, from_call): if len(text) < 11 or text[10] != ':': return None to_call = text[1:10].strip() - body = text[11:] + body = text[11:].rstrip() msg_num = None if '{' in body: brace = body.rfind('{') From 374f1d2978b156449b8de171c657f42b3020ed51 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Mon, 16 Mar 2026 22:41:48 -0700 Subject: [PATCH 35/37] Remove displaying duplicate messages: --- paracon/paracon.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index 4ca8d60..cccd952 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -1607,6 +1607,8 @@ def __init__(self, mwin): self._aprs_msg_counter = 0 self._mon = mwin self._last_sent = '' + self._seen_msg_ids = set() + self._seen_acks = set() self._menubar = FixedMenuBar(self.MenuCommand) self._set_info() urwid.connect_signal(self._menubar.menu, 'select', self._handle_menu_command) @@ -1676,12 +1678,26 @@ def receive_unproto_text(self, call_from, text): if parsed is None: return to_call, body, msg_num, call_from = parsed + # Deduplicate received ACK frames (body "ackNNN", no msg_num) + if not msg_num and body.startswith('ack'): + ack_key = (call_from.upper(), body[3:]) + if ack_key in self._seen_acks: + return + if len(self._seen_acks) > 200: + self._seen_acks.clear() + self._seen_acks.add(ack_key) # Display if addressed to us or if our callsign is not configured if not my_call or to_call.upper() == my_call.upper(): - num_str = ' {{{}}}'.format(msg_num) if msg_num else '' - self.add_line( - ('aprs_inbound', - 'From {} [{}]: {}'.format(call_from, msg_num, body))) + msg_id = (call_from.upper(), msg_num) if msg_num else None + is_duplicate = msg_id is not None and msg_id in self._seen_msg_ids + if not is_duplicate: + self.add_line( + ('aprs_inbound', + 'From {} [{}]: {}'.format(call_from, msg_num, body))) + if msg_id is not None: + if len(self._seen_msg_ids) > 200: + self._seen_msg_ids.clear() + self._seen_msg_ids.add(msg_id) # Send an ACK if we have a message number and a configured source if msg_num and my_call and app.server: self._send_ack(call_from, msg_num) @@ -1699,7 +1715,12 @@ def _send_ack(self, to_call, msg_num): vias = via.split() if via else None try: app.server.send_unproto(port, src, dst, ack_text, vias) - self.add_line(('aprs_ack', 'ACK [{}] sent to {}'.format(msg_num,to_call))) + ack_key = (to_call.upper(), msg_num) + if ack_key not in self._seen_acks: + self.add_line(('aprs_ack', 'ACK [{}] sent to {}'.format(msg_num, to_call))) + if len(self._seen_acks) > 200: + self._seen_acks.clear() + self._seen_acks.add(ack_key) except BrokenPipeError: self.add_line(('aprs_error', 'AGWPE server has disconnected')) app.server_disappeared() From d935ced9dc49ac42c46e41768634cf414e56d6f2 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Thu, 19 Mar 2026 23:57:29 -0700 Subject: [PATCH 36/37] Update user guide to document new features in new_window branch - Add APRS Messages section covering send/receive, ACKs, and configuration - Document Shift-Up recall keybinding in Unproto and APRS Messages - Note monitor highlighting of own transmitted frames - Add aprs_messages.log to the Logging section - Add Shift-Up to the Navigation Cheat Sheet Co-Authored-By: Claude Sonnet 4.6 --- docs/userguide.rst | 50 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/docs/userguide.rst b/docs/userguide.rst index 44d2295..4a2b483 100644 --- a/docs/userguide.rst +++ b/docs/userguide.rst @@ -81,6 +81,10 @@ Navigation - Alt-\- or Alt-r removes a connection tab - Alt- switches to the numbered tab + **Unproto / APRS Messages** + + - Shift-Up recalls the last sent message into the entry field + Connections ----------- @@ -133,7 +137,8 @@ system, is also shown in this panel. The Monitor panel shows all traffic seen on the AGWPE port. This includes the traffic from your connected-mode session, and also any other traffic seen on -the same frequency. +the same frequency. Frames that you transmitted yourself are highlighted in a +distinct color so that your own traffic is easy to pick out at a glance. Managing connections ~~~~~~~~~~~~~~~~~~~~ @@ -161,7 +166,9 @@ messages too. :alt: Unproto window Whatever you enter on the text entry line at the bottom will be sent out when -you hit the Return or Enter key. +you hit the Return or Enter key. If you need to resend or edit a previous +message, press Shift-Up when the entry field is empty to recall the last sent +message. The indicator on the bottom right shows the current configuration that will be used for each message sent. To change this, use the Dest/Src command to bring @@ -185,6 +192,41 @@ server. Click on the down-arrow to open the list. In many cases, you will have only one available port, and can leave this field as it is. If your server provides multiple ports, you can select the appropriate one here. +APRS Messages +------------- + +The Messages window provides a dedicated interface for sending and receiving +APRS direct messages. It is accessible from the top menu once you are connected +to your AGWPE server. + +APRS messages are sent as Unproto UI frames addressed to the configured APRS +destination (``APICON`` by default), with the payload formatted according to +the APRS messaging specification. Incoming APRS messages are picked up from +the monitor stream and displayed here automatically. + +To configure the source callsign, destination, recipient callsign (``To``), +via path, and port, use the Dest/Src command to bring up the APRS Messages +dialog. + +The status bar at the bottom of the window shows the current configuration: +From, Dest, and To callsigns, and any Via path. + +**Sending messages** + +Type your message in the entry field and press Return or Enter to send it. +Each message is assigned a sequential message number. Sent messages are shown +in yellow; received messages are shown in cyan. + +If you need to resend or edit a previous message, press Shift-Up when the +entry field is empty to recall the last sent message. + +**Receiving messages and acknowledgements** + +Paracon automatically sends an acknowledgement (ACK) for every incoming APRS +message that is addressed to your configured source callsign and that carries +a message number. Duplicate messages (same sender and message number) are +suppressed. ACKs sent are shown in green; error conditions are shown in red. + .. _settings: Settings @@ -283,3 +325,7 @@ monitor.log Contains the exchange that occurs during a connection between the two stations of the filename. This is the same information that you see in the connection tab during a connected-mode session. + +aprs_messages.log + Contains the same information as the APRS Messages window, preserving + sent and received messages across Paracon sessions. From b9d037fd88198f43ccb41f809bc30adbe5845273 Mon Sep 17 00:00:00 2001 From: Ryan Herbst Date: Tue, 31 Mar 2026 19:17:33 -0700 Subject: [PATCH 37/37] Add [HH:MM:SS] timestamps to APRS message window Timestamps are prepended to received messages, sent messages, and ACK notifications in the APRS Messages window. Co-Authored-By: Claude Sonnet 4.6 --- paracon/paracon.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/paracon/paracon.py b/paracon/paracon.py index cccd952..45b9c52 100644 --- a/paracon/paracon.py +++ b/paracon/paracon.py @@ -10,6 +10,7 @@ import argparse import codecs +import datetime from enum import Enum import logging import pathlib @@ -1691,9 +1692,10 @@ def receive_unproto_text(self, call_from, text): msg_id = (call_from.upper(), msg_num) if msg_num else None is_duplicate = msg_id is not None and msg_id in self._seen_msg_ids if not is_duplicate: + ts = datetime.datetime.now().strftime('[%H:%M:%S]') self.add_line( ('aprs_inbound', - 'From {} [{}]: {}'.format(call_from, msg_num, body))) + '{} From {} [{}]: {}'.format(ts, call_from, msg_num, body))) if msg_id is not None: if len(self._seen_msg_ids) > 200: self._seen_msg_ids.clear() @@ -1717,7 +1719,8 @@ def _send_ack(self, to_call, msg_num): app.server.send_unproto(port, src, dst, ack_text, vias) ack_key = (to_call.upper(), msg_num) if ack_key not in self._seen_acks: - self.add_line(('aprs_ack', 'ACK [{}] sent to {}'.format(msg_num, to_call))) + ts = datetime.datetime.now().strftime('[%H:%M:%S]') + self.add_line(('aprs_ack', '{} ACK [{}] sent to {}'.format(ts, msg_num, to_call))) if len(self._seen_acks) > 200: self._seen_acks.clear() self._seen_acks.add(ack_key) @@ -1757,7 +1760,8 @@ def _send(self, widget, text): self.add_line(('aprs_error', 'AGWPE server has disconnected')) app.server_disappeared() return - self.add_line(('aprs_outbound', 'To {} [{}]: {}'.format(to, self._aprs_msg_counter, text))) + ts = datetime.datetime.now().strftime('[%H:%M:%S]') + self.add_line(('aprs_outbound', '{} To {} [{}]: {}'.format(ts, to, self._aprs_msg_counter, text))) self._last_sent = text def _valid_config(self, src, to):