From be20f94bc35ac419faabd8c829b148b30577ec47 Mon Sep 17 00:00:00 2001 From: pyopower Date: Thu, 4 Jun 2026 21:38:54 +0200 Subject: [PATCH 1/2] Talker Alias: configurable text encoding (utf8 / iso8 / 7bit) Adds encode_iso8 (format 1 / ISO-8859-1) and encode_7bit (format 0) alongside the existing UTF-8 (format 2) encoder, selectable via a new GLOBAL/per-system TALKER_ALIAS_TEXT_FORMAT key (default 'utf8', so existing behaviour is unchanged). ISO-8 is rendered by radios that do not display UTF-8 Talker Alias (notably many Hytera models, which show UTF-8 TA blank/garbled while Motorola shows it fine); 7-bit is the exact inverse of decode_ta()'s 7-bit path for the oldest radios. Round-trip validated (encode_ta_buffer -> decode_ta) for all three formats, including ISO-8859-1 accents. Co-Authored-By: Claude Opus 4.8 --- config.py | 1 + config/ADN-SAMPLE-commented.cfg | 4 +- talker_alias.py | 71 ++++++++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/config.py b/config.py index a1be2fd..b23d7e8 100755 --- a/config.py +++ b/config.py @@ -162,6 +162,7 @@ def build_config(_config_file): 'TALKER_ALIAS': config.getboolean(section, 'TALKER_ALIAS', fallback=False), 'TALKER_ALIAS_MODE': config.get(section, 'TALKER_ALIAS_MODE', fallback='both'), 'TALKER_ALIAS_FORMAT': config.get(section, 'TALKER_ALIAS_FORMAT', fallback='{callsign} {fname}'), + 'TALKER_ALIAS_TEXT_FORMAT': config.get(section, 'TALKER_ALIAS_TEXT_FORMAT', fallback='utf8'), }) if not CONFIG['GLOBAL']['ANNOUNCEMENT_LANGUAGES']: CONFIG['GLOBAL']['ANNOUNCEMENT_LANGUAGES'] = languages diff --git a/config/ADN-SAMPLE-commented.cfg b/config/ADN-SAMPLE-commented.cfg index ce4f959..f951d71 100755 --- a/config/ADN-SAMPLE-commented.cfg +++ b/config/ADN-SAMPLE-commented.cfg @@ -55,10 +55,12 @@ VALIDATE_SERVER_IDS: True # TALKER_ALIAS - True para activar el reenvio/insercion de Talker Alias (DMRA + LC embebida) # TALKER_ALIAS_MODE - passthrough (solo reenvia el TA recibido), inject (genera el TA desde el alias del suscriptor) # o both (reenvia si llega completo, si no lo genera). Por defecto: both -# TALKER_ALIAS_FORMAT - plantilla del texto generado en modo inject/both. Campos: {callsign} {fname} {surname} {id} +# TALKER_ALIAS_FORMAT - plantilla del texto generado en modo inject/both. Campos: {callsign} {fname} {surname} {city} {state} {country} {id} +# TALKER_ALIAS_TEXT_FORMAT - codificacion del TA: utf8 (defecto), iso8 (ISO-8859-1, recomendado si hay radios Hytera) o 7bit TALKER_ALIAS: False TALKER_ALIAS_MODE: both TALKER_ALIAS_FORMAT: {callsign} {fname} +TALKER_ALIAS_TEXT_FORMAT: utf8 # Servidor de seguridad centralizado URL_SECURITY: diff --git a/talker_alias.py b/talker_alias.py index af6a98a..982ccf8 100644 --- a/talker_alias.py +++ b/talker_alias.py @@ -91,6 +91,53 @@ def encode_utf8(text): return bytes(buf) +def encode_iso8(text): + """Encode text into a 28-byte TA buffer (format 1 / ISO-8859-1). + + More widely displayed than UTF-8 on some radios (notably Hytera, which often + will not render format 2 / UTF-8 Talker Alias).""" + text = truncate_talker_alias(text) + raw = text.encode('latin-1', errors='replace')[:27] + size = len(raw) + header = (TA_FORMAT_ISO8 << 6) | (size << 1) | 0 + buf = bytearray(DMRA_BUF_LEN) + buf[0] = header + buf[1:1 + size] = raw + return bytes(buf) + + +def encode_7bit(text): + """Encode text into a 28-byte TA buffer (format 0 / 7-bit packed). + + Bit stream is format(2) + size(5) + 7 bits per char, MSB-first; this is the + exact inverse of decode_ta()'s 7-bit path. For the oldest radios.""" + text = truncate_talker_alias(text) + chars = text.encode('ascii', errors='replace')[:31] + size = len(chars) + bits = [(TA_FORMAT_7BIT >> 1) & 1, TA_FORMAT_7BIT & 1] + for b in range(4, -1, -1): + bits.append((size >> b) & 1) + for ch in chars: + for b in range(6, -1, -1): + bits.append((ch >> b) & 1) + total = DMRA_BUF_LEN * 8 + bits = (bits + [0] * total)[:total] + buf = bytearray(DMRA_BUF_LEN) + for i, bit in enumerate(bits): + if bit: + buf[i // 8] |= 1 << (7 - (i % 8)) + return bytes(buf) + + +_TA_ENCODERS = {'utf8': encode_utf8, 'iso8': encode_iso8, '7bit': encode_7bit} + + +def encode_ta_buffer(text, text_format='utf8'): + """Encode text into a 28-byte TA buffer in the requested format. + text_format: 'utf8' (default), 'iso8' (ISO-8859-1, best for Hytera), '7bit'.""" + return _TA_ENCODERS.get(text_format, encode_utf8)(text) + + def blocks_from_buffer(buf): """Split a 28-byte encoded buffer into four 7-byte DMRA payloads.""" buf = buf.ljust(DMRA_BUF_LEN, b'\x00')[:DMRA_BUF_LEN] @@ -121,10 +168,10 @@ def buffer_from_blocks(blocks): # Standalone DMRA packet builders / parser # --------------------------------------------------------------------------- -def build_dmra_packets(rf_src, text): +def build_dmra_packets(rf_src, text, text_format='utf8'): """Build the 1-4 HBP DMRA packets needed to carry 'text' (server injection).""" rf = rf_src[:3] if len(rf_src) >= 3 else rf_src.ljust(3, b'\x00')[:3] - encoded = encode_utf8(text) + encoded = encode_ta_buffer(text, text_format) blocks = blocks_from_buffer(encoded) count = required_ta_block_count(encoded) packets = [] @@ -199,9 +246,9 @@ def _encode_emblc(_lc): return {1: out[0:32], 2: out[32:64], 3: out[64:96], 4: out[96:128]} -def encode_talker_alias_emblc(text): +def encode_talker_alias_emblc(text, text_format='utf8'): """Embedded-LC dicts for TA blocks 0..N-1 plus block count N (1-4).""" - encoded = encode_utf8(text) + encoded = encode_ta_buffer(text, text_format) blocks = blocks_from_buffer(encoded) count = required_ta_block_count(encoded) emblcs = [_encode_emblc(talker_alias_lc_bytes(i, blocks[i])) for i in range(count)] @@ -263,7 +310,13 @@ def ta_settings(CONFIG, system_name=None): fmt = sys_cfg.get('TALKER_ALIAS_FORMAT') if fmt is None: fmt = g.get('TALKER_ALIAS_FORMAT', '{callsign} {fname}') - return {'enabled': bool(enabled), 'mode': mode, 'format': str(fmt)} + tfmt = sys_cfg.get('TALKER_ALIAS_TEXT_FORMAT') + if tfmt is None: + tfmt = g.get('TALKER_ALIAS_TEXT_FORMAT', 'utf8') + tfmt = str(tfmt).lower() + if tfmt not in ('utf8', 'iso8', '7bit'): + tfmt = 'utf8' + return {'enabled': bool(enabled), 'mode': mode, 'format': str(fmt), 'text_format': tfmt} def ta_enabled(CONFIG, system_name=None): @@ -351,13 +404,13 @@ def resolve_ta(CONFIG, system_name, rf_src, blocks): 'text': decode_ta(buffer_from_blocks(blocks))} if mode == 'inject': return {'source': 'inject', 'blocks': None, - 'text': format_ta_text(CONFIG, rf_src)} + 'text': format_ta_text(CONFIG, rf_src), 'text_format': settings['text_format']} # both if have_passthrough: return {'source': 'passthrough', 'blocks': dict(blocks), 'text': decode_ta(buffer_from_blocks(blocks))} return {'source': 'inject', 'blocks': None, - 'text': format_ta_text(CONFIG, rf_src)} + 'text': format_ta_text(CONFIG, rf_src), 'text_format': settings['text_format']} def ta_dmra_packets(rf_src, resolved): @@ -366,7 +419,7 @@ def ta_dmra_packets(rf_src, resolved): return [] if resolved['source'] == 'passthrough': return passthrough_packets_from_blocks(rf_src, resolved['blocks']) - return build_dmra_packets(rf_src, resolved['text']) + return build_dmra_packets(rf_src, resolved['text'], resolved.get('text_format', 'utf8')) def ta_emblc(resolved): @@ -375,7 +428,7 @@ def ta_emblc(resolved): return None if resolved['source'] == 'passthrough': return encode_talker_alias_emblc_from_blocks(resolved['blocks']) - return encode_talker_alias_emblc(resolved['text']) + return encode_talker_alias_emblc(resolved['text'], resolved.get('text_format', 'utf8')) # --------------------------------------------------------------------------- From 4743d3e803f705c439b964d55569fadc1687c58a Mon Sep 17 00:00:00 2001 From: pyopower Date: Thu, 4 Jun 2026 22:03:45 +0200 Subject: [PATCH 2/2] Talker Alias: allow multiple text encodings (Motorola + Hytera coexistence) TALKER_ALIAS_TEXT_FORMAT now accepts a comma-separated list (e.g. "utf8,iso8"). encode_talker_alias_emblc emits the TA blocks in each requested encoding back to back, and rewrite_embed_lc cycles through them, so radios of different vendors each pick up the format they support in turn. Rationale: in field testing, Motorola only displayed format 2 (UTF-8) and Hytera only format 1 (ISO-8859-1) -- mutually exclusive in a single TA. Sending both ("utf8,iso8") makes the alias show on both at once. Confirmed on real Motorola and Hytera radios. Co-Authored-By: Claude Opus 4.8 --- config/ADN-SAMPLE-commented.cfg | 4 +++- talker_alias.py | 41 +++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/config/ADN-SAMPLE-commented.cfg b/config/ADN-SAMPLE-commented.cfg index f951d71..b9fb833 100755 --- a/config/ADN-SAMPLE-commented.cfg +++ b/config/ADN-SAMPLE-commented.cfg @@ -56,7 +56,9 @@ VALIDATE_SERVER_IDS: True # TALKER_ALIAS_MODE - passthrough (solo reenvia el TA recibido), inject (genera el TA desde el alias del suscriptor) # o both (reenvia si llega completo, si no lo genera). Por defecto: both # TALKER_ALIAS_FORMAT - plantilla del texto generado en modo inject/both. Campos: {callsign} {fname} {surname} {city} {state} {country} {id} -# TALKER_ALIAS_TEXT_FORMAT - codificacion del TA: utf8 (defecto), iso8 (ISO-8859-1, recomendado si hay radios Hytera) o 7bit +# TALKER_ALIAS_TEXT_FORMAT - codificacion(es) del TA, separadas por coma: utf8 (lo muestra Motorola), +# iso8 (ISO-8859-1, lo muestra Hytera), 7bit. Una lista como 'utf8,iso8' emite el +# TA en ambos formatos seguidos, asi Motorola y Hytera lo ven a la vez. Defecto: utf8 TALKER_ALIAS: False TALKER_ALIAS_MODE: both TALKER_ALIAS_FORMAT: {callsign} {fname} diff --git a/talker_alias.py b/talker_alias.py index 982ccf8..23dfe91 100644 --- a/talker_alias.py +++ b/talker_alias.py @@ -246,13 +246,20 @@ def _encode_emblc(_lc): return {1: out[0:32], 2: out[32:64], 3: out[64:96], 4: out[96:128]} -def encode_talker_alias_emblc(text, text_format='utf8'): - """Embedded-LC dicts for TA blocks 0..N-1 plus block count N (1-4).""" - encoded = encode_ta_buffer(text, text_format) - blocks = blocks_from_buffer(encoded) - count = required_ta_block_count(encoded) - emblcs = [_encode_emblc(talker_alias_lc_bytes(i, blocks[i])) for i in range(count)] - return emblcs, count +def encode_talker_alias_emblc(text, text_formats=('utf8',)): + """Embedded-LC dicts for the TA blocks. `text_formats` may be a single + encoding or several; with several, the TA is emitted in each encoding back to + back (e.g. UTF-8 then ISO-8859-1) so radios of different vendors each pick up + the format they support. Returns (list-of-emblc-dicts, total-count).""" + if isinstance(text_formats, str): + text_formats = [text_formats] + emblcs = [] + for tf in (list(text_formats) or ['utf8']): + encoded = encode_ta_buffer(text, tf) + blocks = blocks_from_buffer(encoded) + cnt = required_ta_block_count(encoded) + emblcs += [_encode_emblc(talker_alias_lc_bytes(i, blocks[i])) for i in range(cnt)] + return emblcs, len(emblcs) def encode_talker_alias_emblc_from_blocks(blocks): @@ -313,10 +320,14 @@ def ta_settings(CONFIG, system_name=None): tfmt = sys_cfg.get('TALKER_ALIAS_TEXT_FORMAT') if tfmt is None: tfmt = g.get('TALKER_ALIAS_TEXT_FORMAT', 'utf8') - tfmt = str(tfmt).lower() - if tfmt not in ('utf8', 'iso8', '7bit'): - tfmt = 'utf8' - return {'enabled': bool(enabled), 'mode': mode, 'format': str(fmt), 'text_format': tfmt} + # comma-separated list -> emit the TA in each encoding back to back, so radios + # of different vendors each pick up the format they support (e.g. 'utf8,iso8' + # for Motorola + Hytera coexistence). + formats = [f.strip().lower() for f in str(tfmt).split(',')] + formats = [f for f in formats if f in ('utf8', 'iso8', '7bit')] + if not formats: + formats = ['utf8'] + return {'enabled': bool(enabled), 'mode': mode, 'format': str(fmt), 'text_formats': formats} def ta_enabled(CONFIG, system_name=None): @@ -404,13 +415,13 @@ def resolve_ta(CONFIG, system_name, rf_src, blocks): 'text': decode_ta(buffer_from_blocks(blocks))} if mode == 'inject': return {'source': 'inject', 'blocks': None, - 'text': format_ta_text(CONFIG, rf_src), 'text_format': settings['text_format']} + 'text': format_ta_text(CONFIG, rf_src), 'text_formats': settings['text_formats']} # both if have_passthrough: return {'source': 'passthrough', 'blocks': dict(blocks), 'text': decode_ta(buffer_from_blocks(blocks))} return {'source': 'inject', 'blocks': None, - 'text': format_ta_text(CONFIG, rf_src), 'text_format': settings['text_format']} + 'text': format_ta_text(CONFIG, rf_src), 'text_formats': settings['text_formats']} def ta_dmra_packets(rf_src, resolved): @@ -419,7 +430,7 @@ def ta_dmra_packets(rf_src, resolved): return [] if resolved['source'] == 'passthrough': return passthrough_packets_from_blocks(rf_src, resolved['blocks']) - return build_dmra_packets(rf_src, resolved['text'], resolved.get('text_format', 'utf8')) + return build_dmra_packets(rf_src, resolved['text'], (resolved.get('text_formats') or ['utf8'])[0]) def ta_emblc(resolved): @@ -428,7 +439,7 @@ def ta_emblc(resolved): return None if resolved['source'] == 'passthrough': return encode_talker_alias_emblc_from_blocks(resolved['blocks']) - return encode_talker_alias_emblc(resolved['text'], resolved.get('text_format', 'utf8')) + return encode_talker_alias_emblc(resolved['text'], resolved.get('text_formats', ['utf8'])) # ---------------------------------------------------------------------------