hunt

package
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Jul 14, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

Documentation

Overview

Package hunt is GopherTrunk's site/system "hunting" layer: it turns the output of the signal-lab decoders into a DiscoveredSystem — a structured map of a trunked radio system observed off the air (or from a capture) — and exports that map to standardized files plus a RadioReference.com submission package.

Why this exists: many states have trunked systems that are undocumented on RadioReference. GopherTrunk can already decode a control channel once you know its frequency and identity; this package closes the loop by accumulating what the decoder observes (system identity, per-site control channels, neighbor sites, talkgroups) into a single model that round-trips straight back into GopherTrunk's import bundle and into other scanners.

The model is intentionally protocol-neutral. Full multi-site topology (WACN/SYSID/RFSS/Site/neighbors) is only available for P25 today; for the other protocols the accumulator records the identity fields the decoder surfaces plus the single observed site and its talkgroups, which is still enough to export and submit.

Index

Constants

This section is empty.

Variables

View Source
var ErrNoSuchRun = errors.New("hunt: no such run id")

ErrNoSuchRun is returned by id-addressed lookups when the run id is unknown or has been evicted from the bounded history.

Functions

func Accumulate

func Accumulate(dst *DiscoveredSystem, obs Observation)

Accumulate folds an Observation into dst, creating sites/talkgroups and merging identity. It de-duplicates control channels by frequency, sites by (RFSS, Site), and talkgroups by decimal id, so re-observing the same control channel is idempotent (apart from bumping talkgroup activity counts).

func Discover

func Discover(captures []CaptureInput, cfg DiscoverConfig) (*DiscoveredSystem, []CaptureReport, error)

Discover folds every capture into a single DiscoveredSystem. Captures whose protocol can't be identified with sufficient confidence (and weren't given an explicit protocol) are skipped, not errored, so a wideband sweep that surfaced non-trunked carriers degrades gracefully. The per-capture reports are always returned, even on a nil error, so the caller can show progress.

func DiscoverWideband added in v0.4.3

func DiscoverWideband(captures []CaptureInput, cfg DiscoverConfig) (*DiscoveredSystem, []CaptureReport, error)

DiscoverWideband folds wideband multi-carrier captures into a single DiscoveredSystem. Each capture is surveyed (siglab.SurveyWideband splits it into per-carrier baseband streams, identifies each, and recognizes a DMR Tier III trunked system), and every DMR carrier of the capture is accumulated into the same system — so the control channels register their identity + frequencies and the voice carriers' grants become talkgroups, all under one discovered system. It is the offline-hunt counterpart of Discover for the `hunt -wideband -in <capture>` path.

Each capture's CaptureInput.FrequencyHz is the capture CENTER frequency (the dongle's tuned center), not a single carrier; the per-carrier absolute frequencies fall out of the survey's spectral offsets.

func LoadSurveyedFreqs added in v0.4.2

func LoadSurveyedFreqs(path string) (map[uint32]struct{}, error)

LoadSurveyedFreqs reads the frequencies already recorded in an NDJSON survey file so a resumed run can skip them. A missing file yields an empty set and no error (first run). Unparseable lines (e.g. a record half-written before a crash) are skipped rather than failing the load.

func RunLiveHunt

func RunLiveHunt(ctx context.Context, opts LiveHuntOptions) (*DiscoveredSystem, []CaptureReport, error)

RunLiveHunt sweeps (or probes a candidate list) on a live IQSource, then identifies, decodes, and maps each candidate into a DiscoveredSystem. It is the on-air sibling of Discover: the sweep replaces operator-supplied captures, but the per-candidate identify→decode→accumulate body is shared (decodeAndAccumulate). Returns the discovered system, a per-candidate report, and an error only for fatal setup/sweep failures (per-candidate failures are recorded in the reports).

func RunLiveSurvey added in v0.3.7

func RunLiveSurvey(ctx context.Context, opts LiveHuntOptions) (*SignalSurvey, []CaptureReport, error)

RunLiveSurvey sweeps (or probes a candidate list) like RunLiveHunt, but instead of only mapping trunking control channels it classifies every detected carrier and routes it: trunking carriers fold into the discovered system (the existing identify→decode→accumulate path), paging and analog carriers run their conventional decoders, and the rest are recorded as classified-only. It returns the full SignalSurvey (which embeds the trunking map) plus the per-candidate trunking reports for the shared export tail.

func RunOfflineSurvey added in v0.3.7

func RunOfflineSurvey(captures []CaptureInput, opts LiveHuntOptions) (*SignalSurvey, []CaptureReport, error)

RunOfflineSurvey classifies and routes a set of capture files without an SDR — the offline sibling of RunLiveSurvey, mirroring how Discover is the offline sibling of RunLiveHunt. Each capture is loaded, treated as one baseband candidate (at its CaptureInput.FrequencyHz), classified, and routed through the same body the live survey uses, so an operator can survey recorded IQ (e.g. a wideband grab) the same way they survey on the air.

func Write

func Write(w io.Writer, sys *DiscoveredSystem, f Format, hints []DuplicateHint) error

Write serializes sys to w in the requested format. RRHints, when non-empty, are rendered into the RR submission package (ignored by the other formats).

func WriteSurvey added in v0.3.7

func WriteSurvey(w io.Writer, sv *SignalSurvey, f SurveyFormat) error

WriteSurvey serializes sv to w in the requested format.

func WriteWithRRDiff added in v0.4.2

func WriteWithRRDiff(w io.Writer, sys *DiscoveredSystem, f Format, hints []DuplicateHint, diff *RRDiff) error

WriteWithRRDiff is Write with an optional cross-reference diff against an existing RadioReference system, rendered into the RR submission package (ignored by the other formats). diff == nil is identical to Write.

Types

type Acquirer

type Acquirer func(ctx context.Context, opts LiveHuntOptions) (src IQSource, release func(), err error)

Acquirer obtains an IQSource for one run plus a release callback (resume the control-channel hunter, close the SDR subscription, …). The daemon supplies this so the hunt package stays free of SDR/pool dependencies; release is invoked exactly once when the run ends (success, error, or stop). Acquirer obtains an IQSource for one run plus a release callback. opts carries the run's parameters — notably opts.Serial, so the daemon can honor an operator-requested SDR — and is passed by the Manager when a run starts.

type AutoGainOptions added in v0.4.2

type AutoGainOptions struct {
	Gains        []int             // tenths of dB to try; empty ⇒ defaultGainSet
	DwellSeconds float64           // capture per (freq, gain); 0 ⇒ 1.0
	Protocol     trunking.Protocol // force the decoder (skip identify) when known
	SampleRateHz float64
	AutoTune     bool
	Log          *slog.Logger
}

AutoGainOptions configures an auto-gain sweep.

type Band

type Band struct {
	LowHz  uint32
	HighHz uint32
}

Band is an inclusive frequency range to sweep, in Hz.

type BandPlanEntry

type BandPlanEntry struct {
	ChannelID   uint8  `json:"channel_id"`
	BaseHz      uint64 `json:"base_hz"`
	SpacingHz   uint32 `json:"spacing_hz"`
	BandwidthHz uint32 `json:"bandwidth_hz,omitempty"`
	TxOffsetHz  int64  `json:"tx_offset_hz,omitempty"`
}

BandPlanEntry is one P25 IDEN_UP-equivalent band-plan mapping.

type Candidate

type Candidate struct {
	FreqHz uint32  `json:"freq_hz"`
	SNRDb  float32 `json:"snr_db"`
	// IsWideband marks a stitched wideband-occupancy span (cellular/WiFi/wide
	// data) rather than a narrowband carrier. Such a span is named by allocation
	// + shape, not decoded.
	IsWideband bool `json:"is_wideband,omitempty"`
	// BwHz is the occupied bandwidth of a wideband span (0 for narrow carriers).
	BwHz uint32 `json:"bw_hz,omitempty"`
}

Candidate is a carrier the sweep found, worth identifying. A narrowband carrier carries FreqHz + SNRDb; a wideband span (IsWideband) additionally carries BwHz, its stitched occupied bandwidth, and FreqHz is the span center.

func SortedCandidates

func SortedCandidates(c []Candidate) []Candidate

SortedCandidates returns candidates sorted by descending SNR (test helper).

type CaptureInput

type CaptureInput struct {
	Path         string
	Format       siglab.SampleFormat
	SampleRateHz float64
	// FrequencyHz is the capture's nominal center frequency (informational;
	// the decoded lock frequency is what gets recorded).
	FrequencyHz uint32
	AutoTune    bool
	Conjugate   bool
	IQCorrect   bool
	// Protocol forces a decoder; trunking.ProtocolUnknown (the zero value)
	// triggers auto-identification across every protocol.
	Protocol trunking.Protocol
	// IdentifyMaxSamples caps the prefix the identifier scans (0 ⇒ a built-in
	// default prefix). Only consulted when Protocol is unknown.
	IdentifyMaxSamples int64
}

CaptureInput is one IQ capture to fold into a discovery — typically a recording centered on a suspected control channel. The hunt orchestrator identifies the protocol (unless Protocol is set), decodes it, and accumulates the result into the running system map.

type CaptureReport

type CaptureReport struct {
	Path       string  `json:"path"`
	Protocol   string  `json:"protocol"`
	Confidence float64 `json:"confidence"`
	Locked     bool    `json:"locked"`
	ControlHz  uint32  `json:"control_hz,omitempty"`
	Talkgroups int     `json:"talkgroups"`
	// ErrorRate is the decode-error events per 1000 symbols from the decode
	// (siglab Signal.DecodeErrorRate), a protocol-neutral demod-quality proxy.
	// Zero when no signal analysis was produced. Used by the auto-gain sweep.
	ErrorRate float64 `json:"error_rate,omitempty"`
	// Encrypted is true when any grant on the control channel was encrypted;
	// EncType names the algorithm when known (e.g. AES-256, ADP/RC4), empty when
	// the protocol surfaces encryption without the ALGID (P25 P1 ALGID is in the
	// voice LDU2, not the CC grant).
	Encrypted  bool   `json:"encrypted,omitempty"`
	EncType    string `json:"enc_type,omitempty"`
	Skipped    bool   `json:"skipped"`
	SkipReason string `json:"skip_reason,omitempty"`
	Error      string `json:"error,omitempty"`
	// Verdict is the wideband survey's system-level summary line (set only by
	// DiscoverWideband). Empty for single-carrier captures.
	Verdict string `json:"verdict,omitempty"`
	// IdentityNote explains a locked P25 capture whose WACN/System ID are still
	// blank — naming which identity broadcasts decoded so the operator knows
	// whether to widen the dwell or improve the signal. Empty when identity
	// resolved (or the capture isn't a locked P25 control channel).
	IdentityNote string `json:"identity_note,omitempty"`
}

CaptureReport records what happened to one capture so the CLI/cockpit can explain the outcome (decoded, skipped as not-trunked, errored).

type DetectedSignal added in v0.3.7

type DetectedSignal struct {
	FreqHz       uint32             `json:"freq_hz"`
	SNRDb        float32            `json:"snr_db"`
	OccupiedBwHz uint32             `json:"occupied_bw_hz"`
	Class        survey.SignalClass `json:"class"`
	Confidence   float64            `json:"confidence"`
	BaudHz       float64            `json:"baud_hz,omitempty"`

	// Inventory naming — the consolidated "what is this" the full-spectrum
	// survey shows for every signal, decodable or not.
	//   Name    — human label: the decoded protocol, else the best-guess signal
	//             from the reference catalog (sigref), else the modulation class.
	//   Service — what the frequency is allocated to (sigref allocation table).
	//   Purpose — what that service is used for.
	Name    string `json:"name,omitempty"`
	Service string `json:"service,omitempty"`
	Purpose string `json:"purpose,omitempty"`

	// Encrypted / EncType report whether the signal is encrypted and, when known,
	// the algorithm (AES-256, ADP/RC4, …). Set from the trunking decode's grants;
	// EncType is empty when the protocol exposes encryption without an ALGID.
	Encrypted bool   `json:"encrypted,omitempty"`
	EncType   string `json:"enc_type,omitempty"`

	// Wideband marks a signal far wider than a channel (cellular/WiFi/OFDM)
	// recovered by the occupancy-stitching pass. Such a signal is named and
	// captured but never decoded; OccupiedBwHz is its stitched bandwidth (a lower
	// bound when the signal ran past the swept range).
	Wideband bool `json:"wideband,omitempty"`

	// Decode summary — set by the router for the carriers it could decode.
	Trunking *TrunkingRef         `json:"trunking,omitempty"`
	Analog   *survey.AnalogReport `json:"analog,omitempty"`
	Pages    []survey.PageRef     `json:"pages,omitempty"`

	// Features carries the raw classifier measurements for diagnostics.
	Features survey.ClassFeatures `json:"features"`
	// Error notes a per-carrier failure (tune/capture), leaving Class as-is.
	Error string `json:"error,omitempty"`
}

DetectedSignal is one classified (and possibly decoded) carrier in a survey. Exactly one of Trunking / Analog / Pages is populated, per the Class.

type DiscoverConfig

type DiscoverConfig struct {
	Name          string
	State         string
	County        string
	Location      string
	MinConfidence float64 // skip auto-identified captures below this (0 ⇒ 0.40)
	Log           *slog.Logger
}

DiscoverConfig carries operator metadata and thresholds for a discovery run.

type DiscoveredChannel

type DiscoveredChannel struct {
	FrequencyHz uint32  `json:"frequency_hz"`
	IsControl   bool    `json:"is_control"`
	Confidence  float64 `json:"confidence,omitempty"`
}

DiscoveredChannel is one observed frequency on a site.

type DiscoveredSite

type DiscoveredSite struct {
	RFSS            uint8               `json:"rfss"`
	SiteID          uint8               `json:"site_id"`
	SiteName        string              `json:"site_name"`
	County          string              `json:"county,omitempty"`
	ControlChannels []DiscoveredChannel `json:"control_channels"`
	Secondary       []uint32            `json:"secondary,omitempty"`
	Neighbors       []NeighborRef       `json:"neighbors,omitempty"`
	// VoiceChannels are the distinct voice/traffic-channel frequencies (Hz)
	// seen granted on this site, resolved from each grant's (ChannelID,
	// ChannelNumber) against the band plan. Empty until a grant resolves.
	VoiceChannels []uint32 `json:"voice_channels,omitempty"`
}

DiscoveredSite is one RF site of the system, keyed by (RFSS, SiteID).

type DiscoveredSystem

type DiscoveredSystem struct {
	// Name is operator-assigned, or synthesized from the identity when blank.
	Name string `json:"name"`
	// Protocol is the trunking.Protocol.String() spelling (e.g. "p25").
	Protocol string `json:"protocol"`

	// P25 / generic identity. Zero ⇒ unknown.
	WACN     uint32 `json:"wacn,omitempty"`
	SystemID uint16 `json:"system_id,omitempty"`
	NAC      uint16 `json:"nac,omitempty"`

	// Identity carries the remaining per-protocol identity fields the decoder
	// surfaced (DMR ColorCode, NXDN RAN, TETRA MCC/MNC, Motorola/EDACS system
	// id, …) keyed by the decoder's field name. It is informational and is
	// rendered into the RR package.
	Identity map[string]any `json:"identity,omitempty"`

	// Geography — operator-supplied; used by the RR duplicate check and the
	// submission package.
	State    string `json:"state,omitempty"`
	County   string `json:"county,omitempty"`
	Location string `json:"location,omitempty"`

	Sites      []DiscoveredSite      `json:"sites"`
	Talkgroups []DiscoveredTalkgroup `json:"talkgroups"`
	BandPlan   []BandPlanEntry       `json:"band_plan,omitempty"`

	// Confidence is the min protocol-identification confidence across the
	// control channels folded into this system (0..1).
	Confidence float64   `json:"confidence"`
	FirstSeen  time.Time `json:"first_seen"`
	LastSeen   time.Time `json:"last_seen"`
}

DiscoveredSystem is the accumulated map of one trunked system. It is built up incrementally as captures/observations are folded in (see Accumulator), then handed to the exporters. Its fields are shaped to convert cleanly onto the importer's parsedSystem (see cmd/gophertrunk/hunt_export.go) so a discovery exported as a bundle re-imports without loss.

func (*DiscoveredSystem) DisplayName

func (s *DiscoveredSystem) DisplayName() string

DisplayName returns Name when set, otherwise a synthesized identifier from the protocol + system id so an unnamed discovery still has a stable handle.

func (*DiscoveredSystem) NetworkReport added in v0.5.0

func (s *DiscoveredSystem) NetworkReport() trunking.NetworkReport

NetworkReport flattens the discovered system into the protocol-neutral trunking.NetworkReport the shared renderer consumes. Unlike the single-site CLI/daemon path, a DiscoveredSystem aggregates several sites, so it emits one ReportSite per DiscoveredSite under a shared identity header + shared band plan. Control channels are stored as resolved frequencies (no band-plan coordinates), so site control channels carry a downlink frequency only; neighbours keep their (id, number) and get an uplink derived from the matching band's transmit offset.

func (*DiscoveredSystem) RFSS

func (s *DiscoveredSystem) RFSS() uint64

RFSS returns the RFSS id harvested into the Identity map (0 when unknown).

func (*DiscoveredSystem) SiteNum

func (s *DiscoveredSystem) SiteNum() uint64

SiteNum returns the Site id harvested into the Identity map (0 when unknown).

type DiscoveredTalkgroup

type DiscoveredTalkgroup struct {
	Dec       uint32    `json:"dec"`
	Hex       string    `json:"hex"`
	Encrypted bool      `json:"encrypted,omitempty"`
	Count     int       `json:"count"`
	FirstSeen time.Time `json:"first_seen"`
}

DiscoveredTalkgroup is one talkgroup observed on the control channel. On a blind discovery only the numeric id and activity are known; the descriptive fields are left blank for the operator (or RR) to fill in.

type DuplicateHint

type DuplicateHint struct {
	SID        int     `json:"sid"`        // RadioReference system id
	Name       string  `json:"name"`       // existing system name
	Reason     string  `json:"reason"`     // why it matched (WACN+SYSID, overlapping CC, name/county)
	Confidence float64 `json:"confidence"` // 0..1
}

DuplicateHint is a possible pre-existing RadioReference system match, surfaced by an optional read-only RR API lookup. It is rendered into the RR submission package so an operator doesn't submit a duplicate.

type FileIQSource

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

FileIQSource is a deterministic IQSource over an in-memory IQ buffer, used by tests. Tune records the requested center (so captured slices can be stamped with an absolute frequency) but does not alter the samples — the buffer is assumed to already be baseband IQ for whatever carrier the test models. Capture serves the buffer cyclically so a sweep with many steps never starves.

func NewFileIQSource

func NewFileIQSource(iq []complex64, rateHz uint32) *FileIQSource

NewFileIQSource builds a FileIQSource over one buffer served cyclically at every tuned center.

func NewMappedIQSource

func NewMappedIQSource(perCenter map[uint32][]complex64, rateHz uint32) *FileIQSource

NewMappedIQSource builds a FileIQSource that serves a distinct buffer per tuned center frequency (and an empty/zero stream for untuned centers), so a test can place a "carrier" at specific frequencies and verify the sweep finds exactly those. rateHz is the common sample rate.

func (*FileIQSource) Capture

func (f *FileIQSource) Capture(ctx context.Context, n int) ([]complex64, error)

func (*FileIQSource) SampleRateHz

func (f *FileIQSource) SampleRateHz() uint32

func (*FileIQSource) Tune

func (f *FileIQSource) Tune(centerHz uint32) error

func (*FileIQSource) TuneCalls

func (f *FileIQSource) TuneCalls() []uint32

TuneCalls returns the centers Tune was called with, in order (test helper).

type Format

type Format int

Format selects an export encoding for a DiscoveredSystem.

const (
	// FormatBundle is GopherTrunk's multi-section CSV import bundle (the exact
	// reverse of cmd/gophertrunk/import_csv.go parseCSVStream — it round-trips
	// straight back into config.yaml via `import-pdf -csv`).
	FormatBundle Format = iota
	// FormatTrunkRecorder is a trunk-recorder JSON system config stanza.
	FormatTrunkRecorder
	// FormatRR is a human-readable RadioReference.com submission package.
	FormatRR
	// FormatSummary is the human-readable P25 network-configuration / activity
	// summary report (GopherTrunk's equivalent of SDRtrunk's network monitor).
	FormatSummary
)

func ParseFormat

func ParseFormat(s string) (Format, error)

ParseFormat maps a -formats list value to a Format.

func (Format) FileExtension

func (f Format) FileExtension() string

FileExtension is the conventional extension for a format's output file.

func (Format) String

func (f Format) String() string

String renders the format as the value operators type on the CLI.

type FreqOffset added in v0.4.2

type FreqOffset struct {
	DiscoveredHz uint32 `json:"discovered_hz"`
	RRHz         uint32 `json:"rr_hz"`
	DeltaHz      int64  `json:"delta_hz"` // discovered - RR
}

FreqOffset is a discovered frequency that matched a RadioReference frequency within tolerance but not exactly — likely a tuning/PPM offset worth checking.

type GainRecommendation added in v0.4.2

type GainRecommendation struct {
	FreqHz          uint32      `json:"freq_hz"`
	BestGainTenthDB int         `json:"best_gain_tenth_db"`
	BestErrorRate   float64     `json:"best_error_rate"`
	Locked          bool        `json:"locked"`
	PerGain         []GainScore `json:"per_gain"`
}

GainRecommendation is the best gain found for one control channel.

func AutoGainSweep added in v0.4.2

func AutoGainSweep(ctx context.Context, src IQSource, gc GainSettable, ccs []uint32, opts AutoGainOptions) ([]GainRecommendation, error)

AutoGainSweep sweeps each control channel across a set of gains and recommends the gain that minimises the decode error rate (preferring a gain that locks). It requires a gain-controllable live SDR and mutates the device gain as it runs — the caller restores the desired gain afterwards. Recommendations are advisory; nothing is applied automatically.

type GainScore added in v0.4.2

type GainScore struct {
	GainTenthDB int     `json:"gain_tenth_db"`
	ErrorRate   float64 `json:"error_rate"`
	Locked      bool    `json:"locked"`
	Err         string  `json:"error,omitempty"`
}

GainScore is the decode outcome at one (frequency, gain).

type GainSettable added in v0.4.2

type GainSettable interface {
	// SetGain sets the front-end gain in tenths of a dB (-1 ⇒ AGC).
	SetGain(tenthDB int) error
}

GainSettable is the optional gain-control capability the auto-gain sweep needs beyond IQSource. The standalone live-device source implements it; the daemon's shared-SDR broker source does not (changing gain would disrupt the daemon's other consumers), so auto-gain is a standalone-live-SDR feature.

type IQSource

type IQSource interface {
	// Tune retunes the source to centerHz. Subsequent Capture calls return IQ
	// centered there.
	Tune(centerHz uint32) error
	// Capture returns the next n complex samples at the current center, or an
	// error (including ctx cancellation). Implementations may return fewer than
	// n only at end-of-stream, in which case they return what remains.
	Capture(ctx context.Context, n int) ([]complex64, error)
	// SampleRateHz is the source's IQ sample rate.
	SampleRateHz() uint32
}

IQSource is the tunable IQ provider the live sweeper and hunter drive. It abstracts over a live SDR (broker-backed, wired in the daemon) and a file/synthetic source (tests), so the sweep/identify/accumulate logic is the same offline and on-air.

type LiveHuntOptions

type LiveHuntOptions struct {
	Source IQSource
	// Bands to sweep. Ignored when Candidates is non-empty.
	Bands []Band
	// Candidates, when set, are explicit control-channel frequencies to probe
	// directly (the sweep is skipped). Mirrors the offline -no-sweep path.
	Candidates []uint32
	// Protocol forces a decoder; trunking.ProtocolUnknown auto-identifies.
	Protocol trunking.Protocol
	// Survey selects the signal-survey pipeline (RunLiveSurvey): classify and
	// decode every detected carrier, not just trunking control channels. The
	// daemon Manager reads this to dispatch the right run.
	Survey bool
	// ClassifyConfig tunes the survey classifier thresholds. The zero value
	// uses survey.DefaultClassifyConfig.
	ClassifyConfig survey.ClassifyConfig
	// IdentifyMinConfidence, when > 0, skips the (expensive) trunking identify
	// for a digital carrier whose classifier confidence is below it — unless it
	// is at a paging baud. 0 ⇒ always identify (never drop a possible CC).
	IdentifyMinConfidence float64
	// ClassifyOnly records the classifier verdict for every carrier and skips
	// all decoding (trunking identify, paging, analog tone scan) for a fast
	// inventory.
	ClassifyOnly bool
	// SkipFreqs, when non-empty, drops candidate carriers whose center
	// frequency is already present (a resumed survey reads these from the
	// existing NDJSON output so it doesn't re-survey carriers it already has).
	SkipFreqs map[uint32]struct{}
	// PersistSurvey requests the daemon Manager stream this survey run's
	// classified carriers to a crash-safe NDJSON file (in ManagerOptions.SurveyDir),
	// so it can be resumed. Ignored for a plain (non-survey) hunt or when no
	// SurveyDir is configured. The CLI persists via its own -survey-ndjson flag.
	PersistSurvey bool
	// Resume, with PersistSurvey, preloads the frequencies already in the run's
	// NDJSON file into SkipFreqs so an interrupted survey continues.
	Resume bool
	// AutoGain requests a post-run gain sweep: after the hunt/survey, each locked
	// control channel is decoded across a set of front-end gains and the gain
	// minimising the decode error rate is recommended (advisory; nothing is
	// applied). Requires a gain-controllable, exclusively-held SDR — a no-op with
	// a clear note when the SDR is shared (the daemon's borrowed control SDR).
	AutoGain bool
	// AutoGainSet is the gain ladder to sweep (tenths of dB); empty uses a default.
	AutoGainSet []int
	// SurveyDeep hands narrowband carriers the blind classifier called analog
	// (am/nbfm/wfm ≤ NBFM bandwidth) to the authoritative siglab identify before
	// the analog tone scan, so a trunked DMR/TETRA/MPT control channel the blind
	// classifier missed is still discovered. Slower (an extra identify per
	// borderline carrier); the accurate-survey opt-in for digital-dense bands.
	SurveyDeep bool
	// Serial requests a specific SDR for the run. Empty ⇒ the daemon
	// auto-selects (spare SDR, else borrow the control SDR). Consumed by the
	// daemon Acquirer; RunLiveHunt itself ignores it (the IQSource is already
	// resolved by the time RunLiveHunt runs).
	Serial string
	// ConfirmBorrow authorizes a hunt that would borrow an SDR currently
	// decoding live wideband traffic (a widebandt2 engine). Borrowing retunes
	// that SDR, so the live control-channel + voice decode is suspended for the
	// hunt's duration. The daemon Acquirer refuses such a borrow unless this is
	// set, so the operator is prompted before knocking the live system offline
	// rather than having it happen silently. Consumed by the daemon Acquirer;
	// RunLiveHunt itself ignores it.
	ConfirmBorrow bool

	FFTSize    int
	SweepDwell time.Duration
	GuardFrac  float64
	PeakOpts   PeakOptions
	// DetectCarriers expands a wideband OFFLINE capture into every carrier it
	// contains (FFT peak-detect) instead of treating the whole file as one
	// channel — so an off-centre / non-dominant control channel is found and
	// the rest of the band is inventoried. Live sweeps already do this via the
	// SDR; this is the offline equivalent. Ignored for live runs.
	DetectCarriers bool
	// DetectWideband adds the wideband-occupancy pass to a live survey sweep:
	// signals far wider than a channel (cellular/WiFi/OFDM) are detected,
	// stitched across tunes, and surfaced as named-but-not-decoded inventory
	// rows. Off by default; the whole-device survey turns it on.
	DetectWideband bool
	// DetectEncryption runs an entropy triage on every digital carrier that no
	// decoder identified: a high-entropy, near-uniform recovered payload marks
	// the row encrypted (EncType "high-entropy (likely encrypted)"). Decoded
	// protocols already surface encryption from their grants; this covers the
	// unknown digital bursts. Off by default (adds a blind demod per carrier).
	DetectEncryption bool

	// DwellSeconds is how much IQ to capture per candidate for identify+decode.
	// 0 ⇒ 3 s.
	DwellSeconds float64
	// MaxDwellSeconds, when > DwellSeconds, lets a survey candidate extend its
	// capture (in DwellSeconds chunks) until carrier activity is seen or this
	// ceiling is reached — useful for bursty paging. 0 ⇒ a single fixed dwell.
	MaxDwellSeconds float64
	// MonitorSeconds, when > 0, switches each candidate from the buffered
	// fixed-dwell capture to a streaming long-dwell control-channel monitor:
	// live IQ is decoded in real time (bounded memory, no multi-GB capture) for
	// up to this many seconds, stopping early once identity + neighbors + band
	// plan settle. The short DwellSeconds prefix is still captured first to
	// identify the protocol. Opt-in: 0 ⇒ today's buffered behavior.
	MonitorSeconds float64
	MinConfidence  float64
	AutoTune       bool

	// SurveyAudioDir, when set, makes a survey write a WAV clip per active
	// analog-FM carrier into this directory.
	SurveyAudioDir string

	// System metadata for the resulting map.
	Name, State, County, Location string

	OnProgress func(LiveHuntProgress)
	// OnSignal, when set, is called once per classified carrier during a survey
	// run (RunLiveSurvey), after its decode summary is filled in. The daemon
	// uses it to publish per-signal events and to persist decoded pages; the CLI
	// leaves it nil. Ignored by RunLiveHunt.
	OnSignal func(DetectedSignal)
	Log      *slog.Logger
}

LiveHuntOptions configure a live hunt.

type LiveHuntPhase

type LiveHuntPhase string

LiveHuntPhase labels the stage a live hunt is in, for progress reporting.

const (
	PhaseSweeping    LiveHuntPhase = "sweeping"
	PhaseIdentifying LiveHuntPhase = "identifying"
	PhaseDone        LiveHuntPhase = "done"
)

type LiveHuntProgress

type LiveHuntProgress struct {
	Phase      LiveHuntPhase `json:"phase"`
	CenterHz   uint32        `json:"center_hz,omitempty"`
	CandidateN int           `json:"candidate_n,omitempty"`
	Candidates int           `json:"candidates,omitempty"`
	Detail     string        `json:"detail,omitempty"`
}

LiveHuntProgress is one progress notification emitted during a live hunt.

type Manager

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

Manager owns the daemon's single live-hunt run: it acquires an SDR, runs the sweep→identify→map pipeline in the background, publishes hunt.* bus events, and holds the latest discovered system for export/commit. Safe for concurrent use by the REST/TUI cockpit.

func NewManager

func NewManager(opts ManagerOptions) (*Manager, error)

NewManager builds a Manager. Acquire is required.

func (*Manager) CaptureSignal added in v0.5.9

func (m *Manager) CaptureSignal(ctx context.Context, freqHz uint32, seconds float64) ([]complex64, uint32, error)

CaptureSignal records seconds of raw IQ centred on freqHz by acquiring an SDR the same way a run does (spare-else-borrow), tuning it, and capturing. It is the daemon counterpart of the CLI's list-driven capture: the cockpit/REST hand it a frequency from the survey inventory and it returns the IQ + the source's sample rate for staging into SigLab/CryptoLab. It refuses while a hunt run is active (the SDR is busy) so it can't fight a live sweep.

func (*Manager) Current

func (m *Manager) Current() (*DiscoveredSystem, []CaptureReport, bool)

Current returns the latest discovered system and its per-candidate reports, or (nil, nil, false) when no run has produced a map yet. The returned system is the live pointer; callers must not mutate it.

func (*Manager) CurrentSurvey added in v0.3.7

func (m *Manager) CurrentSurvey() (*SignalSurvey, bool)

CurrentSurvey returns the latest run's signal inventory, or (nil, false) when the latest run was a plain hunt or none has produced one yet.

func (*Manager) Export

func (m *Manager) Export(w io.Writer, f Format, hints []DuplicateHint) error

Export writes the latest discovered system to w in the given format. Returns an error when no system has been discovered yet.

func (*Manager) ExportRun

func (m *Manager) ExportRun(id int, w io.Writer, f Format, hints []DuplicateHint) error

ExportRun writes a specific run's discovered system (id 0 = latest). Returns ErrNoSuchRun for an unknown/evicted id, or a "no system" error when the run exists but produced nothing.

func (*Manager) ExportSurvey added in v0.3.7

func (m *Manager) ExportSurvey(id int, w io.Writer, f SurveyFormat) error

ExportSurvey writes a run's signal inventory to w in the given format (id 0 = latest). Returns ErrNoSuchRun for an unknown/evicted id, or a "no survey" error when the run exists but was a plain hunt.

func (*Manager) KnownRun

func (m *Manager) KnownRun(id int) bool

KnownRun reports whether id refers to any run the Manager has seen (active or in history), so callers can distinguish "unknown id" (404) from "run exists but produced nothing" (409).

func (*Manager) Run

func (m *Manager) Run(id int) (*DiscoveredSystem, []CaptureReport, bool)

Run returns a specific run's discovered system + reports. id 0 means the latest (same as Current). Returns ok=false for an unknown/evicted id, or for a run that produced no system.

func (*Manager) Start

func (m *Manager) Start(opts LiveHuntOptions) (int, error)

Start launches a live hunt with opts. It returns the new run id, or an error if a run is already active. The run executes in the background; observe it via Status and the hunt.* bus events.

func (*Manager) Status

func (m *Manager) Status() RunStatus

Status returns the current run snapshot.

func (*Manager) Stop

func (m *Manager) Stop() bool

Stop cancels the active run. Returns false if no run is active.

func (*Manager) SurveyRun added in v0.3.7

func (m *Manager) SurveyRun(id int) (*SignalSurvey, bool)

SurveyRun returns a specific run's signal inventory (id 0 = latest). ok=false for an unknown/evicted id or a run that produced no survey (a plain hunt).

type ManagerOptions

type ManagerOptions struct {
	// Acquire obtains the run's IQSource. Required.
	Acquire Acquirer
	Bus     *events.Bus
	Log     *slog.Logger
	// SurveyDir, when set, is the directory the manager streams a survey run's
	// classified carriers into as crash-safe NDJSON (one file per band set), so a
	// web survey can resume after an interruption. Empty disables persistence.
	SurveyDir string
}

ManagerOptions configure a Manager.

type NDJSONSink added in v0.4.2

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

NDJSONSink appends one DetectedSignal per line to a newline-delimited JSON file, fsyncing after each record so an interrupted survey leaves a complete, resumable trail on disk (the per-record durability the end-of-run survey.json can't offer). It is the streaming peer of WriteSurvey: WriteSurvey snapshots the whole inventory at the end, NDJSONSink records each carrier as it is classified, via the survey's OnSignal hook.

func OpenNDJSONSink added in v0.4.2

func OpenNDJSONSink(path string) (*NDJSONSink, error)

OpenNDJSONSink opens (creating if needed) path for append. The caller must Close it when the run ends.

func (*NDJSONSink) Close added in v0.4.2

func (s *NDJSONSink) Close() error

Close closes the underlying file.

func (*NDJSONSink) Write added in v0.4.2

func (s *NDJSONSink) Write(ds DetectedSignal) error

Write marshals ds to one line and fsyncs. Safe for concurrent callers (the daemon invokes OnSignal from a single goroutine, but the lock keeps it sound regardless).

type NeighborRef

type NeighborRef struct {
	RFSS          uint8  `json:"rfss"`
	Site          uint8  `json:"site"`
	ChannelID     uint8  `json:"channel_id,omitempty"`
	ChannelNumber uint16 `json:"channel_number,omitempty"`
	// FrequencyHz is the neighbour's control-channel frequency, resolved from
	// (ChannelID, ChannelNumber) against the system band plan at finish. Zero
	// when the band plan doesn't cover the channel (e.g. no IDEN_UP seen yet).
	FrequencyHz uint32 `json:"frequency_hz,omitempty"`
}

NeighborRef is an adjacent site advertised by the control channel.

type Observation

type Observation struct {
	// Protocol is the trunking.Protocol.String() spelling of the decoded CC.
	Protocol string
	// Confidence is the protocol-identification confidence (0..1). When the
	// protocol was supplied by the operator rather than auto-identified, pass
	// 1.0.
	Confidence float64
	// Result is the signal-lab decode of the capture. Lock + Grants + Events
	// are mined for identity and talkgroups.
	Result *siglab.Result
	// FallbackFreqHz is the capture's nominal center frequency, used as the
	// control-channel frequency when the decoder's lock payload doesn't carry
	// one (e.g. a baseband capture). 0 ⇒ no fallback.
	FallbackFreqHz uint32
	// At is the wall-clock time the capture was taken (defaults to now).
	At time.Time
}

Observation is one control-channel decode folded into a DiscoveredSystem. It is the bridge between the signal lab (which produces a siglab.Result per capture) and the discovery model. One Observation typically corresponds to one captured control channel of one site.

type Occupancy added in v0.5.9

type Occupancy = carriers.Occupancy

Occupancy and OccupancyOptions are re-exported from internal/carriers so the sweeper's wideband-occupancy pass keeps the hunt-package surface while the detector lives in the neutral carriers package.

func DetectOccupancy added in v0.5.9

func DetectOccupancy(frame spectrum.Frame, opts OccupancyOptions) []Occupancy

DetectOccupancy finds wideband occupancy spans in a spectrum frame. Thin wrapper over carriers.DetectOccupancy.

type OccupancyOptions added in v0.5.9

type OccupancyOptions = carriers.OccupancyOptions

type OnStep

type OnStep func(centerHz uint32, peaks []Peak)

OnStep, when set, is called once per tuned step with the step center and the peaks found there — used by the live hunt to publish progress.

type Peak

type Peak = carriers.Peak

Peak and PeakOptions are re-exported from internal/carriers so the live sweeper keeps its existing public surface while the peak detector itself lives in the neutral carriers package (shared with the siglab wideband survey). See internal/carriers for the implementation.

func DetectPeaks

func DetectPeaks(frame spectrum.Frame, opts PeakOptions) []Peak

DetectPeaks finds candidate carriers in a spectrum frame. Thin wrapper over carriers.DetectPeaks (kept for the sweeper's call sites).

type PeakOptions

type PeakOptions = carriers.PeakOptions

type RRDiff added in v0.4.2

type RRDiff struct {
	SID               int          `json:"sid"`
	Name              string       `json:"name"`
	FreqOffsets       []FreqOffset `json:"freq_offsets,omitempty"`
	FreqsNotInRR      []uint32     `json:"freqs_not_in_rr,omitempty"`
	TalkgroupsNotInRR []uint32     `json:"talkgroups_not_in_rr,omitempty"`
}

RRDiff is the cross-reference of a discovered system against the matched RadioReference system: frequencies/talkgroups that differ or are new. It upgrades the RR step from "is this a duplicate?" to "what's new or off here?".

func DiffAgainstRR added in v0.4.2

func DiffAgainstRR(sys *DiscoveredSystem, sid int, name string, rrFreqs, rrTGs []uint32) RRDiff

DiffAgainstRR compares the system's discovered control/secondary frequencies and talkgroups against a flattened RadioReference frequency/talkgroup list (the CLI flattens FullSystem into these slices, keeping this package free of the radioreference import). Frequencies are paired greedily by nearest unused RR frequency: an exact pair is silent, a pair within rrFreqToleranceHz is an offset, and anything farther (or unmatched) is "not in RR".

func (*RRDiff) Empty added in v0.4.2

func (d *RRDiff) Empty() bool

Empty reports whether the diff found nothing worth reporting.

type RunState

type RunState string

RunState is the lifecycle of a live hunt run, surfaced to the cockpit/REST.

const (
	StateRunIdle    RunState = "idle"    // no run has started yet
	StateRunActive  RunState = "running" // a sweep/identify run is in progress
	StateRunDone    RunState = "done"    // last run finished and produced a map
	StateRunStopped RunState = "stopped" // last run was cancelled by the operator
	StateRunFailed  RunState = "failed"  // last run errored (e.g. SDR acquisition)
)

type RunStatus

type RunStatus struct {
	RunID      int              `json:"run_id"`
	State      RunState         `json:"state"`
	Running    bool             `json:"running"`
	Mode       string           `json:"mode"` // "hunt" | "survey"
	Progress   LiveHuntProgress `json:"progress"`
	Sites      int              `json:"sites"`
	Talkgroups int              `json:"talkgroups"`
	SystemName string           `json:"system_name,omitempty"`
	// Signals is the classified-carrier inventory of a survey run (nil for a
	// plain hunt). It is the survey's primary result, rendered by the cockpit.
	Signals []DetectedSignal `json:"signals,omitempty"`
	Error   string           `json:"error,omitempty"`

	// GainRecommendations is the per-control-channel result of an -auto-gain run
	// (nil when auto-gain wasn't requested or no CC locked). GainNote explains
	// why auto-gain produced nothing (e.g. the SDR is shared).
	GainRecommendations []GainRecommendation `json:"gain_recommendations,omitempty"`
	GainNote            string               `json:"gain_note,omitempty"`

	StartedAt  time.Time `json:"started_at,omitempty"`
	FinishedAt time.Time `json:"finished_at,omitempty"`
}

RunStatus is the read snapshot the cockpit/REST renders.

type SignalSurvey added in v0.3.7

type SignalSurvey struct {
	StartedAt  time.Time        `json:"started_at"`
	FinishedAt time.Time        `json:"finished_at"`
	Signals    []DetectedSignal `json:"signals"`
	// System is the trunked system accumulated from the trunk-control carriers,
	// or nil when none was found. It is exported exactly like a hunt result.
	System *DiscoveredSystem `json:"system,omitempty"`
}

SignalSurvey is the inventory a live survey produces: every carrier the sweep detected, classified by modulation family and (where the class warranted it) decoded. It is the peer artifact of DiscoveredSystem — where DiscoveredSystem maps one trunked system, SignalSurvey catalogues everything on the air across the swept band(s). When the survey finds a trunking control channel it still folds it into System, so a survey is a strict superset of a hunt: it yields the same system map plus the surrounding signal landscape.

func (*SignalSurvey) Counts added in v0.3.7

func (s *SignalSurvey) Counts() (trunking, analog, paging, other int)

Counts tallies the inventory by broad category for status summaries.

type SurveyFormat added in v0.3.7

type SurveyFormat int

SurveyFormat selects an export encoding for a SignalSurvey inventory. It is separate from Format (which encodes a DiscoveredSystem) because the survey is a distinct artifact — the full classified-carrier list, not just the trunked map.

const (
	// SurveyJSON marshals the whole SignalSurvey (signals + embedded system).
	SurveyJSON SurveyFormat = iota
	// SurveyCSV writes one row per detected signal, for spreadsheets/diffs.
	SurveyCSV
)

func ParseSurveyFormat added in v0.3.7

func ParseSurveyFormat(s string) (SurveyFormat, error)

ParseSurveyFormat maps a CLI/REST value to a SurveyFormat.

func (SurveyFormat) FileExtension added in v0.3.7

func (f SurveyFormat) FileExtension() string

FileExtension is the conventional extension for a survey export.

func (SurveyFormat) String added in v0.3.7

func (f SurveyFormat) String() string

type SweepOptions

type SweepOptions struct {
	Source IQSource
	Bands  []Band
	// FFTSize is the bins per step (power of two). 0 ⇒ 4096.
	FFTSize int
	// SweepDwell is how long to accumulate per step before detecting peaks.
	// Captures one FFT frame per ~10 ms of dwell, averaged. 0 ⇒ one frame.
	SweepDwell time.Duration
	// GuardFrac reserves this fraction of each step's bandwidth at the edges
	// (where the SDR rolloff lives) when advancing the center. 0 ⇒ 0.1.
	GuardFrac float64
	PeakOpts  PeakOptions
	// DetectWideband enables the wideband-occupancy pass: alongside the
	// narrowband peak detector, each step is scanned for contiguous occupancy
	// spans (OFDM cellular/WiFi blocks), and spans clipped at a step edge are
	// stitched across adjacent overlapping steps to recover a signal wider than
	// the tune. Off by default — the whole-device survey turns it on.
	DetectWideband bool
	// OccupancyOpts tune the wideband detector (see carriers.OccupancyOptions).
	// The sweeper supplies the sweep-wide floor itself, so FloorDbFS is ignored.
	OccupancyOpts OccupancyOptions
	// WidebandFullStepClipDb is how far a step's own noise floor must sit above
	// the sweep-wide floor before the whole step counts as occupied (a signal
	// too wide to leave any quiet region in one tune). 0 ⇒ 6 dB.
	WidebandFullStepClipDb float32
	Log                    *slog.Logger
}

SweepOptions configure a Sweeper.

type Sweeper

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

Sweeper walks operator-given bands on an IQSource and returns the candidate carriers it finds. It is the discovery front-end: its candidates feed the identify→decode→accumulate pipeline (see LiveHunter).

func NewSweeper

func NewSweeper(opts SweepOptions) (*Sweeper, error)

NewSweeper validates options and builds a Sweeper.

func (*Sweeper) Sweep

func (s *Sweeper) Sweep(ctx context.Context, onStep OnStep) ([]Candidate, error)

Sweep walks every band and returns the de-duplicated candidate carriers, sorted by descending SNR. ctx cancellation stops the sweep and returns ctx.Err(). onStep may be nil.

type TrunkingRef added in v0.3.7

type TrunkingRef struct {
	Protocol   string  `json:"protocol"`
	Confidence float64 `json:"confidence"`
	Locked     bool    `json:"locked"`
	ControlHz  uint32  `json:"control_hz,omitempty"`
	// Encrypted / EncType mirror the decode's grant encryption onto the
	// inventory row (also copied to DetectedSignal for the flat view).
	Encrypted bool   `json:"encrypted,omitempty"`
	EncType   string `json:"enc_type,omitempty"`
}

TrunkingRef summarises the trunking decode of a carrier the router handed to the siglab identify path. It mirrors the fields of a CaptureReport that are meaningful at the inventory level; the full system map lives in Survey.System.

Jump to

Keyboard shortcuts

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