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
 

Reply via email to