Architecture - icom-lan
Description: Python library for controlling Icom transceivers over LAN

Skip to content

# Architecture

## Overview

    ┌─────────────────────────────────────────────────────┐
    │                     icom-lan                         │
    │                                                      │
    │  ┌─────────┐   ┌──────────┐   ┌──────────────────┐  │
    │  │  CLI    │   │  Public  │   │  Raw CI-V        │  │
    │  │ (cli.py)│   │   API    │   │  (commands.py)   │  │
    │  └────┬────┘   │(radio.py)│   └────────┬─────────┘  │
    │       │        └────┬─────┘            │             │
    │       └─────────────┤                  │             │
    │                     │                  │             │
    │            ┌────────┴──────────────────┴──────┐      │
    │            │           IcomRadio               │      │
    │            │   ┌──────────┐  ┌──────────┐     │      │
    │            │   │  Control │  │   CI-V   │     │      │
    │            │   │Transport │  │Transport │     │      │
    │            │   │ (:50001) │  │ (:50002) │     │      │
    │            │   └────┬─────┘  └────┬─────┘     │      │
    │            └────────┼─────────────┼───────────┘      │
    │                     │             │                   │
    └─────────────────────┼─────────────┼───────────────────┘
                          │   UDP       │   UDP
                          ▼             ▼
                  ┌───────────────────────────┐
                  │       Icom Radio          │
                  │   Control  CI-V   Audio   │
                  │   :50001  :50002  :50003  │
                  └───────────────────────────┘

## Module Responsibilities

### `radio.py` — High-Level API

The central orchestrator. `IcomRadio` manages:

- Two transport instances: one for control (port 50001), one for CI-V (port 50002)
- Full handshake sequence: discovery → login → token → conninfo → CI-V open
- CI-V command wrapping: takes raw CI-V frames, wraps them in UDP data packets
- Response filtering: skips echoes, waterfall data, and control packets to find CI-V responses
- Public API methods: `get_frequency()`, `set_mode()`, etc.

### `transport.py` — UDP Transport

Low-level asyncio UDP handler. Each `IcomTransport` instance manages:

- UDP socket via `asyncio.DatagramProtocol`
- Discovery handshake (Are You There → I Am Here → Are You Ready)
- Keep-alive pings (500ms interval)
- Sequence tracking with gap detection
- Retransmit requests for missing packets
- Packet queue for consumers

### `commands.py` — CI-V Encoding/Decoding

Pure functions for building and parsing CI-V frames:

- Frame construction with BCD frequency encoding
- Response parsing (frequency, mode, meters, ACK/NAK)
- No state, no I/O — purely data transformation

### `auth.py` — Authentication

Handles Icom's proprietary credential encoding and packet construction:

- `encode_credentials()` — substitution-table obfuscation
- `build_login_packet()` — 0x80-byte login packet
- `build_conninfo_packet()` — 0x90-byte connection info
- Response parsers for auth and status packets

### `protocol.py` — Packet Parsing

Header serialization/deserialization and packet type identification.

### `types.py` — Data Types

Enums (`PacketType`, `Mode`), dataclasses (`PacketHeader`, `CivFrame`), and BCD helpers.

### `exceptions.py` — Error Hierarchy

Custom exception classes for structured error handling.

### `cli.py` — Command Line Interface

Argparse-based CLI that wraps the async API with `asyncio.run()`.

## Data Flow

### Sending a Command

    radio.get_frequency()
        → get_frequency() builds CI-V frame: FE FE 98 E0 03 FD
        → _wrap_civ() adds UDP header (0x15-byte prefix)
        → _civ_transport.send_tracked() assigns sequence number
        → UDP packet sent to radio:50002

### Receiving a Response

    UDP packet arrives on :50002
        → _UdpProtocol.datagram_received()
        → IcomTransport._handle_packet()
            → Check: retransmit request? ping? → handle internally
            → Otherwise: queue for consumer
        → IcomRadio._send_civ_raw() picks up from queue
            → Skip packets that are too small (control)
            → Scan payload for CI-V frames (FE FE ... FD)
            → Filter: skip echoes (from_addr == CONTROLLER), waterfall (cmd 0x27)
            → Match: response from radio with correct command byte
        → parse_frequency_response() extracts Hz from BCD data

## Key Design Decisions

### Dual-Port Architecture

CI-V commands must go through port 50002, not 50001. The control port (50001) is only for authentication and session management. This was discovered by tracing that the radio never responds to CI-V on the control port.

### GUID Echo

The radio won't report CI-V/audio ports in its status packet unless the client echoes the radio's GUID (bytes 0x20–0x2F from its conninfo) in the client's own conninfo. Without this, `civ_port` comes back as 0.

### Response Filtering

The CI-V port receives various traffic: our command echoes, waterfall data (cmd 0x27), and actual responses. `_send_civ_raw()` filters through all of this to find the matching response from the radio.

### Sequence Number Management

- `send_seq` — tracked data packets on each transport
- `ping_seq` — keep-alive pings (separate counter)
- `_auth_seq` — authentication sequence in radio.py
- `_civ_send_seq` — CI-V and OpenClose packets (separate from transport seq)

## Dependencies

    icom-lan (runtime)
    └── Python 3.11+ stdlib only
        ├── asyncio
        ├── struct
        ├── socket
        ├── logging
        └── dataclasses

    icom-lan[dev] (testing)
    ├── pytest
    └── pytest-asyncio

    icom-lan[audio] (future)
    └── opuslib