Мост к DMR-модулю, разбор спектра REFV DualTachyon, карта запчастей прошивки

Баузбенд FM100B:
- найден прозрачный мост ПК<->модуль (включение с зажатой МЕНЮ), проверен живьём
- расшифрована контрольная сумма кадров (one's complement, BE), сверена с ответом рации
- инструмент tools/fm100b.py (ping/send/raw/scan)
- cmd 0x25 = запрос версии, модуль отвечает V1.2.0.32 (совпало с офиц. образом)
- разобран диспетчер входящих кадров: 193 записи, реальный код у 10 команд
- cmd 0x59 = индикация приёма, она же управляет гейтом звука PA14
- исправлено: boot-handshake это cmd 0x84, а не 0x64 (research/re/dmr.md)

Спектроанализатор REFV DualTachyon (docs/refw-spectrum.md):
- вызывается как функция горячей клавиши №22 Analog Spectrum
- вход 0x08009CAA -> обычный 0x080139E4 / по зоне 0x08015318
- спектр это экран №11; тик 0x08013BA4 (автомат на 4 состояния), клавиши 0x08013BFC

Декомпозиция (docs/firmware-parts.md):
- два процессора + внешний SPI = три канала внедрения
- карта ресурсов SPI: шрифты, пиньинь, голос, таблица Unicode
- найден штатный загрузчик ресурсов FontVoicePicture (шрифты/голос/картинки)
- дерево меню целиком: MIC/SPK Gain, RX/TX Limit, SMS Format уже в стоке
- аудио двухступенчатое: PA2 питание УНЧ, PA14 гейт от DMR-модуля

Прочее:
- везде исправлен режим прошивки: тангента PTT вместо клавиши "*"
- устаревший Ru-4D_Flasher.exe удалён из репозитория
- добавлены инструменты реверса: xref, refs, gpiomap, gpioscan, schem
Этот коммит содержится в:
2026-07-22 21:17:20 +09:00
родитель ae36c3b729
Коммит 5f4d207aa5
34 изменённых файлов: 21718 добавлений и 17823 удалений
+311
Просмотреть файл
@@ -0,0 +1,311 @@
# RT-4D Input Subsystem — Keypad / PTT / Dispatch API
Reverse-engineered from `rt4d_stock_v3.25_abs_0x08002800.bin` (Cortex-M4F Thumb, load base `0x08002800`).
Scope: keypad matrix scan, debounce, key-code map, PTT/side keys, event delivery, and the callable
API + hook points a rewritten UI would use to read input and route keys to a custom router.
All addresses are absolute MCU flash vaddrs. RAM state vars are `0x2000xxxx`.
---
## 0. TL;DR — the input pipeline
```
TICK (main loop, ~1ms cadence)
keypad_task(col) 0x0801B398 ── drives one column output line (GPIOA/GPIOF ODR), cycles col 0..3
│ calls per column:
keypad_scan_col(col) 0x0801B294 ── reads 4 row inputs (GPIOF1, GPIOA8, GPIOA9, GPIOB11),
│ sets/clears bit (1<<(col*4+row)) in RAW word 0x20000B7C (active-low)
keypad_decode() 0x0801130C ── maps RAW bitmask 0x20000B7C → compact KEY CODE (0x00..0x12, 0xFF=none)
keypad_process() 0x08005A14 ── debounce + short/long/repeat classify; writes KeyEv struct @0x20000B57
│ KeyEv[+2]=delivered key, KeyEv[+3]=event type (1=short/long,2=repeat)
ui_input_service() 0x08005E54 ── main-loop input step: calls keypad_process, reads KeyEv[+2],
│ then calls the screen router, then clears KeyEv[+2]
screen_dispatch(ctx,aux,key) 0x08018DF4 ── reads current-screen id ctx[+1] (ctx=0x20002120),
tbb-dispatches to the active screen's key handler
```
**This is a polled GPIO 4×4 matrix (NOT an ADC ladder).** ADC1 is used only for battery/RSSI, not keys.
**Up/Down are matrix keys, not a rotary encoder.** PTT and the two side keys are separate GPIO reads.
---
## 1. Hardware: GPIO 4×4 key matrix
The keypad is a 4-column × 4-row matrix. Columns are driven low one at a time (outputs), rows are read
(active-low inputs, pulled high). Confirmed from `keypad_scan_col` (`0x0801B294`) which reads, per column,
four row pins via the GPIO bit-read helper and packs them into the 16-bit raw word `0x20000B7C`:
| Row idx | Port/pin (mask passed to gpio_read_pin) | Evidence (in 0x0801B294) |
|---|---|---|
| row 0 | **GPIOF pin1** (`0x40021400`, mask `0x0002`) | `ldr 0x40021400; movs r1,#2; bl 0x8021316` |
| row 1 | **GPIOA pin8** (`0x40020000`, mask `0x0100`) | `ldr 0x40020000; mov r1,#0x100` |
| row 2 | **GPIOA pin9** (`0x40020000`, mask `0x0200`) | `ldr 0x40020000; mov r1,#0x200` |
| row 3 | **GPIOB pin11** (`0x40020400`, mask `0x0800`) | `ldr 0x40020400; mov r1,#0x800` |
For each row: `bit = 1 << (col*4 + row)`; if `gpio_read_pin()==0` (pin low = pressed) → `raw |= bit`,
else `raw &= ~bit`. So `0x20000B7C` is a live pressed-keys bitmap, one bit per matrix cell.
Column drive lives in `keypad_task` (`0x0801B398`): a 4-state machine using `0x20000B7A` as the current
column index; each phase writes the column-select via `gpio_write_pin`/BSRR on GPIOA (`0x40020000`) and
GPIOF (`0x40021400`, BSRR/ODR region seen as `0x40021428`/`0x40020428`), then invokes `keypad_scan_col`
for that column. Columns cycle 0→1→2→3 across successive ticks.
### GPIO helper primitives (callable API)
| vaddr | signature | behavior |
|---|---|---|
| `0x08021316` | `int gpio_read_pin(GPIO_TypeDef* port, uint32_t mask)` | returns 1 if **all** masked IDR (`+0x10`) bits set, else 0 |
| `0x08021C6E` | `void gpio_write_pin(GPIO_TypeDef* port, uint32_t mask, int state)` | state 1 → `ODR(+0x0C)|=mask`; state 0 → `ODR&=~mask` |
| `0x08021C4C` | `void gpio_bsrr(GPIO_TypeDef* port, uint32_t mask)` (bit-set/reset via BSRR) | used by column drive & LCD |
---
## 2. KEY CODE MAP — `keypad_decode()` @ `0x0801130C`
Signature: **`uint8_t keypad_decode(void)`** — reads the raw matrix word `*(uint16_t*)0x20000B7C`
and returns the firmware key code. Returns `0xFF` when nothing/unknown is pressed. Every comparison in
the function reads the same `ldrh [0x20000B7C]`; verified all 19 literal loads resolve to `0x20000B7C`.
### Raw matrix-bit → key code (single-key)
| raw `0x20000B7C` value | KEY CODE (return) | physical key (inferred) |
|---|---|---|
| `0x4000` | `0x00` | **0** |
| `0x0002` | `0x01` | **1** |
| `0x0020` | `0x02` | **2** |
| `0x0200` | `0x03` | **3** |
| `0x0004` | `0x04` | **4** |
| `0x0040` | `0x05` | **5** |
| `0x0400` | `0x06` | **6** |
| `0x0008` | `0x07` | **7** |
| `0x0080` | `0x08` | **8** |
| `0x0800` | `0x09` | **9** |
| `0x0001` | `0x0B` | **Menu / M** (col0,row0) |
| `0x0010` | `0x0C` | **Up** ▲ |
| `0x0100` | `0x0D` | **Down** ▼ |
| `0x1000` | `0x10` | **Exit / Back** |
| `0x2000` | `0x0E` | **\*** (star) |
| `0x8000` | `0x0F` | **#** (hash) — used elsewhere as "menu enter" sentinel |
Note key code `0x0A` is not produced by a single raw bit; it is produced only by the combo pattern below
(it is the "PTT/M combined" boot code). The digit codes `0x00..0x09` map 1:1 to digits 0-9.
### Combo / multi-line patterns (special)
| raw `0x20000B7C` | KEY CODE | meaning |
|---|---|---|
| `0x1111` | `0x0A` | all-column row0 held (boot key-combo / factory) |
| `0x2222` | `0x11` | **Side key 1** family / all-column row1 |
| `0x4444` | `0x12` | **Side key 2** family / all-column row2 |
> Interpretation of `0x11`/`0x12` as the two programmable side keys is corroborated by
> `ui_input_service` (`0x08005E54`), which special-cases `KeyEv[+2]==0x11` and `==0x12` *before* the
> normal screen dispatch (see §4), matching the CPS "Side Key 1/2 (Short/Long)" menu items.
### Key-code enum (for the rewrite)
```c
enum key {
KEY_0=0x00, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, // 0x00..0x09
KEY_COMBO_A=0x0A, // boot combo
KEY_MENU=0x0B, // "M"
KEY_UP=0x0C,
KEY_DOWN=0x0D,
KEY_STAR=0x0E, // '*'
KEY_HASH=0x0F, // '#' (also menu-enter sentinel)
KEY_EXIT=0x10, // Back/Exit
KEY_SIDE1=0x11,
KEY_SIDE2=0x12,
KEY_NONE=0xFF
};
```
---
## 3. Debounce + short/long/repeat — `keypad_process()` @ `0x08005A14`
Signature: **`void keypad_process(void)`**. Called once per input-service pass from `ui_input_service`.
Operates on the **key-event struct `KeyEv` @ `0x20000B57`** (byte fields unless noted):
| field | addr | meaning |
|---|---|---|
| `KeyEv[+1]` | `0x20000B58` | live decoded key this pass (`= keypad_decode()`), `0xFF`=none |
| `KeyEv[+2]` | `0x20000B59` | **delivered event key** consumed by the UI (`0xFF` when no event) |
| `KeyEv[+3]` | `0x20000B5A` | **event type**: `1` = press/release (short or long), `2` = auto-repeat |
| `KeyEv[+4]` | `0x20000B5B` | repeat-active flag |
| `KeyEv[+5]` | `0x20000B5C` | `uint16_t` **hold-duration counter** (ticks; incremented while held) |
Behavior (decoded from `0x08005A14``0x08005B54`):
1. `KeyEv[+1] = keypad_decode()`. Also reads PTT: `gpio_read_pin(GPIOA, 0x1000)` (GPIOA pin12) → PTT state at `0x20000B75` (`KeyEv[+0x1E]`); PTT keycode is `0x11`-adjacent handling.
2. While a key stays held: `KeyEv[+5]++` (bounded).
3. On **release** (`KeyEv[+1]==0xFF`) with `KeyEv[+5] > 10`: publish the just-released key as an event (`KeyEv[+2]=key, KeyEv[+3]=1`); otherwise call `key_event_clear()` (`0x08012C24`).
4. **Long/repeat threshold `0x2BC` (700 ticks)**: if same key held and `KeyEv[+5] > 0x2BC` → publish repeat (`KeyEv[+2]=key, KeyEv[+3]=2, KeyEv[+4]=1`). A secondary `>10` gate distinguishes the short vs long delivery. Beep feedback is emitted via `0x8014CB8` on valid/invalid keys.
5. Screen-change edge (`0x08005B38`): if the current screen id changed vs `KeyEv[+1]`, resets the hold counter — prevents key bleed across screens.
Supporting call: `key_event_clear()` **`0x08012C24`** — `void key_event_clear(void)` sets
`KeyEv[+0..+2]=0xFF`, `KeyEv[+3..+5]=0`. **This is the "consume/ack the pending key" API.**
---
## 4. Delivery to the active screen — `ui_input_service()` @ `0x08005E54`
Signature: **`void ui_input_service(void)`** — the main-loop input step. Sequence:
1. `keypad_process()` (`0x08005A14`) — refresh KeyEv.
2. Service a couple of periodic timers (`0x200201DC`/`0x201F5E4` housekeeping — not input).
3. Read `KeyEv[+2]` (delivered key). Side keys `0x11`/`0x12` are handled specially (their own screen-independent action path) and are **not** forwarded to the generic router. `0xFF` (no key) is skipped.
4. Otherwise call **`screen_dispatch(ctx=0x20002120, aux=0x20002014, key=KeyEv[+2])`** (`0x08018DF4`).
5. After dispatch, clear: `KeyEv[+2]=0xFF` (`0x20000B59`) and `ctx[+0x15]=0xFF` (`0x20002135`).
6. Then paints: `0x080136E4` (status), `0x080142C0` (menu/standby render).
### The router — `screen_dispatch(ctx, aux, key)` @ `0x08018DF4`
Signature: **`void screen_dispatch(void* ctx, void* aux, uint8_t key)`** — `ctx = 0x20002120`.
- If `key == 0x0F` (`#`): enter/menu path → `0x0800F0E4` then `0x08005484` (menu-enter), return.
- Else read **current-screen id = `ctx[+1]` (byte at `0x20002121`)**, range 0..5, and `tbb`-dispatch:
| `ctx[+1]` | screen key handler | notes |
|---|---|---|
| 0 | `0x08018E5A` | **standby / main VFO screen** |
| 1 | `0x08018E5A` | (shares standby handler) |
| 2 | `0x08019018` | secondary screen (dual-area / menu list) |
| 3 | `0x0801926C` | screen 3 |
| 4 | `0x08018F08` | screen 4 |
| (5) | falls through | |
Inner standby handler `0x08018E5A(ctx, aux, key)`: `if key==0xFF return;` sets `ctx[+0x15]=0xFF`;
then `key-0x0A; cmp #7` `tbb` to handle the nav/function keys `0x0A..0x10` (Menu/Up/Down/*/#/Exit),
and the default branch treats numeric keys as ASCII (`key+0x30`) → digit-entry handler `0x080134F8`.
Menu navigation calls `0x08007984`; audible feedback via `0x08014CB8` (beep freq `0x1B8` ok / `0x65C` err).
**`ctx[+1]` (`0x20002121`) is the current-screen selector, and the `tbb` table at `0x08018E16` is the
screen→handler map. This is the exact hook point for a rewritten UI router** (see §7).
---
## 5. PTT, side keys, monitor, and analog inputs
- **PTT**: GPIO input **GPIOA pin12** (`gpio_read_pin(0x40020000, 0x1000)`), read inside `keypad_process`
and also standalone at `0x08005A32` (parent `0x08005A14`). PTT is *not* part of the matrix; it latches
into `KeyEv[+0x1E]` (`0x20000B75`) and gates TX. A separate small helper stores PTT/key state and
compares against key code `0x11`.
- **Side key 1 / Side key 2**: surface as decoded key codes `0x11` / `0x12` (combo patterns `0x2222` /
`0x4444`), intercepted in `ui_input_service` (`0x08005E54`) ahead of the router, matching the CPS
"Side Key 1/2 Short/Long" definitions. Their configured action is looked up from settings there.
- **Monitor / squelch-open**: driven from the same key/side-key path (no dedicated GPIO monitor line found
separate from the side keys); the "Monitor" function is a side-key/long-press action, not a distinct pin.
- **Rotary/channel knob & Up/Down**: **no rotary encoder**. Channel change is the matrix Up (`0x0C`) /
Down (`0x0D`) keys handled by the active screen. No quadrature-decode code exists.
- **Volume/power knob**: analog volume is a hardware potentiometer in the audio path (not MCU-sampled);
no ADC channel is decoded as a knob position. **ADC1 (`0x40012000`) is battery voltage + RSSI only**
`adc_init` @ `0x08002D64`, battery read/convert @ `0x08010960` (`raw*4/0x42` scaling → `BATT:x.xV`),
ADC EOC counters incremented in the ADC ISR `0x08002D1C`. None of this feeds key input.
### Boot-time key-combo handler `0x08003060`
`void boot_key_scan(void)` — at power-on reads `keypad_decode()` directly (not through KeyEv) and matches
combos: `0x11`, `0x0A` (→ enters a special mode: `0x080149D4`/`0x0801492C`, PC-programming/init screen),
`0x0F`, etc. This implements the hold-key-at-power-on entries (flash mode = **PTT held at power-on**). Uses the same key codes as §2.
---
## 6. RAM state variables (input)
| addr | name | type | meaning |
|---|---|---|---|
| `0x20000B7A` | `kp_cur_col` | u8 | current column being driven/scanned (0..3) |
| `0x20000B7C` | `kp_raw` | u16 | live pressed-key bitmap `1<<(col*4+row)`, active-low packed |
| `0x20000B57` | `KeyEv` | struct | key-event struct (base; see §3 for fields +1..+5) |
| `0x20000B58` | `KeyEv.live` | u8 | live decoded key |
| `0x20000B59` | `KeyEv.key` | u8 | **delivered event key** (read by router; `0xFF`=none) |
| `0x20000B5A` | `KeyEv.type` | u8 | 1=short/long press, 2=auto-repeat |
| `0x20000B5B` | `KeyEv.rep` | u8 | repeat-active flag |
| `0x20000B5C` | `KeyEv.dur` | u16 | hold-duration counter (ticks); long/repeat @ `0x2BC`=700 |
| `0x20000B75` | `KeyEv.ptt` | u8 | PTT pressed flag (GPIOA12) |
| `0x20002120` | `ui_ctx` | struct | UI/screen context passed to router |
| `0x20002121` | `ui_ctx.screen` | u8 | **current-screen id** (router `tbb` selector, 0..5) |
| `0x20002135` | `ui_ctx[+0x15]` | u8 | per-frame scratch (cleared after dispatch) |
---
## 7. CALLABLE API for a rewritten UI
### 7a. Read input (poll model — recommended)
The cleanest reuse is to keep the stock scan/decode/debounce and read the published event:
```c
// stock addresses (Thumb; call with bit0 set)
uint8_t keypad_decode(void); // 0x0801130C -> raw key code, 0xFF=none (no debounce)
void keypad_process(void); // 0x08005A14 -> updates KeyEv (debounce+long/repeat)
void key_event_clear(void); // 0x08012C24 -> ack/consume pending key
#define KeyEv_key (*(volatile uint8_t*)0x20000B59) // delivered key, 0xFF=none
#define KeyEv_type (*(volatile uint8_t*)0x20000B5A) // 1=press,2=repeat
#define KeyEv_dur (*(volatile uint16_t*)0x20000B5C) // hold ticks (for your own long-press cutoff)
#define KeyEv_ptt (*(volatile uint8_t*)0x20000B75) // PTT
// Our UI main loop:
for(;;){
keypad_process(); // let stock code scan+debounce
uint8_t k = KeyEv_key;
if(k != 0xFF){
my_router(k, KeyEv_type); // <-- our dispatch
key_event_clear(); // consume
}
// ... our render ...
}
```
You do **not** need to touch the timer ISRs: `keypad_task`/`keypad_scan_col` are invoked from the stock
tick path that also runs `keypad_process` housekeeping; if you replace the main loop you can call
`keypad_task(col)` yourself per tick, or simpler—call `keypad_process()` which reads the already-scanned
`kp_raw`. (If you fully own the loop, drive `keypad_task(0..3)` round-robin each ~1ms so `kp_raw` refreshes.)
Raw / low-level entry points if you want to bypass debounce:
```c
int gpio_read_pin (void* port, uint32_t mask); // 0x08021316
void gpio_write_pin(void* port, uint32_t mask,int);// 0x08021C6E
void keypad_scan_col(int col); // 0x0801B294 scans one column into kp_raw
void keypad_task(void); // 0x0801B398 column-drive state machine (call each tick)
```
### 7b. Hook to route keys to OUR router (drop-in replacement)
The single interception point is **`ui_input_service` (`0x08005E54`)** → it calls
**`screen_dispatch(ctx=0x20002120, aux=0x20002014, key)` at `0x08005F1E`**. Two options:
1. **Replace the router (minimal patch):** repoint the `bl 0x08018DF4` at `0x08005F1E` to our own
`router(ctx, aux, key)`. We then own all per-screen handling while stock keypad scan/decode/debounce,
PTT, side-key pre-handling, and post-dispatch clear remain intact. Our router reads
`ui_ctx.screen` (`0x20002121`) as the active-screen id (or we manage our own screen id) and dispatches.
2. **Replace the whole input+render loop:** call `keypad_process()` ourselves (7a) and never enter
`ui_input_service`; then stock `screen_dispatch` and the render calls (`0x080136E4`, `0x080142C0`) are
bypassed entirely — full UI ownership. Keep calling stock lower-level draw/RF/DMR APIs.
Either way the **serial/CPS protocol and SPI codeplug format are untouched** — the input path has no
contact with the USART6 framer (`0x0801F864`) or the SPI region writers, so replacing the UI router does
not affect CPS compatibility.
---
## 8. Confidence
| Item | Confidence | Basis |
|---|---|---|
| `keypad_decode` @0x0801130C + full key-code map | **High** | 19 loads all resolve to `0x20000B7C`; explicit `cmp`/`movs r0,#code`; single-exit |
| GPIO 4×4 matrix (not ADC ladder); row pins GPIOF1/GPIOA8/9/GPIOB11 | **High** | `keypad_scan_col` masks `2/0x100/0x200/0x800` on distinct ports + `1<<(col*4+row)` packing |
| `keypad_process` debounce + long/repeat (`0x2BC` threshold) + KeyEv layout | **High** | full disasm `0x08005A14`; field offsets confirmed via stores |
| `ui_input_service``screen_dispatch` route; ctx `0x20002120`, screen id `[+1]` | **High** | resolved call-site literals + `tbb` table at `0x08018E16` |
| PTT = GPIOA pin12; ADC1 = battery/RSSI only (no knob) | **High** | `gpio_read_pin(GPIOA,0x1000)` in key path; ADC only in battery/ISR |
| Physical-key labels (which code = Menu/Up/Down/Exit/*/#) | **Medium-High** | inferred from standby handler usage (digit ASCII, nav tbb, `#`=menu-enter, `0x11/0x12`=side keys); exact silk-screen assignment of the four *non-digit* codes 0x0B/0x0C/0x0D/0x10 should be confirmed on-device |
| Side keys = codes `0x11`/`0x12` (combo `0x2222`/`0x4444`) | **Medium** | special-cased before router; matches CPS side-key menu; combo-pattern origin unusual—verify on hardware |