Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package chirp for openSUSE:Factory checked in at 2026-09-28 10:48:19 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/chirp (Old) and /work/SRC/openSUSE:Factory/.chirp.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "chirp" Mon Sep 28 10:48:19 2026 rev:81 rq:1381041 version:20260925 Changes: -------- --- /work/SRC/openSUSE:Factory/chirp/chirp.changes 2026-09-21 12:02:40.807882846 +0200 +++ /work/SRC/openSUSE:Factory/.chirp.new.383539/chirp.changes 2026-09-28 10:49:25.151409966 +0200 @@ -1,0 +2,9 @@ +Mon Sep 28 05:36:35 UTC 2026 - Andreas Stieger <[email protected]> + +- Update to version 20260925: + * uvk5: Add 7.01 firmware blessing + * radtel_t18: Add new model identification string + * kenwood memory implementation fix + * Retevis RT18 driver + +------------------------------------------------------------------- Old: ---- chirp-20260918.obscpio New: ---- chirp-20260925.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ chirp.spec ++++++ --- /var/tmp/diff_new_pack.6BkdsR/_old 2026-09-28 10:49:26.090449310 +0200 +++ /var/tmp/diff_new_pack.6BkdsR/_new 2026-09-28 10:49:26.091449352 +0200 @@ -20,7 +20,7 @@ %define pythons python3 Name: chirp -Version: 20260918 +Version: 20260925 Release: 0 Summary: Tool for programming amateur radio sets License: GPL-3.0-only ++++++ _service ++++++ --- /var/tmp/diff_new_pack.6BkdsR/_old 2026-09-28 10:49:26.124450735 +0200 +++ /var/tmp/diff_new_pack.6BkdsR/_new 2026-09-28 10:49:26.127450860 +0200 @@ -4,8 +4,8 @@ <param name="scm">git</param> <param name="changesgenerate">enable</param> <param name="filename">chirp</param> - <param name="versionformat">20260918</param> - <param name="revision">c69f7c11bfb6ff4f01002120e38094056273daa3</param> + <param name="versionformat">20260925</param> + <param name="revision">67f5e10f229769023f83c707b8152355b6d90819</param> </service> <service mode="manual" name="set_version"/> <service name="tar" mode="buildtime"/> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.6BkdsR/_old 2026-09-28 10:49:26.147451698 +0200 +++ /var/tmp/diff_new_pack.6BkdsR/_new 2026-09-28 10:49:26.149451782 +0200 @@ -1,7 +1,7 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/kk7ds/chirp.git</param> - <param name="changesrevision">c69f7c11bfb6ff4f01002120e38094056273daa3</param> + <param name="changesrevision">67f5e10f229769023f83c707b8152355b6d90819</param> </service> </servicedata> (No newline at EOF) ++++++ chirp-20260918.obscpio -> chirp-20260925.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/chirp-20260918/chirp/drivers/kenwood_live.py new/chirp-20260925/chirp/drivers/kenwood_live.py --- old/chirp-20260918/chirp/drivers/kenwood_live.py 2026-09-18 03:31:39.000000000 +0200 +++ new/chirp-20260925/chirp/drivers/kenwood_live.py 2026-09-25 15:30:00.000000000 +0200 @@ -513,59 +513,453 @@ return rf -THG71_STEPS = [5, 6.25, 10, 12.5, 15, 20, 25, 30, 50, 100] +THG71_STEPS = [5.0, 6.25, 10.0, 12.5, 15.0, 20.0, 25.0, 30.0, 50.0, 100.0] +THG71_TONES = tuple(x for x in chirp_common.OLD_TONES if x != 69.3) +THG71_SPECIALS = ["CALL-V", "CALL-U", "Pr"] + \ + ["L%i" % i for i in range(10)] + \ + ["U%i" % i for i in range(10)] +THG71_POWER_LEVELS = ["High", "Low", "Economic Low"] +THG71_POWER_MAP = {0: "High", 2: "Low", 3: "Economic Low"} +THG71_POWER_MAP_REV = {"High": 0, "Low": 2, "Economic Low": 3} + + +def _thg71_code_to_tone(code_str): + if not code_str or not code_str.isdigit(): + return 88.5 + idx = int(code_str) - 1 + if 0 <= idx < len(chirp_common.OLD_TONES): + t = chirp_common.OLD_TONES[idx] + if t != 69.3: + return t + return 88.5 + + +def _thg71_tone_to_code(tone): + if tone not in chirp_common.OLD_TONES or tone == 69.3: + tone = 88.5 + idx = chirp_common.OLD_TONES.index(tone) + return "%02i" % (idx + 1) @directory.register -class THG71Radio(TMV7Radio): +class THG71Radio(KenwoodOldLiveRadio): """Kenwood TH-G71""" MODEL = "TH-G71" + HARDWARE_FLOW = False + + _upper = 199 + _kenwood_split = True + _kenwood_valid_tones = list(chirp_common.OLD_TONES) def get_features(self): - rf = super().get_features() + rf = chirp_common.RadioFeatures() rf.has_tuning_step = True + rf.has_bank = False + rf.has_dtcs = False + rf.has_dtcs_polarity = False + rf.has_mode = False + rf.has_ctone = True + rf.has_settings = True + rf.has_sub_devices = False + rf.can_odd_split = True + rf.valid_modes = ["FM"] + rf.valid_tmodes = ["", "Tone", "TSQL"] + rf.valid_duplexes = ["", "+", "-", "split"] + rf.valid_skips = ["", "S"] rf.valid_tuning_steps = list(THG71_STEPS) + rf.valid_tones = THG71_TONES rf.valid_name_length = 6 - rf.has_sub_devices = False + rf.valid_characters = chirp_common.CHARSET_UPPER_NUMERIC + "-/ " + rf.memory_bounds = (0, 199) + rf.valid_special_chans = list(THG71_SPECIALS) rf.valid_bands = [(118000000, 174000000), (320000000, 470000000), - (800000000, 945000000)] + (800000000, 949000000)] return rf - def _make_mem_spec(self, mem): - spec = ( - "%011i" % mem.freq, - "%X" % THG71_STEPS.index(mem.tuning_step), - "%i" % util.get_dict_rev(DUPLEX, mem.duplex), - "0", - "%i" % (mem.tmode == "Tone"), - "%i" % (mem.tmode == "TSQL"), - "0", - "%02i" % (self._kenwood_valid_tones.index(mem.rtone) + 1), - "000", - "%02i" % (self._kenwood_valid_tones.index(mem.ctone) + 1), - "%09i" % mem.offset, - "%i" % ((mem.skip == "S") and 1 or 0)) - return spec + def _cmd_get_memory(self, number): + chan = ("%03i" % number) if isinstance(number, int) else str(number) + return "MR", "0,0,%s" % chan + + def _cmd_get_memory_name(self, number): + chan = ("%03i" % number) if isinstance(number, int) else str(number) + return "MNA", "0,%s" % chan + + def _cmd_get_split(self, number): + chan = ("%03i" % number) if isinstance(number, int) else str(number) + return "MR", "0,1,%s" % chan + + def _cmd_set_memory(self, number, spec): + chan = ("%03i" % number) if isinstance(number, int) else str(number) + if spec: + return "MW", "0,0,%s,%s" % (chan, spec) + return "MW", "0,0,%s" % chan + + def _cmd_set_memory_name(self, number, name): + chan = ("%03i" % number) if isinstance(number, int) else str(number) + return "MNA", "0,%s,%s" % (chan, name) + + def _cmd_set_split(self, number, spec): + chan = ("%03i" % number) if isinstance(number, int) else str(number) + return "MW", "0,1,%s,%s" % (chan, spec) def _parse_mem_spec(self, spec): mem = chirp_common.Memory() - mem.number = int(spec[2]) + chan_str = spec[2] + if chan_str.isdigit(): + mem.number = int(chan_str) + else: + mem.number = 0 + mem.extd_number = chan_str mem.freq = int(spec[3]) - mem.tuning_step = THG71_STEPS[int(spec[4], 16)] - mem.duplex = DUPLEX[int(spec[5])] - if int(spec[7]): + step_idx = int(spec[4], 16) if spec[4] else 0 + if 0 <= step_idx < len(THG71_STEPS): + mem.tuning_step = THG71_STEPS[step_idx] + else: + mem.tuning_step = THG71_STEPS[0] + shift_idx = int(spec[5]) if spec[5] else 0 + mem.duplex = DUPLEX.get(shift_idx, "") + if len(spec) > 7 and int(spec[7]): mem.tmode = "Tone" - elif int(spec[8]): + elif len(spec) > 8 and int(spec[8]): mem.tmode = "TSQL" - mem.rtone = self._kenwood_valid_tones[int(spec[10]) - 1] - mem.ctone = self._kenwood_valid_tones[int(spec[12]) - 1] - if spec[13]: + else: + mem.tmode = "" + mem.rtone = _thg71_code_to_tone(spec[10]) if len(spec) > 10 else 88.5 + mem.ctone = _thg71_code_to_tone(spec[12]) if len(spec) > 12 else 88.5 + if len(spec) > 13 and spec[13] and spec[13].isdigit(): mem.offset = int(spec[13]) else: mem.offset = 0 + if len(spec) > 14 and spec[14] == "1": + mem.skip = "S" + else: + mem.skip = "" + return mem + + def _parse_call_spec(self, band, spec): + mem = chirp_common.Memory() + mem.number = 0 + mem.extd_number = "CALL-V" if band == 0 else "CALL-U" + mem.freq = int(spec[2]) + step_idx = int(spec[3], 16) if spec[3] else 0 + if 0 <= step_idx < len(THG71_STEPS): + mem.tuning_step = THG71_STEPS[step_idx] + else: + mem.tuning_step = THG71_STEPS[0] + shift_idx = int(spec[4]) if spec[4] else 0 + mem.duplex = DUPLEX.get(shift_idx, "") + if len(spec) > 6 and int(spec[6]): + mem.tmode = "Tone" + elif len(spec) > 7 and int(spec[7]): + mem.tmode = "TSQL" + else: + mem.tmode = "" + mem.rtone = _thg71_code_to_tone(spec[9]) if len(spec) > 9 else 88.5 + mem.ctone = _thg71_code_to_tone(spec[11]) if len(spec) > 11 else 88.5 + if len(spec) > 12 and spec[12] and spec[12].isdigit(): + mem.offset = int(spec[12]) + else: + mem.offset = 0 + mem.skip = "" + return mem + + def _make_mem_spec(self, mem, is_call=False): + if mem.tuning_step in THG71_STEPS: + step_idx = THG71_STEPS.index(mem.tuning_step) + else: + step_idx = 0 + if mem.duplex in ["", "+", "-"]: + duplex_code = util.get_dict_rev(DUPLEX, mem.duplex) + offset = mem.offset + elif mem.duplex == "split": + duplex_code = 0 + offset = 0 + else: + duplex_code = 0 + offset = 0 + + spec = [ + "%011i" % mem.freq, + "%X" % step_idx, + "%i" % duplex_code, + "0", + "%i" % (mem.tmode == "Tone"), + "%i" % (mem.tmode == "TSQL"), + "", + _thg71_tone_to_code(mem.rtone), + "", + _thg71_tone_to_code(mem.ctone), + "%09i" % offset, + ] + if not is_call: + spec.append("%i" % (1 if mem.skip == "S" else 0)) + return tuple(spec) + + def _parse_split_spec(self, mem, spec): + mem.duplex = "split" + mem.offset = int(spec[3]) + + def _make_split_spec(self, mem): + if mem.tuning_step in THG71_STEPS: + step_idx = THG71_STEPS.index(mem.tuning_step) + else: + step_idx = 0 + return ("%011i" % mem.offset, "%X" % step_idx) + + def get_memory(self, number): + if isinstance(number, str): + if number not in THG71_SPECIALS: + raise errors.InvalidMemoryLocation( + "Special channel %s is not valid" % number) + mem_key = number + else: + if number < 0 or number > self._upper: + raise errors.InvalidMemoryLocation( + "Number must be between 0 and %i" % self._upper) + mem_key = number + + if mem_key in self._memcache and not NOCACHE: + return self._memcache[mem_key] + + is_call = False + call_band = 0 + if mem_key == "CALL-V": + cmd = ("CR", "0,0") + is_call = True + call_band = 0 + elif mem_key == "CALL-U": + cmd = ("CR", "1,0") + is_call = True + call_band = 1 + else: + cmd = self._cmd_get_memory(mem_key) + + result = self.command(self.pipe, *cmd) + if result in ["N", "E"] or result == "?" or not result: + mem = chirp_common.Memory() + if isinstance(mem_key, int): + mem.number = mem_key + else: + mem.number = 0 + mem.extd_number = mem_key + mem.empty = True + self._memcache[mem_key] = mem + return mem + elif " " not in result: + LOG.error("Not sure what to do with this: %r" % result) + raise errors.RadioError("Unexpected result returned from radio") + + value = result.split(" ", 1)[1] + spec = value.split(",") + + if is_call: + mem = self._parse_call_spec(call_band, spec) + else: + mem = self._parse_mem_spec(spec) + + self._memcache[mem_key] = mem + + if not is_call and self._has_name: + result = self.command(self.pipe, + *self._cmd_get_memory_name(mem_key)) + if " " in result: + value = result.split(" ", 1)[1] + parts = value.split(",") + if len(parts) >= 3: + mem.name = parts[2].rstrip() + elif len(parts) == 2: + mem.name = parts[1].rstrip() + + if mem.duplex == "" and mem.offset == 0: + if is_call: + split_cmd = ("CR", "%i,1" % call_band) + result = self.command(self.pipe, *split_cmd) + if not iserr(result) and " " in result: + value = result.split(" ", 1)[1] + parts = value.split(",") + if len(parts) >= 3: + mem.duplex = "split" + mem.offset = int(parts[2]) + else: + result = self.command(self.pipe, + *self._cmd_get_split(mem_key)) + if not iserr(result) and " " in result: + value = result.split(" ", 1)[1] + self._parse_split_spec(mem, value.split(",")) + return mem + def set_memory(self, memory): + mem_key = memory.extd_number if memory.extd_number else memory.number + if isinstance(mem_key, str): + if mem_key not in THG71_SPECIALS: + raise errors.InvalidMemoryLocation( + "Special channel %s is not valid" % mem_key) + else: + if mem_key < 0 or mem_key > self._upper: + raise errors.InvalidMemoryLocation( + "Number must be between 0 and %i" % self._upper) + + if memory.tmode == "Tone" and memory.rtone not in THG71_TONES: + raise errors.UnsupportedToneError("This radio does not support " + "tone %.1fHz" % memory.rtone) + if memory.tmode == "TSQL" and memory.ctone not in THG71_TONES: + raise errors.UnsupportedToneError("This radio does not support " + "tone %.1fHz" % memory.ctone) + + if memory.empty: + self.erase_memory(mem_key) + return + + is_call = False + call_band = 0 + if mem_key == "CALL-V": + is_call = True + call_band = 0 + elif mem_key == "CALL-U": + is_call = True + call_band = 1 + + spec = self._make_mem_spec(memory, is_call=is_call) + spec_str = ",".join(spec) + self._memcache.pop(mem_key, None) + + if is_call: + r1 = self.command(self.pipe, "CW", + "%i,0,%s" % (call_band, spec_str)) + else: + r1 = self.command(self.pipe, + *self._cmd_set_memory(mem_key, spec_str)) + + if iserr(r1): + raise errors.InvalidDataError("Radio refused channel %s" % mem_key) + + if not is_call and self._has_name: + time.sleep(0.05) + clean_name = memory.name.upper()[:6] + r2 = self.command(self.pipe, + *self._cmd_set_memory_name(mem_key, clean_name)) + if iserr(r2): + raise errors.InvalidDataError("Radio refused name %s: %r" % + (mem_key, clean_name)) + + if memory.duplex == "split": + time.sleep(0.05) + if is_call: + if memory.tuning_step in THG71_STEPS: + step_idx = THG71_STEPS.index(memory.tuning_step) + else: + step_idx = 0 + split_spec = "%011i,%X" % (memory.offset, step_idx) + result = self.command(self.pipe, "CW", + "%i,1,%s" % (call_band, split_spec)) + else: + split_spec = ",".join(self._make_split_spec(memory)) + result = self.command(self.pipe, + *self._cmd_set_split(mem_key, + split_spec)) + if iserr(result): + raise errors.InvalidDataError("Radio refused split on %s" % + mem_key) + + def erase_memory(self, number): + if number in ["CALL-V", "CALL-U"]: + return + resp = self.command(self.pipe, *self._cmd_set_memory(number, "")) + if iserr(resp): + raise errors.RadioError("Radio refused delete of %s" % number) + self._memcache.pop(number, None) + + def get_settings(self): + basic = RadioSettingGroup("basic", "Basic Settings") + dtmf = RadioSettingGroup("dtmf", "DTMF Memories") + top = RadioSettings(basic, dtmf) + + # VHF Transmit Power + curr = "High" + try: + resp = self.command(self.pipe, "PC", "0") + if not iserr(resp) and " " in resp: + val = int(resp.split(" ")[1].split(",")[1]) + curr = THG71_POWER_MAP.get(val, "High") + except Exception: + pass + rs = RadioSetting("pc_vhf", "VHF Transmit Power", + RadioSettingValueList( + THG71_POWER_LEVELS, + current_index=THG71_POWER_LEVELS.index(curr))) + basic.append(rs) + + # UHF Transmit Power + curr = "High" + try: + resp = self.command(self.pipe, "PC", "1") + if not iserr(resp) and " " in resp: + val = int(resp.split(" ")[1].split(",")[1]) + curr = THG71_POWER_MAP.get(val, "High") + except Exception: + pass + rs = RadioSetting("pc_uhf", "UHF Transmit Power", + RadioSettingValueList( + THG71_POWER_LEVELS, + current_index=THG71_POWER_LEVELS.index(curr))) + basic.append(rs) + + # Squelch Level (0-5) + val = 2 + try: + resp = self.command(self.pipe, "SQ", "0") + if not iserr(resp) and " " in resp: + val = int(resp.split(" ")[1].split(",")[1]) + except Exception: + pass + rs = RadioSetting("squelch", "Squelch Level", + RadioSettingValueInteger(0, 5, val)) + basic.append(rs) + + # DTMF Memories (DM 00 to DM 09) + dtmf_charset = "0123456789ABCD*#" + for i in range(10): + dtmf_val = "" + try: + resp = self.command(self.pipe, "DM", "%02i" % i) + if not iserr(resp) and " " in resp: + parts = resp.split(" ", 1)[1].split(",") + if len(parts) > 1: + dtmf_val = parts[1].replace("E", "*").replace("F", "#") + except Exception: + pass + rs = RadioSetting("dm_%02i" % i, "DTMF Memory %i" % i, + RadioSettingValueString(0, 16, dtmf_val, + autopad=False, + charset=dtmf_charset)) + dtmf.append(rs) + + return top + + def set_settings(self, settings): + for element in settings: + if not isinstance(element, RadioSetting): + self.set_settings(element) + continue + if not element.changed(): + continue + name = element.get_name() + if name == "pc_vhf": + code = THG71_POWER_MAP_REV.get(str(element.value), 0) + self.command(self.pipe, "PC", "0,%i" % code) + elif name == "pc_uhf": + code = THG71_POWER_MAP_REV.get(str(element.value), 0) + self.command(self.pipe, "PC", "1,%i" % code) + elif name == "squelch": + self.command(self.pipe, "SQ", "0,%02i" % int(element.value)) + elif name.startswith("dm_"): + idx = int(name.split("_")[1]) + raw = str(element.value).upper().replace("*", "E") + raw = raw.replace("#", "F") + self.command(self.pipe, "DM", "%02i,%s" % (idx, raw)) + THF6A_STEPS = [5.0, 6.25, 8.33, 9.0, 10.0, 12.5, 15.0, 20.0, 25.0, 30.0, 50.0, 100.0] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/chirp-20260918/chirp/drivers/radtel_t18.py new/chirp-20260925/chirp/drivers/radtel_t18.py --- old/chirp-20260918/chirp/drivers/radtel_t18.py 2026-09-18 03:31:39.000000000 +0200 +++ new/chirp-20260925/chirp/drivers/radtel_t18.py 2026-09-25 15:30:00.000000000 +0200 @@ -364,7 +364,7 @@ def do_download(radio): LOG.debug("download") - _t18_enter_programming_mode(radio) + radio._enter_programming_mode() data = b"" @@ -393,7 +393,7 @@ status = chirp_common.Status() status.msg = "Uploading to radio" - _t18_enter_programming_mode(radio) + radio._enter_programming_mode() status.cur = 0 status.max = radio._memsize @@ -483,6 +483,9 @@ def process_mmap(self): self._memobj = bitwise.parse(MEM_FORMAT % self._mem_params, self._mmap) + def _enter_programming_mode(self): + _t18_enter_programming_mode(self) + def sync_in(self): self._mmap = do_download(self) self.process_mmap() @@ -1552,7 +1555,8 @@ _magic = b"KAOGRAM" _fingerprint = [b"\x06\x00\x00\x00\x00\x00\x00\x00", - b"\x06\x03\xE8\x08\xFF\xFF\xFF\xFF"] + b"\x06\x03\xE8\x08\xFF\xFF\xFF\xFF", + b"\x06MP558\xFF\xFF"] _upper = 16 _mem_params = (_upper # number of channels ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/chirp-20260918/chirp/drivers/retevis_rt18.py new/chirp-20260925/chirp/drivers/retevis_rt18.py --- old/chirp-20260918/chirp/drivers/retevis_rt18.py 1970-01-01 01:00:00.000000000 +0100 +++ new/chirp-20260925/chirp/drivers/retevis_rt18.py 2026-09-25 15:30:00.000000000 +0200 @@ -0,0 +1,190 @@ +# Copyright 2026 +# +# EXPERIMENTAL / reverse-engineered driver for the Retevis RT18. +# +# The clone protocol and 16-channel memory layout were derived from +# passive USB capture analysis (Wireshark + USBPcap) of the vendor's +# Windows CPS talking to a real RT18 over its programming cable. Treat +# with caution: +# +# * Channel memory (frequency, tones, power, narrow/wide, scan add, +# busy lock, scramble, compander, spec code) is expected to read +# correctly -- the block-read protocol and the 16-channel/16-byte +# layout at 0x0010-0x0110 were directly confirmed against captured +# traffic. Reprogramming squelch tone/DCS code, wide/narrowband, +# and squelch level was tested end-to-end on a real unit (CHIRP +# write, then read back with the vendor CPS) and matched. +# * The rest of the "Other"/settings screen (timeout timer, VOX, +# beep, scan mode, battery save, etc.) is INHERITED UNCHANGED from +# the Radtel T18 base class and has NOT been individually tested +# on a real RT18 yet -- the exact offsets may differ. Don't trust +# those specific values until confirmed. +# * The 6-digit programming password (separate "Password" settings +# group below) IS confirmed against a real "set password" capture +# (rt18_write_password_to_radio.pcapng) and was also tested +# end-to-end on a real unit (CHIRP write, then read back with the +# vendor CPS) -- see _PASSWORD_ADDR. +# * ALWAYS do a "read from radio" and save the image before ever +# attempting a write, so there is a known-good backup. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import time + +from chirp import chirp_common, directory, errors +from chirp.drivers import radtel_t18 as t18 +from chirp.settings import RadioSetting, RadioSettingGroup, \ + RadioSettingValueString + +# Offset/length of the 6-digit programming password inside the memory +# image, confirmed against rt18_write_password_to_radio.pcapng: writing +# a full image with the password set to "123456" sends a block-write of +# `01 02 03 04 05 06 00 ff` at address 0x03B0 -- one raw byte per digit +# (not ASCII), followed by 2 bytes that stay `00 ff` whether or not a +# password is set (unknown/reserved). All-0xFF digits means "no +# password", matching what the extra 0x05 handshake step reads back. +_PASSWORD_ADDR = 0x03B0 +_PASSWORD_LEN = 6 +_PASSWORD_BLANK = b"\xff" * _PASSWORD_LEN + + [email protected] +class RT18Radio(t18.T18Radio): + """Retevis RT18""" + + VENDOR = "Retevis" + MODEL = "RT18" + + BLOCK_SIZE = 0x08 + CMD_EXIT = b"b" + # Confirmed by capture: no per-block ACK after a block *read* + # (unlike most other T18 siblings, which do ACK_BLOCK = True). + ACK_BLOCK = False + + # Handshake magic, captured verbatim from a real "Read from radio" + # session: b"\x02" + this string. + _magic = b"JSOGRAP" + + # NOTE: unlike most T18 siblings (which check for a text string like + # b"SMP558..."), the 8-byte ident this radio returns does not look + # like a text fingerprint. Originally captured as: + # 06 03 e8 08 ff ff ff ff + # ...but a real unit (see git history) responded instead with + # 06 00 00 00 00 00 00 00 + # confirming this is NOT a fixed brand string -- only the leading + # 0x06 (which is just CMD_ACK, echoed as the first byte of the + # ident reply) is consistent; the remaining bytes vary with radio + # state (e.g. programmed vs. blank). So only match on that. + _fingerprint = [b"\x06"] + + _upper = 16 + _mem_params = _upper + _frs = _frs16 = _murs = _pmr = _gmrs = False + _echo = False + _reserved = False + + _ranges = [ + (0x0000, 0x03F0), + ] + _memsize = 0x03F0 + + VALID_BANDS = [(400000000, 470000000)] + + POWER_LEVELS = [chirp_common.PowerLevel("Low", watts=0.50), + chirp_common.PowerLevel("High", watts=2.00)] + + # 6 raw digit bytes read via the extra 0x05 step during handshake, + # or b"\xff\xff\xff\xff\xff\xff" when no password is set. This is + # also mirrored at 0x03B0 inside the main memory image -- writing + # a full image back (as do_upload always does) rewrites it too. + _password = b"" + + def _enter_programming_mode(self): + """Identical handshake to the Radtel T18 family (magic + ident + + ack), plus one extra step this RT18 firmware does before the + normal block read/write loop: a single 0x05 command that + returns a 6-byte "password" block (raw digit bytes, or 0xFF x6 + when no password is set), acknowledged the same way as a + channel block.""" + # Both captures (read and write sessions) show the vendor CPS + # asserting DTR then RTS on the cable, then waiting ~120-130ms + # before writing the magic string -- CHIRP's generic serial-open + # asserts both lines but writes the magic immediately, which can + # race the radio's cable/UART chip if the port was very recently + # opened/closed (observed as an intermittent "No response from + # radio" on repeated clone attempts). Mirror the vendor's delay. + time.sleep(0.15) + t18.T18Radio._enter_programming_mode(self) + + serial = self.pipe + try: + self.pipe.log("RT18 password step") + serial.write(b"\x05") + if self._echo: + serial.read(1) # Chew the echo + self._password = serial.read(6) + serial.write(t18.CMD_ACK) + if self._echo: + serial.read(1) # Chew the echo + ack = serial.read(1) + except Exception: + raise errors.RadioError( + "Error communicating with radio (password step)") + + if ack != t18.CMD_ACK: + raise errors.RadioError("Bad ACK after password step") + + def _rt18_get_password(self): + raw = self._mmap.get(_PASSWORD_ADDR, _PASSWORD_LEN) + if raw == _PASSWORD_BLANK: + return "" + return "".join(str(b) for b in raw if b != 0xFF) + + def _rt18_apply_password(self, setting): + digits = [int(c) for c in str(setting.value).strip()] + raw = bytes(digits) + _PASSWORD_BLANK[len(digits):] + self._mmap.set(_PASSWORD_ADDR, raw) + + def get_settings(self): + top = t18.T18Radio.get_settings(self) + + # The vendor CPS for the RT18 has no "Voice prompts" control -- + # unlike its T18 siblings, this radio doesn't seem to have voice + # prompts at all. Drop the setting the base class adds + # unconditionally so we don't expose a non-existent feature. + basic = top[0] + if "voiceprompt" in basic: + del basic[basic["voiceprompt"]] + + password = RadioSettingGroup("password", "Password") + top.append(password) + + rs = RadioSetting( + "rt18_password", "Programming password (blank = disabled)", + RadioSettingValueString(0, _PASSWORD_LEN, + self._rt18_get_password(), + autopad=False, + charset="0123456789")) + rs.set_doc( + "Password required (on the radio's vendor CPS) to " + "enter programming mode. 1-6 digits, or blank to disable " + "password protection. Change this field and upload to set a " + "new password, or clear it and upload to remove password " + "protection entirely. Confirmed via passive USB capture of a " + "real 'set password' session; not confirmed to actually gate " + "anything on the radio side beyond what was observed.") + rs.set_apply_callback(self._rt18_apply_password) + password.append(rs) + + return top diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/chirp-20260918/chirp/drivers/uvk5.py new/chirp-20260925/chirp/drivers/uvk5.py --- old/chirp-20260918/chirp/drivers/uvk5.py 2026-09-18 03:31:39.000000000 +0200 +++ new/chirp-20260925/chirp/drivers/uvk5.py 2026-09-25 15:30:00.000000000 +0200 @@ -2009,7 +2009,7 @@ approved_prefixes = ( # These are the original OEM firmware versions 'k5_2.01.', 'app_2.01.', '2.01.', '3.00.', - '4.00.', 'k5_4.00.', '5.00.', '7.00.', + '4.00.', 'k5_4.00.', '5.00.', '7.00.', '7.01.', # This "oneofeeleven" prefix really covers a wide range of # firmwares that are user-built, but people report them working # fine with the base driver. Binary files old/chirp-20260918/tests/images/Retevis_RT18.img and new/chirp-20260925/tests/images/Retevis_RT18.img differ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/chirp-20260918/tests/unit/test_thg71.py new/chirp-20260925/tests/unit/test_thg71.py --- old/chirp-20260918/tests/unit/test_thg71.py 1970-01-01 01:00:00.000000000 +0100 +++ new/chirp-20260925/tests/unit/test_thg71.py 2026-09-25 15:30:00.000000000 +0200 @@ -0,0 +1,255 @@ +from tests.unit import base +from chirp import chirp_common, errors +from chirp.drivers import kenwood_live + + +class TestTHG71(base.BaseTest): + def setUp(self): + super().setUp() + self.radio = kenwood_live.THG71Radio(None) + + def test_features(self): + rf = self.radio.get_features() + self.assertEqual(rf.memory_bounds, (0, 199)) + self.assertEqual(rf.valid_modes, ["FM"]) + self.assertEqual(rf.valid_tmodes, ["", "Tone", "TSQL"]) + self.assertEqual(rf.valid_duplexes, ["", "+", "-", "split"]) + self.assertEqual(rf.valid_skips, ["", "S"]) + self.assertEqual(rf.valid_name_length, 6) + self.assertTrue(rf.has_tuning_step) + self.assertTrue(rf.can_odd_split) + self.assertTrue(rf.has_settings) + self.assertFalse(rf.has_sub_devices) + self.assertFalse(rf.has_dtcs) + self.assertFalse(rf.has_bank) + + # Tone checks + self.assertEqual(len(rf.valid_tones), 38) + self.assertNotIn(69.3, rf.valid_tones) + self.assertIn(67.0, rf.valid_tones) + self.assertIn(71.9, rf.valid_tones) + self.assertIn(250.3, rf.valid_tones) + + # Steps check + self.assertEqual(len(rf.valid_tuning_steps), 10) + self.assertEqual(rf.valid_tuning_steps[0], 5.0) + self.assertEqual(rf.valid_tuning_steps[9], 100.0) + + # Special channels check + self.assertIn("CALL-V", rf.valid_special_chans) + self.assertIn("CALL-U", rf.valid_special_chans) + self.assertIn("Pr", rf.valid_special_chans) + self.assertIn("L0", rf.valid_special_chans) + self.assertIn("U0", rf.valid_special_chans) + self.assertIn("L9", rf.valid_special_chans) + self.assertIn("U9", rf.valid_special_chans) + + # Bands check + self.assertEqual(len(rf.valid_bands), 3) + self.assertEqual(rf.valid_bands[0], (118000000, 174000000)) + self.assertEqual(rf.valid_bands[1], (320000000, 470000000)) + self.assertEqual(rf.valid_bands[2], (800000000, 949000000)) + + def test_tone_codes(self): + # Test code to tone conversion + self.assertEqual(kenwood_live._thg71_code_to_tone("01"), 67.0) + self.assertEqual(kenwood_live._thg71_code_to_tone("03"), 71.9) + self.assertEqual(kenwood_live._thg71_code_to_tone("09"), 88.5) + self.assertEqual(kenwood_live._thg71_code_to_tone("27"), 162.2) + self.assertEqual(kenwood_live._thg71_code_to_tone("39"), 250.3) + # Invalid code 02 should fall back + self.assertEqual(kenwood_live._thg71_code_to_tone("02"), 88.5) + + # Test tone to code conversion + self.assertEqual(kenwood_live._thg71_tone_to_code(67.0), "01") + self.assertEqual(kenwood_live._thg71_tone_to_code(71.9), "03") + self.assertEqual(kenwood_live._thg71_tone_to_code(88.5), "09") + self.assertEqual(kenwood_live._thg71_tone_to_code(162.2), "27") + self.assertEqual(kenwood_live._thg71_tone_to_code(250.3), "39") + self.assertEqual(kenwood_live._thg71_tone_to_code(69.3), "09") + + def test_parse_mr_spec(self): + # MR 0,0,000,00146980000,0,2,0,1,0,,27,,09,000600000,0 + spec = ["0", "0", "000", "00146980000", "0", "2", "0", "1", "0", "", + "27", "", "09", "000600000", "0"] + mem = self.radio._parse_mem_spec(spec) + self.assertEqual(mem.number, 0) + self.assertEqual(mem.freq, 146980000) + self.assertEqual(mem.tuning_step, 5.0) + self.assertEqual(mem.duplex, "-") + self.assertEqual(mem.tmode, "Tone") + self.assertEqual(mem.rtone, 162.2) + self.assertEqual(mem.ctone, 88.5) + self.assertEqual(mem.offset, 600000) + self.assertEqual(mem.skip, "") + + def test_parse_mr_spec_with_skip(self): + # MR 0,0,005,00146520000,0,0,0,0,0,,09,,09,000000000,1 + spec = ["0", "0", "005", "00146520000", "0", "0", "0", "0", "0", "", + "09", "", "09", "000000000", "1"] + mem = self.radio._parse_mem_spec(spec) + self.assertEqual(mem.number, 5) + self.assertEqual(mem.freq, 146520000) + self.assertEqual(mem.duplex, "") + self.assertEqual(mem.skip, "S") + + def test_parse_call_spec(self): + # CR 1,0,00446000000,6,0,0,0,0,,09,,09,005000000 + spec = ["1", "0", "00446000000", "6", "0", "0", "0", "0", "", + "09", "", "09", "005000000"] + mem = self.radio._parse_call_spec(1, spec) + self.assertEqual(mem.extd_number, "CALL-U") + self.assertEqual(mem.freq, 446000000) + self.assertEqual(mem.tuning_step, 25.0) + self.assertEqual(mem.duplex, "") + self.assertEqual(mem.offset, 5000000) + + def test_make_mr_spec(self): + mem = chirp_common.Memory() + mem.number = 0 + mem.freq = 146980000 + mem.tuning_step = 5.0 + mem.duplex = "-" + mem.tmode = "Tone" + mem.rtone = 162.2 + mem.ctone = 88.5 + mem.offset = 600000 + mem.skip = "" + + spec = self.radio._make_mem_spec(mem) + spec_str = ",".join(spec) + self.assertEqual( + spec_str, + "00146980000,0,2,0,1,0,,27,,09,000600000,0" + ) + + def test_make_split_spec(self): + mem = chirp_common.Memory() + mem.number = 0 + mem.freq = 146400000 + mem.tuning_step = 25.0 + mem.duplex = "split" + mem.offset = 445000000 + + split_spec = self.radio._make_split_spec(mem) + self.assertEqual(split_spec, ("00445000000", "6")) + + def test_unsupported_tone(self): + mem = chirp_common.Memory() + mem.number = 1 + mem.freq = 146520000 + mem.tmode = "Tone" + mem.rtone = 69.3 + with self.assertRaises(errors.UnsupportedToneError): + self.radio.set_memory(mem) + + mem.tmode = "TSQL" + mem.rtone = 88.5 + mem.ctone = 69.3 + with self.assertRaises(errors.UnsupportedToneError): + self.radio.set_memory(mem) + + def test_mock_get_memory_regular_and_split(self): + def fake_command(ser, cmd, *args): + if cmd == "MR" and args[0] == "0,0,000": + # Split RX + return "MR 0,0,000,00146400000,0,0,0,0,0,,27,,09,,0" + elif cmd == "MR" and args[0] == "0,1,000": + # Split TX + return "MR 0,1,000,00445000000,6" + elif cmd == "MNA" and args[0] == "0,000": + return "MNA 0,000,SPLIT1" + elif cmd == "MR" and args[0] == "0,0,001": + return "MR 0,0,001,00146980000,0,2,0,1,0,,27,,09,000600000,0" + elif cmd == "MR" and args[0] == "0,1,001": + return "N" + elif cmd == "MNA" and args[0] == "0,001": + return "MNA 0,001,RPT1" + elif cmd == "MR" and args[0] == "0,0,002": + return "N" + return "?" + + self.radio.command = fake_command + + # Test split channel 0 + mem0 = self.radio.get_memory(0) + self.assertEqual(mem0.number, 0) + self.assertEqual(mem0.freq, 146400000) + self.assertEqual(mem0.duplex, "split") + self.assertEqual(mem0.offset, 445000000) + self.assertEqual(mem0.name, "SPLIT1") + + # Test regular channel 1 + mem1 = self.radio.get_memory(1) + self.assertEqual(mem1.number, 1) + self.assertEqual(mem1.freq, 146980000) + self.assertEqual(mem1.duplex, "-") + self.assertEqual(mem1.offset, 600000) + self.assertEqual(mem1.tmode, "Tone") + self.assertEqual(mem1.rtone, 162.2) + self.assertEqual(mem1.name, "RPT1") + + # Test empty channel 2 + mem2 = self.radio.get_memory(2) + self.assertTrue(mem2.empty) + + def test_mock_get_and_set_call_channel(self): + sent_commands = [] + + def fake_command(ser, cmd, *args): + sent_commands.append((cmd, args)) + if cmd == "CR" and args[0] == "0,0": + return "CR 0,0,00147405000,0,0,0,0,0,,27,,09,000600000" + elif cmd == "CR" and args[0] == "0,1": + return "N" + elif cmd == "CW": + return "CW" + return "N" + + self.radio.command = fake_command + + mem = self.radio.get_memory("CALL-V") + self.assertEqual(mem.extd_number, "CALL-V") + self.assertEqual(mem.freq, 147405000) + self.assertEqual(mem.offset, 600000) + + mem.freq = 146520000 + mem.offset = 0 + self.radio.set_memory(mem) + self.assertIn(("CW", ("0,0,00146520000,0,0,0,0,0,,27,,09,000000000",)), + sent_commands) + + def test_mock_get_and_set_settings(self): + sent_commands = [] + + def fake_command(ser, cmd, *args): + sent_commands.append((cmd, args)) + if cmd == "PC" and args[0] == "0": + return "PC 0,2" # Low power VHF + elif cmd == "PC" and args[0] == "1": + return "PC 1,0" # High power UHF + elif cmd == "SQ" and args[0] == "0": + return "SQ 0,03" + elif cmd == "DM" and args[0] == "00": + return "DM 00,E5558881234" # *5558881234 + elif cmd == "DM": + return "DM %s" % args[0] + return cmd + + self.radio.command = fake_command + + settings = self.radio.get_settings() + self.assertEqual(str(settings["basic"]["pc_vhf"].value), "Low") + self.assertEqual(str(settings["basic"]["pc_uhf"].value), "High") + self.assertEqual(int(settings["basic"]["squelch"].value), 3) + self.assertEqual(str(settings["dtmf"]["dm_00"].value), "*5558881234") + + # Change settings and apply + settings["basic"]["pc_vhf"].value = "Economic Low" + settings["basic"]["squelch"].value = 5 + settings["dtmf"]["dm_00"].value = "*123#456" + self.radio.set_settings(settings) + + self.assertIn(("PC", ("0,3",)), sent_commands) + self.assertIn(("SQ", ("0,05",)), sent_commands) + self.assertIn(("DM", ("00,E123F456",)), sent_commands) ++++++ chirp.obsinfo ++++++ --- /var/tmp/diff_new_pack.6BkdsR/_old 2026-09-28 10:49:26.868481908 +0200 +++ /var/tmp/diff_new_pack.6BkdsR/_new 2026-09-28 10:49:26.873482118 +0200 @@ -1,5 +1,5 @@ name: chirp -version: 20260918 -mtime: 1789695099 -commit: c69f7c11bfb6ff4f01002120e38094056273daa3 +version: 20260925 +mtime: 1790343000 +commit: 67f5e10f229769023f83c707b8152355b6d90819
