The Icom RS-BA1 network remote-control protocol — as implemented in Rafe¶
A reproduction-grade specification of Icom's proprietary UDP remote-control protocol (RS-BA1), covering the three-stream control/CI-V/audio model, the login and token handshake, the sequence/retransmit machinery, and the spectrum-scope / waterfall transport — documented from this repo's own client and server code, cross-checked against the wfview reverse-engineering lineage and the Icom CI-V reference guide.
Rafe project · client app/radio/{stream,control,civ,serialciv,audio,passcode}.py
· server rsba1server/{protocol,packets,server,passcode}.py
1. Abstract¶
RS-BA1 is the network protocol Icom's "Remote Utility" and radios such as the
IC-705 (and, through wfview / this project's server, USB-only rigs like the
IC-7300) use to carry rig control and audio over an IP network. It is
undocumented by Icom; the wire format used here was reverse-engineered by the
wfview and kappanhang projects and re-implemented from scratch in this repo as
both a client (app/radio/, which controls a remote radio) and a server
(rsba1server/, which presents a local radio to RS-BA1 clients). The two ends
interoperate over loopback in test_rsba1.py.
The protocol runs three parallel UDP conversations on three consecutive ports —
control (50001), serial/CI-V (50002) and audio (50003) — each
built on a common 16-byte packet header and a small family of fixed "control"
packets (are-you-there, i-am-here, are-you-ready, ping, retransmit-request,
disconnect). On top of that base layer the control stream performs a login
(username + a position-substituted obfuscation of the password) and a token
exchange that opens the other two streams; the serial stream tunnels
transparent CI-V frames (FE FE … FD); and the audio stream carries
uncompressed 16-bit LPCM at 48 kHz in both directions with a sequence-numbered
retransmit/reorder scheme. The spectrum scope / waterfall is not a fourth
stream: it rides inside the CI-V serial stream as Icom CI-V command 0x27
messages — 0x27 0x00 waveform frames of 475 amplitude points (0–160) plus
0x27 0x14/0x15/0x17 for centre/fixed mode, span and hold. This document
specifies each of these layers with exact constants (type bytes, ports,
offsets, timers) cited to the source, and grounds the 0x27 scope format — most
of which is not otherwise explicit in our code — in the wfview and Icom CI-V
references.
2. Background¶
2.1 RS-BA1 and its reverse-engineering lineage¶
"RS-BA1" is the model number of Icom's optional Windows remote-control product. The IC-705 speaks the same protocol natively over Wi-Fi/LAN; the RS-BA1 "Remote Utility" is essentially a software radio-server plus a control head. Icom publishes no protocol document, so every open implementation descends from packet-capture reverse engineering. Two projects are the canonical references, and both are cited in our source headers:
- wfview (
icomudp*.cpp,rigcommander.cpp,packettypes.h) — the C++ cross-platform controller.app/radio/serialciv.pyandapp/radio/audio.pycitewfview icomudpcivdata.cpp/icomudpaudio.cpp;rsba1server/packets.pyciteswfview's icomserver.cpp / packettypes.hfor the byte layouts. - kappanhang (Go:
streamcommon.go,pkt0.go,pkt7.go,controlstream.go,serialstream.go,audiostream.go,passcode.go) — a headless Go client.app/radio/stream.py,control.pyandpasscode.pyare ported from it.
Our code is the source of truth for this document. Where a detail is only visible in wfview or the Icom CI-V reference (notably the internal structure of the 0x27 scope frames, §7), it is cited as such and flagged.
2.2 The three-UDP-stream model¶
An RS-BA1 session is three independent UDP conversations, one per port
(app/radio/control.py, rsba1server/server.py):
| Port | Stream | Carries |
|---|---|---|
| 50001 | control | handshake, login, token/auth, capabilities + connection-info exchange, keepalive, retransmit |
| 50002 | serial | transparent CI-V (FE FE … FD) in both directions — including the 0x27 scope frames |
| 50003 | audio | RX audio radio→client and TX audio client→radio, 16-bit LPCM 48 kHz |
Each stream is an instance of the same base class — UdpStream
(app/radio/stream.py) on the client, SessionStream
(rsba1server/protocol.py) on the server — and each carries its own
session-id pair, send sequence counter, retransmit buffer and keepalive timer.
The control stream is opened first; once it has authenticated and negotiated,
the client opens the serial and audio streams to the same host on 50002/50003.
The client binds each socket to the same port it targets and connect()s it
to the radio (app/radio/stream.py); this fixes the local UDP port,
which the protocol folds into the session id (§4.1). test_rsba1.py overrides
this to bind ephemeral local ports so the client and server can share one host
(test_rsba1.py).
3. The common packet format¶
3.1 The 16-byte header¶
Every datagram on every stream begins with the same 16-byte header
(app/radio/stream.py, _hdr at stream.py; mirror at
rsba1server/protocol.py, hdr at protocol.py):
offset size endian field
0 4 LE len total packet length (including this header)
4 2 LE type packet type
6 2 LE seq sequence number (per-stream, for type-0x00 data)
8 4 BE sid_a sender's session id
12 4 BE sid_b receiver's session id
The mixed endianness is load-bearing and exact: len, type, seq are
little-endian; the two session ids are big-endian. In code:
struct.pack("<IHH", length, ptype, seq) + struct.pack(">II", local_sid,
remote_sid). From the client's point of view sid_a is local_sid and
sid_b is remote_sid; the server's hdr() reverses the roles
(our_sid, their_sid).
3.2 The fixed "control" packets¶
Small control packets are 16 bytes (len = 0x10) — header only, no payload —
and are distinguished purely by the type field. The client's send_ctl
(stream.py) sends them, twice by default (twice=True) for
datagram-loss resilience. The type bytes actually used:
| type | name (this doc) | len | who sends | source |
|---|---|---|---|---|
| 0x03 | are-you-there (pkt3) | 0x10 | opener | stream.py, server classifies protocol.py |
| 0x04 | i-am-here (pkt4 answer) | 0x10 | responder | client waits stream.py, server sends server.py |
| 0x06 | are-you-ready (pkt6) | 0x10 | opener → answered 0x06 | stream.py, server protocol.py/server.py |
| 0x05 | disconnect (pkt5) | 0x10 | either | stream.py, server protocol.py |
| 0x00 | tracked data / idle (pkt0) | ≥0x10 | either | stream.py |
| 0x07 | ping / keepalive (pkt7) | 0x15 | either | stream.py |
| 0x01 | retransmit request (single) | 0x10 | either | stream.py, detect stream.py |
| 0x01 | retransmit request (range) | 0x18 | either | stream.py, detect stream.py |
Two families deserve their exact layout:
Idle / tracked data (type 0x00). The base data packet. A bare idle is just
the 16-byte header with type=0x00 (send_idle, stream.py). A
tracked send (send_tracked, stream.py) stamps the current
send_seq into the header's seq field (bytes [6:8], LE), keeps a copy in a
retransmit buffer, then increments send_seq. Every payload-bearing packet
(login, auth, CI-V data, audio data) is sent tracked so it can be retransmitted
on request. The buffer is a bounded OrderedDict (1024 entries client-side,
stream.py; 2048 server-side, protocol.py).
Ping (type 0x07, pkt7). 21 bytes: the 16-byte header (len=0x15) + one
flag byte + a 4-byte "reply id" (_send_pkt7, stream.py). The flag
byte at offset 16 is 0x00 for a request and 0x01 for the echo. The 4-byte
reply id is [random, inner_seq_lo, inner_seq_hi, 0x06] where inner_seq
starts at 0x8304 on the client (stream.py) and
0x8000 | (random<<4) on the server (protocol.py). On receipt of a
request (stream.py), the peer echoes the same reply id back with the
flag set to 0x01 and the request's seq. Detection is by the exact prefix
len==21 and r[1:6]==b"\x00\x00\x00\x07\x00".
Retransmit request (type 0x01). Two forms (§4.6).
4. Connection lifecycle¶
The full client sequence lives in ControlStream.connect
(app/radio/control.py); the server's mirror is the state machine in
RSBA1Server._control_rx and friends (rsba1server/server.py).
4.1 Session ids¶
Each stream computes a local_sid from its own bound IP and UDP port:
sid = (ip_u32 << 16) | (local_port & 0xFFFF) (stream.py). The
server derives its our_sid per wfview as
(octet3<<24) | (octet4<<16) | port from the client's address
(server.py). The remote_sid is learned from the peer during the
handshake (client) or from each inbound datagram's sid_a
(protocol.py, learn_their_sid). Both ids then appear in every
header for the life of the stream.
4.2 Per-stream handshake (are-you-there / i-am / are-you-ready)¶
Before any stream can carry data it runs a four-packet handshake
(UdpStream.handshake, stream.py), retried up to 8 times with a
0.5 s gap:
client → radio : pkt3 (type 0x03, "are you there")
radio → client: pkt4 (type 0x04) — client reads remote_sid from bytes [8:12] BE
client → radio : pkt6 (type 0x06, seq=1, "are you ready")
radio → client: pkt6 answer (type 0x06)
The client's predicates require len==16 and the exact type/subtype bytes
(d[4]==0x04 and d[5]==0x00, then d[4]==0x06 and d[5]==0x00). Timeout on the
last attempt raises ConnectionDied (stream.py). The server answers
0x03→0x04 and 0x06→0x06 in _control_rx (server.py), and — a subtle
point — echoes the received seq literally unless it was 0, in which case it
replies with a tracked packet so the reply carries the server's own
tx-seq (_reply_ctl, server.py).
The serial and audio streams run the same handshake before they carry data
(serialciv.py, audio.py).
4.3 Login (username + obfuscated password)¶
Once the control handshake completes, the client sends a login packet
(_send_login, control.py): a tracked type-0x00 data packet of total
length 0x80 (128) whose body is an "inner" request. The layout the server
parses (_handle_login, server.py, offsets confirmed by
packets.py):
[0x00:0x10] 16-byte outer header (type 0x00, tracked)
[0x10:0x14] inner payloadsize 0x70 (BE) _inner_hdr, control.py
[0x14] requestreply 0x01
[0x15] requesttype 0x00 (login)
[0x16:0x18] inner sequence (LE)
[0x1a:0x1c] tokrequest 2 random bytes (os.urandom(2)) — the token seed
[0x1c:0x40] zero padding
[0x40:0x50] passcode(username) 16 bytes, obfuscated (§8)
[0x50:0x60] passcode(password) 16 bytes, obfuscated (§8)
[0x60:0x70] connection name "icom-pc\0…" (client) / NUL-split by server
[0x70:0x80] zero padding
The two random bytes the client writes at [0x1a] (control.py,
os.urandom(2)) are read by the server as tokrequest (server.py) — the
seed for the token exchange. The server checks the credentials in _check_auth
(server.py): it compares the 16-byte username field against
passcode(user) zero-padded, and accepts the password if it equals either
passcode(pass) or the plain zero-padded password (so a client that sends the
password un-obfuscated still authenticates).
The client waits for a 96-byte (0x60) login response with d[0]==0x60 and
d[4]==0x00 (control.py). A rejection is signalled by the bytes
FF FF FF FE at [0x30:0x34] (r[48:52], decimal) → AuthFailed; the server
writes exactly that marker at offset 0x30 on failure (packets.login_response,
packets.py, error branch packets.py). On success the client reads a
6-byte auth_id from [0x1a:0x20] (r[26:32], control.py), which it
then quotes in every subsequent auth/conninfo packet.
4.4 Token / auth and stream negotiation¶
With auth_id in hand the client (control.py):
- starts the pkt7 keepalive (
start_pkt7); - sends auth magic 0x02 (
_send_auth(0x02),control.py) — a 0x40-byte tracked packet: inner header0x30/magic + the 6-byteauth_id+ 32 zero bytes; - starts the pkt0 idle keepalive (
start_pkt0_idle); - sends auth magic 0x05.
The server treats these as token requests (_handle_token, server.py,
requesttype = r[0x15]):
- 0x02 ("create token") → the server answers with a capabilities packet
(len
0xA8/168; one radio: guid at[0x42:0x52], name at[0x52:0x72],ICOM_VAUDIOmarker, conntype0x0707wifi, CI-V address at[0x94], baudrate BE at[0x9c]—packets.capabilities,packets.py) followed by a conninfo packet withbusy=0(packets.conninfo,packets.py). - 0x01 ("disconnect token") → a token_response then teardown.
- anything else (e.g. 0x05 status/keepalive) → a plain token_response
(
packets.token_response,packets.py).
The client, meanwhile, keys its own state machine off the shapes of the
replies (_handle, control.py):
| reply len | prefix | meaning | client action |
|---|---|---|---|
| 168 (0xA8) | A8 00 00 00 00 00 |
capabilities | store a8_reply_id = r[66:82]; maybe request streams |
| 64 (0x40) | 40 00 00 00 00 00 |
auth answer | if r[21]==0x05, auth_ok=True; maybe request streams |
| 80 (0x50) | 50 00 00 00 00 00 |
status | FF FF FF at [0x30] → auth failure; zeros + r[0x40]==1 → radio disconnected us |
| 144 (0x90) | 90 00 00 00 00 00, r[96]==1 |
conninfo (streaming) | streams granted |
When both auth_ok and a8_reply_id are set the client sends a conninfo /
stream request (_send_conninfo, control.py): a 0x90-byte packet
carrying the auth_id, the 16-byte a8_reply_id, the device string
"IC-705\0\0", passcode(username) again, and a 32-byte tail encoding the
audio sample rate (48000, big-endian, twice — RX and TX), the serial port
(50002), the audio port (50003) and the TX buffer depth (300 ms), all
big-endian (control.py).
The server answers a 0x90 stream request (_handle_stream_request,
server.py) with a status packet (len 0x50; CI-V port at [0x42]
BE, audio port at [0x46] BE — packets.status, packets.py) and a
conninfo with busy=1 (packets.conninfo with computer/ip_be,
packets.py). The client treats the 144-byte conninfo with the busy
field ==1 at offset [0x60]/r[96] as "streams granted"
(control.py): it adopts the session ids from the packet
(remote_sid=r[8:12], local_sid=r[12:16]), re-reads auth_id from
[0x1a:0x20], decodes the device name from [0x40:0x60], and resolves the
_ready_fut. Only now does the caller open the serial and audio streams.
4.5 Keepalive, re-auth and teardown¶
Three periodic tasks keep a stream alive:
- pkt7 ping every
PKT7_SEND_INTERVAL = 3.0 s(stream.py,_pkt7_loopstream.py; serverPKT7_SEND_INTERVAL = 3.0protocol.py). All three streams run it. - pkt0 idle — a tracked idle every
IDLE_INTERVAL_FAST = 0.1 swhile recently active, backing off toIDLE_INTERVAL_SLOW = 1.0 safterIDLE_AFTER = 1.0 sof quiet (stream.py,_pkt0_idle_loopstream.py). The control and serial streams run it; the audio stream deliberately does not (audio.py"no periodic idle pkt0"). - re-auth — the client re-sends auth magic 0x05 every
REAUTH_INTERVAL = 60.0 s(control.py,_reauth_loopcontrol.py).
The server's single _ticker (server.py) drives all of this from one
100 ms loop: idle keepalives on control+serial every tick, self-pings every
PING_INTERVAL = 1.0 s (control+serial) and every AUDIO_PING_INTERVAL = 0.5 s
(audio), and it drops a session that has been silent for
STALE_AFTER = 15.0 s (server.py, 375-377).
Teardown. The client's ControlStream.close (control.py) cancels
re-auth, sends a de-auth (auth magic 0x01), then a disconnect (type 0x05) on the
control stream; the serial and audio streams each send their own
close/disconnect (serialciv.py, audio.py). The server tears a
session down on receiving a disconnect on the control stream, or on a
disconnect-token, or on timeout (server.py, 242, 364-366).
4.6 Sequence numbering and retransmission recovery¶
Every stream keeps a monotonically increasing 16-bit send_seq starting at 1
(stream.py, protocol.py), stamped into the header seq field by
send_tracked and wrapped mod 0x10000. Recovery is symmetric — either end can
both request retransmits and answer them.
Requesting (request_retransmit, stream.py):
- single missing seq → a 16-byte type-0x01 packet whose
seqfield is the wanted sequence number (send_ctl(0x01, seq=start)); - a contiguous range → a 24-byte (
len=0x18) type-0x01 packet followed by the pairstruct.pack("<HH", start, end)(start, end little-endian). Sent twice.
Answering (_on_datagram, stream.py; server
handle_retransmit_request, protocol.py): a single request
(prefix 10 00 00 00 01 00) retransmits one buffered packet; a range request
(prefix 18 00 00 00 01 00) walks the (start,end) pairs in the body, capped
at MAX_RETRANSMIT_RANGE-ish batches (min(span+1, 64) client,
min(span+1, 128) server). If the requested seq is no longer buffered, the
responder sends an idle at that seq instead (_retransmit_single,
stream.py) so the requester's gap is filled and its window advances.
Reordering applies only to the audio stream. RxReorder
(stream.py) delivers packets in seq order, buffering out-of-order
arrivals, requesting the gap once, and giving up on anything still missing
after REORDER_WINDOW = 0.12 s (stream.py, flush_expired
stream.py) — audio favours low latency over completeness. The serial
(CI-V) stream does not reorder: "CI-V state updates are idempotent and the
poller re-reads anything missed" (serialciv.py); it only drops exact
duplicates by comparing the inbound seq (serialciv.py).
5. CI-V transport (serial stream, port 50002)¶
The serial stream is a transparent bridge for Icom CI-V frames. A CI-V frame is
FE FE <dst> <src> <cmd> [sub…] [data…] FD (civ.frame, civ.py); this
project uses RADIO_ADDR = 0xA4 (IC-705) and CTRL_ADDR = 0xE0
(civ.py), so a controller→radio frame is FE FE A4 E0 … FD and a reply
is FE FE E0 A4 … FD.
5.1 Framing inside data packets¶
CI-V frames are carried inside tracked type-0x00 data packets with a 5-byte
inner header after the 16-byte outer header (SerialStream.send_civ,
serialciv.py; server builder packets.serial_data, packets.py):
[0x00:0x10] outer header (type 0x00, tracked; len = 0x15 + civ_len)
[0x10] 0xC1 CI-V data marker
[0x11:0x13] civ_len (LE)
[0x13:0x15] inner send-seq (BE; server writes 0)
[0x15: …] the CI-V frame FE FE … FD
A second inner marker, 0xC0, is an open/close control for the CI-V bridge
(_send_open_close, serialciv.py): the 6-byte body
C0 01 00 <seq_hi> <seq_lo> <05|00> opens (0x05) or closes (0x00) the CI-V
port. The client sends the open immediately after its serial handshake
(serialciv.py).
5.2 Receiving and de-framing¶
On receive (SerialStream._handle, serialciv.py) the client validates
r[16]==0xC1 and that the header len matches the datagram, dedups on the
inbound seq (read big-endian at [6:8], serialciv.py), extracts
civ_len = r[17] | r[18]<<8 bytes starting at offset 21, and feeds them to a
CivDecoder. The server's receive path is the mirror (_serial_rx,
server.py): marker 0xC1 at [0x10], datalen at [0x11], frame at
[0x15:0x15+datalen], handed to the radio backend.
CivDecoder (civ.py) is a resynchronising byte accumulator: it splits
the stream on the 0xFD end-of-frame byte, then locates the FE FE preamble
within each candidate, discarding anything shorter than 6 bytes and flushing its
buffer if it grows past 4096 bytes without a terminator. This tolerates
partial/merged datagrams. Decoded frames are dispatched by
RadioManager._on_civ (manager.py), which keys on cmd = f[4] and
body = f[5:-1] and ignores anything whose source address is not the radio
(f[3] != 0xA4).
CI-V commands the client issues span frequency, mode, meters, PTT, filters,
levels, RIT, keyer and the scope controls — see civ.py and the constant
table in §9.4. They are queued and paced through _civ_sender and polled by
_poll_loop (manager.py), but the pacing is a client policy, not part of
the wire protocol.
6. Audio transport (audio stream, port 50003)¶
6.1 Format¶
Audio is uncompressed 16-bit linear PCM, little-endian, mono, 48 000 Hz in
both directions (audio.py), negotiated in the control-stream conninfo
packet (the 48000 sample-rate fields, control.py). The Opus / µ-law
codec options that some real RS-BA1 clients offer are not implemented on either
end (docs/rsba1server.md). A 20 ms frame is therefore
FRAME_BYTES_20MS = 1920 bytes (48000 × 2 × 0.02, audio.py).
6.2 RX (radio → client)¶
RX audio packets are tracked type-0x00 datagrams with a 24-byte header; the PCM
payload begins at offset 24 (AudioStream._handle, audio.py). The
client validates the header len, ignores type 0x01, reads the outer seq
(LE at [6:8]) and hands (seq, r[24:]) to the RxReorder
(audio.py), which delivers PCM in order (§4.6) and drops gaps older than
120 ms. A _flush_loop runs flush_expired every 30 ms (audio.py).
6.3 TX (client → radio)¶
Mic PCM is queued (feed_tx / stream_tx, audio.py) and paced out by
_tx_loop (audio.py) at exactly one 20 ms frame every 20 ms,
inserting silence to hold cadence when the queue underruns. Each 1920-byte frame
is split into two packets, TX_PART1 = 1364 bytes then TX_PART2 = 556
bytes (audio.py, _send_tx_frame audio.py) — 1364 being the
per-packet audio payload cap (AUDIO_CHUNK = 1364, server.py;
packets.audio_data comment "payload ≤ 1364"). Each part
(_send_tx_part, audio.py) carries an 8-byte inner header after the
16-byte outer header:
[0x00:0x10] outer header (type 0x00, tracked; len = 24 + payload)
[0x10] 0x80 audio ident
[0x11] 0x00
[0x12:0x14] audio send-seq (BE) — separate 16-bit counter from the outer seq
[0x14:0x16] 0x00 0x00
[0x16:0x18] datalen (BE)
[0x18: …] LPCM payload
The server's builder is identical (packets.audio_data, packets.py:
ident 0x0080 at [0x10], send-seq BE at [0x12], datalen BE at [0x16],
payload at [0x18]). The server chunks radio audio at AUDIO_CHUNK = 1364 and
sends each chunk tracked with an incrementing per-session audio_send_seq
(_deliver_audio, server.py); it accepts client TX audio as
everything after offset 0x18 (_audio_rx, server.py).
7. Spectrum scope / waterfall¶
The panadapter is not a separate stream. The radio emits its scope sweeps as
ordinary CI-V frames on the serial stream, using command 0x27; the client
turns them into waterfall lines. Our code carries the transport and the frame
parsing (civ.py, manager.py); the internal structure of the 0x27 0x00
frame is grounded below in the Icom IC-705 CI-V reference and wfview.
7.1 Enabling the scope¶
At connect the client enables the scope and its data output, then reads back the
mode and span (manager.py):
27 10 01 scope waveform on cmd_scope_on(True) civ.py
27 11 01 scope data output on cmd_scope_data_output(True) civ.py
27 14 00 read centre/fixed mode cmd_read_scope_mode() civ.py
27 15 00 read span cmd_read_scope_span() civ.py
and can set them at runtime (manager.py):
27 14 00 <00|01> centre (00) / fixed (01) mode cmd_scope_mode civ.py
27 15 00 <5-byte BCD> span in Hz cmd_scope_span civ.py
27 17 00 <00|01> scope hold off/on cmd_scope_hold civ.py
The selectable spans are
[2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000] Hz
(SCOPE_SPANS, civ.py).
7.2 The 0x27 0x00 waveform frame¶
A scope sweep arrives as a CI-V frame FE FE E0 A4 27 00 <main/sub> <wave-info…>
<data…> FD. RadioManager._on_civ matches cmd==0x27, body[0]==0x00 and calls
civ.parse_spectrum(body[2:], …) (manager.py) — i.e. it strips the
0x00 sub-command byte and the following main/sub-receiver byte before parsing.
parse_spectrum (civ.py) then reads the wave-information header
(offsets relative to the stripped payload):
payload[0] division order (current) — BCD nibbles → decimal (seq)
payload[1] division number (maximum) — BCD nibbles → decimal (seq_max)
payload[2] mode: 0 = centre scope, non-0 = fixed scope
payload[3:8] f1: 5-byte BCD frequency
payload[8:13] f2: 5-byte BCD frequency
payload[13] out-of-range flag (non-0 = out of range)
payload[14:] waveform data bytes (one amplitude per pixel)
Frequency interpretation (civ.py): in centre mode f1 is the
centre and f2 the half-span, so start = f1 − f2, end = f1 + f2; in
fixed mode f1/f2 are the lower/upper edge frequencies directly. On an
out-of-range frame the waveform data is omitted and the parser returns a
full-length run of zeros (civ.py).
7.3 The divided sweep, grounded in the reference¶
Icom sends the sweep either whole or in segments depending on the link, and this is where our LAN-focused parser leans on the reference. Per the Icom IC-705 CI-V reference guide and confirmed by wfview:
- Over WLAN/LAN, the whole sweep is sent in a single frame — "division
number (maximum) = 01", i.e. segment 1 of 1. Our
parse_spectrumdocstring records exactly this: "Over LAN the IC-705 sends the whole 475-pixel wave in a single frame (seq 1 of 1)" (civ.py). - Over microUSB, the sweep is divided into 11 segments (order 01–11, maximum 11) sent in sequence. The 1st segment carries the full wave information (fields 1–6: division order, division maximum, centre/fixed mode, lower edge, higher edge, out-of-range flag) and no waveform data; the 2nd and later segments carry the minimal wave information (fields 1–3) followed by their slice of waveform data. [Icom IC-705 CI-V ref; wfview forum "Waveform Data via CI-V".]
Our parser implements exactly the reassembly this implies (civ.py): on
seq == 1 it resets the accumulator, records mode/start/end/oor and appends
payload[14:]; on seq > 1 it appends payload[2:]; when seq == seq_max it
returns the completed line. Because the IC-705 sends 1-of-1 over the network,
the single-segment path is the one exercised in practice. Note/limitation:
the continuation path strips only the two leading bytes (division order +
maximum), which is exact for the WLAN single-frame case but does not skip the
mode byte that the reference lists among the "minimum wave information (1–3)" on
USB continuation segments — a USB multi-segment source would need the extra byte
skipped. Flagged here rather than papered over.
7.4 Bin / amplitude encoding and line assembly¶
Per the IC-705 CI-V reference guide, a full sweep is 475 data points,
each an amplitude in the range 0–160 (0x00–0xA0); wfview documents the
same 475-point length and treats each pixel as one byte of FFT magnitude (wfview
notes values ranging up to ~200 on some radios). Our code treats each waveform
byte as an opaque unsigned magnitude and does not clamp it: _publish_spectrum
takes the first 475 bytes as the pixel row (manager.py).
A completed waveform dict ({mode, start, end, oor, data}) is published to
websocket subscribers (subscribe_spectrum, manager.py) as a binary
message with a fixed 20-byte little-endian header followed by the pixels
(manager.py):
struct "<BBBBdd":
0x53 ('S') message tag
mode 0 centre / 1 fixed
oor out-of-range flag
0 reserved
start_hz float64
end_hz float64
… then up to 475 amplitude bytes
The manager also keeps an exponential rolling average of the pixel row per
(start,end) key (0.85·avg + 0.15·new, manager.py) that feeds a
signal scanner (scan_signals, manager.py+) — application logic layered
on top of the protocol, not part of it.
7.5 Scope on the server side¶
rsba1server is a transparent CI-V bridge and does not synthesise scope
data: there is no 0x27 handling anywhere under rsba1server/ (verified by
search). If the backing radio is a real Icom that emits 0x27 sweeps, the server
relays them like any other CI-V frame; the bundled FakeRadio and the SoapySDR
backend do not generate them, so a client talking to those backends gets rig
control and audio but no panadapter. This is an honest asymmetry between the
client (full scope consumer) and this server (scope pass-through only).
8. The login passcode obfuscation algorithm¶
RS-BA1 does not hash credentials; it applies a fixed, position-dependent
substitution — trivially reversible, so it is obfuscation, not security. The
algorithm is identical on both ends (app/radio/passcode.py,
rsba1server/passcode.py, both ported from kappanhang passcode.go). Quoted
exactly:
def passcode(s: str) -> bytes:
res = bytearray(16)
data = s.encode("ascii", errors="replace")
for i, c in enumerate(data[:16]):
p = c + i
if p > 126:
p = 32 + p % 127
res[i] = _SEQUENCE.get(p, 0)
return bytes(res)
Each of the first 16 characters is offset by its position i, wrapped into the
printable range [32,126] if it overflows, and looked up in a fixed 95-entry
substitution table _SEQUENCE (indices 32–126). The result is always exactly 16
bytes, zero-padded. The full table (passcode.py):
_SEQUENCE = {
32:0x47, 33:0x5D, 34:0x4C, 35:0x42, 36:0x66, 37:0x20, 38:0x23, 39:0x46,
40:0x4E, 41:0x57, 42:0x45, 43:0x3D, 44:0x67, 45:0x76, 46:0x60, 47:0x41,
48:0x62, 49:0x39, 50:0x59, 51:0x2D, 52:0x68, 53:0x7E, 54:0x7C, 55:0x65,
56:0x7D, 57:0x49, 58:0x29, 59:0x72, 60:0x73, 61:0x78, 62:0x21, 63:0x6E,
64:0x5A, 65:0x5E, 66:0x4A, 67:0x3E, 68:0x71, 69:0x2C, 70:0x2A, 71:0x54,
72:0x3C, 73:0x3A, 74:0x63, 75:0x4F, 76:0x43, 77:0x75, 78:0x27, 79:0x79,
80:0x5B, 81:0x35, 82:0x70, 83:0x48, 84:0x6B, 85:0x56, 86:0x6F, 87:0x34,
88:0x32, 89:0x6C, 90:0x30, 91:0x61, 92:0x6D, 93:0x7B, 94:0x2F, 95:0x4B,
96:0x64, 97:0x38, 98:0x2B, 99:0x2E, 100:0x50, 101:0x40, 102:0x3F, 103:0x55,
104:0x33, 105:0x37, 106:0x25, 107:0x77, 108:0x24, 109:0x26, 110:0x74,
111:0x6A, 112:0x28, 113:0x53, 114:0x4D, 115:0x69, 116:0x22, 117:0x5C,
118:0x44, 119:0x31, 120:0x36, 121:0x58, 122:0x3B, 123:0x7A, 124:0x51,
125:0x5F, 126:0x52,
}
The client obfuscates the username and password with this and writes them at
[0x40]/[0x50] of the login packet (control.py) and the username
again in conninfo (control.py). The server compares the received username
field to passcode(user) and accepts the password if it matches either
passcode(pass) or the plain password (_check_auth, server.py).
9. Constants & tables¶
9.1 Ports¶
| Port | Stream | Client const | Server const |
|---|---|---|---|
| 50001 | control | CONTROL_PORT control.py |
CONTROL_PORT server.py |
| 50002 | serial | SERIAL_PORT control.py |
SERIAL_PORT server.py |
| 50003 | audio | AUDIO_PORT control.py |
AUDIO_PORT server.py |
9.2 Packet type bytes (header [4:6], LE)¶
| type | len | name | notes |
|---|---|---|---|
| 0x00 | ≥0x10 | tracked data / idle (pkt0) | seq-stamped; retransmit-buffered |
| 0x01 | 0x10 | retransmit request (single) | header seq = wanted seq |
| 0x01 | 0x18 | retransmit request (range) | body = <start:u16LE><end:u16LE> pairs |
| 0x03 | 0x10 | are-you-there (pkt3) | handshake open |
| 0x04 | 0x10 | i-am-here (pkt4) | answer; carries remote sid |
| 0x05 | 0x10 | disconnect (pkt5) | teardown |
| 0x06 | 0x10 | are-you-ready / answer (pkt6) | handshake finalise |
| 0x07 | 0x15 | ping / keepalive (pkt7) | flag [16]: 0x00 req / 0x01 echo |
Inner data-packet markers: 0xC1 CI-V data (serialciv.py, packets.py),
0xC0 CI-V open/close (serialciv.py), 0x80 audio data ident
(audio.py, packets.py). Auth-fail marker: FF FF FF FE
(control.py, packets.py).
9.3 Timers and sizes¶
| constant | value | where | meaning |
|---|---|---|---|
PKT7_SEND_INTERVAL |
3.0 s | stream.py, protocol.py |
ping period |
IDLE_INTERVAL_FAST |
0.1 s | stream.py |
idle period when active |
IDLE_INTERVAL_SLOW |
1.0 s | stream.py |
idle period when quiet |
IDLE_AFTER |
1.0 s | stream.py |
quiet threshold |
REORDER_WINDOW |
0.12 s | stream.py |
audio gap give-up |
MAX_RETRANSMIT_RANGE |
10 | stream.py |
gap-request cap |
REAUTH_INTERVAL |
60.0 s | control.py |
client re-auth |
AUDIO_SAMPLE_RATE |
48000 | control.py |
LPCM rate |
TX_BUF_MS |
300 | control.py |
negotiated TX buffer |
FRAME_BYTES_20MS |
1920 | audio.py |
20 ms of s16le/48k mono |
TX_PART1 / TX_PART2 |
1364 / 556 | audio.py |
TX split per 20 ms frame |
AUDIO_CHUNK |
1364 | server.py |
max audio payload/packet |
PING_INTERVAL |
1.0 s | server.py |
server self-ping (ctl/serial) |
AUDIO_PING_INTERVAL |
0.5 s | server.py |
server self-ping (audio) |
STALE_AFTER |
15.0 s | server.py |
server session timeout |
| tx buffers | 1024 / 2048 | stream.py, protocol.py |
retransmit history depth |
9.4 CI-V scope subcommands (0x27)¶
| bytes | direction | meaning | builder |
|---|---|---|---|
27 00 … |
radio→ctl | waveform data (see §7.2) | parsed parse_spectrum civ.py |
27 10 <on> |
ctl→radio | scope waveform on/off | cmd_scope_on civ.py |
27 11 <on> |
ctl→radio | scope data output to controller on/off | cmd_scope_data_output civ.py |
27 14 00 [<00\|01>] |
both | read/set centre(00)/fixed(01) mode | cmd_scope_mode/cmd_read_scope_mode civ.py |
27 15 00 [<5-byte BCD>] |
both | read/set span (Hz) | cmd_scope_span/cmd_read_scope_span civ.py |
27 17 00 <on> |
ctl→radio | scope hold on/off | cmd_scope_hold civ.py |
Waveform frame fields (§7.2), amplitude range and length grounded in the Icom IC-705 CI-V reference (475 points, 0–160) and wfview.
9.5 CI-V addresses¶
RADIO_ADDR = 0xA4 (IC-705), CTRL_ADDR = 0xE0 (civ.py). Server backends
use the radio's own address (IC-7300 0x94, IC-705 0xA4, IC-9700 0xA2,
IC-7610 0x98, … — docs/rsba1server.md).
10. Interoperability & validation¶
- Client ↔ this server, end-to-end.
test_rsba1.pyruns the real client (ControlStream/SerialStream/AudioStream) againstRSBA1Serverwith theFakeRadiobackend over loopback in one process (test_rsba1.py). It asserts: the control login + stream negotiation yields the correct device name ("IC-705 (sim)",test_rsba1.py); acmd_read_freq()CI-V request returns a frame decoding to 14 074 000 Hz (test_rsba1.py); RX audio exceeds 10 kB and TX audio is relayed to the radio (test_rsba1.py). This exercises the full handshake, passcode auth, token flow, CI-V request/reply and bidirectional audio. - vs wfview / kappanhang. The byte layouts are ported from and
cross-referenced to wfview (
icomserver.cpp,packettypes.h,icomudp*.cpp) and kappanhang (streamcommon.go,controlstream.go,serialstream.go,audiostream.go,passcode.go) — cited inline in the source headers. The server reports the connection nameWFVIEW(packets.py) and theICOM_VAUDIOaudio marker (packets.py) that real clients expect. - vs the real RS-BA1 app / a real radio. The client is used in production
against a networked IC-705 (the whole
manager.pyscope/meter/CI-V surface). The server side is verified against this client only; testing it against Icom's own RS-BA1 Remote and against wfview is explicitly recommended before relying on it on the air (docs/rsba1server.md). The GPS (0x23 0x00) and, to a lesser extent, the scope byte layout were confirmed empirically against captured frames (civ.py,manager.py).
11. Limitations / notes¶
- Obfuscation, not encryption. The passcode substitution (§8) is reversible and the whole protocol is plaintext UDP; treat an RS-BA1 link as trusted-LAN or tunnel it (VPN) for anything exposed.
- One control client per source IP on the server (
docs/rsba1server.md, keyed byaddr[0]inserver.py). Serial/audio are admitted only from an IP that has an authenticated control session (server.py). - LPCM only. Opus/µ-law negotiation is unimplemented on both ends
(
docs/rsba1server.md); a client must request LPCM. - Scope is receive-only and IC-705-shaped in our parser. The client parses
the WLAN single-segment 0x27 0x00 form; the USB 11-segment continuation-header
nuance is noted in §7.3. The server does not generate scope data at all
(§7.5), and SDR/USB backends are receive-only (no TX —
docs/rsba1server.md). - Amplitude semantics are radio-dependent. Our code forwards raw waveform bytes; the 0–160 range is the IC-705 reference figure, while wfview observes values up to ~200 on some models. Consumers should treat pixels as relative magnitudes, not calibrated dB.
- Session-id derivation assumes IPv4 (
stream.py,server.py); there is no IPv6 path. - Endianness is unforgiving.
len/type/seqLE, session ids BE, and several inner fields (audio send-seq, datalen, ports, sample rate, edge frequencies) BE — mixing these up silently breaks framing.
12. References¶
- wfview — open-source Icom/Kenwood controller. Reverse-engineered RS-BA1
packet layout and the 475-point / centre-fixed scope handling.
rigcommander.cpp,icomudpcivdata.cpp,icomudpaudio.cpp,icomserver.cpp,packettypes.h. https://wfview.org, https://gitlab.com/eliggett/wfview. Forum: "Access to Waveform Data by Code" and "Waveform Data via CI-V Bus Interface" (https://forum.wfview.org) — 475 waveform points, one byte/pixel FFT magnitude, centre vs fixed with StartFrequency/EndFrequency Icom-encoded. - kappanhang (nonoo) — headless Go RS-BA1 client; the direct port source
for
stream.py,control.py,passcode.py(streamcommon.go,pkt0.go,pkt7.go,controlstream.go,serialstream.go,audiostream.go,passcode.go). - Icom IC-705 CI-V Reference Guide (2020) — command
27 00scope waveform data: division order/maximum, WLAN single-frame vs USB 11-segment division, centre/fixed mode byte, lower/higher edge frequencies (BCD), out-of-range flag, 475 data points, amplitude 0–160 (0x00–0xA0); and27 10/11/14/15/17scope controls. - This repo — the source of truth:
app/radio/stream.py,control.py,civ.py,serialciv.py,audio.py,passcode.py,manager.py;rsba1server/protocol.py,packets.py,server.py,passcode.py;test_rsba1.py. Companion setup guide:docs/rsba1server.md. ```