From 6b0a49d45929a4363016a7508fc347ed7c2dfd4c Mon Sep 17 00:00:00 2001 From: Nate Palmer <1745731+tympaniplayer@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:31:21 -0400 Subject: [PATCH 1/3] tests: Scope the Alinco identify test to radios using the AL~ protocol test_all_alinco_identify() selected drivers by VENDOR == 'Alinco' and called _identify() on each, using the vendor string as a proxy for "uses the AL~ handshake". That holds for every Alinco driver in tree today, but it is not what the test actually means to cover -- the check is specifically about the _model byte string and the identify exchange built on it. Select on the presence of _model instead, so an Alinco radio speaking some other protocol does not fail a test that was never about it. This keeps all ten drivers currently exercised, including DR735T, which is not an AlincoStyleRadio subclass but does use _model. --- tests/unit/test_alinco_clone.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_alinco_clone.py b/tests/unit/test_alinco_clone.py index c5e28caa7..dd7eaf1de 100644 --- a/tests/unit/test_alinco_clone.py +++ b/tests/unit/test_alinco_clone.py @@ -182,9 +182,11 @@ def test_dj175(self): def test_all_alinco_identify(self): # Make sure all the alinco models have bytes for their _model - # and can identify properly + # and can identify properly. Not every Alinco radio speaks the + # AL~ handshake -- the DJ-VX50 uses an unrelated protocol with no + # _model -- so scope this to the ones that do. alincos = [x for x in directory.DRV_TO_RADIO.values() - if x.VENDOR == 'Alinco'] + if x.VENDOR == 'Alinco' and hasattr(x, '_model')] for rclass in alincos: pipe = FakeAlincoSerial(__file__) radio = rclass(None) From 03432d74c2a6e07b3cc484f99ba7e034342107e3 Mon Sep 17 00:00:00 2001 From: Nate Palmer <1745731+tympaniplayer@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:31:22 -0400 Subject: [PATCH 2/3] Add support for the Alinco DJ-VX50 Adds a driver for the Alinco DJ-VX50 (DJ-VX50HT/HE) handheld: 200 memories with 6-character names, CTCSS and DCS, three power levels, and wide/narrow FM. The protocol and memory layout were reverse engineered from the factory programming software and verified against hardware. Cloning in reproduces a USB capture of the factory software byte for byte, and cloning out writes all 370 blocks with an identical read-back. Co-Authored-By: Claude Opus 4.8 --- chirp/drivers/alinco_djvx50.py | 503 ++++++++++++++++++++++++++++++++ tests/images/Alinco_DJ-VX50.img | Bin 0 -> 8357 bytes 2 files changed, 503 insertions(+) create mode 100644 chirp/drivers/alinco_djvx50.py create mode 100644 tests/images/Alinco_DJ-VX50.img diff --git a/chirp/drivers/alinco_djvx50.py b/chirp/drivers/alinco_djvx50.py new file mode 100644 index 000000000..f40b0ecce --- /dev/null +++ b/chirp/drivers/alinco_djvx50.py @@ -0,0 +1,503 @@ +# Copyright 2026 +# +# 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 . + +"""Alinco DJ-VX50 / DJ-VX50HT. + +Protocol and memory map reverse engineered from the factory programming +software (DJ-VX50HT.exe v1.00.0008, native Visual Basic 6) and verified +against a USB capture of a real programming session. + +Wire protocol (9600 8N2, no flow control, DTR and RTS asserted). Holding +RTS is what puts the radio into its "PC" state, where it locks the front +panel for the duration of the session; cloning works without it, but the +radio stays live. All frames are +raw binary, fixed length -- no terminator and no checksum. Every command +comes back echoed before the reply, so each exchange reads len(cmd) echo +bytes first. (The echo persists with no radio attached, so it is almost +certainly the cable's half-duplex data line looping back rather than the +radio -- either way it must be consumed.) + + 02 "PROGRA" -> 06 enter program mode + 52 10 -> 57 10 +16 read 16 bytes + 4D 02 -> 46 03 01 25 48 02 01 identify / band info + 57 10 +16 -> 06 write 16 bytes + 45 -> (nothing) end session + +Address is a 16-bit big-endian EEPROM address, always 16-byte aligned. +Note the inversion: you send 'R' to read, and the radio answers with 'W'. + +PROTOCOL FAMILY. This transport is not unique to Alinco. The same +"\x02PROGRA" magic, the same M\x02 identify, and the same +struct.pack('>BHB', cmd, addr, length) read/write framing already appear +in fd268.py (Feidaxin) and th9800.py (TYT): + + driver magic ACK ident frame block memsize + fd268.py \x02PROGRA 0x06 M\x02 -> 8 >BHB 0x08 0x0800 + th9800.py \x02PROGRA 'A' M\x02 -> 16 >cHB 0x80 0x10000 + this driver \x02PROGRA 0x06 M\x02 -> 7 >BHB 0x10 0x2000 + +No code is shared with them, deliberately. fd268.py predates the py3 +port -- it uses MemoryMap and str-based I/O throughout, which a new +driver may not do. th9800.py's transport lives in module-level +functions hard-wired to that radio's ACK byte, block size and ENDR +terminator, with no reusable base. Neither exposes a class a new model +could subclass, and the three memory maps have nothing in common beyond +the wire format. If a shared transport base is wanted, it should be a +separate refactor across all three rather than a prerequisite here. + +Memories are 0-based, matching the radio's own display. + +Per-channel flags were decoded by toggling one attribute at a time on a +live radio and diffing re-reads. Power spans two bytes (power_high plus +power_mid) to encode three levels. The unknown1 bits shift when a channel +is front-panel edited but track no setting -- all four combinations were +observed on channels at identical settings -- so they are preserved +verbatim, never interpreted. + +IMPORTANT: only the North American **HT** variant has been tested. The +European DJ-VX50HE is believed to share this layout and protocol -- the +model ID at 0x0F80 reads "HBE" on the HT and the model check keys on that +rather than on the region suffix -- but no HE radio was available to +confirm it. Treat HE support as untested. + +Verified end to end against hardware (Alinco DJ-VX50HT, July 2026): + - sync_in output matches a USB capture of the factory software byte + for byte + - get_memory/set_memory round-trips every populated channel of four + separate codeplugs with zero byte changes + - a single-block write landed exactly 16 bytes at the target address + and nothing else + - a full sync_out wrote all 370 blocks; re-reading afterwards showed + zero differing bytes, and the frame sequence it emits (370 writes + plus 4 control frames, with the same skipped regions) matches the + factory software's captured session exactly +""" + +import logging + +from chirp import (bitwise, chirp_common, directory, errors, kenwood_tone, + memmap, util) + +LOG = logging.getLogger(__name__) + +MEM_FORMAT = """ +struct memory { + lbcd rxfreq[4]; + lbcd txfreq[4]; + ul16 rxtone; + ul16 txtone; + u8 unknown1:2, // not deterministic; preserved, never interpreted + power_high:1, + wide:1, // 1 = Wide, 0 = Narrow + scan:1, // 1 = scanned, 0 = skipped + unknown2:1, + bcl:1, // busy channel lockout; not exposed in the UI + unknown3:1; + u8 unknown4:4, + power_mid:1, // with power_high clear, 1 = Mid, 0 = Low + unknown5:3; + u8 unknown6; + u8 unknown7; +}; + +struct name { + char name[6]; +}; + +struct memory memory[200]; + +#seekto 0x1000; +struct name names[200]; +""" + +MEM_SIZE = 0x2000 # 8192 byte EEPROM +BLOCK = 0x10 # only block size the radio accepts +XFER_START = 0x0000 +XFER_END = 0x1800 # the factory software never touches 0x1800-0x1FFF + +# Regions the factory software deliberately refuses to write. 0x0ED0-0x0F8F +# holds per-radio TX power calibration and the model ID; overwriting it would +# be destructive and is not recoverable from a saved image. +WRITE_SKIP = [(0x0ED0, 0x0F8F), (0x14E0, 0x14FF)] + +MAGIC = b"\x02PROGRA" +CMD_READ = 0x52 # 'R' +CMD_WRITE = 0x57 # 'W' +CMD_IDENT = b"\x4d\x02" # 'M' +CMD_END = b"\x45" # 'E' +ACK = 0x06 + +MODEL_ADDR = 0x0F80 # 6 bytes compared by the factory software +MODEL_LEN = 6 + +TONES = chirp_common.TONES +DTCS_CODES = chirp_common.DTCS_CODES + +# All power levels come from published spec +POWER_LEVELS = [ + chirp_common.PowerLevel("Low", watts=1), + chirp_common.PowerLevel("Mid", watts=2), + chirp_common.PowerLevel("High", watts=5), +] + + +class DJVX50ToneModel(kenwood_tone.KenwoodToneModel): + """Kenwood tone scheme with the values stored as packed BCD. + + 131.8 Hz is stored as 0x1318 and D023 as 0x8023, so only the two value + converters differ from the base class. DCS-R is inferred from the + encoding, not observed on hardware. + """ + _DCS_MASK = 0x0FFF # 12 bits: a BCD code such as 754 occupies 0x754 + + def __init__(self): + super().__init__(dcs_base=0x8000, pol_mask=0x4000, + tone_init=0xFFFF, tone_flag=0x0000) + + def _get_tone_val(self, tone_val): + tone_val = int(tone_val) + if tone_val in (0x0000, 0xFFFF): + return None, None + if tone_val & self.dcs_base: + return (int("%03x" % (tone_val & self._DCS_MASK)), + "R" if tone_val & self.pol_mask else "N") + return int("%04x" % tone_val) / 10.0, None + + def _set_tone_val(self, code, pol): + if code is None: + return self.tone_init + if pol is not None: + val = self.dcs_base | int("%03i" % code, 16) + return val | self.pol_mask if pol == "R" else val + return int("%04i" % round(code * 10), 16) + + +def _skipped(addr): + return any(lo <= addr <= hi for lo, hi in WRITE_SKIP) + + +def _configure_pipe(pipe): + """Match the line settings the factory software uses. + + Two details matter here, both taken from a USB capture of the factory + software rather than from its own configuration: + + Asserting RTS for the duration of the session is what puts the radio + into its "PC" state -- it displays PC and locks the front panel, so a + stray PTT press or knob turn cannot disturb a transfer in progress. + Cloning works without it, but the radio stays live, so raise it. + + The factory app's MSComm string reads "9600,N,8,1", but what actually + reaches the FTDI chip is SET_DATA 0x1008 -- eight data bits, no + parity, and *two* stop bits. Match the wire, not the config string. + """ + try: + pipe.stopbits = 2 + except Exception as e: + LOG.warning('Could not set two stop bits: %s', e) + try: + pipe.dtr = True + pipe.rts = True + except Exception as e: + LOG.warning('Could not raise DTR/RTS: %s', e) + + +class _Proto: + """Thin protocol helper bound to a pipe.""" + + def __init__(self, pipe): + self.pipe = pipe + + def _xfer(self, cmd, replylen): + """Send cmd, consume the echo, return replylen bytes of reply.""" + self.pipe.write(cmd) + echo = self.pipe.read(len(cmd)) + if echo != cmd: + raise errors.RadioError( + "No echo from radio (got %s, expected %s) -- check cable " + "and that the radio is on" % (util.hexprint(echo), + util.hexprint(cmd))) + if not replylen: + return b"" + data = self.pipe.read(replylen) + if len(data) != replylen: + raise errors.RadioError( + "Short reply: wanted %i bytes, got %i" % (replylen, len(data))) + return data + + def start(self): + if self._xfer(MAGIC, 1)[0] != ACK: + raise errors.RadioError("Radio did not acknowledge program mode") + + def ident(self): + """Identify/band-info frame. Reply is 7 bytes beginning with 'F'.""" + reply = self._xfer(CMD_IDENT, 7) + if reply[0] != 0x46: + raise errors.RadioError("Unexpected identify reply: %s" + % util.hexprint(reply)) + # TODO(fields): only one sample of this frame has been observed + # (46 03 01 25 48 02 01); the field layout is unknown. The factory + # software uses it to raise "Frequency not match!" on a band mismatch. + LOG.debug("Identify: %s", util.hexprint(reply)) + return reply + + def read_block(self, addr): + cmd = bytes([CMD_READ, addr >> 8, addr & 0xFF, BLOCK]) + reply = self._xfer(cmd, 4 + BLOCK) + if reply[0] != CMD_WRITE: + raise errors.RadioError( + "Bad read header at 0x%04X: %s" % (addr, util.hexprint(reply))) + got = (reply[1] << 8) | reply[2] + if got != addr: + raise errors.RadioError( + "Radio returned address 0x%04X, expected 0x%04X" % (got, addr)) + return reply[4:4 + BLOCK] + + def write_block(self, addr, data): + assert len(data) == BLOCK + cmd = bytes([CMD_WRITE, addr >> 8, addr & 0xFF, BLOCK]) + bytes(data) + if self._xfer(cmd, 1)[0] != ACK: + raise errors.RadioError("Radio refused write at 0x%04X" % addr) + + def end(self): + self._xfer(CMD_END, 0) + + +def _status(radio, addr, msg): + s = chirp_common.Status() + s.cur = addr + s.max = XFER_END + s.msg = msg + radio.status_fn(s) + + +def _download(radio): + _configure_pipe(radio.pipe) + proto = _Proto(radio.pipe) + proto.start() + + model = proto.read_block(MODEL_ADDR)[:MODEL_LEN] + LOG.info("Model bytes at 0x%04X: %s", MODEL_ADDR, util.hexprint(model)) + if not radio._model_ok(model): + raise errors.RadioError( + "Model check failed (got %s). This does not look like a " + "DJ-VX50." % util.hexprint(model)) + + proto.ident() + + data = bytearray(b"\xFF" * MEM_SIZE) + for addr in range(XFER_START, XFER_END, BLOCK): + data[addr:addr + BLOCK] = proto.read_block(addr) + _status(radio, addr, "Cloning from radio") + proto.end() + return memmap.MemoryMapBytes(bytes(data)) + + +def _upload(radio): + _configure_pipe(radio.pipe) + proto = _Proto(radio.pipe) + proto.start() + + model = proto.read_block(MODEL_ADDR)[:MODEL_LEN] + if not radio._model_ok(model): + raise errors.RadioError( + "Model check failed (got %s); refusing to write." + % util.hexprint(model)) + + proto.ident() + + data = radio.get_mmap().get_byte_compatible() + for addr in range(XFER_START, XFER_END, BLOCK): + if _skipped(addr): + # Calibration / model ID. The factory software skips these and + # so do we -- writing them can brick the radio's calibration. + continue + proto.write_block(addr, data[addr:addr + BLOCK]) + _status(radio, addr, "Cloning to radio") + proto.end() + + +@directory.register +class AlincoDJVX50Radio(chirp_common.CloneModeRadio): + """Alinco DJ-VX50 / DJ-VX50HT""" + + VENDOR = "Alinco" + MODEL = "DJ-VX50" + BAUD_RATE = 9600 + NEEDS_COMPAT_SERIAL = False + + _memsize = MEM_SIZE + # The factory software compares 6 bytes at 0x0F80. Observed on a US + # (HT) radio: 14 14 48 42 45 00 -> ..'H''B''E'. 'HBE' appears on both + # variants; the region-distinguishing 'T' sits at 0x0F86, outside the + # compared range. We therefore only require the 'HBE' signature. + _model_signature = b"HBE" + _tone_model = DJVX50ToneModel() + + def _model_ok(self, model): + return self._model_signature in bytes(model) + + def get_features(self): + rf = chirp_common.RadioFeatures() + rf.has_settings = False # TODO(settings): blocks not decoded + rf.has_bank = False + rf.has_ctone = True + rf.has_cross = True + rf.has_rx_dtcs = True + rf.has_tuning_step = False + rf.can_odd_split = True + rf.valid_modes = ["FM", "NFM"] + rf.valid_tmodes = ["", "Tone", "TSQL", "DTCS", "Cross"] + # "Tone->" is deliberately absent: it is byte-identical to Tone and + # could not survive a round trip. + rf.valid_cross_modes = ["Tone->Tone", "Tone->DTCS", "DTCS->Tone", + "->Tone", "->DTCS", "DTCS->"] + rf.valid_duplexes = ["", "-", "+", "split", "off"] + rf.valid_name_length = 6 + rf.valid_characters = chirp_common.CHARSET_ASCII + rf.valid_skips = ["", "S"] + rf.valid_power_levels = list(POWER_LEVELS) + rf.valid_bands = [(136000000, 174000000), + (400000000, 470000000)] + rf.memory_bounds = (0, 199) + rf.valid_tones = list(TONES) + rf.valid_dtcs_codes = list(DTCS_CODES) + return rf + + def process_mmap(self): + self._memobj = bitwise.parse(MEM_FORMAT, self._mmap) + + def sync_in(self): + try: + self._mmap = _download(self) + except errors.RadioError: + raise + except Exception as e: + LOG.exception("Download failed") + raise errors.RadioError("Failed to download from radio: %s" % e) + self.process_mmap() + + def sync_out(self): + try: + _upload(self) + except errors.RadioError: + raise + except Exception as e: + LOG.exception("Upload failed") + raise errors.RadioError("Failed to upload to radio: %s" % e) + + def get_raw_memory(self, number): + return repr(self._memobj.memory[number]) + + def get_memory(self, number): + _mem = self._memobj.memory[number] + _nam = self._memobj.names[number] + + mem = chirp_common.Memory() + mem.number = number + + if _mem.get_raw()[:4] == b"\xFF\xFF\xFF\xFF": + mem.empty = True + return mem + + mem.freq = int(_mem.rxfreq) * 10 + + # An all-0xFF tx frequency is the "transmit disabled" sentinel. It + # must be caught on the raw bytes -- 0xFF nibbles are not valid BCD + # and decoding them first yields garbage. + if bytes(_mem.txfreq.get_raw()) == b"\xFF\xFF\xFF\xFF": + txfreq = None + else: + txfreq = int(_mem.txfreq) * 10 + + if txfreq is None or txfreq == 0: + mem.duplex = "off" + mem.offset = 0 + elif txfreq == mem.freq: + mem.duplex = "" + mem.offset = 0 + else: + delta = txfreq - mem.freq + if abs(delta) < 70000000: + mem.duplex = "-" if delta < 0 else "+" + mem.offset = abs(delta) + else: + mem.duplex = "split" + mem.offset = txfreq + + self._tone_model.get_tone(_mem, mem) + + if _mem.power_high: + mem.power = POWER_LEVELS[2] + elif _mem.power_mid: + mem.power = POWER_LEVELS[1] + else: + mem.power = POWER_LEVELS[0] + + mem.mode = "FM" if _mem.wide else "NFM" + mem.skip = "" if _mem.scan else "S" + + mem.name = str(_nam.name).rstrip("\xFF ").rstrip() + return mem + + def set_memory(self, memory): + _mem = self._memobj.memory[memory.number] + _nam = self._memobj.names[memory.number] + + if memory.empty: + _mem.set_raw(b"\xFF" * 16) + _nam.set_raw(b"\xFF" * 6) + return + + was_empty = _mem.get_raw()[:4] == b"\xFF\xFF\xFF\xFF" + if was_empty: + # Seed the pattern observed on every populated channel. + _mem.set_raw(b"\xFF" * 12 + b"\xF9\x00\x00\xF0") + + _mem.rxfreq = memory.freq // 10 + + if memory.duplex == "off": + _mem.txfreq.set_raw(b"\xFF\xFF\xFF\xFF") + elif memory.duplex == "split": + _mem.txfreq = memory.offset // 10 + elif memory.duplex == "+": + _mem.txfreq = (memory.freq + memory.offset) // 10 + elif memory.duplex == "-": + _mem.txfreq = (memory.freq - memory.offset) // 10 + else: + _mem.txfreq = memory.freq // 10 + + self._tone_model.set_tone(memory, _mem) + + lvl = str(memory.power) if memory.power else "High" + _mem.power_high = lvl == "High" + _mem.power_mid = lvl == "Mid" + _mem.wide = memory.mode == "FM" + _mem.scan = memory.skip != "S" + + _nam.name = memory.name.ljust(6)[:6] + + @classmethod + def match_model(cls, filedata, filename): + if len(filedata) != MEM_SIZE: + return False + return cls._model_signature in bytes( + filedata[MODEL_ADDR:MODEL_ADDR + MODEL_LEN]) + + +# The radio ships as DJ-VX50HT (North America) and DJ-VX50HE (Europe). The +# suffix appears in the model ID at 0x0F86. A single driver class is +# intended to cover both, but ONLY THE HT HAS BEEN TESTED -- the HE is +# assumed to share the layout and protocol, not confirmed to. diff --git a/tests/images/Alinco_DJ-VX50.img b/tests/images/Alinco_DJ-VX50.img new file mode 100644 index 0000000000000000000000000000000000000000..a6941bb2a9472dd5d588c41a22fd188df9e69937 GIT binary patch literal 8357 zcmeH~U2D@&7{{M>bWM99bBS z_!-2H;YF`SDEK-203zOcF>20PlVOovv=j$Ef5^!>`RDncbAExqflP~sOpVWFbJ<@2 zKLPGn0KCe0>?GNEL**g3#(^&VTHt@EILV1GcsvO2u6)ns!SngZclLR3>cJe4q4hKe zg#?fQ5q zGYkLtI58XbxPz(d7B(t2VQq)%NI{hHPNWKQfK2t_C;4LH+pihWy)o7Gw4^JlLD(1 vNnaD~uC7?7F57!7pFOjhr_y1gf4J5)6}#!k#dYQ>b>mHU=b$Ls`A5G2MU286 literal 0 HcmV?d00001 From ba5f845cecc12083c91c22fc6d9a0a50d098965a Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Sun, 2 Aug 2026 18:44:34 -0700 Subject: [PATCH 3/3] WIP Move DJ-VX50 to use native BCD support in kenwood_tone --- chirp/drivers/alinco_djvx50.py | 36 ++++------------------------------ 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/chirp/drivers/alinco_djvx50.py b/chirp/drivers/alinco_djvx50.py index f40b0ecce..44b4994d2 100644 --- a/chirp/drivers/alinco_djvx50.py +++ b/chirp/drivers/alinco_djvx50.py @@ -153,37 +153,6 @@ ] -class DJVX50ToneModel(kenwood_tone.KenwoodToneModel): - """Kenwood tone scheme with the values stored as packed BCD. - - 131.8 Hz is stored as 0x1318 and D023 as 0x8023, so only the two value - converters differ from the base class. DCS-R is inferred from the - encoding, not observed on hardware. - """ - _DCS_MASK = 0x0FFF # 12 bits: a BCD code such as 754 occupies 0x754 - - def __init__(self): - super().__init__(dcs_base=0x8000, pol_mask=0x4000, - tone_init=0xFFFF, tone_flag=0x0000) - - def _get_tone_val(self, tone_val): - tone_val = int(tone_val) - if tone_val in (0x0000, 0xFFFF): - return None, None - if tone_val & self.dcs_base: - return (int("%03x" % (tone_val & self._DCS_MASK)), - "R" if tone_val & self.pol_mask else "N") - return int("%04x" % tone_val) / 10.0, None - - def _set_tone_val(self, code, pol): - if code is None: - return self.tone_init - if pol is not None: - val = self.dcs_base | int("%03i" % code, 16) - return val | self.pol_mask if pol == "R" else val - return int("%04i" % round(code * 10), 16) - - def _skipped(addr): return any(lo <= addr <= hi for lo, hi in WRITE_SKIP) @@ -344,7 +313,10 @@ class AlincoDJVX50Radio(chirp_common.CloneModeRadio): # variants; the region-distinguishing 'T' sits at 0x0F86, outside the # compared range. We therefore only require the 'HBE' signature. _model_signature = b"HBE" - _tone_model = DJVX50ToneModel() + _tone_model = kenwood_tone.KenwoodToneModel( + dcs_base=0x8000, pol_mask=0x4000, + tone_init=0xFFFF, tone_flag=0x0000, + tone_enc_base=16, dcs_enc_base=16) def _model_ok(self, model): return self._model_signature in bytes(model)