mirror of
https://github.com/kk7ds/chirp
synced 2026-08-13 17:51:36 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
6b0a49d459
commit
03432d74c2
2 changed files with 503 additions and 0 deletions
503
chirp/drivers/alinco_djvx50.py
Normal file
503
chirp/drivers/alinco_djvx50.py
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""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 <hi> <lo> 10 -> 57 <hi> <lo> 10 +16 read 16 bytes
|
||||
4D 02 -> 46 03 01 25 48 02 01 identify / band info
|
||||
57 <hi> <lo> 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.
|
||||
BIN
tests/images/Alinco_DJ-VX50.img
Normal file
BIN
tests/images/Alinco_DJ-VX50.img
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue