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 ¶
- Variables
- func Accumulate(dst *DiscoveredSystem, obs Observation)
- func Discover(captures []CaptureInput, cfg DiscoverConfig) (*DiscoveredSystem, []CaptureReport, error)
- func DiscoverWideband(captures []CaptureInput, cfg DiscoverConfig) (*DiscoveredSystem, []CaptureReport, error)
- func LoadSurveyedFreqs(path string) (map[uint32]struct{}, error)
- func RunLiveHunt(ctx context.Context, opts LiveHuntOptions) (*DiscoveredSystem, []CaptureReport, error)
- func RunLiveSurvey(ctx context.Context, opts LiveHuntOptions) (*SignalSurvey, []CaptureReport, error)
- func RunOfflineSurvey(captures []CaptureInput, opts LiveHuntOptions) (*SignalSurvey, []CaptureReport, error)
- func Write(w io.Writer, sys *DiscoveredSystem, f Format, hints []DuplicateHint) error
- func WriteSurvey(w io.Writer, sv *SignalSurvey, f SurveyFormat) error
- func WriteWithRRDiff(w io.Writer, sys *DiscoveredSystem, f Format, hints []DuplicateHint, ...) error
- type Acquirer
- type AutoGainOptions
- type Band
- type BandPlanEntry
- type Candidate
- type CaptureInput
- type CaptureReport
- type DetectedSignal
- type DiscoverConfig
- type DiscoveredChannel
- type DiscoveredSite
- type DiscoveredSystem
- type DiscoveredTalkgroup
- type DuplicateHint
- type FileIQSource
- type Format
- type FreqOffset
- type GainRecommendation
- type GainScore
- type GainSettable
- type IQSource
- type LiveHuntOptions
- type LiveHuntPhase
- type LiveHuntProgress
- type Manager
- func (m *Manager) CaptureSignal(ctx context.Context, freqHz uint32, seconds float64) ([]complex64, uint32, error)
- func (m *Manager) Current() (*DiscoveredSystem, []CaptureReport, bool)
- func (m *Manager) CurrentSurvey() (*SignalSurvey, bool)
- func (m *Manager) Export(w io.Writer, f Format, hints []DuplicateHint) error
- func (m *Manager) ExportRun(id int, w io.Writer, f Format, hints []DuplicateHint) error
- func (m *Manager) ExportSurvey(id int, w io.Writer, f SurveyFormat) error
- func (m *Manager) KnownRun(id int) bool
- func (m *Manager) Run(id int) (*DiscoveredSystem, []CaptureReport, bool)
- func (m *Manager) Start(opts LiveHuntOptions) (int, error)
- func (m *Manager) Status() RunStatus
- func (m *Manager) Stop() bool
- func (m *Manager) SurveyRun(id int) (*SignalSurvey, bool)
- type ManagerOptions
- type NDJSONSink
- type NeighborRef
- type Observation
- type Occupancy
- type OccupancyOptions
- type OnStep
- type Peak
- type PeakOptions
- type RRDiff
- type RunState
- type RunStatus
- type SignalSurvey
- type SurveyFormat
- type SweepOptions
- type Sweeper
- type TrunkingRef
Constants ¶
This section is empty.
Variables ¶
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
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 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 ¶
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) 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 ¶
ParseFormat maps a -formats list value to a Format.
func (Format) FileExtension ¶
FileExtension is the conventional extension for a format's output file.
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 ¶
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 ¶
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
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 ¶
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.
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
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 ¶
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 ¶
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".
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.
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.