Files
rt-4d/research/re/dmr.md
T
viktor 5f4d207aa5 Мост к 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

23 KiB
Исходник Ответственный История

RT-4D — DMR / FM100B interface (MCU side) — API reference (dmr)

Scope: the MCU-side code that talks to the FM100B DMR baseband over USART3 (0x40004800). All addresses are absolute in the MCU app image (rt4d_stock_v3.25_abs_0x08002800.bin, load base 0x08002800, ARM Thumb). This is the layer a rewritten UI must reuse verbatim to do DMR: originate calls, render incoming calls, set radio ID / TG / color code / slot, SMS, and handle remote stun/kill/wake.

Boundary note: none of this touches the SPI codeplug format or the CPS serial protocol — the FM100B link is a third, internal, binary UART with its own 0x68…0x10 framing. It is completely independent of the CPS 0x34/0x52/region-id framing (USART6). Reusing these functions does not change any CPS-visible format. The only codeplug coupling is read-only: caller-name display reads contact records from SPI 0x5C000/0x5E000 and the key/SMS-target table at 0x0D0000 (same layout the CPS already writes).


0. TL;DR — the callable entry points that matter

vaddr name (inferred) C signature what it does
0x0801B044 fm100b_send1 void(u8 cmd,u8 b,u8 sub,u8 data,void* respbuf,u16 timeout) build+send a 1-data-byte 0x68 frame to FM100B, block until its *Cnf arrives (or timeout)
0x0801B0C4 fm100b_send void(u8 cmd,u8 b,u8 sub,u16 len,void* respbuf,const u8* payload,u16 timeout) same, with an N-byte payload
0x08006C9C usart3_tx_buf void(const u8* buf,u16 len) raw byte-blit of a frame out USART3
0x08006CB8 usart3_tx_byte void(u8 b) one byte out USART3->DR, spin on TC
0x08003050 poll_serial void(void) pump: run FM100B RX parse + CPS framer + RX drain once. Call this in any wait loop.
0x08018CB0 fm100b_rx_parse int(void) scan USART3 RX ring for one 0x68 frame, verify checksum, dispatch it; returns 1 if a frame consumed
0x08006348 fm100b_on_frame void(u8* frame) master *Cnf/*Ind dispatch: writes resp[cmd]=frame[3] then jump-tables to the per-cmd Ind handler
0x08006CFC dmr_call_start_from_contact void(u8 dummy, u16 contact_idx) originate a call to a stored contact: look up record, send cmd6 setup, latch current-call state
0x08006FD8 dmr_call_resend void(void) re-send cmd6 for the latched current call (PTT continue)
0x08006C4C dmr_set_radio_id void(u8 idHi,u8 idLo) cmd 0x49 — set our personal DMR ID on the module
0x0800736C dmr_sms_send void(u16 target_or_contact) cmd 0x82 — send an SMS
0x08006D00 dmr_contact_read void(u8 dummy,u16 idx,...) read a 21-byte DMR contact record idx*27 + 0x5E000 from SPI (for name/ID display)
0x0801A38C nvic_system_reset noreturn void(void) reboot (used by remote-kill enforcement)

dmr_call_start_from_contact is the function whose body begins at 0x08006CFC/0x08006D00; dmr_call_resend body starts 0x08006FD8. Signatures below.

Confidence: high on the framing, the two send primitives, the RX parser, the fm100b_on_frame dispatch table, the incoming-call state block, and the reset. Medium-high on individual command semantics (cmd numbers are proven from call sites; their meaning is inferred from surrounding code + the FM100B ATC_* symbol list in the prior report).


1. MCU ↔ FM100B wire protocol (USART3 0x40004800)

1.1 Frame format (both directions)

Every message is a framed packet built/parsed at the byte level. Layout (offsets in bytes):

+0   0x68              sync / SOF (constant; parser rejects anything else)
+1   cmd              command id (see §2)
+2   b                secondary/opcode byte (usually 1 on Req; on Ind = subtype)
+3   sub              sub-command / status. On *Cnf this byte is the result code.
+4..+5  cksum         16-bit checksum, big-endian (see §1.2)
+6..+7  len           payload length, big-endian (u16)
+8..+8+len-1  payload  (len bytes; for send1 it is a single data byte)
+8+len   0x10          EOF / end marker (constant)

Total on-wire size = len + 9. The send1 primitive uses len=1, so its frame is 10 bytes (68 cmd b sub CKh CKl 00 01 data 10).

Evidence — fm100b_send1 @0x0801B044:

0x0801b054 movs r0,#0x68 ; str [buf+0]        ; SOF
0x0801b05c strb r4,[buf+1] / r5,[+2] / r6,[+3] ; cmd,b,sub
0x0801b062 movw #0xffff ; strh [buf+4]         ; cksum placeholder
0x0801b068 bl 0x800bd2c ; strh r0,[buf+6]      ; len = bswap16(1)
0x0801b074 strb r7,[buf+8]                     ; single data byte
0x0801b078 movs #0x10 ; strb [buf+9]           ; EOF marker
0x0801b07c bl 0x8002ea8 (sum16, len=10)        ; checksum over 10 bytes
0x0801b086 strh r0,[buf+4]                     ; store bswap16(cksum) at +4
0x0801b092 ldr r0,=0x2000706e ; bl 0x8006c9c   ; usart3_tx_buf(buf,10)

fm100b_send @0x0801B0C4 is identical but len=r3, copies payload ([sp+0x20]) into buf+8 via memcpy 0x80062EC, writes 0x10 at buf+8+len, and sends len+9 bytes.

Helpers:

  • 0x0800BD2C = bswap16(u16) — byte-swap; used to store the BE 16-bit len and cksum.
  • 0x08002EA8 = sum16(const u8* buf,u16 len) — sum of big-endian 16-bit words → the checksum.
  • 0x08021EB0 = usart_write_DR(base,byte) (str [base+4]), 0x08021EA8 = usart_read_DR(base), 0x08021EC2 = usart_get_flag(base,mask).

1.2 TX path

  • usart3_tx_byte @0x08006CB8 void(u8 b): optionally mirrors the byte into the RX ring when a loopback flag (0x20000B67) is set, then usart_write_DR(0x40004800,b) and spins on TX-complete (SR bit 0x80).
  • usart3_tx_buf @0x08006C9C void(const u8* buf,u16 len): for i in 0..len: usart3_tx_byte(buf[i]).
  • Shared TX frame buffer at SRAM 0x2000706E (both send primitives build here; not re-entrant — the send primitives block until *Cnf, so a single global buffer is safe only from the main loop).

1.3 RX path

Per-byte RX is interrupt-driven (USART3 ISR @0x080205B0, IRQ 39). It pushes each byte into a 4 KB ring:

  • ring struct head/word at 0x20000C64, data buffer at 0x200092EF, index mask 0xFFF.
  • (There is also a 1 KB ring at 0x20000C2C/0x20007575, mask 0x3FF, filled in parallel — a secondary/debug capture.)

Draining/parsing happens in the main loop, not in the ISR:

  • fm100b_rx_ring_drain @0x0801FE50 void(void): while tail < head, pull one byte and feed the byte accumulator… actually it calls fm100b_rx_parse per available byte via 0x08018BFC? — the concrete flow is: poll_serial calls fm100b_rx_parse directly.

  • fm100b_rx_parse @0x08018CB0 int(void):

    1. Search the ring for a 0x68 byte (advance tail past junk).
    2. Read len = (ring[p+6]<<8)|ring[p+7] (BE). Reject if len >= 0x200.
    3. Require head-tail >= len+9 bytes buffered, and ring[p+8+len] == 0x10 (EOF).
    4. Copy the whole len+9 frame out of the ring into a linear work buffer.
    5. sum16(frame,len+9) must equal the stored checksum at +4; else drop.
    6. On success advance the tail past the frame and call fm100b_on_frame(frame) (0x08006348); return 1.
  • poll_serial @0x08003050 = fm100b_rx_parse(); cps_framer(0x0801F854); fm100b_rx_ring_drain(0x0801FE50);. This is the cooperative pump. Every blocking send loop (see §1.4) calls this; a rewritten UI's idle/wait loop must call it too.

1.4 Request/Confirm handshake (how blocking works)

Both send primitives implement a synchronous Req→Cnf:

resp[cmd] = 0xFF               ; mark pending   (resp array @0x20007476, indexed by cmd)
usart3_tx_buf(frame,len)       ; send
timeout_ctr = timeout          ; @0x20000C52
do { poll_serial(); } while (resp[cmd]==0xFF && timeout_ctr!=0);

fm100b_on_frame (§3) sets resp[cmd] = frame[3] when the matching *Cnf arrives, which breaks the loop. So respbuf/timeout args are: timeout is the last stacked arg (e.g. 0x3E8=1000 for call setup, 0x64=100 for config); the "respbuf" stack arg is a copy of the timeout counter seed. The response/status code for a command after the call returns is resp[cmd] at 0x20007476+cmd.


2. Command set (MCU → FM100B *Req), from call sites

Extracted by decoding (cmd=r0, b=r1, sub=r2, data/len=r3) at every call to the two send primitives. cmd is proven from the immediate; the name maps to the FM100B ATC_*Req symbol families documented in the prior RE report (§5.4).

cmd via b sub payload wrapper vaddr inferred meaning (ATC_*Req)
0x02 send1 1 1 1B 0x0800760A misc mode set
0x05 send1 1 2 data=2 0x08006C74 channel/RF config set (ATC_ChannelSetReq-class)
0x06 send 1 call_type 5B [type,ID_be32] 0x08006CFC DMR call setup (ATC_CallProcessReq)
0x07 send 1 1 var 0x08007180 contact/data set (ATC_CurChDigdataSetReq)
0x09 send1 1 1 1B 0x080076CC misc
0x0A send 1 1 5B [type,ID_be32] 0x08006FD8 send/originate call (TX PTT) variant
0x0B send1 1 1 1B 0x08007530 set param
0x0C send1 1 1 data=0 0x08007598 set param
0x25 send1 1 1 data=1 0x08006C88 init/enable
0x2A send 1 1 4B 0x080074FC set 32-bit param
0x42 send1 1 1 1B 0x080075B4 set param
0x48 send1 1 1 1B 0x080075C6 set param
0x49 send 1 1 4B 0x08006C4C set our radio DMR ID (ATC_RadioIDSetReq)
0x4D send1 1 1 1B 0x080075F4 set param
0x55 send1 1 1 data+1 0x080075DC set param (increment)
0x4C send1 1 1 1B 0x08007680 set param
0x57 send 1 1 2B 0x080074E0 set param
0x62 send 1 1 2B 0x080071F0 set param
0x81 send 1 1 var 0x080073E0 SMS payload block (SPSendInBandDataReq)
0x82 send 1 1 20B 0x0800736C SMS send (header+target)
0x84 send r0 r0 0x080074BC contact info query (ATC_CalledContactINfoQuery)
0x84 rawTX 10B fixed 0x08007548 boot/wake handshake — literal frame 68 84 01 01 00 00 00 01 00 10 sent raw via usart3_tx_buf, marks resp [+0x84]. Verified live on hardware: reply 68 84 00 00 87 7B 00 00 10 (status 0 = OK). Earlier listed as 0x64 — that was a misread.

The single byte b is almost always 1 on a Req; on Ind frames frame[2] is the subtype selector (see §3). sub (frame[3]) is the module's status on the returned *Cnf.

2.1 Selected wrapper decompilations (callable API)

dmr_set_radio_id @0x08006C4C void dmr_set_radio_id(u8 idHi, u8 idLo)

payload[0]=idHi; payload[1]=idLo; payload[2..3]=0;
fm100b_send(cmd=0x49,b=1,sub=1,len=4,payload,timeout=0x64);

Sets the module's own DMR ID. (Only 2 bytes filled here; the personal ID low 16 bits — the caller composes the full 24-bit ID before calling.)

dmr_call_start_from_contact @0x08006CFC void dmr_call_start_from_contact(u8 unused, u16 contact_idx)

rec = dmr_contact_read(0xFF, contact_idx);     // 21B record @ contact_idx*27 + 0x5E000
if (rec[0] > 2) { error("Call type error"); return; }   // 0800aeb8 = show msg
call_type = (rec[0]==0)?1 : (rec[0]==1)?2 : (rec[0]==2)?4 : ...;  // 1=Group,2=Private,4=AllCall
target_id = be32(rec[+1]);                      // 32/24-bit target
build payload = [call_type, target_id_be32];    // 5 bytes
fm100b_send(cmd=0x06, b=1, sub=call_type, len=5, payload, timeout=0x3E8);
// latch current-call state @0x20007DA9: [0]=call_type, [1..4]=target_id

This is the originate-call entry. It maps the contact record's stored type to the module's call_type (Group→1, Private→2, All→4) and sends the setup, then also fires a follow-on raw frame (0x8006DFC region) that TX-blits a 0x1F-byte packet.

dmr_call_resend @0x08006FD8 void dmr_call_resend(void)

type = curcall[0]; id = be32(curcall[+5]);      // curcall @0x20007DA9
payload=[type,id_be32]; fm100b_send(0x06,1,type,5,payload,0x3E8);

Re-issues the setup for the already-latched call (used to keep a group call up / PTT re-key).

dmr_send_call_0a @0x08006FD8-region (0x08007000) void(u8 type, u32 id) — cmd 0x0A, same 5-byte [type,id_be32] payload, timeout=0x3E8. This is the alternate "start voice" path (the two, cmd6 vs cmd0xA, correspond to ATDigCallSetupCnf vs a direct voice-start).

dmr_sms_send @0x0800736C void dmr_sms_send(u16 target)

if (target != 0) {                              // resolve target contact
   rec = SPI_read(0x0D0000 + (target-1)*48, 48);// SMS-target table (0x0D0000, 48B stride)
   switch(rec[+1]) { type=1→grp, 4→prv, 5→all } // map record type
}
build 0x22-byte msg: dst = 0xAAAAAAAA if all-call else target;
fm100b_send(cmd=0x82,b=1,sub=1,len=20,payload,timeout=0x64);  // header
// followed by cmd 0x81 payload block(s) for the text (0x080073E0)

dmr_contact_read @0x08006D00 void dmr_contact_read(u8 unused, u16 idx, out u8 rec[21])

base = idx*27 + 0x5E000;                         // 27-byte stride, contacts region
SPI_read(base, 21, rec);                         // 0x8021828 = spi_read(dst,addr,len)
// rec[0] = contact type (0=Group,1=Private,2=AllCall); rec[+1..]=ID + name

The stride is 27 bytes at 0x5E000 (= codeplug contacts 0x05C000 + 0x2000). This is the routine the UI calls to turn a contact index into a type+ID+name for display and for call setup. (Note the on-flash contact record the CPS writes is 32 bytes at 0x5E000 per the codeplug report; the module-facing read here pulls the first 21 bytes.)


3. Incoming frames (FM100B → MCU *Cnf / *Ind) — the RX side the UI renders

3.1 Master dispatch fm100b_on_frame @0x08006348

void fm100b_on_frame(u8* f) {
    resp[f[1]] = f[3];               // 0x20007476[cmd] = status  → unblocks the Req wait
    if (f[1] >= 0xC1) return;
    switch (f[1]) { /* jump table @0x0800636C, cmd*4 half-word offsets */ }
}

Jump-table result (cmds with a real Ind handler; all others fall to the no-op default 0x08006C26 and only update resp[]):

cmd handler vaddr meaning
0x01 0x08006670 status
0x02 0x08006672 status
0x030x04 0x08006674/76 status
0x05 0x0800668C channel/config change Ind (latches new state, sets a "changed" flag)
0x06 0x080066AA INCOMING CALL Ind — caller/TG/type → UI (see §3.2)
0x07 0x0800671E call/PTT status Ind (call end, TX status)
0x09 0x08006816 call-timer/ready Ind (arms a 0x320 timer)
0x0A 0x08006870 remote-command Ind (stun/kill; see §3.3)
others (0x0B+, 0x120xC0) small resp[]-only stubs pure *Cnf acknowledgements

3.2 Incoming-call Ind 0x080066AA — what the standby/RX screen reads

Frame layout for a cmd6 Ind: f[8]=call_type (1=Group, 2=Private, 4=AllCall), f[9..12]=source(caller) ID (BE), f[13..16]=dest/TG ID (BE). Handler:

status = f[3]                         → 0x20000C3C / 0x20000C3D
call_type: 1→0, 2→1, 4→2              → curcall[0] @0x20007DC2
dest_id  = be32(f[+0xD])              → curcall[+1] (u32)   (0x80112B8 = be32_to_u32)
src_id   = be32(f[+9])                → curcall[+5] (u32)   (the CALLER id the UI shows)
if (first-of-call flag) {
    slot = curcall[+1]>>4;  set_rx_slot_indicator(slot);   // 0x8018530
    copy state block                                        // 0x80062ec
}

Incoming-call state block 0x20007DC2 (this is what a rewritten RX screen reads to draw "caller / TG / type"):

+0  u8   call_type   (0=Group, 1=Private, 2=AllCall)
+1  u32  dest_id / talkgroup   (little-endian in RAM)
+5  u32  source_id  (the caller's DMR ID)

0x80112B8 = be32_to_u32(const u8* p) converts the on-wire big-endian IDs. Additional call-status bytes: 0x20000C3C (raw status), 0x20000C3D (mirror).

Talker alias / caller name: the frame carries the numeric IDs only. The UI resolves the caller name by looking the source_id up against the contacts table (dmr_contact_read / the by-ID search at 0x08007E68 → 0x08017F60, and 0x08006E6C alt lookup). If no contact matches, the raw ID is shown (Unknown station string at 0x08028815).

3.3 Remote-command Ind 0x08006870 (cmd 0x0A) — stun / kill / wake

sub = f[2]; code = f[3] → 0x20000C?? state
if (code == 0xA1) show_msg(...);                 // e.g. remote check / stun-related
if (enabled_flag[+0x184]) {
  if (code == 0xA2) {                            // REMOTE KILL
     kill_state = 4;
     persist_word = 0x4444; store @[+0xC];       // marker written to NV
     0x801A900();                                // commit to SPI/NV
     delay(0x7D0);  0x8007946(0x7D0);
     nvic_system_reset();                        // 0x801A38C — reboot into killed state
  }
}

So the enforcement of a remote kill is a persisted 0x4444 marker + reboot via nvic_system_reset @0x0801A38C. A rewritten UI that wants to ignore remote kill would stub this handler or the 0x184 enable flag; to keep stock behavior, leave fm100b_on_frame's cmd-0x0A path intact. (Prohibit TX string 0x0801ED28 and DMR Remote Kill/Stun anchors 0x0800346C/0x08006B30 live on the UI side that reads these flags.)

3.4 Incoming SMS (module → MCU)

SMS received by the module arrives as an Ind carrying the text block; the MCU stores it into the SMS/inbox codeplug area. The upload confirm corresponds to the FM100B ATUploadRxSmsCnf symbol. The MCU-side receive path shares the same fm100b_on_frame dispatch (one of the resp[]-updating cmds) plus a data-copy into RAM; the inbox commit reuses the standard SPI codeplug writer (unchanged format).


4. Contact / address-book lookup for caller-name display

Two record stores are involved (both are read-only from DMR's perspective; the CPS owns their format):

  1. Contacts (module-facing)dmr_contact_read @0x08006D00: record = SPI[idx*27 + 0x5E000], 21 bytes: [0]=type, [+1..]=ID, name. Used both to originate calls and to name a contact index.
  2. By-ID reverse lookup — the RX screen turns a numeric source_id/dest_id into a name via the search wrapper at 0x08007E680x08017F60 (walks the contacts region comparing the 24-bit ID), with an alternate at 0x08006E6C. On a hit it renders the stored name; on a miss it renders the raw decimal ID (24-bit, max 16777215 per string 0x08007E07).
  3. SMS-target / key-name table0x0D0000, 48-byte stride (per the live SPI dump), used by dmr_sms_send to resolve an SMS destination.

Group IDs are stored BCD/LE in the contact record (66 06 → TG 666, per the codeplug report); the module wire format uses plain big-endian 24/32-bitbe32_to_u32 @0x80112B8 and the payload-build shifts in the wrappers do the conversion. Keep both conversions if reusing these functions.


5. Call sequences for a rewritten UI

5.1 Boot / attach the module

// stock boot fires: raw 0x84 handshake (0x08007548), then a burst of config Reqs
fm100b_send1(0x05,1,2, data=2, resp, 0x64);   // channel/RF config
fm100b_send (0x49,1,1, len=4, [idHi,idLo,0,0], resp, 0x64);  // dmr_set_radio_id
// ... other 0x0B/0x0C/0x42/0x48/0x4D param sets as needed
// each call blocks via poll_serial() until resp[cmd] != 0xFF

5.2 Originate a DMR call (private or group)

// UI has a contact index (or build an ad-hoc record):
dmr_call_start_from_contact(0xFF, contact_idx);   // 0x08006CFC
//   → looks up record, maps type, sends cmd6 [type,id_be32], latches curcall@0x20007DA9
// while PTT held, keep the call up:
while (ptt_down) { dmr_call_resend(); poll_serial(); }   // 0x08006FD8, re-sends cmd6/0x0A
// on release: send the corresponding stop/param Req and drop PTT.

For a raw call without a stored contact: build payload=[call_type, target_id_be32] yourself and call fm100b_send(0x06,1,call_type,5,payload,0x3E8) (or cmd 0x0A for the voice-start variant), then set curcall@0x20007DA9.

5.3 Render an incoming call (standby/RX screen)

// In the main loop, keep pumping the link:
poll_serial();                     // 0x08003050 — drains USART3, dispatches Inds
// When cmd6 Ind fires, the state block @0x20007DC2 is populated:
u8   type = curcall_rx[0];         // 0=Group,1=Private,2=AllCall
u32  tg   = *(u32*)(curcall_rx+1); // talkgroup / dest
u32  src  = *(u32*)(curcall_rx+5); // caller DMR ID
// Resolve caller name:
name = contact_name_by_id(src);    // 0x08017F60 search; fallback → decimal(src)
draw: "<name or src>  →  TG <tg>"  (type-dependent: SID/GID/AID labels @0x0800A36C)
// status/end: cmd7 Ind updates call-status bytes; cmd9 arms the call timer.

5.4 Send an SMS

// text staged in RAM by the editor; target is a contact index or 0 for the default
dmr_sms_send(target);              // 0x0800736C → cmd 0x82 header + cmd 0x81 payload
// wait resp[0x82]/resp[0x81]; ATUpload* / send-fail handled by fm100b_on_frame.

6. RAM state map (DMR)

addr size contents
0x2000706E ~0x200 TX frame build buffer (0x68…0x10)
0x20007476 0xC1 resp[cmd] response/status array (0xFF=pending)
0x20000C52 u16 Req timeout counter
0x200092EF 0x1000 USART3 RX ring data
0x20000C64 USART3 RX ring head/index
0x20007575 0x400 secondary RX capture ring
0x20000B67 u8 USART3 TX→RX loopback capture flag
0x20007DA9 5+ outgoing current-call: [0]=type,[1..4]=id, [+5]=id copy
0x20007DC2 9 incoming call: [0]=type,[1..4]=dest/TG,[5..8]=caller id
0x20000C3C/3D u8×2 incoming-call status bytes

7. Reuse guidance for the UI rewrite

  • Keep and call as-is: fm100b_send1 (0x0801B044), fm100b_send (0x0801B0C4), poll_serial (0x08003050), fm100b_rx_parse (0x08018CB0), fm100b_on_frame (0x08006348), dmr_contact_read (0x08006D00), nvic_system_reset (0x0801A38C), and the wrappers in §2. They contain the whole USART3 protocol and are codeplug/CPS-neutral.
  • Read, never reframe: the incoming-call block 0x20007DC2 and resp[] 0x20007476 are your UI inputs. Poll poll_serial() from your event loop; read those to render.
  • To originate: prefer the wrappers (dmr_call_start_from_contact, dmr_sms_send, dmr_set_radio_id) so type-mapping and current-call latching stay correct. If you bypass them, replicate the Group→1/Private→2/AllCall→4 mapping and the big-endian ID packing.
  • Do not re-implement framing/checksums yourself — call the two send primitives; that guarantees the FM100B never sees a malformed frame and keeps the module firmware (unchanged) happy.
  • Color code / timeslot are set through the per-channel config Reqs (cmd 0x05 and the 0x0B/0x0C/0x42/0x48/0x4D family — set from the channel record fields); these carry no codeplug-format dependency beyond reading the channel record the CPS already writes.

Open items (medium confidence, worth a second pass on-target)

  • Exact sub/field meaning of the 0x0B/0x0C/0x42/0x48/0x4D/0x55/0x57/0x62 param Reqs (which is color-code vs squelch vs power vs denoise) — the cmd numbers are certain; individual mapping needs tracing each wrapper's caller (channel-settings menu handlers).
  • The cmd 0x84 ATC_CalledContactINfoQuery return payload layout (talker-alias source) — its Ind path falls to the default stub here, so alias text likely arrives on a different cmd or is assembled MCU-side from contacts.
  • Encryption enable/key-select Req (menu Encryption Set @0x080165D0) — routed through one of the param Reqs above; not yet pinned to a specific cmd byte.