diff --git a/README.md b/README.md index 252ef93..3614648 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - ✅ **Прошивка разложена на запчасти:** два процессора + внешний SPI = три независимых канала внедрения. Карта — [`docs/firmware-parts.md`](docs/firmware-parts.md). - ✅ **Найден штатный загрузчик ресурсов** (`Unicode Download.exe`, внутри `FontVoicePicture`) — официальный путь замены **шрифтов, голосовых подсказок и картинок** (в т.ч. загрузочного логотипа) без патча MCU. - ✅ **Разобран спектроанализатор REFV DualTachyon** — та самая реализация, которую хвалят пользователи. См. [`docs/refw-spectrum.md`](docs/refw-spectrum.md). -- 🎯 **DMR-чип опознан: FM100B = Sicomm SCT3258TD** — документированный baseband. Датащит и Packet Interface (126 стр) в [`docs/SCT3258_packet_interface.txt`](docs/SCT3258_packet_interface.txt). Открывает калибровку/AMBE/вокодер по спеке. Родня: UV-4R, китайские клоны (та же связка SCT3258+AT32). +- 🎯 **DMR-чип опознан по схеме: FM100B = HF6853 / AUCTUS A6 (внутри RDA8809)** — одночиповый DMR-SoC (CPU+ВЧ-трансивер+DSP+AMBE-вокодер+кодек), прошивка во внешнем SPI-flash. См. [`docs/hf6853-auctus-a6.md`](docs/hf6853-auctus-a6.md). Скрытый ATE-интерфейс полностью реверснут ([`research/a6tools`](research/a6tools)) — **чинит частотную калибровку TX** (`AT+DMOFREQERR`) без Radtel. Родня: COTRE CO01D, GOCOM GD900, UV-4R. *(Прежняя версия «SCT3258» — ошибка по строкам вокодера; SCT3258 это baseband-only, HF6853 содержит ВЧ-тракт сам.)* - ✅ **Приём Talker Alias** — позывной вызывающего крупно на экране (cmd `0x60`). Сборка [`tools/build_ta.py`](tools/build_ta.py). - ✅ **Интерактивный агент** — управление FM100B через COM без перешивки ([`tools/build_agent.py`](tools/build_agent.py) + [`tools/agent.py`](tools/agent.py)); снят полный [список команд FM100B](docs/fm100b-commands.md). - ✅ **Работа с SPI** — чтение/запись/восстановление codeplug и калибровки из дампа ([`tools/spi_read.py`](tools/spi_read.py), [`tools/spi_wr.py`](tools/spi_wr.py), [`tools/spi_restore.py`](tools/spi_restore.py)). diff --git a/docs/dmr-tx-recovery.md b/docs/dmr-tx-recovery.md index 6b21354..0897111 100644 --- a/docs/dmr-tx-recovery.md +++ b/docs/dmr-tx-recovery.md @@ -30,6 +30,14 @@ Software «Write» залил ДЕФОЛТНЫЕ** значения смещен ## Пути восстановления (по приоритету) +0. **⭐ AT+DMOFREQERR (скрытый ATE-интерфейс чипа) — ЛУЧШИЙ.** Опознано (24.07.2026): + DMR-чип = **HF6853 / AUCTUS A6 (RDA8809)**, не SCT3258 (см. `docs/hf6853-auctus-a6.md`). + У него реверснутый ATE-интерфейс с частотной калибровкой TCXO: + `AT+GETFREQERR` / `AT+DMOFREQERR=`, где `offset[Гц] = -2500 + 10*N`, N=0..500, ±2500 Гц. + Наше смещение ~1100 Гц — внутри диапазона, чинится одной записью. После записи сменить + канал. Инструмент: `tools/a6_freqfix.py` (probe/read/write, требует SDR-измерения TX). + Не требует Radtel `.updat` и Radio Tuning Software. Библиотека — `research/a6tools`. + 1. **Компенсация на хотспоте (RXOffset)** — БЕЗ прибора. Если TX рации смещена на постоянный X Гц, выставить на хотспоте `RXOffset` так, чтобы приёмник поймал смещённую передачу. Перебор RXOffset в MMDVM.ini (±1000 Гц шагом ~100), рестарт MMDVMHost, при передаче рации diff --git a/docs/hf6853-auctus-a6.md b/docs/hf6853-auctus-a6.md new file mode 100644 index 0000000..1633bdb --- /dev/null +++ b/docs/hf6853-auctus-a6.md @@ -0,0 +1,104 @@ +# RT-4D DMR-модуль «FM100B» = чип **HF6853** (семейство AUCTUS A6 / RDA8809) + +**Дата: 24.07.2026. Опознание БЕЗ разбора рации — по схеме `RT4DDLT01` (Radtel, v2.1, 2024-06-04).** + +## Что на схеме + +Лист `RT4DDLT01` («DMR daughterboard») содержит РОВНО ОДИН основной чип: + +- **U700 — маркировка `HF6853`**, 41-выводный корпус. +- Обвязка: кварц **X700 = 26 МГц (2520)**, LDO **U702 = XC6204E332MR (3.3 В)**, + аудио-ОУ **U701**, аналоговый ключ **Q700** (DIG/ANA AF-SW), пассив. +- Внешний **SPI-flash** к чипу (выводы `F_SO / F_SI / F_SCK / F_CSN`, они же + `MCLK/IO2`, `TCK/IO3`, `IO0`, `IO1`) — там лежит прошивка модуля (`FM100B_V1.2.0.13`). +- UART к хосту (AT32): `P28/RX_EN` / `P25/TX_EN` → **DIG-TXD / DIG-RXD** на разъёме. + +### HF6853 — это ВСЁ-в-одном (не только baseband!) + +Выводы U700 однозначно показывают интеграцию всего радиотракта в один кристалл: + +| Группа выводов | Назначение | +|---|---| +| `VCCRXFE, ANT, VCCPA, VCCPAD, VCCIF, VSYS` | ВЧ-приёмник (RX front-end), PA, ПЧ — **интегрированный трансивер** | +| `MICBIAS, MICLP, MICLN, LINEL, LINER` | вход микрофона/линии — **аудио-кодек** | +| `AUDLP, AUDLN, VCOM, VDDAUD, GNDAUD` | выход аудио — **ЦАП/кодек** | +| `F_SO/F_SI/F_SCK/F_CSN` | внешний SPI-flash с прошивкой | +| `P28/RX, P25/TX, P0/TXD2, P12/CTS, P13/RTS` | UART’ы к хосту | +| `XI/XO (26 МГц), CLK_26M/PWM0` | опорный генератор | + +То есть DSP + AMBE-вокодер + ВЧ-трансивер + аудио-кодек + CPU — **в одном чипе HF6853**. + +## Идентификация: семейство AUCTUS A6 + +Архитектура «CPU + ВЧ-трансивер + DSP + AMBE-вокодер + кодек в одном кристалле, +прошивка во внешнем SPI-flash, 26 МГц» — это **AUCTUS A6** ( shenzhen Auctus). +Реверс jhart99 показал: **A6 внутри = RDA8809** (дериватив RDA8955, 2G-GSM SoC, +перепрофилированный под рацию). Auctus также владеет правами на AT1846. + +Тот же чип/модуль стоит в: COTRE CO01D/CO04D/CO06D, GOCOM GD900/GD100/GD700/GD800, +CONNECOM. Прошивочные строки RT-4D (`Vocoder/WebRTC/codec adc/dac/mic`, +`F312/F315/DP275/FM100/PM100`) — это общая прошивка Auctus/iRadio/Radtel DMR-модуля. + +> **Коррекция прежней версии:** ранее модуль был опознан как *Sicomm SCT3258TD* по +> строкам вокодера. Это неверно: SCT3258 — только baseband (нужен внешний ВЧ SCT3700), +> а HF6853 содержит ВЧ-тракт сам. Нативный протокол тоже другой (не `84 A9 61`, а +> RDA-debug `AD 00 …` + CPS `aa … bb`, см. ниже). Актуальная идентификация — **HF6853 / Auctus A6 / RDA8809**. + +## Скрытый ATE/CPS-интерфейс (реверс jhart99, репо `a6tools`) + +Полностью реверснут. Копия в [`research/a6tools/`](../research/a6tools). Транспорт: UART **921600 8N1, XON/XOFF**. + +### Два уровня протокола + +1. **RDA-debug** (низкий уровень, доступ к памяти/регистрам): + кадр `AD 00 FF `; спецбайты `0x5C/0x11/0x13` экранируются + (`5C, 0xFF^x`); ACK = `11 13`. + Команды: `0x02` read_word, `0x04` read_reg8, `0x84` write_reg8, `0x83` write_block. +2. **ATE/CPS** (высокий уровень): команда пишется `write_block`’ом в спец-регистр + `ate_cps_addr` (адрес = read_word(`0x81C00270`)), затем «звонок» — запись в + регистр `0x00000005` (h2p): `0x00`=clear, `0xA5`=выполнить, `0xEE`=reboot, `0xFF`=boot handler. + - ATE-кадр: строка `AT+…\r`, добита нулями до кратности 4. + - CPS-кадр: `aa bb` + null-паддинг до кратности 4. + - Ответ читается из памяти: длина по `resp_addr-4`, данные с `resp_addr` + (`resp_addr` = read_word(`0x81C00264`), uart_resp = `0x81C0026C`). + +Вход в debug: держать UART в debug-режиме записью регистра `0x00000003 = 0x80`. +`rebootandfreeze` (запись рег.`0` = `0x03`) — сброс+останов CPU для чтения ROM. + +## ⭐ Починка частотного смещения TX (наша проблема [[rt4d-dmr-tx-broken]]) + +**Ровно наш случай.** У A6-раций заводское смещение TCXO (у jhart99 GOCOM GD900 было ++800 Гц «в допуске», но MMDVM не тянул). У нас ~1100 Гц — тоже в пределах ±2500 Гц. + +Команды: `AT+GETFREQERR` (прочитать), `AT+DMOFREQERR=` (записать). +**Формула:** `offset[Гц] = -2500 + 10*N` ⇔ `N = (offset + 2500)/10` (N = 0..500, шаг 10 Гц). +После записи **сменить канал**, чтобы применилось. Диапазон ±2500 Гц. + +`freqoffset.py`: `delta = target - current; N = ((get_freq_err()+delta)+2500)/10`. +Т.е. измеряем реальную TX по SDR, даём (измеренную, номинальную) — тул сам считает N. + +### Как применить на RT-4D +1. Ввести модуль в bridge/update-режим («Обновление DMR чипа» — вкл. с зажатой MENU). + Проверить, слушает ли он RDA-debug (`AD 00 … FF 04 <03000000> 01 …` → ответ `content=0x80`) + на нужной скорости (пробовать 921600, затем скорость нашего моста). +2. Если отвечает → адаптировать `tools/a6_freqfix.py` (заготовка в репо) под COM рации через + powershell-мост (как в `tools/fm100b.py`). +3. Измерить реальную TX-частоту рации по SDR (RTL-SDRv3) относительно номинала (438.800 и т.д.). +4. `AT+DMOCONNECT` → `AT+GETFREQERR` → посчитать N → `AT+DMOFREQERR=N` → сменить канал → проверить BER на хотспоте. + +**НЕ выполнять вслепую** (боевая рация, нужен SDR для измерения — см. [[rt4d-dmr-tx-broken]]). +Это первый путь, НЕ требующий Radtel `.updat` и не требующий Radio Tuning Software. + +## Что это открывает помимо TX +- `AT+DMOSETVAFC/DMOGETVAFC`, `AT+DMOSETPOWER/DMOSETCHPOWER`, `AT+DMORDRSSI` — вся калибровка. +- CPS-команды (`0x0012` chanInfo и др.) — прямое чтение/запись каналов в модуле. +- `radiodump-ng.py` — дамп прошивки модуля через debug-интерфейс (свой вокодер/reverse). +- Полный список — `research/a6tools/atcommands.md`, `cpecommands.md`. + +## Источники +- jhart99: [A6 обзор](https://jhart99.com/auctus-a6/), [A6 in depth](https://jhart99.com/auctus-a6-revealed/), + [hidden interface](https://jhart99.com/a6-hidden-interface/), [ATE/CPS commands](https://jhart99.com/atecps/), + [Frequency Fix (GD900)](https://jhart99.com/frequency-fix/) +- Код: [github.com/jhart99/a6tools](https://github.com/jhart99/a6tools) (копия в `research/a6tools/`) +- qdmr `AuctusA6Interface` (libdmrconf) +- Схема: `RT4DDLT01.pdf` (Radtel), рендеры листа DMR-модуля. diff --git a/research/a6tools/LICENSE b/research/a6tools/LICENSE new file mode 100644 index 0000000..34811f2 --- /dev/null +++ b/research/a6tools/LICENSE @@ -0,0 +1,20 @@ +Copyright 2021 Jonathan Hart + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/research/a6tools/README.md b/research/a6tools/README.md new file mode 100644 index 0000000..4cbc03d --- /dev/null +++ b/research/a6tools/README.md @@ -0,0 +1,35 @@ +## A6tools + +A Python library and tools AUCTUS A6 based radios. + +### Radios +Tested and working with: +* COTRE CO01D +* COTRE CO04D +* COTRE CO06D +* GOCOM GD900 + +Probably also works with: +* GOCOM GD100 +* GOCOM GD700 +* GOCOM GD800 +* CONNECOM radios as well + +### Usage + +#### atcommander + +atcommander sends AT commands to the radio which can be used to change many of the parameters. + +Usage: +``` +$ python3 atcommander.py AT+DMOCONNECT +OnCmd_DMOCONNECT +ATE_SendCmdAck: cmd:DMOCONNECT isOk:0x1 +tx_length:17 +ATE_SendDataFrame: ++DMOCONNECT:0 + +ATE pipe. used[HOST] +``` +You must start with AT+DMOCONNECT prior to any other commands diff --git a/research/a6tools/a6/__init__.py b/research/a6tools/a6/__init__.py new file mode 100644 index 0000000..6ed1893 --- /dev/null +++ b/research/a6tools/a6/__init__.py @@ -0,0 +1,26 @@ +from .escaper import escaper +from .escaper import unescaper +from .rdadebug import compute_check +from .rdadebug import rda_debug_frame +from .rdadebug import read_word +from .rdadebug import write_register_int8 +from .rdadebug import read_register_int8 +from .rdadebug import write_block +from .a6commands import h2p_command +from .a6commands import set_uart_to_host +from .a6commands import set_uart_to_normal +from .a6commands import read_uart_to_host +from .a6commands import ate_command +from .a6commands import cps_command +from .a6commands import reboot_and_freeze +from .serialio import send_uart_setup +from .serialio import fetch_memory_address +from .serialio import send_ate_command +from .serialio import send_cps_command +from .serialio import atecps_resp_read +from .serialio import read_mem_range +from .serialio import get_chan_info +from .serialio import get_freq_err +from .serialio import parse_freq_err_resp +from .serialio import set_freq_err +from .serialio import SerialIO diff --git a/research/a6tools/a6/a6commands.py b/research/a6tools/a6/a6commands.py new file mode 100644 index 0000000..ae881d7 --- /dev/null +++ b/research/a6tools/a6/a6commands.py @@ -0,0 +1,168 @@ +from .rdadebug import write_register_int8 +from .rdadebug import read_register_int8 +from .rdadebug import write_block +from .rdadebug import compute_check +from .eprint import eprint + +def h2p_command(msg): + """ Format a frame for an h2p command + + The CPS software sends commands to a special debug register + 00000005. Writing a value to this register throws an interupt + which is picked up by a function on the device. + + 0x00 : Command finished, clears semaphore + 0xA5 : Process command with RxByHostPortCB + 0xEE : Reboot + 0xFF : Handle with boot_HstCmdBasicHandler + + @param msg: the message to send + @return: the frame to send + + """ + return write_register_int8(0x5, msg) + +def set_uart_to_normal(): + """ Set device uart to host mode + + The CPS software sends repeated requests to set internal register + 00000003 to 0x80 which has the effect of locking the UART to debug + mode + + @return: the frame to send + + """ + return write_register_int8(3, 0x00) + +def set_uart_to_host(): + """ Set device uart to host mode + + The CPS software sends repeated requests to set internal register + 00000003 to 0x80 which has the effect of locking the UART to debug + mode + + @return: the frame to send + + """ + return write_register_int8(3, 0x80) + +def reboot_and_freeze(): + """ Reboot and freeze the processor + + This command comes from coolwatcher and resets the processor and + immediately halts it. This is useful for stepping through the + boot process, but also allows some areas of ROM to be read without + crashing + + @return: the frame to send + + """ + return write_register_int8(0, 0x03) + +def read_uart_to_host(): + """ make a frame containing a knock command + + this function creates a frame that I assume wakes up the device + for further commands. + + @return: the frame to send + + """ + return read_register_int8(3) + +def ate_command(cmd, p_atecps_write): + """ make a frame containing an ATE command + + @param cmd: the command to send + @param p_atecps_write: the address of the CPS write register + @return: the frame to send + + """ + cmd = bytearray(cmd, 'utf-8') + b'\r' + cmd += bytes(4 - len(cmd) % 4) + return write_block(p_atecps_write, cmd) + +def cps_command(cmd, p_atecps_write): + """ make a frame containing an CPS command + + @param cmd: the command to send + @param p_atecps_write: the address of the CPS write register + @return: the frame to send + + """ + length = (len(cmd) + 4).to_bytes(1, 'big') + check = compute_check(length + cmd) + begin = bytes([0xaa]) + end = bytes([0xbb]) + msg = begin + length + cmd + check + end + padding = 4 - (len(msg) % 4) + return write_block(p_atecps_write, msg + bytes([0x00]) * padding) + +class CPSFrame: + """ Received CPS class + + This class decodes CPS frames received from the device. + """ + check_fail = False + length = 0 + type = 0 + content = bytes([]) + def __init__(self, msg): + eprint(msg.hex()) + if (msg[-1].to_bytes(1, 'big') != compute_check(msg[1:-2])): + self.check_fail = True + eprint('CPS frame check failed') + return + self.length = msg[1] + self.type = int.from_bytes(msg[2:4], 'big') + self.is_ok = msg[4] == 0x01 + self.content = msg[5:-3] + def __repr__(self): + return 'packet length {} type {} is_ok {} content {}'.format(self.length, self.type, self.is_ok, self.content) + +class ChanInfoFrame(CPSFrame): + """ Received ChanInfoFrame class + + This class decodes ChanInfoFrame frames received from the device. + + "\tcpsInst.chanInfo.nChanIndex=%d\n + \tcpsInst.chanInfo.nChanType=%d\n + \tcpsInst.chanInfo.nVox=%d\n + \tcpsInst.chanInfo.nPower=%d\n + \tcpsInst.chanInfo.nRxFreq=%d\n + \tcpsInst.chanInfo.nTxFreq=%d\n + \tcpsInst.chanInfo.nTxContactsIdx=0x%08x\n + \tcpsInst.chanInfo.nColorCode=%d\n + \tcpsInst.chanInfo.nTimeSlot=%d\n + \tcpsInst.chanInfo.bPoliteCall=%d\n" + \tcpsInst.chanInfo.nEmrSys=%d\n + \tcpsInst.chanInfo.nEncry=%d\n + \tcpsInst.chanInfo.nTypeWideNarrow=%d\n + \tcpsInst.chanInfo.nRxCtdcs=%d\n + \tcpsInst.chanInfo.bRxCtdcsInvert=%d\n + \tcpsInst.chanInfo.bTxCtdcsInvert=%d\n + \tcpsInst.chanInfo.nTxCtdcs=%d\n + \tcpsInst.chanInfo.nRxGrpListIdx=%d\n" + """ + def __init__(self, msg): + super().__init__(msg) + self.index = int.from_bytes(self.content[0:2], 'little') + self.chantype = self.content[2] + self.rxFreq = int.from_bytes(self.content[4:8], 'little') + self.txFreq = int.from_bytes(self.content[8:12], 'little') + self.txContactIndex = int.from_bytes(self.content[12:16], 'little') + self.colorCode = self.content[16] + self.timeslot = self.content[17] + self.polite = self.content[18] + self.emrSys = int.from_bytes(self.content[1:2], 'big') + self.encryption = int.from_bytes(self.content[1:2], 'big') + self.widenarrow = int.from_bytes(self.content[1:2], 'big') + self.rxctdcs = int.from_bytes(self.content[1:2], 'big') + self.rxctdcsinvert = int.from_bytes(self.content[1:2], 'big') + self.txctdcsinvert = int.from_bytes(self.content[1:2], 'big') + self.txctdcs = int.from_bytes(self.content[1:2], 'big') + self.rxGroupIdx = int.from_bytes(self.content[1:2], 'big') + self.vox = int.from_bytes(self.content[1:2], 'big') + def __repr__(self): + return 'packet length {} type {} is_ok {} index {} chantype {} rxfreq {} txfreq {}'.format( + self.length, self.type, self.is_ok, self.index, self.chantype, self.rxFreq, self.txFreq) \ No newline at end of file diff --git a/research/a6tools/a6/eprint.py b/research/a6tools/a6/eprint.py new file mode 100644 index 0000000..3966219 --- /dev/null +++ b/research/a6tools/a6/eprint.py @@ -0,0 +1,14 @@ +import sys + +def eprint(*args, **kwargs): + """ print to stderr + + This function takes its arguments just as if it were the normal + print function and instead prints to stderr. + + @param args: the arguments to print + @param kwargs: the keyword arguments to print + + """ + + print(*args, file=sys.stderr, **kwargs) diff --git a/research/a6tools/a6/escaper.py b/research/a6tools/a6/escaper.py new file mode 100644 index 0000000..b28827c --- /dev/null +++ b/research/a6tools/a6/escaper.py @@ -0,0 +1,34 @@ +def escaper(msg): + """ escape message + + this function escapes special characters in the message. These + are 0x5c, 0x11 and 0x13 which are '\' and XON and XOFF characters. + + @param msg: the message to escape + @return: the escaped message + + """ + + out = bytes(sum([[0x5c, 0xFF ^ x ] if x in [0x11, 0x13, 0x5c] else [x] for x in msg], [])) + return out + +def unescaper(msg): + """ unescape message + + this function undoes any escape sequences in a received message + + @param msg: the message to unescape + @return: the unescaped message + """ + + out = [] + escape = False + for x in msg: + if x == 0x5c: + escape = True + continue + if escape: + x = 0x5c ^ x ^ 0xa3 + escape = False + out.append(x) + return bytes(out) diff --git a/research/a6tools/a6/rdadebug.py b/research/a6tools/a6/rdadebug.py new file mode 100644 index 0000000..1a0c5a8 --- /dev/null +++ b/research/a6tools/a6/rdadebug.py @@ -0,0 +1,121 @@ +import functools +import operator +from .escaper import escaper +from .escaper import unescaper +from .eprint import eprint + +__author__ = "jhart99" +__license__ = "MIT" + +def compute_check(msg): + """ Compute the check value for a message + + AUCTUS messages use a check byte which is simply the XOR of all + the values of the message. + + """ + + if len(msg) == 0: + return int(0).to_bytes(1, 'little') + return functools.reduce(operator.xor, msg).to_bytes(1, 'little') + +def rda_debug_frame(flow, cmd, message): + """ Format a raw message into a frame + + AUCTUS frames are of the form AD 00 XX FF ...message... YY + where XX is the length of the message and YY is the check byte + Additionally certain bytes in the message are escaped. + + """ + + header = int(0xad).to_bytes(1, 'big') + msg = flow + cmd + message + msglen = len(msg).to_bytes(2, 'big') + check = compute_check(msg) + return escaper(header + msglen + msg + check) + +def read_word(addr, seq = 1): + """ make a frame to read a word at a memory address + + this function creates a frame to read the memory from the device + suitable for serial transmission. + + """ + + flow = bytes([0xff]) + command = bytes([0x02]) + if isinstance(addr, int): + addr = addr.to_bytes(4, 'little') + msg = addr + seq.to_bytes(1, 'big') + return rda_debug_frame(flow, command, msg) + +def write_register_int8(addr, msg): + """ write to a byte to an internal register + + this function creates a frame to do some device magic and these + frames are used in the preamble and finalizer commands. + + """ + + flow = bytes([0xff]) + command = bytes([0x84]) + msg = addr.to_bytes(4, 'little') + msg.to_bytes(1, 'little') + return rda_debug_frame(flow, command, msg) + +def read_register_int8(addr, seq=1): + """ make a frame containing a knock command + + this function creates a frame that I assume wakes up the device + for further commands. + + """ + + flow = bytes([0xff]) + command = bytes([0x04]) + msg = addr.to_bytes(4, 'little') + seq.to_bytes(1, 'big') + return rda_debug_frame(flow, command, msg) + +def write_block(addr, msg): + """ make a frame containing a write command + + this function creates a frame to do write a multiple byte content + at a specific memory address. The length need not be a word, but + could be 16 bytes or more. + + """ + + flow = bytes([0xff]) + command = bytes([0x83]) + if isinstance(addr, int): + addr = addr.to_bytes(4, 'little') + msg = addr + msg + return rda_debug_frame(flow, command, msg) + + +class RdaFrame: + """ Received Frame class + + This class decodes possible received Frames. + """ + ack = False + check_fail = False + seq = 0 + length = 0 + content = bytes([]) + def __init__(self, msg): + msg = unescaper(msg) + if len(msg) <= 4: + if(msg == b'\x11\x13'): + self.ack = True + else: + # impossibly short frame something is wrong. + self.check_fail = True + return + if (msg[-1].to_bytes(1, 'big') != compute_check(msg[3:-1])): + self.check_fail = True + return + self.seq = msg[4] + self.length = msg[2] + self.content = msg[5:-1] + def __repr__(self): + return 'packet length {} seq {} content {} ack {} check {}'.format(self.length, self.seq, self.content, self.ack, self.check_fail) diff --git a/research/a6tools/a6/serialio.py b/research/a6tools/a6/serialio.py new file mode 100644 index 0000000..73690f1 --- /dev/null +++ b/research/a6tools/a6/serialio.py @@ -0,0 +1,365 @@ +import serial +import time +import sys +import re +from .eprint import eprint +from .a6commands import CPSFrame, h2p_command +from .a6commands import ChanInfoFrame, h2p_command +from .a6commands import ate_command +from .a6commands import cps_command +from .a6commands import read_uart_to_host +from .rdadebug import RdaFrame +from .rdadebug import read_word + +class Singleton(object): + def __new__(cls, *args, **kwargs): + """ Singleton class + + @param args: arguments + @param kwargs: keyword arguments + @return: object + """ + it = cls.__dict__.get("__it__") + if it is not None: + return it + cls.__it__ = it = object.__new__(cls) + it.init(*args, **kwargs) + return it + + def init(self, *args, **kwargs): + """ + """ + pass + +class SerialIO(Singleton): + def init(self, port, baudrate=921600, verbosity=0, timeout=0.1): + """ Initialize the serial port + + @param port: serial port + @param baudrate: baud rate + @param verbosity: verbosity level + """ + self.port = port + self.sio = serial.Serial(port, baudrate, + serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE, + xonxoff=True, rtscts=False, timeout=timeout) + self.verbosity = verbosity + self._ate_cps_addr = 0 + self._ate_cps_resp_addr = 0 + self._ate_cps_resp_length_addr = 0 + self._uart_resp_addr = 0 + self.sio.flush() + if verbosity > 0: + eprint("SerialIO: {} initialized".format(self.port)) + + def __del__(self): + """ Close the serial port + """ + self.sio.close() + + def write(self, msg): + """ Write a message to the serial port + + @param msg: message + """ + if self.verbosity > 0: + eprint("write : ", msg.hex()) + self.sio.write(msg) + + def read(self, nbytes): + """ Read nbytes from the serial port + + @param nbytes: number of bytes + @return: message + """ + data = self.sio.read(nbytes) + if self.verbosity > 0: + eprint("read : ", data.hex()) + return data + + def flush(self): + """ Flush the serial port + """ + self.sio.flush() + + @property + def in_waiting(self): + """ return the number of bytes in the serial port + """ + return self.sio.in_waiting + + @property + def ate_cps_addr(self): + """ return the address of the ate command + """ + if self._ate_cps_addr == 0: + self._ate_cps_addr = fetch_memory_address(0x81c00270) + self._ate_cps_addr = int.from_bytes(self._ate_cps_addr, byteorder='little') + return self._ate_cps_addr + + @property + def ate_cps_resp_addr(self): + """ return the address of the ate command response + """ + if self._ate_cps_resp_addr == 0: + self._ate_cps_resp_addr = fetch_memory_address(0x81c00264) + self._ate_cps_resp_addr = int.from_bytes(self._ate_cps_resp_addr, byteorder='little') + return self._ate_cps_resp_addr + + @property + def ate_cps_resp_length_addr(self): + """ return the address of the ate command response + """ + return self.ate_cps_resp_addr - 4 + + @property + def uart_resp_addr(self): + """ return the address of the ate command response + """ + if self._uart_resp_addr == 0: + self._uart_resp_addr = fetch_memory_address(0x81c0026c) + self._uart_resp_addr = int.from_bytes(self._uart_resp_addr, byteorder='little') + return self._uart_resp_addr + + + +def write_flush_pause(msg, sleep = 0.07): + """ Write out to serial and wait for the radio to process the command + + @param msg: bytes to write + @param sleep: time to sleep after writing in ms + + """ + uart = SerialIO() + uart.write(msg) + uart.flush() + time.sleep(0.07) + + +def send_ate_command(msg): + """ Send a command to the ATE/CPS function on the radio + + To send a command to the ATE or CPS software on the radio, it has + to be surrounded by these h2p commands which clear the registers + and then throw and interupt which causes the command to be + executed + + @param msg: bytes to write + + """ + uart = SerialIO() + write_flush_pause(h2p_command(0)) + write_flush_pause(ate_command(msg, uart.ate_cps_addr)) + write_flush_pause(h2p_command(0xa5)) + +def send_cps_command(msg): + """ Send a command to the ATE/CPS function on the radio + + To send a command to the ATE or CPS software on the radio, it has + to be surrounded by these h2p commands which clear the registers + and then throw and interupt which causes the command to be + executed + + @param msg: bytes to write + + """ + + uart = SerialIO() + write_flush_pause(h2p_command(0)) + write_flush_pause(cps_command(msg, uart.ate_cps_addr)) + write_flush_pause(h2p_command(0xa5)) + +def wait_on_read(retries=256, delay=0): + """ Wait until a read happens + + This function waits until something is received from the serial or + will abort after a certain number of retries. + + @param retries: number of retries before aborting + @param delay: time to sleep between retries + """ + + uart = SerialIO() + size = uart.in_waiting + countdown = retries + while size == 0 and countdown > 0: + size = uart.in_waiting + countdown -= 1 + if delay: time.sleep(delay) + if countdown == 0: + # nothing received + return b'' + if size > 0: + data = uart.read(size) + return data + +def send_uart_setup(): + """ Replays the initial UART setup sequence + + This sequence and timing is from the CPS software capture. + """ + uart = SerialIO() + knock_worked = False + retries = 25 + while not knock_worked and retries > 0: + uart.write(read_uart_to_host()) + uart.flush() + time.sleep(0.001) + data = wait_on_read() + response = RdaFrame(data) + if response.seq == 1 and response.content == b'\x80': + knock_worked = True + else: + time.sleep(0.25) + retries -= 1 + return knock_worked + +def fetch_memory_address(addr, seq=1): + """ Attempt to read a memory address and keep trying until it succeeds + + """ + uart = SerialIO() + read_ok = False + retval = b'' + retries = 25 + while not read_ok: + frame = read_word(addr, seq) + uart.write(frame) + uart.flush() + size = uart.in_waiting + i = retries + while size == 0 and i > 0: + time.sleep(0.001) + size = uart.in_waiting + i -= 1 + if retries == 0: + continue + data = uart.read(size) + inbound_frame = RdaFrame(data) + read_ok = inbound_frame.seq == seq and not inbound_frame.check_fail + retval = inbound_frame.content + return retval + +def atecps_resp_read(): + """ Read the response from an ATECPS command + + @return: response from ATECPS command + + """ + uart = SerialIO() + length = fetch_memory_address(uart.ate_cps_resp_length_addr) + length = int.from_bytes(length, 'little') + response = read_mem_range(uart.ate_cps_resp_addr, uart.ate_cps_resp_addr + length) + return response + +def uart_resp_read(): + """ Read the response from an ATECPS command + + @return: response from ATECPS command + + """ + uart = SerialIO() + length = fetch_memory_address(uart.uart_resp_addr) + length = length[1] + response = read_mem_range(uart.uart_resp_addr, uart.uart_resp_addr + length) + return response + +def read_mem_range(begin, end): + """ Read a memory range + + @param begin: start address + @param end: end address + @return: the data in bytes + + """ + addr = begin + datalist = [] + while addr < end: + data = fetch_memory_address(addr) + if len(data) == 4: + datalist.append(data) + addr = addr + 4 + return b''.join(datalist) + +def read_mem_burst(sio, begin, end, offset=0, verbosity=0): + """ Read a limited memory range using a burst read + + @param sio: serial object + @param begin: start address + @param end: end address + @param offset: offset of the sequence number + @param verbosity: verbosity level + @return: the data in bytes + """ + + if end - begin > 0x100: + raise ValueError('burst read only supports ranges of less than 256 bytes') + # the burst is in words of 4 bytes + burst = (end - begin) / 4 + # preallocate the lists + recvflags = [False] * burst + recvdata = [0] * burst + i = 0 + data = b'' + while sum(recvflags) != burst: + while i < burst: + if not recvflags[i]: + sio.write(read_word(begin + 4 * i, i + offset + 1)) + i += 1 + size = sio.in_waiting + if size > 0: + data += sio.read(size) + i = 0 + + return b''.join(recvdata) + +def get_chan_info(channel = 0): + """ Get the channel info + + @param channel: channel number + @return: the channel info + """ + cmd = bytes([0, 0x12]) + channel.to_bytes(1, 'little') + send_cps_command(cmd) + resp = uart_resp_read() + print(ChanInfoFrame(resp)) + # sys.stdout.buffer.write(resp) + +def get_freq_err(): + """ Get the frequency error from the Radio + + @return: frequency error in Hz + """ + send_ate_command("AT+DMOCONNECT") + send_ate_command("AT+GETFREQERR") + resp = atecps_resp_read() + resp = resp.split(b'\x00') + resp = [x.decode('utf-8') for x in resp] + return parse_freq_err_resp(resp[0]) + +def parse_freq_err_resp(resp): + """ Parse the frequency error response + + @param resp: response from ATECPS + @return: frequency error in Hz + """ + pattern = '\[(.+)\]' + freqerr = re.search(pattern, resp) + if freqerr: + return int(freqerr.group(1)) + else: + return 0 + +def set_freq_err(freqerr): + """ Set the frequency error on the Radio + + @param freqerr: frequency error parameter which is (-2500 + 10 * freqerr) in Hz + + """ + send_ate_command("AT+DMOCONNECT") + send_ate_command("AT+DMOFREQERR={}".format(freqerr)) + resp = atecps_resp_read() + resp = resp.split(b'\x00') + resp = [x.decode('utf-8') for x in resp] + for line in resp: + print(line) diff --git a/research/a6tools/atcommander.py b/research/a6tools/atcommander.py new file mode 100644 index 0000000..d9f14b6 --- /dev/null +++ b/research/a6tools/atcommander.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" AT Commander for AUCTUS based radios + +Allow for communication with AUCTUS A6 radios to their serial +interface through the debug interface. Commands can either be "AT" +commands or "CPS" commands. Both styles will work. + +""" + +import time +from a6 import send_ate_command, send_cps_command, atecps_resp_read, SerialIO + +__author__ = "jhart99" +__license__ = "MIT" + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description='Auctus A6 ATECPS commander') + parser.add_argument('-p', '--port', default='/dev/ttyUSB0', + type=str, help='serial port') + parser.add_argument('-b','--baudrate', default=921600, + type=int, help='baud rate') + parser.add_argument('-v','--verbosity', default=0, action='count', + help='print sent and received frames to stderr for debugging') + parser.add_argument('-V', '--version', action='version', + version='%(prog)s 0.0.1', + help='display version information and exit') + parser.add_argument('command') + args = parser.parse_args() + + uart = SerialIO(args.port, args.baudrate, args.verbosity) + + if args.command[0:3] == 'AT+': + send_ate_command(args.command) + else: + send_cps_command(bytes.fromhex(args.command)) + time.sleep(0.1) + data = atecps_resp_read() + data = data.split(b'\x00') + for line in data: + print(line.decode('utf-8')) diff --git a/research/a6tools/atcommands.md b/research/a6tools/atcommands.md new file mode 100644 index 0000000..26f7e0d --- /dev/null +++ b/research/a6tools/atcommands.md @@ -0,0 +1,120 @@ +Command | function +---------------------|---------- +DMOCONNECT | Required before issuing other commands +DMODISCONNECT | Ends the current session +DMORLIC | Returns the Device Serial Number +DMOVERQ | Returns Ver 1.0.0 +DMOREVDATA | No response +BINARY | No response +DMOGETSTARTUP | No response +DMOGETSOFTVERSION | Returns the firmware version +DMOGETSQSTATUS | No response +DMOGETCURRCH | Replies with the current channel parameters +DMOGETCURRCH:0,DIG,438802000,438802000,0,0,1,2,0,0,0 or +DMOGETCURRCH:15,ANA,405000000,405000000,1,1 +DMOGETCHFREQ | Replies with the channel parameters for the given channel number AT+DMOGETCHFREQ= +DMOGETLNAOFFS | Returns the LNA offset +DMOGETRSSIPARAM | Returns the RSSI offset +DMOGETCHIPID | A unique ID for the A6 processor +DMOGETGRPADDR | Returns the DMR contact for the current channel +DMOGETWLKTLKID | Returns the DMR radio ID of the radio +DMORDRSSI | Returns the current receive signal strength in dBm +DMOTESTMODE | +DMOSETGROUP | Sets all of the frequency settings for the given channel +DMOSETCTCDCS | Sets the CTCSS or DCS mode for the current channel AT+DMOSETCTCDCS=,,, +DMOSETCTCDCSGAIN | Sets the audio gain for the CTCSS or DCS tone AT+DMOSETCTCDCSGAIN=, +DMOSETTAILFREQ | Sets the tail frequency(550-2000 in tenths of Hz) for the current channel AT+DMOSETTAILFREQ= +DMOAUTOPOWCONTR | Sets the automatic power control mode for the current channel AT+DMOAUTOPOWCONTR= +DMOSETMIC | Sets the microphone gain for the current channel AT+DMOSETMIC= +DMOSETVOLUME | Sets the volume for the current channel AT+DMOSETVOLUME= +DMOSETVOLUMELEVEL | Sets the volume level for the current channel AT+DMOSETVOLUMELEVEL= +DMOSETVOX | Sets the VOX level for the current channel AT+DMOSETVOX= +DMOSENDDATA | ¯\\_(ツ)_/¯ +DMOSETAPC | +DMOSETSN | +DMORESETSN | +DMOSETRSSIPARAM | +DMOSETLNAOFFS | +DMOSAVEPARAM | +DMOERASEPARAM | +DMOSETSMARTLO | +DMOGETRXBER | Returns the received bit error rate +DMOCHSWITCH | Changes the current channel AT+DMOCHSWITCH= +DMOSETCHFREQ | Changes the channel parameters for the given channel number AT+DMOSETCHFREQ=, where the frequencies are like this 438.8001. Must have this number of digits to work +DMOSETCHPOWER | +DMOSETPOWER | Sets the power level for the current channel AT+DMOSETPOWER= where level is 0-2 +DMOSETPWRSAVELV | Sets the FM power save level for the current channel if no audio, the radio will stop transmitting AT+DMOSETPWRSAVELV= +DMOSETWLKTLKID | Set the DMR Radio ID +DMOSETGRPADDR | Set the DMR group address +DMOSETFREQBANDNUM | Set the number of frequency bands AT+DMOSETFREQBANDNUM= where number is less than 16 +DMOGETFREQBANDNUM | Get the current number of frequency bands +DMOSETFREQBANDVAL | Set the values of the frequency bands +DMOGETFREQBANDVAL | Get the values of the frequency bands +DMOAMPCTRLTEST | Sets an amplifier gain and tests the amplifier AT+DMOAMPCTRLTEST= gain is 0-128 +DMODIGPWRSAVECTRL | +DMOSETLCHEADERCNT | Sets the number of LC headers sent AT+DMOSETLCHEADERCNT= +DMOGRPCALLREQ | +DMOINDCALLREQ | +DMOALLCALLREQ | +DMOALERTCSBKREQ | Sends an alert call request to the radio AT+DMOALERTCSBKREQ= +DMOCHECKCSBKREQ | Sends a check call request to the radio AT+DMOCHECKCSBKREQ= +DMOALARMCSBKREQ | Sends an alarm call request to the radio AT+DMOALARMCSBKREQ= +DMORESPCSBKREQ | Sends a response call request to the radio AT+DMORESPCSBKREQ= +SETIMAGEREJEMODE | +SETIMAGEREJE | +SETEQUALIZER | +SETCOMPANDER | +SETSCRAMBLER | +SETTONELIST | +SETDTMF | +RESTOREFACTORYSETTING | Restores the original settings. The password for this is 778123456 +BERTEST | +SETCOLORCODE | Set the color code for the current channel AT+SETCOLORCODE= +GETGPADCVALUE | Get the current value of the GPADC channel(0-1) AT+GETGPADCVALUE= +SETGPADCCHAN | Set the GPADC channel enable AT+SETGPADCCHAN=, channel=0,1 enable=0,1 +GETGPIOVALUE | Get the current value of the GPIO channel(0-32) AT+GETGPIOVALUE= +SETGPIOVALUE | Set the GPIO channel value AT+SETGPIOVALUE=, channel=0-32 value=0,1 +SETGPIODIR | Set the direction of the GPIO channel(0-32) AT+SETGPIODIR=, channel=0-32 direction=0,1 +GETPOWERAMPGAIN | Get the power amplifier gain values AT+GETPOWERAMPGAIN= returns ,,,,,, +SETPOWERAMPGAIN | Sets the power amplifier gain +GETPOWERAPCGAIN | Get the automatic power control gain values AT+GETPOWERAPCGAIN= returns ,,,,,, +SETPOWERAPCGAIN | Sets the automatic power control gain +SETPOWERAPCSW | Sets the automatic power control switch ? +GETEFLTRXADJ | Returns the RX filter adjustment for a band AT+GETEFLTRXADJ= where the value is ,, +SETEFLTRXADJ | Sets the RX filter adjustment for a band AT+SETEFLTRXADJ=,, +DMOGETVAFC | Returns the current Automatic Frequency Correction value +DMOSETVAFC | Sets the Automatic Frequency Correction value +DMOPTT | Sets the PTT state for the current channel AT+DMOPTT= where state is 0 for off and 1 for on +GETFREQERR | Get the TCXO frequency error in 10s of Hz +DMOFREQERR | Set the TCXO frequency error in 10s of Hz +GETBANDBYFREQ | Returns the band number for the given frequency AT+GETBANDBYFREQ= +DMOGETFREQBANDVAL | Returns the cut offs for the different frequency bands +DMOGETFREQBANDNUM | Returns the number of frequency bands in use/defined AT+DMOGETFREQBANDNUM +DMOSETFREQBANDNUM | Can be used to change the number of defined frequency bands AT+DMOSETFREQBANDNUM=2 +DMRSIGNATURE | Unknown command. The signature looks something like this: AT+DMRSIGNATURE/aa,aa,aa,... with a total of 30 bytes worth of numbers Returns something that says AUTHSETKEYS +WRITECFS | Does something similar to the DMRSIGNATURE command +GETINCALLID | Returns the incoming call ID, ,, where call type is 0,1,2 corresponding to individual, group and all calls and from and to are DMR IDs +SETALERTTONE | +SETBATTDETRANGADC | +SETBATTADJ | +SETPROFILERAMPSSHIFT | +SETPROFILERAMPSDWPOS | +GETPROFILERAMPS | +SETRAMPINGTABLE | +GETAPC_FINEVALUE | +SETAPC_FINEVALUE | +DMR_ADJTXSYMDEV | +DMR_GETTXSYMDEV | +SETSTANDARD | Sets the testing standard for the radio AT+SETSTANDARD= where standard is 0-4. Completely resets the radio's program! +SETSQLEVEL | +SETSQADJUST | +SETSQMODE | +SETTXIQGAIN | +GETSQLEVEL | +SETMODULE | +SETCHANNELKNOBMODE | +FGU_AFC_ADJ | +FGU_AFC_GET | +FGU_VCON_BIAS | +FGU_VCON_BIAS_GET | +_FGU_TUNE_GET | +FGU_TUNE_GET | + diff --git a/research/a6tools/cpecommands.md b/research/a6tools/cpecommands.md new file mode 100644 index 0000000..8bdb5e1 --- /dev/null +++ b/research/a6tools/cpecommands.md @@ -0,0 +1,48 @@ +Command | Description +--------|------------ +0000 | SendBaseInfo +0001 | Unknown +0002 | Unknown Writes to NVRAM! +0003 | SetEmbInfo +0004 | GetEmbInfoAll +000f | SetBaseSetting +0010 | SendBaseSettingsBasicSet +0011 | SetChanInfo +0012 | GetChanInfo +0013 | SetChanName +0014 | GetChanName +0015 | SetTalkieName +0016 | Unknown +0017 | SetIndividualCallCnt +0018 | SetIndividualCallInfo +0019 | SendIndividualCallCnt +001a | SendIndividualCallInfo +001b | SendCmdResponse ? +001c | SetGroupCallInfo +001d | Unknown +001e | GetGroupCallInfo +001e | SendCmdResponse ? +0020 | Unknown +0021 | SetShortcutMsgInfo ? +0022 | SetShortcutCnt +0023 | SetShortcutMsgInfo ? +0024 | GetShortcutMsgInfo +0025 | SetKeyFunc +0026 | GetKeyFunc +0027 | SetScanlist +0028 | GetScanlistCnt +002a | GetScanlist +002b | Checks the programming password +0100 | Unknown +0101 | Unknown +0102 | Unknown +0104 | Unknown +0a00 | @CpsByHostPort_ConcatenateHdr CPS_Hdr[StartAddr:0x82004810 len:772] 0x88105948 +0a01 | Unknown +0a02 | Unknown +0a03 | Unknown +0a04 | Unknown but same as 0a07 +0a05 | Unknown +0a06 | Unknown +0a07 | Unknown but same as 0a04 +0a08 | PROT_SendDataFrame Seems to echo the sent packet back diff --git a/research/a6tools/downloadchannel.py b/research/a6tools/downloadchannel.py new file mode 100644 index 0000000..fe0de23 --- /dev/null +++ b/research/a6tools/downloadchannel.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +""" AT Commander for AUCTUS based radios + +Allow for communication with AUCTUS A6 radios to their serial +interface through the debug interface. Commands can either be "AT" +commands or "CPS" commands. Both styles will work. + +""" + +from a6 import get_chan_info, SerialIO + +__author__ = "jhart99" +__license__ = "MIT" + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description='Auctus A6 ATECPS commander') + parser.add_argument('-p', '--port', default='/dev/ttyUSB0', + type=str, help='serial port') + parser.add_argument('-b','--baudrate', default=921600, + type=int, help='baud rate') + parser.add_argument('-v','--verbosity', default=0, action='count', + help='print sent and received frames to stderr for debugging') + parser.add_argument('-V', '--version', action='version', + version='%(prog)s 0.0.1', + help='display version information and exit') + parser.add_argument('channel', type=int, help='channel number') + args = parser.parse_args() + + + uart = SerialIO(args.port, args.baudrate, args.verbosity) + get_chan_info(args.channel) diff --git a/research/a6tools/freqoffset.py b/research/a6tools/freqoffset.py new file mode 100644 index 0000000..2f3cb1b --- /dev/null +++ b/research/a6tools/freqoffset.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +""" Frequency offset fix for AUCTUS A6 based radios + +This program takes a measured frequency and a desired frequency and +then reads a TCXO error programmed into the radio and sets a new TCXO +offset in the radio. The radio needs to have its channel changed +after for the new setting to take effect. + +This fixes the high BER seen on some radios like my GOCOM GD900 which +had a 800 Hz offset from the factory which while within spec was +outside what my poor MMDVM board could tolerate. + +""" + +from a6 import SerialIO, get_freq_err, set_freq_err + +__author__ = "jhart99" +__license__ = "MIT" + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description='Auctus A6 Frequency Error Fixer') + parser.add_argument('-p', '--port', default='/dev/ttyUSB0', + type=str, help='serial port') + parser.add_argument('-b','--baudrate', default=921600, + type=int, help='baud rate') + parser.add_argument('-v','--verbosity', default=0, action='count', + help='print sent and received frames to stderr for debugging') + parser.add_argument('-V', '--version', action='version', + version='%(prog)s 0.0.1', + help='display version information and exit') + parser.add_argument('current', type=int, + help='the measured frequency the radio is currently transmitting in Hz') + parser.add_argument('target', type=int, + help='the programmed frequency in the radio in Hz') + args = parser.parse_args() + + uart = SerialIO(args.port, args.baudrate, args.verbosity) + + delta = args.target - args.current + curerr = get_freq_err() + target = curerr + delta + if abs(target) > 2500: + raise ValueError("Desired offset exceeds maximum of 2500 Hz") + set_freq_err(int((target + 2500)/10)) \ No newline at end of file diff --git a/research/a6tools/radiodump-ng.py b/research/a6tools/radiodump-ng.py new file mode 100644 index 0000000..e7d73ac --- /dev/null +++ b/research/a6tools/radiodump-ng.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +""" AT Commander for AUCTUS based radios + +Allow for communication with AUCTUS A6 radios to their serial +interface through the debug interface. Commands can either be "AT" +commands or "CPS" commands. Both styles will work. + +""" + +import serial +import sys +from a6 import read_mem_range, SerialIO + + +__author__ = "jhart99" +__license__ = "MIT" + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description='Auctus A6 dumper') + parser.add_argument('--begin', type=lambda x: int(x,0), + help='begin address default 0x82000000', + default=0x82000000) + parser.add_argument('--end', type=lambda x: int(x,0), + help='end address default 0x8200ff00', + default=0x8200ff00) + parser.add_argument('-p', '--port', default='/dev/ttyUSB0', + type=str, help='serial port') + parser.add_argument('-b','--baudrate', default=921600, + type=int, help='baud rate') + parser.add_argument('-v','--verbosity', default=0, action='count', + help='print sent and received frames to stderr for debugging') + parser.add_argument('-V', '--version', action='version', + version='%(prog)s 0.0.1', + help='display version information and exit') + args = parser.parse_args() + + uart = SerialIO(args.port, args.baudrate, args.verbosity) + data = read_mem_range(args.begin, args.end) + sys.stdout.buffer.write(data) diff --git a/research/a6tools/rebootandfreeze.py b/research/a6tools/rebootandfreeze.py new file mode 100644 index 0000000..acbf5a5 --- /dev/null +++ b/research/a6tools/rebootandfreeze.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +""" Reboot and freeze + +Reset the AUCTUS A6 processor and freeze execution. This allows for +uninterupted dumping of the normally problematic BCPU rom. + +""" + +from a6 import SerialIO, reboot_and_freeze + +__author__ = "jhart99" +__license__ = "MIT" + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description='Auctus A6 reboot and freeze') + parser.add_argument('-p', '--port', default='/dev/ttyUSB0', + type=str, help='serial port') + parser.add_argument('-b','--baudrate', default=921600, + type=int, help='baud rate') + parser.add_argument('-v','--verbosity', default=0, action='count', + help='print sent and received frames to stderr for debugging') + parser.add_argument('-V', '--version', action='version', + version='%(prog)s 0.0.1', + help='display version information and exit') + args = parser.parse_args() + + uart = SerialIO(args.port, args.baudrate, args.verbosity) + uart.write(reboot_and_freeze()) \ No newline at end of file diff --git a/research/rt4ddlt01-dmr-module-sheet.png b/research/rt4ddlt01-dmr-module-sheet.png new file mode 100644 index 0000000..bec0691 Binary files /dev/null and b/research/rt4ddlt01-dmr-module-sheet.png differ diff --git a/tools/a6_freqfix.py b/tools/a6_freqfix.py new file mode 100644 index 0000000..68f1d11 --- /dev/null +++ b/tools/a6_freqfix.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""RT-4D DMR-модуль (HF6853 / AUCTUS A6) — починка частотного смещения TX. + +Модуль RT-4D — это одночиповый Auctus A6 (RDA8809), у которого частотная +калибровка TCXO хранится в NVRAM и правится СКРЫТЫМ ATE-интерфейсом: + AT+GETFREQERR -> прочитать N (в десятках Гц) + AT+DMOFREQERR= -> записать N; offset[Гц] = -2500 + 10*N, N=0..500 +После записи СМЕНИТЬ КАНАЛ на рации, чтобы применилось. + +Наш случай ([[rt4d-dmr-tx-broken]]): TX смещена ~1100 Гц (в пределах ±2500), +поэтому один AT+DMOFREQERR должен вернуть декодирование хотспотом. + +Реверс/библиотека — jhart99/a6tools (копия в research/a6tools). См. +docs/hf6853-auctus-a6.md. + +БЕЗОПАСНОСТЬ: + * По умолчанию РЕЖИМ probe/read (--probe / --read). Запись только с --write И --yes. + * Точное значение требует ИЗМЕРЕНИЯ реальной TX по SDR (RTL-SDR). + * Рация боевая — вслепую не писать. + +Транспорт: два варианта COM-доступа — + --port /dev/ttyUSBx прямой pyserial (если модуль виден напрямую) + --bridge через powershell-мост WSL (как tools/fm100b.py) — TODO, + заготовка ниже; сперва проверить probe напрямую. + +Скорость Auctus по умолчанию 921600 8N1 XON/XOFF. Режим рации: «Обновление DMR +чипа» (вкл. с зажатой MENH/MENU) — прозрачный мост PC<->модуль. + +Примеры: + python3 a6_freqfix.py --port /dev/ttyUSB0 --probe + python3 a6_freqfix.py --port /dev/ttyUSB0 --read + # измерили: рация реально шлёт 438.799100 вместо 438.800000 (=-900 Гц) + python3 a6_freqfix.py --port /dev/ttyUSB0 --measured 438799100 --nominal 438800000 --write --yes +""" +import sys, os, time, argparse + +# использовать вендоренную библиотеку a6tools +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "research", "a6tools")) + + +def load_a6(port, baud, verbosity): + from a6.serialio import (SerialIO, send_ate_command, atecps_resp_read, + send_uart_setup, get_freq_err, set_freq_err) + from a6.a6commands import set_uart_to_host + from a6.serialio import write_flush_pause + SerialIO(port, baud, verbosity) # singleton init + return dict(send_ate=send_ate_command, resp=atecps_resp_read, + setup=send_uart_setup, geterr=get_freq_err, seterr=set_freq_err, + to_host=set_uart_to_host, wfp=write_flush_pause) + + +def probe(a6): + """Проверить, отвечает ли модуль на RDA-debug (knock). content==0x80 => ок.""" + print("[probe] knock RDA-debug (read reg 0x03)…") + ok = a6["setup"]() + print(" ответ:", "МОДУЛЬ ОТВЕЧАЕТ (debug активен)" if ok else "нет ответа") + if not ok: + print(" -> модуль не в RDA-debug. Проверь режим рации / скорость (пробуй --baud 115200).") + return ok + + +def read_err(a6): + a6["send_ate"]("AT+DMOCONNECT") + n = a6["geterr"]() + print(f"[read] GETFREQERR N (в получаемом виде) -> offset={n} Гц") + return n + + +def main(): + ap = argparse.ArgumentParser(description="HF6853/A6 TX freq-offset fix") + ap.add_argument("--port", required=True, help="COM/tty (напр. /dev/ttyUSB0)") + ap.add_argument("--baud", type=int, default=921600) + ap.add_argument("-v", "--verbose", action="count", default=1) + ap.add_argument("--probe", action="store_true", help="только проверить связь") + ap.add_argument("--read", action="store_true", help="прочитать текущий freqerr") + ap.add_argument("--measured", type=int, help="реально измеренная TX-частота, Гц (по SDR)") + ap.add_argument("--nominal", type=int, help="номинальная (запрограммированная) частота, Гц") + ap.add_argument("--write", action="store_true", help="применить новое значение") + ap.add_argument("--yes", action="store_true", help="подтвердить запись (обязателен с --write)") + a = ap.parse_args() + + a6 = load_a6(a.port, a.baud, a.verbose) + + if not probe(a6): + if a.probe: + return + print("Прерываю: без связи писать нельзя.") + return + if a.probe: + return + + cur = read_err(a6) + if a.read and not a.write: + return + + if a.write: + if a.measured is None or a.nominal is None: + print("Для --write нужны --measured и --nominal (по SDR).") + return + delta = a.nominal - a.measured # сколько добавить к текущему offset + target = cur + delta + if abs(target) > 2500: + print(f"Целевой offset {target} Гц вне ±2500 — так фиксить нельзя (нужна другая калибровка).") + return + N = int((target + 2500) / 10) + print(f"[plan] cur={cur} Гц, delta={delta} Гц, target={target} Гц -> N={N}") + if not a.yes: + print("Добавь --yes чтобы записать. (ничего не записано)") + return + a6["send_ate"]("AT+DMOCONNECT") + a6["seterr"](N) + print(f"[write] AT+DMOFREQERR={N} отправлено. ТЕПЕРЬ СМЕНИ КАНАЛ на рации и проверь BER.") + + +if __name__ == "__main__": + main()