# HG changeset patch
# User Jim Unroe <rock.unroe@gmail.com>
# Date 1628124907 14400
#      Wed Aug 04 20:55:07 2021 -0400
# Node ID 3890109ecedf53c654217cc6582c2fa1058cdf10
# Parent  fc2d4c080e1220bbfe40f7e41447924ae489aa1c
[RT87] Add Retevis RT87

This patch adds support for the Retevis RT87 two-way radio.

#6769

diff -r fc2d4c080e12 -r 3890109ecedf chirp/drivers/retevis_rt87.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/chirp/drivers/retevis_rt87.py	Wed Aug 04 20:55:07 2021 -0400
@@ -0,0 +1,1042 @@
+# Copyright 2021 Jim Unroe <rock.unroe@gmail.com>
+#
+# 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 3 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/>.
+
+"""Retevis RT87 radio management module"""
+
+import logging
+import struct
+
+from chirp import (
+    bitwise,
+    chirp_common,
+    directory,
+    errors,
+    memmap,
+    util,
+)
+from chirp.settings import (
+    RadioSetting,
+    RadioSettingGroup,
+    RadioSettings,
+    RadioSettingValueBoolean,
+    RadioSettingValueInteger,
+    RadioSettingValueList,
+    RadioSettingValueString,
+)
+
+LOG = logging.getLogger(__name__)
+
+MEM_FORMAT = """
+struct memory {
+  bbcd rx_freq[4];
+  bbcd tx_freq[4];
+  lbcd rx_tone[2];
+  lbcd tx_tone[2];
+  u8 unknown10:2,
+     pttid:2,
+     unknown11:1,
+     highpower:1,
+     option:2;
+  u8 optsigtype:3,
+     unknown20:1,
+     narrow:1,
+     vox:1,
+     bcl:2;
+  u8 unknown31:1,
+     scanadd:1,
+     unknown32:3,
+     scrambler:3;
+  u8 unknown4;
+};
+
+struct name {
+  char name[10];
+};
+
+#seekto 0x0010;
+struct memory channels[128];
+
+#seekto 0x0830;
+struct {
+  u8 scanmode:2,        // Scan Modes (Time, Carrier, Seek)                [17]
+     autolock:1,        // Auto Keypad Lock (Off, On)                      [18]
+     unknown_0830:2,
+     manager:1,         // Manager (Off, On)
+     unknown_08301:2;
+  u8 batterydis:1,      // Battery Display (Voltage, Icon)                 [20]
+     unknown_0831:1,
+     sidetone:1,        // Side Tone (Off, On)
+     unknown_08311:1,
+     chnameshow:1,      // Display Channel Names (Off, On)                 [25]
+     voice:1,           // Voice Prompt (Off, On)                          [19]
+     beep:1             // Beep (Off, On)                                  [09]
+     batterysave:1;     // Battery Save aka Receiver Saver (Off, On)       [16]
+  u8 unknown_0832:3,
+     manual:1,          // Manual (Disabled, Enabled)
+     cancelcodesql:1,   // Cancel Code Squelch When Press PTT (Disabled,
+                        //                                     Enabled)
+     ani:1,             // Automatic Number Identity (Off, On)             [10]
+     roger:1,           // Roger Time (Off, On)                            [14]
+     dwatch:1;          // Inhibit Receiver When Radio Working (Disabled,  [15]
+                        // aka Dual Watch/Monitor               Enabled)
+  u8 unknown_0833:2,
+     txsel:1,           // Priority Transmit (Edit, Busy)                  [02]
+     dwait:1,           // Dual Wait/Standby (Off, On)                     [06]
+     unknown_08331:1,
+     dtmfsig:1,         // DTMF Signaling (Code Squelch, Selective Call)
+     msgmode:2;         // Message Mode (Off, DC, MSG, Image)              [23]
+  u8 unk_0834:4,
+     squelch:4;         // Squelch Level (0 - 9)                           [05]
+  u8 unk_0835:4,
+     tot:4;             // Time-out Timer (Off, 30sec, 60sec, 90sec,       [11]
+                        //                 120sec, 150sec, 180sec,
+                        //                 210sec, 240sec, 270sec)
+  u8 unknown_0836:2,
+     voxdelaytime:2,    // VOX Delay Time (1sec, 2sec, 3sec, 4sec)
+     voxgainlevel:4;    // VOX Gain Level (1 - 8)                          [03]
+  u8 unknown_0837;
+  u8 unknown_0838;
+  u8 unknown_0839;
+  u8 lamp:5,            // Lamp            00000    Off                    [07]
+                        //                 00001    On
+                        //                 00010    5s
+                        //                 00100    10s (default)
+                        //                 01000    20s
+                        //                 10000    30s
+     brightness:3;      // Brightness           000 Off                    [08]
+                        //                      001 1
+                        //                      010 2
+                        //                      011 3
+                        //                      100 4
+                        //                      101 5
+                        //                      110 6  (default)
+                        //                      111 7
+  u8 unknown_083B:1,
+     pf1:3,             // PF1 (Off, Reverse, Opt Call, 1750Hz, A/B)       [21]
+     unknown_083B1:1,
+     pf2:3;             // PF2 (Off, Reverse, Opt Call, 1750Hz, A/B)       [22]
+  u8 unknown_083c;      // factory = 05, reset = FF
+  u8 unknown_083d;      // factory = 12, reset = FF
+  u8 unknown_083e;      // factory = 50, reset = FF
+  u8 stunmode;          // Stun Mode (Stun Rx/Tx, Stun Tx, Normal)
+} settings;
+
+#seekto 0x08C0;
+struct {
+  char line1[16];       // PowerOn Message Line 1 (16 characters)
+  char line2[16];       // PowerOn Message Line 2 (16 characters)
+  char line3[16];       // PowerOn Message Line 3 (16 characters)
+  char line4[16];       // PowerOn Message Line 4 (16 characters)
+} poweron_msg;
+
+#seekto 0x0D20;
+struct {
+  u8 unknown_0d20:4,
+     autoresettimer:4;  // Auto Reset Timer[s]
+  u8 delay;             // Digit Delay
+  u8 unknown_0d22:4,
+     dtmflen:4;         // DTMF Code Length
+  u8 unknown_0d23;
+  u8 unknown_0d24:4,
+     intcode:4;         // Intermediate Code
+  u8 unknown_0d25:4,
+     groupcode:4;       // Group Code
+} optsig;
+
+#seekto 0x0D30;
+struct {
+  u8 mskid[2];          // MSK ID Code
+} msk;
+
+#seekto 0x0D38;
+struct {
+  u8 mskcall[2];        // MSK CallList
+  u8 unused[2];
+} msklist[10];
+
+#seekto 0x0D90;
+struct {
+  u8 bot[7];            // PTT-ID BOT (Beginning of Transmission)
+  u8 unused_0d97;
+  u8 eot[7];            // PTT-ID EOT (End of Transmission) "0123456789ABCD#* "
+  u8 unused_0d9f;
+} pttid;
+
+#seekto 0x0DA8;
+struct {
+  char code[7];         // Local Name
+} msk_name;
+
+#seekto 0x0DB0;
+struct {
+  u8 code[16];          // MSK CallList
+} dtmfenc[10];
+
+#seekto 0x0E50;
+struct {
+  u8 stunid[4];         // Stun/Wake-Up ID Code
+} stun;
+
+#seekto 0x1900;
+struct name names[128];
+"""
+
+
+# Radio supports UPPER/lower case and symbols
+CHARSET_ASCII_PLUS = chirp_common.CHARSET_ALPHANUMERIC + \
+                     "!\"#$%&'()*+,-./:;<=>?@[\]^_`"
+
+POWER_LEVELS = [chirp_common.PowerLevel('Low', watts=1),
+                chirp_common.PowerLevel('High', watts=4)]
+
+VALID_BANDS = [(136000000, 174000000),
+               (400000000, 480000000)]
+
+RT87_DTCS = sorted(chirp_common.DTCS_CODES + [645])
+
+NUMERIC_CHARSET = list("0123456789")
+DTMF_CHARSET = NUMERIC_CHARSET + list("ABCD*#")
+
+LIST_BATTERY = ["Volt", "Icon"]
+LIST_BCL = ["Off", "Carrier", "QT/DQT"]
+LIST_BRIGHTNESS = ["Off"] + ["%s" % x for x in range(1, 8)]
+LIST_DTMFLEN = ["%s" % x for x in range(30, 160, 10)]
+LIST_DTMFSIG = ["Code Squelch", "Selective Call"]
+LIST_INTCODE = list(DTMF_CHARSET)
+LIST_OPTION = ["Off", "Compand", "Scramble"]
+LIST_POWERON = ["Off", "Voltage", "Message", "Picture"]
+LIST_PTTID = ["Off", "BOT", "EOT", "Both"]
+LIST_RESET = ["Off", "1 second"] + ["%s seconds" % x for x in range(2, 16)]
+LIST_SCNREV = ["Time Operated", "Carrier Operated", "Seek"]
+LIST_SIDEKEY = ["Off", "Reverse", "Opt Call", "1750 Hz", "A/B"]
+LIST_TOT = ["Off"] + ["%s seconds" % x for x in range(30, 300, 30)]
+LIST_TXSEL = ["Edit", "Busy"]
+LIST_VFOMR = ["Frequency (VFO)", "Channel (MR)"]
+LIST_VOXDELAYTIME = ["1 second", "2 seconds", "3 seconds", "4 seconds"]
+
+LED_CHOICES = ["Off", "On", "5 seconds", "10 seconds", "20 seconds",
+               "30 seconcds"]
+LED_VALUES = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10]
+
+DELAY_CHOICES = ["%s ms" % x for x in range(300, 1250, 50)]
+DELAY_VALUES = [x for x in range(0, 95, 5)]
+
+STUN_CHOICES = ["Stun Rx/Tx", "Stun Tx", "Normal"]
+STUN_VALUES = [0x55, 0xAA, 0xFF]
+
+GROUP_CHOICES = ["Off", "A", "B", "C", "D", "*", "#"]
+GROUP_VALUES = [0x00, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F]
+
+
+def _identify(radio):
+    try:
+        magic = radio._MAGIC
+        radio.pipe.write(magic)
+        echo = radio.pipe.read(len(magic))  # Chew the echoed response
+
+        if magic != echo:
+            LOG.debug("expecting echo\n%s\n", util.hexprint(magic))
+            LOG.debug("got echo\n%s\n", util.hexprint(echo))
+            raise Exception("Got false echo. Expected: '{}', got: '{}'."
+                            .format(magic, echo))
+
+        ack = radio.pipe.read(2)
+        if ack != "\x06\x30":
+            raise errors.RadioError("Radio did not ACK first command: %r" %
+                                    ack)
+    except:
+        LOG.exception('')
+        raise errors.RadioError("Unable to communicate with the radio")
+
+
+def _download(radio):
+    _identify(radio)
+    data = []
+    for i in range(0, 0x2000, 0x40):
+        msg = struct.pack('>cHb', 'R', i, 0x40)
+        radio.pipe.write(msg)
+        echo = radio.pipe.read(len(msg))  # Chew the echoed response
+
+        if msg != echo:
+            LOG.debug("expecting echo\n%s\n", util.hexprint(msg))
+            LOG.debug("got echo\n%s\n", util.hexprint(echo))
+            raise Exception("Got false echo. Expected: '{}', got: '{}'."
+                            .format(msg, echo))
+
+        block = radio.pipe.read(0x40 + 4)
+        if len(block) != (0x40 + 4):
+            raise errors.RadioError("Radio sent a short block (%02x/%02x)" % (
+                len(block), 0x44))
+        data += block[4:]
+
+        if radio.status_fn:
+            status = chirp_common.Status()
+            status.cur = i
+            status.max = 0x2000
+            status.msg = "Cloning from radio"
+            radio.status_fn(status)
+
+    radio.pipe.write("E")
+
+    return memmap.MemoryMap(data)
+
+
+def _upload(radio):
+    _identify(radio)
+
+    for start_addr, end_addr, block_size in radio._ranges:
+        for addr in range(start_addr, end_addr, block_size):
+            msg = struct.pack('>cHb', 'W', addr, block_size)
+            msg += radio._mmap[addr:(addr + block_size)]
+            radio.pipe.write(msg)
+            radio.pipe.read(block_size + 4)
+            ack = radio.pipe.read(1)
+            if ack != '\x06':
+                raise errors.RadioError('Radio did not ACK block %i (0x%04x)'
+                                        % (addr, addr))
+
+            if radio.status_fn:
+                status = chirp_common.Status()
+                status.cur = addr
+                status.max = 0x2000
+                status.msg = "Cloning to radio"
+                radio.status_fn(status)
+
+    radio.pipe.write("E")
+
+
+def _split(rf, f1, f2):
+    """Returns False if the two freqs are in the same band (no split)
+    or True otherwise"""
+
+    # determine if the two freqs are in the same band
+    for low, high in rf.valid_bands:
+        if f1 >= low and f1 <= high and \
+                f2 >= low and f2 <= high:
+            # if the two freqs are on the same Band this is not a split
+            return False
+
+    # if you get here is because the freq pairs are split
+    return True
+
+
+class Rt87BaseRadio(chirp_common.CloneModeRadio):
+    VENDOR = "Retevis"
+    MODEL = "RT87 Base"
+    BAUD_RATE = 9600
+    _MAGIC = "PGM2017"
+
+    def get_features(self):
+        rf = chirp_common.RadioFeatures()
+        # General
+        rf.has_bank = False
+        rf.has_rx_dtcs = True
+        rf.has_tuning_step = False
+        rf.has_ctone = True
+        rf.has_cross = True
+        rf.has_settings = True
+        # Attributes
+        rf.valid_modes = ["FM", "NFM"]
+        rf.valid_tmodes = ["", "Tone", "TSQL", "DTCS", "Cross"]
+        rf.valid_duplexes = ['', '-', '+', 'split', 'off']
+        rf.valid_tuning_steps = [0.5, 2.5, 5, 6.25, 10, 12.5, 25, 37.5, 50,
+                                 100]
+        rf.valid_bands = VALID_BANDS
+        rf.valid_skips = ["", "S"]
+        rf.valid_power_levels = POWER_LEVELS
+        rf.valid_characters = CHARSET_ASCII_PLUS
+        rf.valid_name_length = 10
+        rf.valid_cross_modes = ["Tone->Tone", "Tone->DTCS", "DTCS->Tone",
+                                "->Tone", "->DTCS", "DTCS->", "DTCS->DTCS"]
+
+        rf.memory_bounds = (1, 128)
+        rf.can_odd_split = True
+        return rf
+
+    def sync_in(self):
+        """Download from radio"""
+        try:
+            data = _download(self)
+        except errors.RadioError:
+            # Pass through any real errors we raise
+            raise
+        except:
+            # If anything unexpected happens, make sure we raise
+            # a RadioError and log the problem
+            LOG.exception('Unexpected error during download')
+            raise errors.RadioError('Unexpected error communicating '
+                                    'with the radio')
+        self._mmap = data
+        self.process_mmap()
+
+    def sync_out(self):
+        """Upload to radio"""
+        try:
+            _upload(self)
+        except:
+            # If anything unexpected happens, make sure we raise
+            # a RadioError and log the problem
+            LOG.exception('Unexpected error during upload')
+            raise errors.RadioError('Unexpected error communicating '
+                                    'with the radio')
+
+    @classmethod
+    def match_model(cls, filedata, filename):
+        # This radio has always been post-metadata, so never do
+        # old-school detection
+        return False
+
+    def process_mmap(self):
+        self._memobj = bitwise.parse(MEM_FORMAT, self._mmap)
+
+    def get_raw_memory(self, number):
+        return (repr(self._memobj.channels[number - 1]) +
+                repr(self._memobj.names[number - 1]))
+
+    def _get_memobjs(self, number):
+        return (self._memobj.channels[number - 1],
+                self._memobj.names[number - 1])
+
+    def _decode_tone(self, toneval):
+        pol = "N"
+        rawval = (toneval[1].get_bits(0xFF) << 8) | toneval[0].get_bits(0xFF)
+
+        if toneval[0].get_bits(0xFF) == 0xFF:
+            mode = ""
+            val = 0
+        elif toneval[1].get_bits(0xC0) == 0xC0:
+            mode = "DTCS"
+            val = int("%x" % (rawval & 0x3FFF))
+            pol = "R"
+        elif toneval[1].get_bits(0x80):
+            mode = "DTCS"
+            val = int("%x" % (rawval & 0x3FFF))
+        else:
+            mode = "Tone"
+            val = int(toneval) / 10.0
+
+        return mode, val, pol
+
+    def _encode_tone(self, _toneval, mode, val, pol):
+        toneval = 0
+        if mode == "Tone":
+            toneval = int("%i" % (val * 10), 16)
+        elif mode == "DTCS":
+            toneval = int("%i" % val, 16)
+            toneval |= 0x8000
+            if pol == "R":
+                toneval |= 0x4000
+        else:
+            toneval = 0xFFFF
+
+        _toneval[0].set_raw(toneval & 0xFF)
+        _toneval[1].set_raw((toneval >> 8) & 0xFF)
+
+    def _is_txinh(self, _mem):
+        raw_tx = ""
+        for i in range(0, 4):
+            raw_tx += _mem.tx_freq[i].get_raw()
+        return raw_tx == "\xFF\xFF\xFF\xFF"
+
+    def _bbcd2num(self, bcdarr, strlen=8):
+        # doing bbcd
+        LOG.debug(bcdarr.get_value())
+        string = ''.join("%02X" % b for b in bcdarr)
+        LOG.debug("@_bbcd2num, received: %s" % string)
+        if strlen <= 8:
+            string = string[:strlen]
+        return string
+
+    def _num2bbcd(self, value, strlen=8):
+        numstr = value.get_value()
+        numstr = str.ljust(numstr.strip(), strlen, "F")
+        bcdarr = list(bytearray.fromhex(numstr))
+        LOG.debug("@_num2bbcd, sending: %s" % bcdarr)
+        return bcdarr
+
+    def get_memory(self, number):
+        _mem, _name = self._get_memobjs(number)
+
+        mem = chirp_common.Memory()
+
+        if isinstance(number, str):
+            mem.number = SPECIALS[number]
+            mem.extd_number = number
+        else:
+            mem.number = number
+
+        if _mem.get_raw().startswith("\xFF\xFF\xFF\xFF"):
+            mem.empty = True
+            return mem
+
+        mem.freq = int(_mem.rx_freq) * 10
+
+        if self._is_txinh(_mem):
+            # TX freq not set
+            mem.duplex = "off"
+            mem.offset = 0
+        else:
+            # TX freq set
+            offset = (int(_mem.tx_freq) * 10) - mem.freq
+            if offset != 0:
+                if _split(self.get_features(), mem.freq, int(
+                          _mem.tx_freq) * 10):
+                    mem.duplex = "split"
+                    mem.offset = int(_mem.tx_freq) * 10
+                elif offset < 0:
+                    mem.offset = abs(offset)
+                    mem.duplex = "-"
+                elif offset > 0:
+                    mem.offset = offset
+                    mem.duplex = "+"
+            else:
+                mem.offset = 0
+
+        txmode, txval, txpol = self._decode_tone(_mem.tx_tone)
+        rxmode, rxval, rxpol = self._decode_tone(_mem.rx_tone)
+
+        chirp_common.split_tone_decode(mem,
+                                       (txmode, txval, txpol),
+                                       (rxmode, rxval, rxpol))
+
+        mem.mode = 'NFM' if _mem.narrow else 'FM'
+        mem.skip = '' if _mem.scanadd else 'S'
+        mem.power = POWER_LEVELS[int(_mem.highpower)]
+        mem.name = str(_name.name).rstrip('\xFF ')
+
+        # Extra
+        mem.extra = RadioSettingGroup("extra", "Extra")
+
+        rs = RadioSettingValueList(LIST_BCL, LIST_BCL[_mem.bcl])
+        rset = RadioSetting("bcl", "BCL", rs)
+        mem.extra.append(rset)
+
+        rs = RadioSettingValueList(LIST_PTTID, LIST_PTTID[_mem.pttid])
+        rset = RadioSetting("pttid", "PTT-ID", rs)
+        mem.extra.append(rset)
+
+        rs = RadioSettingValueBoolean(bool(_mem.vox))
+        rset = RadioSetting("vox", "VOX", rs)
+        mem.extra.append(rset)
+
+        rs = RadioSettingValueList(LIST_OPTION, LIST_OPTION[_mem.option])
+        rset = RadioSetting("option", "Option", rs)
+        mem.extra.append(rset)
+
+        rs = RadioSettingValueInteger(1, 8, _mem.scrambler + 1)
+        rset = RadioSetting("scrambler", "Compander/Scrambler", rs)
+        mem.extra.append(rset)
+
+        return mem
+
+    def set_memory(self, mem):
+        _mem, _name = self._get_memobjs(mem.number)
+        if mem.empty:
+            _mem.set_raw('\xFF' * 16)
+            _name.set_raw('\xFF' * 10)
+            return
+        _mem.set_raw('\x00' * 16)
+
+        _mem.rx_freq = mem.freq / 10
+
+        if mem.duplex == "off":
+            for i in range(0, 4):
+                _mem.tx_freq[i].set_raw("\xFF")
+        elif mem.duplex == "split":
+            _mem.tx_freq = mem.offset / 10
+        elif mem.duplex == "+":
+            _mem.tx_freq = (mem.freq + mem.offset) / 10
+        elif mem.duplex == "-":
+            _mem.tx_freq = (mem.freq - mem.offset) / 10
+        else:
+            _mem.tx_freq = mem.freq / 10
+
+        (txmode, txval, txpol), (rxmode, rxval, rxpol) = \
+            chirp_common.split_tone_encode(mem)
+
+        self._encode_tone(_mem.tx_tone, txmode, txval, txpol)
+        self._encode_tone(_mem.rx_tone, rxmode, rxval, rxpol)
+
+        _mem.narrow = mem.mode == 'NFM'
+        _mem.scanadd = mem.skip != 'S'
+        _mem.highpower = POWER_LEVELS.index(mem.power) if mem.power else 1
+        _name.name = mem.name.rstrip(' ').ljust(10, '\xFF')
+
+        # extra settings
+        for setting in mem.extra:
+            if setting == "scrambler":
+                setattr(_mem, setting.get_name(), element.value - 1)
+            else:
+                setattr(_mem, setting.get_name(), setting.value)
+
+    def _get_settings(self):
+        _msg = self._memobj.poweron_msg
+        _optsig = self._memobj.optsig
+        _settings = self._memobj.settings
+        _stun = self._memobj.stun
+        _msk = self._memobj.msk
+        _mskname = self._memobj.msk_name
+
+        basic = RadioSettingGroup("basic", "Basic Settings")
+        group = RadioSettings(basic)
+
+        # Menu 01: SCAN
+
+        # Menu 02: TX.SEL aka PRI
+        rs = RadioSettingValueList(LIST_TXSEL,
+                                   LIST_TXSEL[_settings.txsel])
+        rset = RadioSetting("txsel", "Priority Transmit", rs)
+        basic.append(rset)
+
+        # Menu 03: VOX (VOX Gain Level)
+        rs = RadioSettingValueInteger(1, 8, _settings.voxgainlevel + 1)
+        rset = RadioSetting("voxgainlevel", "VOX Gain Level", rs)
+        basic.append(rset)
+
+        # Function Setting: VOX Delay Time
+        rs = RadioSettingValueList(LIST_VOXDELAYTIME,
+                                   LIST_VOXDELAYTIME[_settings.voxdelaytime])
+        rset = RadioSetting("voxdelaytime", "VOX Delay Time", rs)
+        basic.append(rset)
+
+        # Menu 04: TX POW (TX Power)
+
+        # Menu 05: SQL (Squelch Level)
+        rs = RadioSettingValueInteger(0, 9, _settings.squelch)
+        rset = RadioSetting("squelch", "Squelch Level", rs)
+        basic.append(rset)
+
+        # Menu 06: D.WAIT aka DW (Dual Wait/Standby)
+        rs = RadioSettingValueBoolean(bool(_settings.dwait))
+        rset = RadioSetting("dwait", "Dual Wait/Standby", rs)
+        basic.append(rset)
+
+        # Menu 07: LED (Backlight Time)
+        def apply_lamp_listvalue(setting, obj):
+            LOG.debug("Setting value: " + str(setting.value) +
+                      " from list")
+            val = str(setting.value)
+            index = LED_CHOICES.index(val)
+            val = LED_VALUES[index]
+            obj.set_value(val)
+
+        if _settings.lamp in LED_VALUES:
+            idx = LED_VALUES.index(_settings.lamp)
+        else:
+            idx = LED_VALUES.index(0x04)
+        rs = RadioSettingValueList(LED_CHOICES, LED_CHOICES[idx])
+        rset = RadioSetting("settings.lamp", "Backlight Time", rs)
+        rset.set_apply_callback(apply_lamp_listvalue, _settings.lamp)
+        basic.append(rset)
+
+        # Menu 08: BRIGHT (Backlight Brightness)
+        rs = RadioSettingValueList(LIST_BRIGHTNESS,
+                                   LIST_BRIGHTNESS[_settings.brightness])
+        rset = RadioSetting("brightness", "Backlight Brightness", rs)
+        basic.append(rset)
+
+        # Menu 09: BEEP (Key Beep)
+        rs = RadioSettingValueBoolean(bool(_settings.beep))
+        rset = RadioSetting("beep", "Key Beep", rs)
+        basic.append(rset)
+
+        # Menu 10: ANI (Automatic Number Identification)
+        rs = RadioSettingValueBoolean(bool(_settings.ani))
+        rset = RadioSetting("ani", "ANI", rs)
+        basic.append(rset)
+
+        # Menu 11: TOT (Transmitter Time-out Timer)
+        rs = RadioSettingValueList(LIST_TOT,
+                                   LIST_TOT[_settings.tot])
+        rset = RadioSetting("tot", "Time-Out Timer", rs)
+        basic.append(rset)
+
+        # Menu 12: BUSY LOCK
+        # Menu 13: VOX SW
+
+        # Menu 14: ROGER (Transmit Ended Beep)
+        rs = RadioSettingValueBoolean(bool(_settings.roger))
+        rset = RadioSetting("roger", "Roger Beep", rs)
+        basic.append(rset)
+
+        # Menu 15: DW (Dual Watch/Monitor)
+        rs = RadioSettingValueBoolean(bool(_settings.dwatch))
+        rset = RadioSetting("dwatch", "Dual Watch/Monitor", rs)
+        basic.append(rset)
+
+        # Menu 16: RX SAVE (Battery Save)
+        rs = RadioSettingValueBoolean(bool(_settings.batterysave))
+        rset = RadioSetting("batterysave", "Battery Save", rs)
+        basic.append(rset)
+
+        # Menu 17: SCN-REV (Scan Mode)
+        rs = RadioSettingValueList(LIST_SCNREV,
+                                   LIST_SCNREV[_settings.scanmode])
+        rset = RadioSetting("scanmode", "Scan Mode", rs)
+        basic.append(rset)
+
+        # Menu 18: AUTOLK (Auto Keypad Lock)
+        rs = RadioSettingValueBoolean(bool(_settings.autolock))
+        rset = RadioSetting("autolock", "Auto Keypad Lock", rs)
+        basic.append(rset)
+
+        # Menu 19: VOICE (Voice Prompt)
+        rs = RadioSettingValueBoolean(bool(_settings.voice))
+        rset = RadioSetting("voice", "Voice Prompt", rs)
+        basic.append(rset)
+
+        # Menu 20: BATT SHOW (Battery Display Method)
+        rs = RadioSettingValueList(LIST_BATTERY,
+                                   LIST_BATTERY[_settings.batterydis])
+        rset = RadioSetting("batterydis", "Battery Display Method", rs)
+        basic.append(rset)
+
+        # Menu 21: PF1 (Side-Key 1)
+        rs = RadioSettingValueList(LIST_SIDEKEY,
+                                   LIST_SIDEKEY[_settings.pf1])
+        rset = RadioSetting("pf1", "Side Key Function 1", rs)
+        basic.append(rset)
+
+        # Menu 22: PF2 (Side-Key 2)
+        rs = RadioSettingValueList(LIST_SIDEKEY,
+                                   LIST_SIDEKEY[_settings.pf2])
+        rset = RadioSetting("pf2", "Side Key Function 2", rs)
+        basic.append(rset)
+
+        # Menu 23: OPN.SET (Power-On Display)
+        rs = RadioSettingValueList(LIST_POWERON,
+                                   LIST_POWERON[_settings.msgmode])
+        rset = RadioSetting("msgmode", "Power-On Display Method", rs)
+        basic.append(rset)
+
+        # Menu 24: PON.MSG (Power-On Message Editing)
+        def _filter(name):
+            filtered = ""
+            for char in str(name):
+                if char in CHARSET_ASCII_PLUS:
+                    filtered += char
+                else:
+                    filtered += " "
+            return filtered
+
+        rs = RadioSettingValueString(0, 16, _filter(_msg.line1))
+        rset = RadioSetting("poweron_msg.line1", "Power-On Message 1", rs)
+        basic.append(rset)
+
+        rs = RadioSettingValueString(0, 16, _filter(_msg.line2))
+        rset = RadioSetting("poweron_msg.line2", "Power-On Message 2", rs)
+        basic.append(rset)
+
+        rs = RadioSettingValueString(0, 16, _filter(_msg.line3))
+        rset = RadioSetting("poweron_msg.line3", "Power-On Message 3", rs)
+        basic.append(rset)
+
+        rs = RadioSettingValueString(0, 16, _filter(_msg.line4))
+        rset = RadioSetting("poweron_msg.line4", "Power-On Message 4", rs)
+        basic.append(rset)
+
+        # Menu 25: CHNAME SHOW (Channel Name Show)
+        rs = RadioSettingValueBoolean(bool(_settings.chnameshow))
+        rset = RadioSetting("chnameshow", "Display Channel Names", rs)
+        basic.append(rset)
+
+        # Menu 26: CH.NAME (Channel Name Editing)
+        # Menu 27: OFFSET (Repeater Shift)
+        # Menu 28: C-CDC (TX/RX Tone Coder)
+        # Menu 29: R-CDC (RX Tone Coder)
+        # Menu 30: T-CDC (TX Tone Coder)
+        # Menu 31: SFT-D (Shift Direction)
+        # Menu 32: STEP (STEP)
+        # Menu 33: N/W (Channel Spacing Select)
+        # Menu 34: CTCSS SCAN (CTCSS Scan)
+        # Menu 35: DCS SSCAN (DCS Scan)
+        # Menu 36: SCR.NO (Scrambler)
+        # Menu 37: APRO (Voice Compander/Scrambler)
+
+        pttid = RadioSettingGroup("pttid", "PTT ID Settings")
+        group.append(pttid)
+
+        # PTT ID Setting: BOT
+        _codeobj = self._memobj.pttid.bot
+        _code = "".join([DTMF_CHARSET[x] for x in _codeobj if int(x) < 0x1F])
+        val = RadioSettingValueString(0, 7, _code, False)
+        val.set_charset(DTMF_CHARSET)
+        rs = RadioSetting("pttid.bot", "PTT ID BOT Code", val)
+
+        def apply_bot_code(setting, obj):
+            value = []
+            for j in range(0, 7):
+                try:
+                    value.append(DTMF_CHARSET.index(str(setting.value)[j]))
+                except IndexError:
+                    value.append(0xFF)
+            obj.bot = value
+        rs.set_apply_callback(apply_bot_code, self._memobj.pttid)
+        pttid.append(rs)
+
+        # PTT ID Setting: EOT
+        _codeobj = self._memobj.pttid.eot
+        _code = "".join([DTMF_CHARSET[x] for x in _codeobj if int(x) < 0x1F])
+        val = RadioSettingValueString(0, 7, _code, False)
+        val.set_charset(DTMF_CHARSET)
+        rs = RadioSetting("pttid.eot", "PTT ID EOT Code", val)
+
+        def apply_eot_code(setting, obj):
+            value = []
+            for j in range(0, 7):
+                try:
+                    value.append(DTMF_CHARSET.index(str(setting.value)[j]))
+                except IndexError:
+                    value.append(0xFF)
+            obj.eot = value
+        rs.set_apply_callback(apply_eot_code, self._memobj.pttid)
+        pttid.append(rs)
+
+        # Optional Signal
+        optsig = RadioSettingGroup("optsig", "Option Signal")
+        group.append(optsig)
+
+        # Common Set
+        common = RadioSettingGroup("common", "Common Set")
+        optsig.append(common)
+
+        # Common Set: Digit Delay[ms] (broken in OEM software)
+        def apply_delay_listvalue(setting, obj):
+            LOG.debug("Setting value: " + str(setting.value) +
+                      " from list")
+            val = str(setting.value)
+            index = DELAY_CHOICES.index(val)
+            val = DELAY_VALUES[index]
+            obj.set_value(val)
+
+        if _optsig.delay in DELAY_VALUES:
+            idx = DELAY_VALUES.index(_optsig.delay)
+        else:
+            idx = DELAY_VALUES.index(30)
+        rs = RadioSettingValueList(DELAY_CHOICES, DELAY_CHOICES[idx])
+        rset = RadioSetting("optsig.delay", "Digit Delay", rs)
+        rset.set_apply_callback(apply_delay_listvalue, _optsig.delay)
+        common.append(rset)
+
+        # Common Set: Auto Reset Timer[s]
+        rs = RadioSettingValueList(LIST_RESET,
+                                   LIST_RESET[_optsig.autoresettimer])
+        rset = RadioSetting("optsig.autoresettimer", "Auto Reset Timer", rs)
+        common.append(rset)
+
+        # Common Set: Side Tone
+        rs = RadioSettingValueBoolean(bool(_settings.sidetone))
+        rset = RadioSetting("sidetone", "Side Tone", rs)
+        common.append(rset)
+
+        # Common Set: Stun Mode
+        def apply_stun_listvalue(setting, obj):
+            LOG.debug("Setting value: " + str(setting.value) +
+                      " from list")
+            val = str(setting.value)
+            index = STUN_CHOICES.index(val)
+            val = STUN_VALUES[index]
+            obj.set_value(val)
+
+        if _settings.stunmode in STUN_VALUES:
+            idx = STUN_VALUES.index(_settings.stunmode)
+        else:
+            idx = STUN_VALUES.index(0xFF)
+        rs = RadioSettingValueList(STUN_CHOICES, STUN_CHOICES[idx])
+        rset = RadioSetting("settings.stunmode", "Stun Mode", rs)
+        rset.set_apply_callback(apply_stun_listvalue, _settings.stunmode)
+        common.append(rset)
+
+        # Common Set: Stun/Wake-Up ID
+
+        def _filter(name):
+            filtered = ""
+            for char in str(name):
+                if char in NUMERIC_CHARSET:
+                    filtered += char
+                else:
+                    filtered += " "
+            return filtered
+
+        codesetting = getattr(_stun, "stunid")
+        codestr = self._bbcd2num(codesetting, 8)
+        code = RadioSettingValueString(0, 8, _filter(codestr))
+        code.set_charset(NUMERIC_CHARSET + list(" "))
+        rs = RadioSetting("stun.stunid", "Stun ID Code", code)
+        common.append(rs)
+
+        # Common Set: Manager
+        rs = RadioSettingValueBoolean(bool(_settings.manager))
+        rset = RadioSetting("manager", "Manager", rs)
+        common.append(rset)
+
+        # MSK
+        msk = RadioSettingGroup("msk", "MSK")
+        optsig.append(msk)
+
+        # MSK: ID Code
+        codesetting = getattr(_msk, "mskid")
+        codestr = self._bbcd2num(codesetting, 4)
+        code = RadioSettingValueString(0, 4, _filter(codestr))
+        code.set_charset(NUMERIC_CHARSET + list(" "))
+        rs = RadioSetting("msk.mskid", "MSK ID Code", code)
+        msk.append(rs)
+
+        # MSK: Local Name
+        rs = RadioSettingValueString(0, 7, _filter(_mskname.code))
+        rset = RadioSetting("msk_name.code", "Local Name", rs)
+        msk.append(rset)
+
+        # MSK: CallList
+        for i in range(0, 10):
+            codesetting = getattr(self._memobj.msklist[i], "mskcall")
+            codestr = self._bbcd2num(codesetting, 4)
+            code = RadioSettingValueString(0, 4, _filter(codestr))
+            code.set_charset(NUMERIC_CHARSET + list(" "))
+            code.set_mutable(False)
+            rs = RadioSetting("msklist/%i.mskcall" % i, "CallList %i" % i,
+                              code)
+            msk.append(rs)
+
+        # DTMF
+        dtmf = RadioSettingGroup("dtmf", "DTMF")
+        optsig.append(dtmf)
+
+        # DTMF: Encode
+
+        for i in range(0, 10):
+            _codeobj = self._memobj.dtmfenc[i].code
+            _code = "".join([DTMF_CHARSET[x] for x in _codeobj if
+                            int(x) < 0x1F])
+            val = RadioSettingValueString(0, 16, _code, False)
+            val.set_charset(DTMF_CHARSET)
+            rs = RadioSetting("dtmfenc/%i.code" % i,
+                              "Encode %i" % i, val)
+
+            def apply_code(setting, obj):
+                code = []
+                for j in range(0, 16):
+                    try:
+                        code.append(DTMF_CHARSET.index(str(setting.value)[j]))
+                    except IndexError:
+                        code.append(0xFF)
+                obj.code = code
+            rs.set_apply_callback(apply_code, self._memobj.dtmfenc[i])
+            dtmf.append(rs)
+
+        # DTMF: DTMF Signaling
+        rs = RadioSettingValueList(LIST_DTMFSIG,
+                                   LIST_DTMFSIG[_settings.dtmfsig])
+        rset = RadioSetting("dtmfsig", "DTMF Signaling", rs)
+        dtmf.append(rset)
+
+        # DTMF: Intermediate Code
+        rs = RadioSettingValueList(LIST_INTCODE,
+                                   LIST_INTCODE[_optsig.intcode])
+        rset = RadioSetting("optsig.intcode", "Intermediate Code", rs)
+        dtmf.append(rset)
+
+        # DTMF: Group Code
+        def apply_group_listvalue(setting, obj):
+            LOG.debug("Setting value: " + str(setting.value) +
+                      " from list")
+            val = str(setting.value)
+            index = GROUP_CHOICES.index(val)
+            val = GROUP_VALUES[index]
+            obj.set_value(val)
+
+        if _optsig.groupcode in GROUP_VALUES:
+            idx = GROUP_VALUES.index(_optsig.groupcode)
+        else:
+            idx = GROUP_VALUES.index(0x00)
+        rs = RadioSettingValueList(GROUP_CHOICES, GROUP_CHOICES[idx])
+        rset = RadioSetting("optsig.groupcode", "Group Code", rs)
+        rset.set_apply_callback(apply_group_listvalue, _optsig.groupcode)
+        dtmf.append(rset)
+
+        # DTMF: DTMF Code Length
+        rs = RadioSettingValueList(LIST_DTMFLEN,
+                                   LIST_DTMFLEN[_optsig.dtmflen - 3])
+        rset = RadioSetting("optsig.dtmflen", "DTMF Code Length", rs)
+        dtmf.append(rset)
+
+        return group
+
+    def get_settings(self):
+        try:
+            return self._get_settings()
+        except:
+            import traceback
+            LOG.error("failed to parse settings")
+            traceback.print_exc()
+            return None
+
+    def set_settings(self, settings):
+        _mem = self._memobj
+        _settings = self._memobj.settings
+        for element in settings:
+            if not isinstance(element, RadioSetting):
+                self.set_settings(element)
+                continue
+            else:
+                try:
+                    name = element.get_name()
+                    if "." in name:
+                        bits = name.split(".")
+                        obj = self._memobj
+                        for bit in bits[:-1]:
+                            if "/" in bit:
+                                bit, index = bit.split("/", 1)
+                                index = int(index)
+                                obj = getattr(obj, bit)[index]
+                            else:
+                                obj = getattr(obj, bit)
+                        setting = bits[-1]
+                    else:
+                        obj = _settings
+                        setting = element.get_name()
+
+                    if element.has_apply_callback():
+                        LOG.debug("using apply callback")
+                        element.run_apply_callback()
+                    elif setting == "voxgainlevel":
+                        setattr(obj, setting, int(element.value) - 1)
+                    elif setting == "stunid":
+                        numstr = self._num2bbcd(element.value, 8)
+                        setattr(_mem.stun, setting, numstr)
+                    elif setting == "mskid":
+                        numstr = self._num2bbcd(element.value, 4)
+                        setattr(_mem.msk, setting, numstr)
+                    elif setting == "dtmflen":
+                        setattr(obj, setting, int(element.value) + 3)
+                    elif element.value.get_mutable():
+                        LOG.debug("Setting %s = %s" % (setting, element.value))
+                        setattr(obj, setting, element.value)
+                except Exception, e:
+                    LOG.debug(element.get_name())
+                    raise
+
+
+@directory.register
+class RT87(Rt87BaseRadio):
+    VENDOR = "Retevis"
+    MODEL = "RT87"
+
+    _ranges = [
+               (0x0000, 0x0A00, 0x40),
+               (0x0BC0, 0x0C00, 0x08),
+               (0x0C00, 0x0F00, 0x40),
+               (0x1000, 0x2000, 0x40),
+              ]
diff -r fc2d4c080e12 -r 3890109ecedf tools/cpep8.manifest
--- a/tools/cpep8.manifest	Wed Aug 04 20:53:37 2021 -0400
+++ b/tools/cpep8.manifest	Wed Aug 04 20:55:07 2021 -0400
@@ -83,6 +83,7 @@
 ./chirp/drivers/retevis_rt23.py
 ./chirp/drivers/retevis_rt26.py
 ./chirp/drivers/retevis_rt76p.py
+./chirp/drivers/retevis_rt87.py
 ./chirp/drivers/retevis_rt98.py
 ./chirp/drivers/rfinder.py
 ./chirp/drivers/tdxone_tdq8a.py

