ccdecoder

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 2, 2026 License: Apache-2.0 Imports: 39 Imported by: 0

Documentation

Overview

Package ccdecoder is the connector that closes the IQ → control- channel decoder gap listed in the README "Status & known gaps".

What this package does:

  • Owns the control SDR's IQ stream (one StreamIQ loop per Decoder lifetime).
  • Subscribes to events.KindHuntProgress so it learns which system / frequency the CC Hunter supervisor is currently attempting.
  • On every HuntProgress transition, swaps the active per- protocol pipeline (IQ → symbol-domain decoder → CC state machine) via the package-local factory map keyed on trunking.Protocol.
  • Down-converts every raw IQ chunk to a narrowband channel stream — rational polyphase decimation, ~48 kHz for the 4800-baud C4FM family, wider for TETRA — before the pipeline sees it. The per-protocol receivers size their matched filters for this channelized rate, not the raw SDR rate, so this stage is what lets a live 2.048 MHz RTL-SDR stream actually lock (issue #275).
  • Pumps every down-converted IQ chunk through the active pipeline's Process method. The pipeline's CC state machine publishes events.KindCCLocked / events.KindGrant on the same bus, which the supervisor + engine consume to drive the rest of the daemon.

What this package does NOT do:

  • It doesn't retune the SDR — that's the CC Hunter supervisor's job (`internal/scanner/cchunt`). The Decoder follows the supervisor's lead via HuntProgress events.
  • It doesn't open / close the SDR device — the daemon does that and hands a Tuner + IQSource through Options.
  • It doesn't decode every protocol from day one. Each pipeline is gated on having a control-channel state machine that accepts a raw dibit / bit stream. Protocols whose CC state machine still consumes pre-parsed PDUs (DMR / NXDN / dPMR / EDACS / MPT 1327 / LTR / Motorola / P25 P2 / TETRA) need a Process(...) adapter on their control package first — a documented follow-up per the per-protocol receiver PRs that already shipped.

Index

Constants

View Source
const DDCTargetRateHz = ddcTargetRateHz

DDCTargetRateHz is the exported alias of ddcTargetRateHz. The in-package callers keep using the lowercase name for source stability; new external callers (replay, integration tests) use the exported value.

Variables

View Source
var ErrIQStreamClosed = errors.New("ccdecoder: IQ stream closed unexpectedly")

ErrIQStreamClosed is returned by Run whenever the SDR's IQ stream is unexpectedly unavailable while the context is still live — either the channel closed mid-stream (the USB reaper died of an unrecoverable error: host controller hang, ENODEV, EPROTO storm under load) or StreamIQ failed to open at all on a Run retry (the underlying device has physically disconnected and the dead handle now rejects every control transfer with `usb: device disconnected`). Both shapes feed the same restart loop in the daemon; the underlying error is preserved via %w chaining so callers can still inspect the root cause. See issue #345.

Functions

func SetTestFactory

func SetTestFactory(protocol trunking.Protocol, f PipelineFactory) (restore func())

factories maps a trunking.Protocol to its pipeline factory. Only protocols whose ControlChannel state machine already accepts a raw dibit / bit stream are wired here. Others land in follow-up PRs as the per-protocol Process(...) adapters ship.

The Protocol enum currently lumps P25 Phase 1 and Phase 2 together; this factory targets Phase 1 (the more common deployment + the protocol with a complete IQ → dibits → CC → bus chain shipping today). A future PR splits Phase 1 / Phase 2 once the daemon's config grows a per-system phase selector.

DMR / NXDN / dPMR / EDACS / MPT 1327 / LTR / Motorola Type II / TETRA all have IQ → symbol receivers shipping but their ControlChannel state machines still consume pre-parsed PDUs. Adding `Process(stream, baseIdx)` adapters that buffer + detect sync + frame + dispatch into the existing parsers is a follow-up. SetTestFactory replaces the registered pipeline factory for a single protocol and returns a restore function the caller is expected to defer. INTENDED FOR INTEGRATION TESTS ONLY — the in-package unit tests substitute factories by mutating the unexported map directly. Out-of-package integration tests (e.g. cmd/gophertrunk's end-to-end "lights up live trunked reception" check) need an exported hook so they can pump known-good dibit streams through the daemon's real ccdecoder without owning a working C4FM modulator.

Production code MUST NOT call this — the factory map is initialised once at package load and the daemon assumes it stays stable for the rest of the process lifetime.

Types

type Decoder

type Decoder struct {
	// contains filtered or unexported fields
}

Decoder is the long-lived component that converts the control SDR's IQ stream into CC / grant events on the bus. Construct via New, run via Run.

func New

func New(opts Options) (*Decoder, error)

New constructs a Decoder. Returns an error when required Options are missing.

func (*Decoder) Close

func (d *Decoder) Close() error

Close releases the active pipeline. Safe to call from outside Run; Run also runs Close on the active pipeline as part of normal swap cleanup.

func (*Decoder) Run

func (d *Decoder) Run(ctx context.Context) error

Run blocks until ctx cancels. It opens one StreamIQ loop on the control SDR, subscribes to KindHuntProgress, swaps the active per-protocol pipeline whenever the supervisor reports a new (system, frequency) under attempt, and pumps every IQ chunk through the active pipeline.

Returns ctx.Err() on shutdown; any StreamIQ error from the SDR surfaces as the return value.

type Downconverter added in v0.2.5

type Downconverter struct {
	// contains filtered or unexported fields
}

Downconverter decimates a wideband SDR IQ stream to a narrowband channel rate.

Decimation is a rational polyphase resample (dsp.Resampler) whose L/M ratio is chosen so the output rate lands exactly on the requested target for every standard SDR rate. When the SDR already streams at (or below) the target the resampler is skipped and the chunk passes straight through — keeping the rate==target unit tests (and any future low-rate SDR) on a no-op path.

It deliberately does NOT remove the front-end DC offset: a C4FM / FM control channel carries real signal energy at 0 Hz (the FM carrier component), so an IQ-domain DC blocker distorts the very signal being decoded — measured here as a >60% RMS error on a round-tripped C4FM stream. DC-spike handling, when a site needs it, belongs in the frequency domain (a deliberate tuning offset so the channel no longer sits at 0 Hz) or after the FM discriminator (coarse AFC on the real symbol stream); both are tracked as follow-ups to issue #275.

Exported (issue #402 Phase 2) so the gophertrunk replay subcommand can construct an identical down-converter and exactly mirror the production receiver chain instead of feeding the receiver raw wideband IQ — the latter sizes the matched filter and AFC/AGC time constants for 2.4 MHz instead of 48 kHz, so a replayed capture decodes nothing like its live counterpart.

func NewDownconverter added in v0.2.5

func NewDownconverter(inRateHz, targetHz float64) *Downconverter

NewDownconverter builds a down-converter that decimates inRateHz to ~targetHz. The exact achieved output rate is reported by OutRateHz (it equals targetHz for every SDR rate that reduces to a sane L/M, and equals inRateHz in pass-through mode).

func NewDownconverterWithOffset added in v0.2.8

func NewDownconverterWithOffset(inRateHz, targetHz, offsetHz float64) *Downconverter

NewDownconverterWithOffset is NewDownconverter plus a tuning offset: the stream is first frequency-shifted so a channel sitting at +offsetHz lands at 0 Hz, then decimated to ~targetHz. This is the "deliberate tuning offset" the package doc describes — needed to replay a wideband capture whose control channel is not centred (the live pipeline gets this for free from the SDR tuner). A zero offset is identical to NewDownconverter (no NCO is built, so existing centred-capture behaviour is byte-exact).

func (*Downconverter) OutRateHz added in v0.2.5

func (d *Downconverter) OutRateHz() float64

OutRateHz returns the achieved narrowband output rate (equals the requested target for standard SDR rates that reduce to a sane L/M; equals inRateHz in pass-through mode). Callers building a receiver against the downconverter's output should use this value for SampleRateHz so matched-filter sizing matches the actual stream.

func (*Downconverter) Process added in v0.2.5

func (d *Downconverter) Process(dst, raw []complex64) []complex64

Process decimates one raw IQ chunk to the narrowband rate. dst is reused if it has capacity; the returned slice holds the narrowband output (len ≈ len(raw)·outRateHz/inRateHz). In pass-through mode raw is returned unchanged. raw is never mutated.

func (*Downconverter) Reset added in v0.2.5

func (d *Downconverter) Reset()

Reset clears the decimation filter history. Called on every pipeline swap so a retune doesn't bleed the previous channel's filter state into the new one.

type IQPowerObserver added in v0.1.7

type IQPowerObserver interface {
	RecordIQPowerDbFS(system string, dbfs float64)
	ClearIQPowerDbFS(system string)
	RecordIQDCRatioDb(system string, ratioDb float64)
	ClearIQDCRatioDb(system string)
}

IQPowerObserver is the minimal Metrics surface the decoder uses to publish its window-averaged dBFS gauge. internal/metrics.Metrics satisfies this; nil disables the gauge entirely.

RecordIQDCRatioDb reports the per-window DC-bin power relative to total IQ power, in dB (so 0 means all power is in the DC bin, very negative means the DC content is negligible). It surfaces the RTL-SDR R820T2 DC-spike-on-channel failure mode behind issue #402: when the channel of interest sits on top of the tuner zero, the DC bin dominates the 48 kHz pipeline passband and the C4FM eye collapses. Healthy off-channel signals show ≤ -20 dB; a DC-dominated capture shows within ~5 dB of 0.

type IQSource

type IQSource interface {
	StreamIQ(ctx context.Context) (<-chan []complex64, error)
}

IQSource is the subset of sdr.Device the decoder consumes for IQ samples. Matches conventional.IQSource so the same Device satisfies both interfaces.

type Options

type Options struct {
	Bus     *events.Bus
	Log     *slog.Logger
	Tuner   Tuner    // currently unused but kept for API symmetry with cchunt
	IQ      IQSource // control SDR providing the live IQ stream
	Systems []trunking.System
	// SampleRateHz is the raw SDR IQ stream rate (e.g. 2_048_000).
	// The decoder's digital down-converter decimates it to a
	// narrowband channel rate (~48 kHz for most protocols); the
	// per-protocol receiver factories are handed that decimated
	// rate, not this one, so they size their matched filters for
	// the channelized stream.
	SampleRateHz float64
	// Metrics is the optional IQ-power observer the pump updates
	// once per iqPowerWindow. Nil disables the gauge but leaves the
	// low-power debug log in place — operators without Prometheus
	// still get a hint when the dongle goes silent.
	Metrics IQPowerObserver
	// IQCorrect enables blind I/Q-imbalance correction on the raw IQ
	// before decimation (issue #402). Off by default; opt-in per device
	// via config. Validate with `replay -iq-correct -diag` on a capture
	// before enabling in production.
	IQCorrect bool
}

Options configure a Decoder.

type PipelineFactory

type PipelineFactory func(PipelineOptions) (ProtocolPipeline, error)

PipelineFactory constructs a fresh ProtocolPipeline for one tuned system. The factory returns an error when the protocol's per-receiver / per-state-machine wiring isn't complete enough to drive a live CC pipeline end-to-end yet — the connector skips the retune in that case and the system stays in `state=hunting`.

type PipelineOptions

type PipelineOptions struct {
	Bus          *events.Bus
	Log          *slog.Logger
	SystemName   string
	FrequencyHz  uint32
	SampleRateHz float64
	System       trunking.System
}

PipelineOptions is the per-pipeline construction shape — the connector hands the bus + log down, plus the (system, frequency) the supervisor is currently attempting and the IQ sample rate the receiver needs to size its matched filter.

System carries the full trunking.System the supervisor is hunting, so per-protocol factories can read protocol-specific config off it (TETRA colour code + expected channel, P25 WACN, etc.) without needing a new field on PipelineOptions per protocol. SystemName + FrequencyHz remain at the top level because they're consumed by every factory.

type ProtocolPipeline

type ProtocolPipeline interface {
	Process(iq []complex64)
	Reset()
	Close() error
}

ProtocolPipeline is the contract every per-protocol receiver pipeline satisfies. Process consumes one chunk of complex IQ; Reset clears symbol-domain state on stream re-sync; Close releases any held resources (it's idempotent and may return nil).

type Tuner

type Tuner interface {
	SetCenterFreq(hz uint32) error
}

Tuner is the subset of sdr.Device the decoder uses for retuning. Matches the same interface cchunt + conventional consume so the daemon can hand the same Device to all three.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL