Skip to main content...

ReallyOpenGD77 WebCPSd Technical Specifications

ReallyOpenGD77 WebCPSd keeps a deliberately small surface: one Python file for the backend, one JavaScript file for the frontend’s codec and WebUSB protocol layer, no build step, no bundler, no framework. This page details the stack, the caching design, and the lower-level protocol/codec work that makes browser-to-radio read/write possible at all.

Architecture Overview

  • Backend: FastAPI on Uvicorn (ASGI), a single main.py. Request/response bodies are typed with Pydantic models (Channel, Contact, Zone, ScanList, APRSConfig, Satellite, etc.), and outbound calls to upstream APIs use httpx.AsyncClient.
  • Frontend: Vanilla JavaScript (ES6+), no framework, no external JS/CSS libraries. frontend/js/app.js holds the UI logic, the OpenGD77 codeplug codec, and the entire WebUSB protocol implementation in one file.
  • Fonts: Self-hosted, subsetted .woff2 (IosevkaTerm, Exo 2, Share Tech Mono) – no Google Fonts or other third-party font CDN call.
  • Dependencies: fastapi, uvicorn[standard], httpx, python-multipart, pydantic on the backend. Zero runtime dependencies on the frontend.

Backend Design

Request Lifecycle & API Routes

main.py has no router class or autoloader – routes are plain @app.get/@app.post decorators on top-level functions, grouped by what they touch rather than by URL depth. The one thing that happens outside any request at all is the DMR ID Database refresh, kicked off once from the lifespan context manager when the process starts:

flowchart TD
    S["Process starts
lifespan() context manager"] S --> T["asyncio.create_task(_dmrid_refresh_loop())
loads user.csv immediately, then every 24h
-- runs independently of any request"] R["HTTP request arrives"] R --> P{"Path starts with
/api/?"} P -- Yes --> API{"Route group"} API --> C["Codeplug
new / validate / parse-g77 / export-csv"] API --> D["DMR ID Database
status / countries / search / export"] API --> BM["Repeaters
brandmeister (24h lazy cache)"] API --> TG["Talkgroups
{network}"] API --> SAT["Satellites
defaults / tle (CelesTrak proxy)"] API --> H["Health
/api/health"] P -- No --> F{"/, /index.html,
/css/styles.css,
or /js/app.js?"} F -- Yes --> G["Rewrite HTML's asset URLs to ?v=<hash>
Serve hashed css/js as immutable,
HTML as no-cache"] F -- No --> M["StaticFiles mount
fonts, favicons, other static assets"]

The grouping matters more than it looks: Codeplug routes are pure, stateless request/response (validate a JSON body, parse an uploaded .g77, no server-side storage of any codeplug ever) – the server never keeps a copy of anyone’s codeplug between requests. Repeaters, DMR ID Database, and Satellites are the three routes that talk to something outside the process (BrandMeister, RadioID.net, CelesTrak respectively), which is exactly why they’re also the three routes with a caching story – see below.

Static Asset Cache-Busting

index.html is served with Cache-Control: no-cache (it must always be revalidated, since it carries the hash), while css/styles.css and js/app.js are rewritten at request time to ?v=<hash> query strings and served with a one-year immutable cache header. The hash is the first 10 hex characters of an MD5 of the file’s own bytes, memoized against the file’s mtime so it’s recomputed only when the file actually changes – not on every request.

Dual Caching Strategy

The backend caches two very differently-shaped upstream data sets, and deliberately uses a different strategy for each:

Source Size Strategy TTL
BrandMeister repeaters Small (single API response) Lazy: refreshed on the next request after expiry; serves stale data (flagged) if the live refetch fails, rather than an error 24h
RadioID.net DMR ID dump ~17MB CSV, ~309k rows Eager: a background `asyncio` task loads it once at startup and reloads every TTL, so no user request ever blocks on the fetch+parse 24h
Note
The RadioID dump is parsed from user.csv, not users.json – same fields, a fraction of the size (JSON’s per-row key overhead adds up at ~309k rows). The unused STATE column is dropped at parse time, and the whole thing is kept as id -> (callsign, name, city, country) tuples rather than a list of dicts: the cheapest shape for this many short-string records, and it gives O(1) exact-ID lookup for free.

A cache swap is only ever applied after a complete, successful parse – if the fetch or parse fails partway through, the existing in-memory cache is left untouched rather than partially overwritten. BrandMeister’s lazy cache carries that same “never make things worse” principle one step further, into what happens when the live refetch itself fails:

flowchart TD
    Q["GET /api/repeaters/brandmeister"]
    Q --> V{"Cache present
and age < 24h?"} V -- Yes --> RC["Return cached data
cached: true"] V -- No --> FE["Fetch live from
BrandMeister API"] FE --> OK{"Fetch succeeded?"} OK -- Yes --> ST["Store in cache
Return fresh data
cached: false"] OK -- No --> SP{"Any cache at all,
even stale?"} SP -- Yes --> STALE["Return the stale cache anyway
cached: true + failure note"] SP -- No --> ERR["Return an empty result + error"]

A user only ever sees a hard error if BrandMeister has never once been reachable since the process started – any prior success, however old, is still better than nothing.

WebUSB Radio Protocol

The radio is identified by a single USB vendor/product ID pair – 0x1fc9 / 0x0094 – taken directly from the OpenGD77 firmware’s own #define USB_VID / #define USB_PID. There is no separate ID per radio model; every supported radio enumerates identically, and the app asks the radio what it actually is once connected.

The wire protocol itself is a USB-CDC serial link carrying framed 'C'ommand / 'R'ead / 'W'|'X'rite requests:

  • 'W' – write framing for the MK22 family (GD-77, GD-77S, DM-1801, RD-5R).
  • 'X' – write framing for the STM32 family (MD-UV380, MD-9600, MD-2017, DM-1701).

Which framing byte to use is auto-detected from what the connected radio reports about itself, not from the model dropdown – the dropdown only exists so the memory map/capacity math has something to show before a radio is plugged in. applyRadioFamilyMemoryMap() swaps the codeplug’s base addresses (channel banks, contacts, TG/RX group lists, satellite config) between the two families’ real flash/EEPROM layouts once the family is known.

sequenceDiagram
    participant U as User
    participant B as Browser (app.js)
    participant R as Radio (OpenGD77 firmware)
    U->>B: Click "Connect"
    B->>R: navigator.usb.requestDevice()
filter: VID 0x1fc9 / PID 0x0094 R-->>U: Browser's native USB device picker U->>B: Select device B->>R: open() + claimInterface() B->>R: 'C'ommand: identify / read firmware info R-->>B: Radio type + firmware family B->>B: applyRadioFamilyMemoryMap(isUv380Family)
picks 'W' vs 'X' write framing
and the correct flash/EEPROM addresses U->>B: Click "Read" or "Write" loop Each codeplug region B->>R: 'R'ead or 'W'/'X'rite request
(framed, raced against a timeout) R-->>B: Header + payload
(coalesced into one USB transfer
when both fit in 64 bytes) end B-->>U: Decoded codeplug in the editor UI
Tip
Chrome’s USBDevice.transferIn() has no built-in timeout – if the radio never answers (wrong endpoint, not in CPS mode, etc.) it hangs forever rather than rejecting. Every low-level read is raced against a timer (usbTransferInWithTimeout()) so a dead connection surfaces as an error instead of a frozen UI.

A Real Bug, Found With a USB Capture

Every framed response is logically read as a fixed-size header followed by a variable-length payload. Against real hardware (confirmed with a usbmon capture), the radio actually answers with header and payload as a single USB transfer whenever both fit under the endpoint’s 64-byte max packet size – which they always do here (3+46 and 3+32 bytes). transferIn() hands back the entire transfer regardless of how many bytes were requested, so a naive “read N header bytes, then read the payload” implementation silently discards the payload bytes that arrived bundled with the header – and then hangs forever waiting for bytes that were already thrown away. usbReadBytes() fixes this by carrying any such leftover bytes forward in a small overflow buffer for the next read call, instead of dropping them.

Codeplug Codec & Memory Map

The codeplug itself is never treated as an opaque blob – app.js reads and writes it as raw bytes against the radio’s real flash/EEPROM address space, using a set of low-level helpers: little-endian U16/U24/U32 get/set, single-bit and multi-bit field packing, BCD (binary-coded decimal) encode/decode in both nibble orders, and CTCSS/DCS tone <-> 16-bit code conversion (bit 15 = DCS, bit 14 = inverted, low 14 bits = BCD digits). Structured decode/encode functions (ogdDecodeChannel, ogdDecodeScanList, ogdDecodeDTMFContact, ogdDecodeAPRS, ogdDecodeSatellite, and their encode counterparts) sit on top of these primitives.

Note
STM32-family radios have no discrete EEPROM chip – every region that’s EEPROM on MK22 lives in FLASH instead, and the contacts/TG-lists/most-channels region sits a constant 0x020000 bytes further into FLASH than the equivalent MK22 address. This single offset, applied inside applyRadioFamilyMemoryMap(), is the entire practical difference between reading/writing the two chipset families once the write-framing byte is correct.

Binary .g77 codeplug files apply a further constant, G77_FLASH_OFFSET (0x070000) – the file format stores data at its real flash address, so this offset is subtracted/added when converting between the file’s addressing and the in-memory representation used everywhere else.

DMR ID Database: 6-bit Compression

The DMR ID Database is a separate, much larger flash region from the rest of the codeplug – a read-only caller-ID lookup table the firmware checks on incoming calls, holding up to hundreds of thousands of entries depending on radio and record length. Its addresses, 12-byte header, two-region layout, and record format were cross-checked against the official CPS’s decompiled DMRIDForm.cs/DMRDataItem.cs (independently corroborated against grid.radio’s own decompilation of the same source – no code from either project is included or linked here).

Records use 6-bit character packing to fit more entries per byte than 8-bit ASCII would allow: space = 0, '0'-'9' = 1-10, 'A'-'Z' = 11-36, 'a'-'z' = 37-62, and anything else collapses (lossy, on encode only) to a literal . at value 63. Four 6-bit characters pack into three bytes.

Capacity is genuinely two-region math, not a flat “bytes ÷ record length” division – a 262,132-byte first region plus whatever fits in a second, radio-type-dependent region (which can itself grow by another ~163KB if the radio’s voice-prompt memory is repurposed for it, on every family except STM32):

flowchart LR
    subgraph Region1["Region 1 -- fixed, all radio types"]
        H["12-byte header"]
        B1["262,132 bytes of records"]
    end
    subgraph Region2["Region 2 -- radio-type dependent"]
        B2["(memory_size - 262,144) bytes of records"]
        VP["+ 166,912 bytes
if voice-prompt memory is
repurposed (non-STM32 only)"] end Region1 -.-> Region2
max_records = floor(262132 / record_length)
            + max(0, floor((memory_size - 262144) / record_length))

memory_size and the two possible second-region base addresses (voice-prompt vs. non-voice-prompt) are looked up per radio type; STM32-family radios apply the same +0x020000 addressing offset used everywhere else in the codec.

External Data Sources

Source Used For Notes
BrandMeister Network API DMR repeater import Uses the undocumented-but-functional /v2/device endpoint with repeater=true (server-side filter to 6-digit repeater IDs) -- the documented /v2/repeater path 404s. "Active" status is derived from last_seen recency (last 30 minutes), since the API's own status field is an undocumented link-state int, not a usable online/offline boolean.
RadioID.net DMR Contacts search/import, DMR ID Database bulk export Parsed from the user.csv dump; see Dual Caching Strategy above.
CelesTrak Live satellite orbital elements (TLE) Fetched on demand by catalog number or group, parsed server-side, and combined with a curated set of default transponder metadata.

See Also

Last Revision: 2026-07-12 -- Document Version: 49b0071
Permanent Link: <https://w0chp.radio/ogd77-webcpsd/tech-specs/>