DMR End to End, Part 8: Tier III Trunking — C_ALOHA, Grants & LCNs

Part 8 of DMR End to End, a 14-part deep dive that follows the world’s most widely deployed digital PMR protocol through GopherTrunk — from a 4FSK carrier to two simultaneous recorded calls, direct-mode handhelds, and decrypted Enhanced Privacy voice. Part 7 met the CSBK as a keep-alive on a conventional repeater. This part gives it a control channel: DMR Tier III, where the same 96-bit block advertises the site, describes its neighbours, and hands out voice calls — by logical channel number, never by frequency.

TL;DR: tier3.ControlChannel (internal/radio/dmr/tier3/) reads CSBKs off a Tier III control channel: BPTC(196,96) → 12 bytes → ParseCSBK (LB, PF, 6-bit CSBKO, FID, 8-octet payload, CRC-CCITT XOR 0x5A5A — the earlier “init 0xFFFF, complement” convention rejected every real CSBK). C_ALOHA (0x19) locks the channel on its raw SystemID (dmr cc locked); C_BCAST (0x28) sub-types fill the topology; TV_GRANT / PV_GRANT (0x31 / 0x30) lead with a 12-bit LPCN + timeslot bit — #639 read the LCN from the source address’s low byte, so it changed with every radio. An LCN resolves through dmr_band_plan (LinearBandPlan / TableBandPlan) or the learned plan of The Hunt Part 8; unresolved grants publish decode.error stage=no-bandplan. Vendor FIDs dispatch before opcodes; MBC assembles under LB=1; and the wideband low-power WARN no longer fires against a CC decoding every C_ALOHA at −56 dBFS (lowPowerDecodeGrace).

Key takeaways

  • Tier III is the same bursts with a CSBK reader on top. Sync, slot type and BPTC are shared with Tier II.
  • A CRC convention is a claim about the air, and only air can check it. Real off-air vectors from two live TSCCs pin the 0x5A5A mask; round-trips passed the wrong one for as long as they were the only test.
  • A DMR grant names a channel number, not a frequency. The 12-bit LPCN leads the payload; the band plan — configured or learned — turns it into Hz.
  • Never gate a health WARN on absolute dBFS when decode evidence exists. A control channel decoding every beacon is healthy at any power reading.

Cheat sheet

Concern What it does Where it lives
CSBK parse + pin CRC ^ 0x5A5A, pinned by off-air blocks tier3/csbk.go (ParseCSBK), csbk_realvectors_test.go
Lock + topology C_ALOHA → maybeLock; C_BCAST → topologyModel control.go (handleCSBK, handleBroadcast), topology.go
Grants LPCN + TS + 24-bit target + 24-bit source payloads.go (ParseTVGrant, ParsePVGrant)
LCN → Hz linear base/spacing/offset or table; hot-swappable bandplan.go (Resolver, LinearBandPlan), SetResolver
Vendor + MBC FID before opcode; header + continuations under LB=1 vendor.go (VendorFromFID), mbc.go (handleMBC)
Low-power WARN gate decode within 15 s ⇒ healthy at any dBFS widebandt2/engine.go (lowPowerDecodeGrace)

In this post

  • Same bursts, a different reader — where Tier III forks from Tier II.
  • The CSBK and its CRC — the mask that rejected every real block.
  • Lock on C_ALOHA, learn from C_BCAST — identity and topology.
  • Grants carry an LCN — the LPCN layout, #639, the band plan.
  • Vendor FIDs and MBC — dispatch order and honest limits.
  • The −56 dBFS lesson — decode evidence beats a power gauge.

Same bursts, a different reader

Protocol Decoders Part 5 made the point: the tier is a policy, not a wire format. tier3.Process is the Tier II adapter’s twin — buffer dibits, nine-pattern SyncDetector, 132-dibit slices, Hamming(20,8) slot type — except that it tries both discriminator polarities on every burst and never locks one, letting the CSBK CRC drop the wrong image. IngestBurst routes on data type:

// internal/radio/dmr/tier3/control.go (shape)
func (c *ControlChannel) IngestBurst(b *dmr.Burst, slot dmr.SlotType) {
    switch slot.DataType {
    case dmr.DTCSBK:
        info, ok := c.decodeInfoBlock(b)          // BPTC(196,96) → 12 bytes
        csbk, err := ParseCSBK(info)               // CRC ^ 0x5A5A
        if err != nil { c.log.Debug("dmr/tier3: CSBK CRC failed"); return }
        c.csbkDecoded.Add(1); c.noteActivity()
        c.handleCSBK(slot.ColorCode, csbk)
    case dmr.DTMBCHeader, dmr.DTMBCContinuation:
        c.handleMBC(slot.ColorCode, slot.DataType, b)
    }
}

Two counters fall out of that switch: csbkDecoded is the DecodedFrames figure the wideband engine polls, and noteActivity stamps the heartbeat the ccdecoder drought watchdog compares.

The CSBK, and the CRC that rejected every real one

A CSBK is 96 information bits:

// internal/radio/dmr/tier3/csbk.go (shape)
//	bit 0 LB · bit 1 PF · bits 2-7 CSBKO · bits 8-15 FID (0x00 = ETSI)
//	bits 16-79 payload · bits 80-95 CRC-CCITT(init 0x0000) of bits 0-79 ^ 0x5A5A
const (
    OpAloha    CSBKOpcode = 0x19 // C_ALOHA — the TSCC beacon the CC locks on
    OpBcast    CSBKOpcode = 0x28 // C_BCAST — Gen_Site, CallTimer, Adjacent_Site …
    OpPVGrant  CSBKOpcode = 0x30 // Private Voice Channel Grant
    OpTVGrant  CSBKOpcode = 0x31 // TalkGroup Voice Channel Grant
    OpBTVGrant CSBKOpcode = 0x32 // Broadcast TalkGroup Voice Grant
    OpPDGrant  CSBKOpcode = 0x33 // Private Data Grant (observed, not followed)
    OpTDGrant  CSBKOpcode = 0x34 // TalkGroup Data Grant
)
const csbkCRCMask uint16 = 0x5A5A // ETSI TS 102 361-1 §B.3.11, Table B.21

The CRC reference describes the mask; the code comment describes the scar. The first convention — init 0xFFFF, store the complement — rejected every real CSBK while passing every synthesized round-trip, so the control channel never locked on air. TestParseCSBKRealOffAirVectors is the counter-measure: 12-byte blocks from 2 MS/s captures of two live TSCCs (440.5625 and 440.2625 MHz), zero BPTC corrections, repeating identically. They validate under 0x5A5A and nothing else — and pinned the opcode table too, where 0x28 had been mislabelled “Preamble” and is C_BCAST (dsd-neo agrees). That is From Spec to Shipping Part 3 in one test file: a spec plus your own inverse proves nothing about the air.

Lock on C_ALOHA, learn from C_BCAST

handleCSBK dispatches standard-FID blocks by opcode. C_ALOHA is the TSCC beacon; ParseAloha reads the raw 16-bit SystemID from payload octets 2–3 and maybeLock publishes cc.locked:

INF dmr cc locked freq=440262500 cc=1 sysid=18689
DBG dmr/tier3: csbk opcode=C_ALOHA fid=0 cc=1 repeats_suppressed=163

That second line is parked on purpose. An idle TSCC repeats the same Aloha ~16 times a second — the field report counted 2177 C_ALOHA lines in two minutes. A change of (opcode, FID, cc) logs immediately; an unchanged repeat summarises every csbkRepeatLogInterval (10 s) with the suppressed count.

C_BCAST carries sub-types in a 5-bit anncd_type at the top of the first payload octet, validated on real bursts (Gen_Site_Params, type 7, decodes from payload[0]=0x3A; CallTimer_Parms, type 1, from 0x0F). handleBroadcast folds two into the topology model Trunking Engine Part 10 consumes: Gen_Site_Params gives the camped site’s identity, RFSS and site number; Adjacent_Site (type 6) adds a neighbour with system ID, site ID, CC LCN and colour code. Locking stays Aloha-driven — the two identity fields differ. A third sub-type is handled by not handling it: Announce Channel-Frequency (type 5) is the LCN↔frequency relationship, but its layout has never been validated on a real burst, so the raw payload is logged once (dmr/tier3: announce channel-frequency (raw, layout unvalidated)) and nothing is guessed from it.

Grants carry an LCN, not a frequency

The payload that matters most, and the bug it replaced:

// internal/radio/dmr/tier3/payloads.go (shape)
//	payload bits 0-11 LPCN (12-bit) · bit 12 Timeslot (0 = TS1, 1 = TS2)
//	octets 2-4 target / group · octets 5-7 source subscriber
func ParseTVGrant(p [8]byte) TVGrant {
    return TVGrant{
        LCN:          uint16(p[0])<<4 | uint16(p[1])>>4,
        Timeslot:     (p[1] >> 3) & 0x01,
        GroupAddress: uint32(p[2])<<16 | uint32(p[3])<<8 | uint32(p[4]),
        SourceID:     uint32(p[5])<<16 | uint32(p[6])<<8 | uint32(p[7]),
    }
}

The channel-grant CSBKs lead with the LPCN, cross-checked against dsd-neo’s dmr_csbk_parse.c. The earlier layout read an “LCN” from octet 7 — the low byte of the source address — so the decoded channel changed with every transmitting radio (#639, catalogued in grant-field gotchas). The timeslot bit is real here, unlike Tier II’s synthetic token in Part 6: publishGrant maps it to Grant.Timeslot = slot + 1. PV_GRANT shares the layout with Individual = true; data grants are observed, never followed. What a grant does not carry is service options: the ETSI content has no emergency or privacy bits, so the voice chain backfills Encrypted, Emergency, Priority and the source RID from the embedded LC of Part 5.

Then the LCN must become a frequency, and DMR never broadcasts the map:

# config.example.yaml (shape)
- name: "Example-DMR-T3"
  protocol: dmr
  control_channels: [851_037_500]
  dmr_band_plan:
    linear: { base_hz: 851_012_500, spacing_hz: 25_000, offset: 1 }  # or a table:

ResolverFromPlan builds a LinearBandPlan (BaseHz + (LCN − Offset) × SpacingHz) or a TableBandPlan. publishGrant first publishes KindDMRGrantObserved so the autoconfig learner sees every grant even with no plan configured — the case it exists to fix; only then does resolveLCN run. No resolver, or an LCN outside the plan, drops the grant with decode.error stage=no-bandplan (dmr/tier3: grant dropped, no band-plan resolver configured / dmr/tier3: band-plan miss) — how a configuration gap shows in metrics. Omit dmr_band_plan and the dmrlcn.Learner of The Hunt Part 8 correlates onsets with granted LCNs, confirms each by decoding a DMR sync, fits a grid snapped to 6.25 / 12.5 / 25 kHz once four LCNs confirm, and hot-swaps it in via SetResolver. Cookbook Part 2 walks both routes.

CSBK burst CRC ^ 0x5A5A FID, then opcode C_ALOHA 0x19 → dmr cc locked C_BCAST 0x28 Gen_Site · Adjacent_Site TV_GRANT 0x31 12-bit LPCN + TS bit Resolver: LCN → Hz linear / table / learned dmr.grant.observed → dmrlcn.Learner
One CSBK, three fates: C_ALOHA locks, C_BCAST maps the site, a grant yields an LPCN that only a band plan — configured or learned — can turn into a frequency.

Vendor FIDs and multi-block control

handleCSBK dispatches on FID before opcode, and the order is load-bearing: a vendor block whose opcode happens to be 0x30 would otherwise parse as a standard PV_GRANT and emit a bogus grant (vendor FID reference). Motorola Capacity Plus / Max (FID 0x10) carries grants in the ETSI-shaped payload, so those decode through the same parsers; its system-info CSBK advertises the rest channel in the SiteID octet. Connect Plus (0x06) and Hytera XPT (0x08 / 0x68) are recognised and logged (dmr/tier3: vendor csbk recognised (payload decode pending capture validation)), never force-parsed.

Multi-Block Control spreads a CSBK-opcode message across a DTMBCHeader burst and DTMBCContinuation bursts, the last flagged LB=1. handleMBC assembles per colour code — at most mbcMaxBlocks (8), evicting a partial older than mbcMaxAge (1 s) — and dispatchMBC structurally parses only the grant opcodes, whose header-block octets 2–9 reuse the validated grant layout. Everything else is assembled and logged (mbc payload decode pending capture validation): no off-air MBC capture pins the last-block CRC convention, and the CSBK history above is what an unverified convention costs.

The −56 dBFS lesson

One more Tier III story, from the wideband engine (internal/scanner/widebandt2/), which WARNs when a tap’s IQ power sits below iqpower.LowPowerThresholdDbFS. A reporter’s Tier III control channel drew that WARN every 5 s at −56 dBFS while decoding every C_ALOHA — an absolute-dBFS gate contradicting live decode evidence:

// internal/scanner/widebandt2/engine.go (shape)
// A channel with recent protocol decodes is healthy whatever its absolute
// dBFS says — never warn "outside the passband" against live decode evidence.
const lowPowerDecodeGrace = 15 * time.Second

decodingRecently := !ec.lastDecodeAt.IsZero() &&
    now.Sub(ec.lastDecodeAt) <= lowPowerDecodeGrace
if dbfs < iqpower.LowPowerThresholdDbFS && !decodingRecently { /* WARN */ }

lastDecodeAt advances whenever DecodedFrames — Tier III’s csbkDecoded — moves in a diagnostics window. TestLowPowerDecodingControlChannelIsSilent drives a dmr-tier3 channel whose counter climbs every window and asserts zero WARNs (the DEBUG power line stays); TestLowPowerNonDecodingControlChannelStillWarns pins that silence without decodes still warns. It is the principle Weak-Signal Part 10 learned for MRC: absolute power is a gain-staging number; a gate built on it re-fires on the next front end.

How Tier III shaped the Go code

  • Real vectors sit beside the parser. csbk_realvectors_test.go is the only test that could have caught the CRC convention; mask and opcodes are pinned to on-air bytes, not to AssembleCSBK.
  • Observation precedes resolution. KindDMRGrantObserved is published before resolveLCN, so the learner works with no plan configured.
  • The resolver is an interface behind a lock. SetResolver swaps it from another goroutine, so a learned plan lands without a restart.
  • Unvalidated layouts are logged raw. Announce Channel-Frequency, Connect Plus, Hytera and non-grant MBC payloads surface as bytes.

Where this goes next

Everything so far ran on a continuous repeater carrier. Part 9 goes to the other extreme: a simplex handheld transmitting one 27.5 ms burst per 60 ms frame, and the gaps between bursts that blinded the receiver for the whole life of #836.

FAQ

How does GopherTrunk lock onto a DMR Tier III control channel? On C_ALOHA, the periodic TSCC beacon (CSBKO 0x19). Once a CSBK clears BPTC(196,96) and the 0x5A5A-masked CRC, ParseAloha reads its 16-bit system ID and the control channel publishes cc.locked — logged as dmr cc locked with frequency, colour code and system ID.

Why does a DMR Tier III grant need a band plan? Because the grant carries a 12-bit logical channel number, not a frequency, and the map is never broadcast. GopherTrunk resolves it through dmr_band_plan (a linear grid or a table) or, if the plan is omitted, learns it by correlating grants with carriers keying up.

What was the DMR CSBK CRC bug? The first implementation used CRC-CCITT with init 0xFFFF and stored the complement — self-consistent with its own encoder, so every synthetic test passed while every real CSBK failed and the control channel never locked. Off-air vectors from two live TSCCs pinned the correct convention: init 0x0000, XOR 0x5A5A.

Does GopherTrunk decode Capacity Plus and Connect Plus? Capacity Plus / Max (FID 0x10) yes — its grants share the ETSI payload layout and its system-info CSBK carries the rest channel. Connect Plus (0x06) and Hytera XPT (0x08 / 0x68) are recognised and logged, not force-parsed, pending on-air captures.

Why did the low-power WARN fire on a working DMR control channel? It was gated on absolute dBFS: a Tier III CC at −56 dBFS decoding every C_ALOHA read as “outside the passband”. The engine now suppresses the WARN for any channel whose decode counter advanced within 15 s (lowPowerDecodeGrace); a channel that decodes nothing keeps it.

Series navigation

Part 8 of 14 · ← Part 7: The Idle Beacon & the CSBK Train · Next → Part 9: Direct Mode — The Gaps That Blinded the Receiver