Documentation
¶
Overview ¶
Package trunking holds the cross-protocol orchestration: System definitions, control-channel hunting, talkgroup priority, voice grant following, and (later) multi-site neighbor tracking.
Index ¶
- Variables
- func CanPreempt(active Grant, activeTG *TalkGroup, incoming Grant, incomingTG *TalkGroup) bool
- func EffectivePriority(g Grant, tg *TalkGroup) int
- func FormatNetworkReport(r NetworkReport) string
- func IDHex(v uint64) string
- func RenderNeighborLines(t *TopologySnapshot) []string
- func RenderNetworkReport(w io.Writer, r NetworkReport)
- type ActiveCall
- type Affiliation
- type AffiliationResponse
- type AffiliationTracker
- type AffiliationTrackerOptions
- type Cache
- type CachedSystem
- type CallComplete
- type CallEncryption
- type CallEnd
- type CallSegment
- type CallSourceUpdate
- type CallStart
- type ConfiguredSite
- type DMRBandPlan
- type DMRBandPlanTableEntry
- type DMRLinearBandPlan
- type EncryptedMode
- type EndReason
- type Engine
- func (e *Engine) ActiveCalls() []*ActiveCall
- func (e *Engine) Close()
- func (e *Engine) EndCall(deviceSerial string, reason EndReason) bool
- func (e *Engine) EndSyntheticCall(deviceSerial string, reason EndReason) bool
- func (e *Engine) HandleGrant(g Grant)
- func (e *Engine) HandleSyntheticCall(g Grant, deviceSerial string)
- func (e *Engine) ObservedCalls() []*ActiveCall
- func (e *Engine) Patches() []PatchGroup
- func (e *Engine) Run(ctx context.Context) error
- func (e *Engine) ScanMode() ScanMode
- func (e *Engine) SetScanMode(m ScanMode) ScanMode
- func (e *Engine) TalkgroupForDevice(deviceSerial string) *TalkGroup
- func (e *Engine) Touch(deviceSerial string)
- func (e *Engine) UpdateSignal(deviceSerial string, dbfs float64)
- type EngineOptions
- type FrequencyChecker
- type Grant
- type HuntDiagnostics
- type HuntFailed
- type HuntProgress
- type Hunter
- type HunterOptions
- type Location
- type LockResult
- type LockedPayload
- type NetworkReport
- type P25BandPlanEntry
- type P25Phase2Decode
- type Patch
- type PatchGroup
- type PatchRegistry
- type Protocol
- type RID
- type RIDDB
- func (d *RIDDB) Add(r *RID)
- func (d *RIDDB) All() []*RID
- func (d *RIDDB) Delete(id uint32) bool
- func (d *RIDDB) Len() int
- func (d *RIDDB) LoadCSV(r io.Reader) (int, error)
- func (d *RIDDB) LoadCSVFile(path string) (int, error)
- func (d *RIDDB) LoadJSON(r io.Reader) (int, error)
- func (d *RIDDB) LoadJSONFile(path string) (int, error)
- func (d *RIDDB) Lookup(id uint32) *RID
- func (d *RIDDB) UpdateFields(id uint32, fn func(*RID)) bool
- type ReacquireFunc
- type RegistrationResponse
- type ReportBand
- type ReportChannel
- type ReportNeighbor
- type ReportSite
- type ScanMode
- type SiteInfo
- type SiteTracker
- type SiteTrackerOptions
- type SiteUpdate
- type System
- type TalkGroup
- type TalkerAlias
- type TalkgroupDB
- func (d *TalkgroupDB) Add(tg *TalkGroup)
- func (d *TalkgroupDB) All() []*TalkGroup
- func (d *TalkgroupDB) Delete(id uint32) bool
- func (d *TalkgroupDB) Len() int
- func (d *TalkgroupDB) LoadCSV(r io.Reader) (int, error)
- func (d *TalkgroupDB) LoadCSVFile(path string) (int, error)
- func (d *TalkgroupDB) LoadJSON(r io.Reader) (int, error)
- func (d *TalkgroupDB) Lookup(id uint32) *TalkGroup
- func (d *TalkgroupDB) UpdateFields(id uint32, fn func(*TalkGroup)) bool
- type TopoBandPlanSlot
- type TopoChannelRef
- type TopoNeighborRef
- type TopologyProvider
- type TopologySnapshot
- type Tuner
- type UnitActivity
- type UnitRegistration
- type UnitToUnitRequest
- type VoiceDevice
- type VoicePool
- func (p *VoicePool) Active() []*ActiveCall
- func (p *VoicePool) ArmEncryptedRelease(serial string, at time.Time)
- func (p *VoicePool) ArmedCallBySource(system string, sourceID uint32) *ActiveCall
- func (p *VoicePool) Bind(d *VoiceDevice, g Grant, tg *TalkGroup, now time.Time) (*ActiveCall, error)
- func (p *VoicePool) Devices() []*VoiceDevice
- func (p *VoicePool) DisarmEncryptedRelease(serial string)
- func (p *VoicePool) EncryptedReleasesDue(now time.Time) []*ActiveCall
- func (p *VoicePool) FindFree() *VoiceDevice
- func (p *VoicePool) FindFreeForFrequency(hz uint32) *VoiceDevice
- func (p *VoicePool) HasCapableDevice(hz uint32) bool
- func (p *VoicePool) LowestPriorityActive() *ActiveCall
- func (p *VoicePool) LowestPriorityActiveForFrequency(hz uint32) *ActiveCall
- func (p *VoicePool) Release(serial string) *ActiveCall
- func (p *VoicePool) Retune(serial string, g Grant, now time.Time) error
- func (p *VoicePool) SetReacquire(fn ReacquireFunc)
- func (p *VoicePool) Touch(serial string, now time.Time)
- func (p *VoicePool) UpdateEncryption(serial string, algID uint8, keyID uint16) (Grant, bool)
- func (p *VoicePool) UpdateSignal(serial string, dbfs float64)
- func (p *VoicePool) UpdateSource(serial string, sourceID uint32, encrypted bool) (Grant, bool)
Constants ¶
This section is empty.
Variables ¶
var ErrNoControlChannel = errors.New("trunking/hunter: no control channel found")
ErrNoControlChannel is returned when every candidate frequency exhausts its dwell window without locking.
Functions ¶
func CanPreempt ¶
CanPreempt reports whether a new grant should kick an active call off a Voice device. The rule is strict-higher: equal priority does NOT preempt (so a stable call holds the device against same-priority grants).
Returns false if the new grant is locked out by talkgroup policy — the engine handles lockout earlier in the dispatch path, but the predicate is defensive here so callers can compose freely.
func EffectivePriority ¶
EffectivePriority returns the runtime priority used by the engine when comparing grants. Lower number = higher priority.
func FormatNetworkReport ¶ added in v0.5.0
func FormatNetworkReport(r NetworkReport) string
FormatNetworkReport renders r to a string.
func IDHex ¶ added in v0.5.1
IDHex renders a P25 identity number (WACN, System ID, NAC, RFSS, Site) as an uppercase hex string with no "0x" prefix — the convention these are quoted in the field, e.g. System ID 706 as "2C2". Returns "" for zero so it drops out of omitempty JSON fields (an unknown/absent identity carries no hex).
func RenderNeighborLines ¶ added in v0.5.3
func RenderNeighborLines(t *TopologySnapshot) []string
RenderNeighborLines returns one line per adjacent site in a topology snapshot, each like "RFSS:01[1] SITE:07[7] CHANNEL:2-1754 DOWNLINK:450.962500 MHz UPLINK:461.437500 MHz", sorted by (RFSS, Site). It reuses the same band-plan resolution and channel formatting as the full network report. The slice is empty when the snapshot is nil or carries no neighbours. The decoded-message log uses it to surface adjacent sites the way SDRtrunk's "Neighbor Sites" block does, without re-deriving uplink frequencies.
func RenderNetworkReport ¶ added in v0.5.0
func RenderNetworkReport(w io.Writer, r NetworkReport)
RenderNetworkReport writes the GopherTrunk-native network-configuration report to w. Empty sections are suppressed; sites, neighbours and bands are emitted in a stable order so the output is golden-test friendly.
Types ¶
type ActiveCall ¶
type ActiveCall struct {
Device *VoiceDevice
Grant Grant
Talkgroup *TalkGroup
StartedAt time.Time
LastHeardAt time.Time
// EncReleaseAt is the deadline at which the encrypted-call-handling
// policy (trunking.encrypted_calls mode: metadata) releases this
// voice SDR. Zero means unarmed — either the call is clear, the mode
// is follow/ignore, or the operator holds a decryption key for it.
// Armed by ArmEncryptedRelease, cleared by DisarmEncryptedRelease,
// and reaped by the engine watchdog via EncryptedReleasesDue. Issue
// #711.
EncReleaseAt time.Time
// SignalDbFS is the call's mean received channel power in dBFS,
// measured by the voice composer over the call's baseband IQ and
// stamped in via UpdateSignal shortly before end-of-call. nil until
// measured — calls ended by non-composer paths (watchdog timeout,
// preemption, shutdown) leave it nil. See composer.boundaryTracker
// for the semantics (channel power, not calibrated RSSI or SNR).
SignalDbFS *float64
}
ActiveCall describes a grant currently being followed on a specific Voice device. The engine creates these via VoicePool.Bind.
type Affiliation ¶ added in v0.1.7
type Affiliation struct {
System string // System name, matches trunking.System.Name
Protocol string // "p25" / "dmr" / "nxdn"
SourceID uint32 // radio unit being affiliated
GroupID uint32 // talkgroup the unit is joining
AnnouncementGroup uint32 // optional announcement-group association (0 if unused)
Response AffiliationResponse // accepted / failed / denied / refused
// RFSSID and SiteID identify the P25 site that handled this
// affiliation, decoded from the control channel's RFSS Status
// Broadcast. Unlike a grant (announced on every site participating
// in a wide-area call), an affiliation is handled by the radio's
// actual serving site — so these are a genuine RID→site location
// fix for mobility tracking (issue #698). NAC is the control
// channel's Network Access Code (Phase 2: the NSB Color Code); it
// is not unique per site, so consumers must key on (RFSSID, SiteID).
// All three stay zero on non-P25 affiliations and until the site's
// RFSS Status / Network Status broadcast has been decoded.
RFSSID uint8
SiteID uint8
NAC uint16
At time.Time
}
Affiliation is published on the events bus when a radio unit affiliates with a talkgroup. Emitted by P25 control-channel decoders on opcode 0x28 (Group Affiliation Response).
type AffiliationResponse ¶ added in v0.1.7
type AffiliationResponse uint8
AffiliationResponse encodes the P25 Group Affiliation Response value (TIA-102.AABF Table 7-37). The integer values are wire constants — do not renumber.
const ( AffiliationAccepted AffiliationResponse = 0 AffiliationFailed AffiliationResponse = 1 AffiliationDenied AffiliationResponse = 2 AffiliationRefused AffiliationResponse = 3 )
func (AffiliationResponse) String ¶ added in v0.1.7
func (r AffiliationResponse) String() string
type AffiliationTracker ¶ added in v0.1.9
type AffiliationTracker struct {
// contains filtered or unexported fields
}
AffiliationTracker maintains a live, protocol-agnostic table of which radio units are active on which talkgroups. It subscribes to the events bus and updates the table from three sources:
- KindGrant — the grant's SourceID is transmitting on its GroupID.
- KindAffiliation — an explicit decoded affiliation message.
- KindUnitRegistration — the unit registered on a site.
Because grants carry SourceID + GroupID for every protocol, the tracker works uniformly across P25, DMR (all tiers and vendors), NXDN and the rest — no per-protocol decoding is required. Entries expire after a configurable idle TTL.
func NewAffiliationTracker ¶ added in v0.1.9
func NewAffiliationTracker(opts AffiliationTrackerOptions) (*AffiliationTracker, error)
NewAffiliationTracker validates opts and returns a tracker that has already subscribed to the bus.
func (*AffiliationTracker) Close ¶ added in v0.1.9
func (t *AffiliationTracker) Close() error
Close releases the bus subscription and waits for Run to drain.
func (*AffiliationTracker) Len ¶ added in v0.1.9
func (t *AffiliationTracker) Len() int
Len returns the number of tracked units.
func (*AffiliationTracker) Run ¶ added in v0.1.9
func (t *AffiliationTracker) Run(ctx context.Context) error
Run drains grant / affiliation / registration events and sweeps expired units until ctx cancels or the bus closes.
func (*AffiliationTracker) Snapshot ¶ added in v0.1.9
func (t *AffiliationTracker) Snapshot() []UnitActivity
Snapshot returns every tracked unit, most-recently-seen first.
func (*AffiliationTracker) UnitsOnTalkgroup ¶ added in v0.1.9
func (t *AffiliationTracker) UnitsOnTalkgroup(talkgroup uint32) []uint32
UnitsOnTalkgroup returns the radio IDs currently associated with the given talkgroup.
type AffiliationTrackerOptions ¶ added in v0.1.9
type AffiliationTrackerOptions struct {
Bus *events.Bus
// TTL is how long a unit stays in the table after it was last
// seen. Default 30 minutes.
TTL time.Duration
// Now is injectable for tests; defaults to time.Now.
Now func() time.Time
}
AffiliationTrackerOptions configure a tracker.
type Cache ¶
type Cache struct {
// contains filtered or unexported fields
}
Cache persists the last-known control-channel frequency per system to a JSON file. The hunter consults this cache on startup so it can re-tune to a known-good CC before scanning the full frequency list.
func OpenCache ¶
OpenCache loads (or creates) a cache file at path. A non-existent file is treated as an empty cache.
func (*Cache) Get ¶
func (c *Cache) Get(name string) (CachedSystem, bool)
Get returns the cached entry for the named system, if any.
type CachedSystem ¶
type CachedSystem struct {
LastFrequencyHz uint32 `json:"last_frequency_hz"`
LastLockAt time.Time `json:"last_lock_at,omitempty"`
NAC uint16 `json:"nac,omitempty"`
}
CachedSystem is the on-disk record for one system.
type CallComplete ¶ added in v0.1.9
type CallComplete struct {
Grant Grant
Talkgroup *TalkGroup
DeviceSerial string
StartedAt time.Time
EndedAt time.Time
Reason EndReason
AudioPath string
SampleRate uint32
}
CallComplete is the payload of an events.KindCallComplete event. The recorder publishes it once a call's WAV has been flushed and closed, so the outbound-streaming subsystem (internal/broadcast) can read the finished file and upload it to call aggregators. AudioPath is the absolute or working-directory-relative path to the .wav the recorder wrote; SampleRate is its PCM rate in Hz.
func (CallComplete) Duration ¶ added in v0.1.9
func (c CallComplete) Duration() time.Duration
Duration returns how long the call ran.
type CallEncryption ¶ added in v0.2.2
type CallEncryption struct {
DeviceSerial string
System string // filled in by the engine on republish
Protocol string
GroupID uint32
AlgorithmID uint8
KeyID uint16
MessageIndicator [9]byte
At time.Time
}
CallEncryption is the payload of an events.KindCallEncryption event. It is published by the voice composer when an in-call Encryption Sync is recovered (P25 Phase 1 LDU2 carries it; the grant TSBK has only the encrypted flag). DeviceSerial keys the update to a specific active call; the engine backfills the bound ActiveCall.Grant's AlgorithmID / KeyID and republishes the event with System / Protocol / GroupID populated so SSE + TUI consumers can patch their live view without re-deriving the call's identity.
MessageIndicator carries the 72-bit per-call cryptographic sync vector — not surfaced in any DTO today, but plumbed through for future key-discovery tooling.
type CallEnd ¶
type CallEnd struct {
Grant Grant
Talkgroup *TalkGroup
DeviceSerial string
StartedAt time.Time
EndedAt time.Time
Reason EndReason
// SignalDbFS is the call's mean received channel power in dBFS,
// measured by the voice composer over the call's baseband IQ. nil when
// no measurement was taken (calls ended by the watchdog, preemption, or
// shutdown, or decoded outside the composer). It is a channel-power /
// RSSI-style figure — NOT calibrated absolute RSSI and NOT SNR/EVM.
SignalDbFS *float64
}
CallEnd is the payload of an events.KindCallEnd event.
type CallSegment ¶ added in v0.3.6
CallSegment is the payload of an events.KindCallSegment event. The voice composer publishes it at an end-of-transmission boundary when per-transmission recording is enabled, so the recorder closes the current file and starts a fresh one for the next over. At marks the boundary instant; the recorder uses it as the new segment's start timestamp.
type CallSourceUpdate ¶ added in v0.2.5
type CallSourceUpdate struct {
DeviceSerial string
System string // filled in by the engine on republish
Protocol string
GroupID uint32 // filled in by the engine on republish from the bound Grant
SourceID uint32
Encrypted bool
At time.Time
}
CallSourceUpdate is the payload of an events.KindCallSourceUpdate event. The voice composer publishes one when it recovers the source radio ID + encryption state from in-call traffic-channel signalling — e.g. a P25 Phase 2 GROUP_VOICE_CHANNEL_USER PDU where the CC grant arrived in a compressed form without those fields (src=0 + enc=false). The engine subscribes, backfills the bound ActiveCall.Grant.SourceID + .Encrypted via the voice pool, and republishes the event with System / Protocol / GroupID populated so SSE + TUI consumers can patch their live view. DeviceSerial keys the update to a specific active call.
type CallStart ¶
type CallStart struct {
Grant Grant
Talkgroup *TalkGroup // resolved via the engine's TalkgroupDB; nil if unknown
DeviceSerial string // which Voice SDR is following the call
StartedAt time.Time
}
CallStart is the payload of an events.KindCallStart event. The engine publishes this once a Voice device has been retuned to the grant's frequency; downstream pipelines (the demod composer, the recorder) subscribe and start consuming IQ.
type ConfiguredSite ¶ added in v0.4.6
ConfiguredSite is an operator-supplied name for a P25 site, keyed by its RFSS and Site IDs. It carries no decoding role — GET /api/v1/sites merges the Name onto the matching discovered site (issue #698).
type DMRBandPlan ¶ added in v0.3.2
type DMRBandPlan struct {
Linear *DMRLinearBandPlan // regular base+spacing grid
Table []DMRBandPlanTableEntry // irregular hand-coded LCN→Hz map
}
DMRBandPlan maps the 12-bit Logical (Physical) Channel Number (LCN) carried in a DMR Tier III voice-grant CSBK to its downlink frequency. T3 grants reference a channel by LCN, never an absolute frequency, so the decoder cannot follow a voice call without this plan — see internal/radio/dmr/tier3 (Resolver / tier3.ResolverFromPlan). The trunking package keeps its own LCN→Hz shape (rather than importing tier3) the same way P25BandPlanEntry mirrors phase1.IdentifierUpdate. Exactly one of Linear or Table is populated (enforced by config validation).
type DMRBandPlanTableEntry ¶ added in v0.3.2
DMRBandPlanTableEntry is one explicit LCN→downlink-frequency mapping for sites whose channels don't fall on a regular grid.
type DMRLinearBandPlan ¶ added in v0.3.2
DMRLinearBandPlan lays channels out on a regular grid: freq = BaseHz + (LCN - Offset) × SpacingHz. Offset lets sites that number LCNs from 1 (the common case) pin BaseHz to the channel-1 downlink — set Offset=1 there.
type EncryptedMode ¶ added in v0.4.6
type EncryptedMode uint8
EncryptedMode controls how Engine.HandleGrant and the in-call encryption handlers treat calls discovered to be encrypted (a P25 ALGID other than clear, or a DMR/grant Encrypted flag).
- EncryptedFollow (default): hold a voice SDR for the full duration of an encrypted call, exactly like a clear call. This is the backwards-compatible behaviour — pre-existing configs see no change because it is the zero value.
- EncryptedMetadata: follow the call briefly so the traffic channel's metadata (P25 Phase 2 MAC PDUs — talker alias, source RID, encryption sync) can be captured, then release the voice SDR a configurable window (metadata_follow_ms) after the call is known to be encrypted. Frees scarce voice tuners while preserving alias / RID discovery.
- EncryptedIgnore: never tie up a voice SDR on an encrypted call — drop the grant before allocation when encryption is known up front, and release the tuner the moment encryption is discovered mid-call.
In every mode a call the operator has a configured decryption key for (a trunking.systems[].encryption_keys entry whose key_id matches the call) is exempt and always followed — see Engine.keyConfigured.
const ( EncryptedFollow EncryptedMode = iota EncryptedMetadata EncryptedIgnore )
func ParseEncryptedMode ¶ added in v0.4.6
func ParseEncryptedMode(s string) EncryptedMode
ParseEncryptedMode is the inverse of String. Empty string maps to the safe default (follow = current behaviour); unknown values also map to follow so a typo in config never silently stops following calls.
func (EncryptedMode) String ¶ added in v0.4.6
func (m EncryptedMode) String() string
String renders the wire form used by config + REST + TUI clients.
type EndReason ¶
type EndReason uint8
EndReason classifies why a call ended; carried in CallEnd events so the API layer can surface the cause to UIs.
const ( EndReasonUnknown EndReason = iota // EndReasonNormal is the carrier-drop natural end: either the CC // announced a channel release / talk-off, or — far more common // on P25 where no such announcement is ever sent — the watchdog // reaped a call whose Touch advanced past StartedAt (frames were // decoded and then the transmitter stopped). Operator-visible // meaning: the call ended cleanly, no decode problem. EndReasonNormal // EndReasonTimeout is the silent-from-start decode failure: the // watchdog reaped a call whose LastHeardAt never moved past // StartedAt — not a single LDU / voice subframe was delivered. // This is the real failure mode (wrong demod mode, gain too low, // LSM site decoded as C4FM, etc.) — distinct from EndReasonNormal // above, which fires when the radio simply stopped transmitting. EndReasonTimeout EndReasonPreempted // higher-priority grant kicked us off EndReasonLockout // talkgroup is locked out by policy EndReasonNoVoiceSDR // every Voice-role SDR was busy EndReasonError EndReasonManual // operator ended the call via API / TUI // EndReasonEncrypted is the encrypted-call-handling teardown: the // trunking.encrypted_calls policy released the voice SDR because the // call was discovered to be encrypted — either immediately // (mode: ignore) or after the metadata-follow window (mode: // metadata). Distinct from the reasons above so operators can see a // tuner was freed by policy, not by a decode problem or a higher- // priority preemption. Issue #711. EndReasonEncrypted )
type Engine ¶
type Engine struct {
// contains filtered or unexported fields
}
Engine is the central trunking state machine. It subscribes to events.KindGrant, looks up the talkgroup, dispatches to the voice pool (preempting lower-priority active calls when necessary), and emits events.KindCallStart / events.KindCallEnd.
The engine deliberately knows nothing about the demod pipeline — it just tunes Voice devices and publishes structured events. Downstream consumers (the voice composer + recorder, the SQLite call log) subscribe to the CallStart / CallEnd events to do their work.
func NewEngine ¶
func NewEngine(opts EngineOptions) (*Engine, error)
NewEngine validates opts and returns a ready-to-Run engine.
func (*Engine) ActiveCalls ¶
func (e *Engine) ActiveCalls() []*ActiveCall
ActiveCalls returns a snapshot of every active call — trunked calls allocated through the voice pool plus synthetic calls owned by external scanners (the conventional FM scanner publishes these through HandleSyntheticCall).
func (*Engine) Close ¶
func (e *Engine) Close()
Close releases the engine's subscription. Safe to call concurrently with Run; idempotent on repeat calls. Subscription.Close is itself idempotent so we don't need to nil the field — that nil-write was previously a race with Run's read of e.sub.C.
func (*Engine) EndCall ¶
EndCall is the explicit external signal that a call has ended (e.g. the protocol decoder saw a channel-release announcement, or an upstream test wants to release the device). reason is published in the CallEnd event payload.
func (*Engine) EndSyntheticCall ¶
EndSyntheticCall is the conventional scanner's "carrier dropped" signal. Publishes CallEnd and forgets the call. Returns false if the engine has no synthetic call bound to deviceSerial.
func (*Engine) HandleGrant ¶
func (*Engine) HandleSyntheticCall ¶
HandleSyntheticCall registers a call originated by a non-trunked source (the conventional FM scanner is the canonical example) that already owns its SDR — no VoicePool binding, no re-tune, no preemption logic. The engine publishes CallStart and adds the call to ActiveCalls() so the API + TUI surfaces light up like any other call. Pair with EndSyntheticCall to release.
deviceSerial must be unique across the daemon's call set so the recorder can route WritePCM samples to the right WAV.
func (*Engine) ObservedCalls ¶ added in v0.5.6
func (e *Engine) ObservedCalls() []*ActiveCall
ObservedCalls returns the voice calls the control channel has announced that are NOT currently being followed by a voice tuner — e.g. talkgroups that keyed up while every voice device was busy. They carry a nil Device. Combined with ActiveCalls at the API layer, they let an operator see every talkgroup up on the system (the P25 "only one active talkgroup" report), while audio stays limited to the number of voice tuners. Entries age out via runWatchdog.
func (*Engine) Patches ¶ added in v0.1.8
func (e *Engine) Patches() []PatchGroup
Patches returns a snapshot of the engine's active patch groups.
func (*Engine) Run ¶
Run drains grant events from the bus and runs the watchdog until ctx cancels. Returns ctx.Err(). Run does NOT close the engine's subscription; call Close when you're done with the engine.
func (*Engine) ScanMode ¶
ScanMode returns the engine's current scan mode. Safe to call from any goroutine.
func (*Engine) SetScanMode ¶
SetScanMode swaps the engine's scan mode at runtime — the API cockpit calls this when the operator flips the global scan_mode from the TUI. Returns the previous mode so the caller can log / audit the change.
func (*Engine) TalkgroupForDevice ¶ added in v0.1.9
TalkgroupForDevice returns the talkgroup of the active call bound to deviceSerial, or nil when no call is active on that device. The live audio path uses it to honour the per-talkgroup Mute flag. Safe to call from any goroutine.
func (*Engine) Touch ¶
Touch refreshes the LastHeardAt timestamp on the active call bound to deviceSerial. The protocol decoder calls this every time it sees voice activity on the followed frequency so the watchdog doesn't time it out.
func (*Engine) UpdateSignal ¶ added in v0.6.6
UpdateSignal stamps the call's measured received channel power (dBFS) onto the active call bound to deviceSerial. The voice composer calls it once at end-of-call, just before EndCall, so endCall carries the value into the published CallEnd. Covers both pool-bound trunked calls and synthetic (conventional-FM) calls.
type EngineOptions ¶
type EngineOptions struct {
Bus *events.Bus
Log *slog.Logger
VoicePool *VoicePool
Talkgroups *TalkgroupDB
// CallTimeout is how long a call can run without a Touch before
// the watchdog reaps it. Default 30 s. The end reason depends on
// whether the call ever decoded frames: EndReasonNormal when
// frames arrived and the carrier later dropped (P25's natural
// end-of-call mechanism, since the CC has no explicit channel
// release); EndReasonTimeout when no frames ever arrived (silent
// decode failure).
CallTimeout time.Duration
// Now is injectable for tests; defaults to time.Now.
Now func() time.Time
// ScanMode controls whether HandleGrant respects the per-talkgroup
// Scan flag. Default ScanModeAll keeps every non-locked-out grant
// flowing through; ScanModeList enforces the talkgroup scan list.
ScanMode ScanMode
// EncryptedModes maps system name -> how encrypted calls on that
// system are handled. EncryptedFollow (the default for a system absent
// from the map) holds a voice SDR for the full call (legacy
// behaviour); EncryptedMetadata follows briefly then releases;
// EncryptedIgnore never allocates a voice SDR to an encrypted call.
// Per-system (trunking.systems[].encrypted_calls). Issue #711.
EncryptedModes map[string]EncryptedMode
// EncryptedFollows maps system name -> how long an encrypted call on
// that system is followed under EncryptedMetadata before its voice SDR
// is released, measured from when the call is first known to be
// encrypted. A system absent from the map (or mapped to <= 0) uses
// defaultEncryptedMetadataFollow (1.5 s). Issue #711.
EncryptedFollows map[string]time.Duration
// ConfiguredKeys maps system name -> set of configured encryption key
// IDs. Calls whose KeyID matches are exempt from the encrypted-call
// policy (always followed). nil disables the exemption. Issue #711.
ConfiguredKeys map[string]map[uint16]bool
}
EngineOptions configure a new Engine.
type FrequencyChecker ¶ added in v0.2.4
FrequencyChecker is implemented by Tuners that can serve only a limited range of centre frequencies — e.g. a virtual voice tuner backed by a wideband DDC tap can only follow grants inside the wideband dongle's IQ window. FindFreeForFrequency consults this interface to skip incapable tuners; physical SDRs that don't implement it are treated as universally tunable.
type Grant ¶
type Grant struct {
System string // System name, matches trunking.System.Name
Protocol string // "p25" / "dmr" / "nxdn"
GroupID uint32 // talkgroup or destination subscriber address
SourceID uint32 // originator (subscriber unit)
FrequencyHz uint32 // voice channel frequency
ChannelID uint8 // raw channel ID (P25 band-plan ID, DMR LCN high)
ChannelNum uint16 // raw channel number within the ID
// RFSSID and SiteID identify the P25 site whose control channel
// granted the call, decoded from the site's RFSS Status Broadcast
// (TSBK 0x3A) and accumulated in the control channel's NetworkModel.
// They let downstream consumers (Prometheus exporters, dashboards)
// label a grant by site instead of resolving the opaque ChannelID
// against a manual lookup table (issue #698). Both stay zero on
// non-P25 grants and until the first RFSS Status TSBK has landed.
RFSSID uint8
SiteID uint8
// NAC is the control channel's Network Access Code (Phase 2: the
// Network Status Broadcast Color Code, equal to the Phase 1 NAC per
// spec). It is a coarse sanity-check field — not unique per site —
// so site labelling must key on (RFSSID, SiteID) (issue #698). Zero
// on non-P25 grants.
NAC uint16
// Timeslot identifies the TDMA logical channel a call occupies on
// its carrier, 1-based: 0 = not applicable / unknown (P25 Phase 1,
// NXDN, analog — frequency alone identifies the call), 1 = TS1,
// 2 = TS2. DMR Tier III carries two independent calls on one
// 12.5 kHz carrier (TS1 + TS2), so the engine treats
// (FrequencyHz, Timeslot) as the call identity rather than
// frequency alone. Populated by the protocol layer; the DMR CSBK
// parser maps its 0-based slot bit (0 = TS1, 1 = TS2) onto this
// 1-based convention so 0 stays reserved for "no slot".
Timeslot uint8
Encrypted bool
Emergency bool
// DMRInterleavedVoice mirrors the system-level
// trunking.System.DMRInterleavedVoice opt-in onto the grant so the
// voice composer selects the 2-slot interleaved decoder and routes
// this call to its timeslot by matching the embedded Link Control's
// talkgroup to GroupID. Set by the DMR Tier III control channel;
// false (the default) keeps the single-slot decoder. Ignored for
// non-DMR grants.
DMRInterleavedVoice bool
// AlgorithmID and KeyID carry the encryption parameters the
// protocol's privacy header advertises (the DMR PI header, etc.).
// They are meaningful only when Encrypted is true and stay zero
// until a privacy header has been parsed. Persisted to the call
// log so an operator can see which key a recorded call needs.
AlgorithmID uint8
KeyID uint16
DataCall bool // false = voice call (default)
// Individual marks a grant whose GroupID is NOT a talkgroup but an
// individual destination — a unit-to-unit target radio (WUID), a
// telephone-interconnect target, or an SNDCP data unit. P25 group
// talkgroups are 16-bit; these targets are 24-bit subscriber addresses,
// so recording them as "talkgroups" produces phantom >16-bit entries
// (e.g. a unit-to-unit call surfacing as bogus TG 140957). Discovery /
// hunt skips individual grants when building the talkgroup list. False
// (the default) for group calls and Motorola patch grants, which ARE
// talkgroups.
Individual bool
// ProVoice marks the grant as an EDACS ProVoice (digital) call. The
// vocoder is patent + trade-secret encumbered so we cannot ship a
// built-in decoder; the recorder treats this flag as a directive to
// emit a `.raw` frame sidecar regardless of its global WriteRaw
// setting, so researchers can decode out-of-band.
ProVoice bool
// PatchedGroups, when non-empty, lists the member talkgroups of a
// patch / dynamic-regroup super-group: the call on GroupID is
// physically the shared traffic of these groups. The engine fills
// it from its PatchRegistry so the call can be attributed to every
// member. Empty for an ordinary (non-patched) grant.
PatchedGroups []uint32
// P25Phase1DemodMode mirrors the system-level
// trunking.System.P25Phase1DemodMode setting so the voice composer
// can pick the matching symbol-recovery path on grants for the
// system (C4FM vs CQPSK / LSM). The control-channel decoder
// already honours the setting via the ccdecoder connector; without
// this field every voice grant landed in a hardcoded C4FM voice
// receiver and never decoded on LSM-modulated simulcast sites
// (issue #356 follow-up). Populated by the protocol layer when it
// publishes the grant; ignored for non-P25-Phase-1 grants.
P25Phase1DemodMode string
// P25Phase2Decode carries the per-channel FEC parameters the voice
// composer's Phase 2 chain needs to decode MAC PDUs that
// interleave with voice subframes on the traffic channel (talker
// alias, in-call signalling). Populated by the Phase 2 control
// channel when publishing the grant; zero on non-Phase-2 grants.
P25Phase2Decode P25Phase2Decode
At time.Time
// CallID is a process-monotonic identifier the voice pool assigns when
// it binds a device to this grant (VoicePool.Bind); a real handoff
// (VoicePool.Retune) preserves it so a followed call keeps one identity.
// It lets the downstream voice chain tag each decoded frame with the
// call it belongs to and the recorder reject a frame whose CallID
// doesn't match the open session — closing the cross-call audio-bleed
// window when a voice-tap serial is immediately reused for the next
// call. Zero on grants that never went through the voice pool (synthetic
// / conventional follows); a zero-vs-zero match is a no-op, preserving
// prior behaviour.
CallID uint64
}
Grant is the protocol-agnostic voice-channel grant payload published on the events bus by P25/DMR/NXDN control-channel decoders. The trunking engine subscribes to events of kind events.KindGrant and dispatches them through the priority + voice-device pool.
FrequencyHz must be filled in by the protocol layer (P25 derives it from IdentifierUpdate band-plan TSBKs, DMR/NXDN from the configured System). If FrequencyHz is zero, the engine logs and drops the grant.
type HuntDiagnostics ¶ added in v0.3.1
type HuntDiagnostics struct {
// IQObserved is true when the decoder saw at least one IQ sample
// for this system during the hunt. False is the strongest single
// signal that the SDR isn't streaming at all.
IQObserved bool `json:"iq_observed"`
// IQSamples is the cumulative raw IQ sample count the decoder
// processed for this system since it became the active pipeline.
IQSamples int64 `json:"iq_samples"`
// IQPowerDbFS is the last window-averaged IQ power in dBFS. Very
// low (≲ -55) points at antenna / gain / USB; near 0 with a high
// clip ratio is front-end overload.
IQPowerDbFS float64 `json:"iq_power_dbfs"`
// IQClipRatio is the last window's fraction of ADC-rail-clipped
// samples — front-end overload when sustained (≳ 0.002).
IQClipRatio float64 `json:"iq_clip_ratio"`
// IQDCRatioDb is the last window's DC-bin power relative to total
// channel power, in dB — an on-channel DC spike when ≳ -10.
IQDCRatioDb float64 `json:"iq_dc_ratio_db"`
// PipelineActive is true when a live decode pipeline for the
// system's protocol was running during the hunt. (A protocol with
// no wired pipeline sits in state=hunting rather than failing, so
// this is rarely false on a real failure.)
PipelineActive bool `json:"pipeline_active"`
// Diagnosis is a one-line, human-readable best guess at the cause,
// derived from the fields above. Empty when no decoder is wired.
Diagnosis string `json:"diagnosis,omitempty"`
}
HuntDiagnostics is an optional snapshot of the control SDR's IQ health at the moment a hunt round failed. The cchunt supervisor fills it from the live ccdecoder (internal/scanner/ccdecoder) so `cchunt.failed` answers the operator's first question — distinguishing a silent SDR (USB/driver binding, dead handle, no antenna) from front-end overload, an on-channel DC spike, or a clean signal that simply never carried a decodable control channel. All fields are best-effort; the zero value means "no decoder wired".
type HuntFailed ¶
type HuntFailed struct {
System string `json:"system"`
At time.Time `json:"at"`
BackoffMs int `json:"backoff_ms"`
// Diagnostics is a best-effort IQ-health snapshot captured when the
// hunt failed, so a `cchunt.failed` event/log explains *why* without
// a round-trip to the operator. Zero value (and an empty Diagnosis)
// when no decoder is wired to the supervisor — e.g. in unit tests.
Diagnostics HuntDiagnostics `json:"diagnostics"`
}
HuntFailed is the payload for events.KindHuntFailed — published when a system's CC candidate list exhausts without locking. BackoffMs is the supervisor's next sleep window so the TUI can show "retry in 5 s".
type HuntProgress ¶
type HuntProgress struct {
System string `json:"system"`
AttemptedFreqHz uint32 `json:"attempted_freq_hz"`
AttemptIndex int `json:"attempt_index"`
TotalCandidates int `json:"total_candidates"`
At time.Time `json:"at"`
}
HuntProgress is the payload published with events.KindHuntProgress. One event fires per CC candidate the hunter tries; the TUI uses AttemptIndex / TotalCandidates to render a position indicator.
type Hunter ¶
type Hunter struct {
// contains filtered or unexported fields
}
Hunter scans a System's candidate control channels and parks on the first frequency that produces a matching cc.locked event within the per-frequency dwell timeout.
The hunter is intentionally protocol-agnostic at the wiring level: it retunes the SDR and watches the events.Bus. The downstream demod pipeline (channelizer + C4FM/H-DQPSK demod + protocol decoder) publishes cc.locked events; the hunter parks on the first match.
func NewHunter ¶
func NewHunter(o HunterOptions) (*Hunter, error)
func (*Hunter) Hunt ¶
func (h *Hunter) Hunt(ctx context.Context) (LockResult, error)
Hunt scans the candidate frequencies until either a CC locks (success) or ctx cancels (returns ctx.Err()) or the candidate list is exhausted (returns ErrNoControlChannel).
On success the locked frequency and NAC are persisted to the cache and returned to the caller.
type HunterOptions ¶
type HunterOptions struct {
System System
Tuner Tuner
Bus *events.Bus
Cache *Cache
Log *slog.Logger
// Dwell is how long to wait on each candidate before giving up.
// Defaults to 3 seconds.
Dwell time.Duration
}
HunterOptions configure a Hunter at construction.
type Location ¶ added in v0.1.9
type Location struct {
System string // trunking system name
Protocol string // "p25", "dmr", ...
RadioID uint32 // reporting subscriber unit
Talkgroup uint32 // associated talkgroup; 0 when not call-associated
Latitude float64 // decimal degrees, positive north
Longitude float64 // decimal degrees, positive east
SpeedKnots float64 // 0 when not reported
HeadingDeg float64 // 0 when not reported
At time.Time
}
Location is the payload of an events.KindLocation event: a geographic fix a subscriber unit reported over the air. P25 Motorola Unit GPS, P25 L3Harris Talker GPS, and DMR LRRP all ultimately resolve to one of these. Latitude is positive north, Longitude positive east, both in decimal degrees.
The storage layer persists every Location to the location_log table and the API surfaces recent fixes for map display.
type LockResult ¶
LockResult is returned by a successful Hunt.
func (LockResult) String ¶
func (r LockResult) String() string
String renders a one-line summary of a LockResult for logs.
type LockedPayload ¶
LockedPayload is the protocol-neutral shape the hunter expects on CCLocked events. Each radio package's LockState satisfies it via methods, so the hunter can stay protocol-agnostic instead of importing every radio package (which would also create import cycles, since some radio packages now import this `trunking` package to publish `Grant` events).
type NetworkReport ¶ added in v0.5.0
type NetworkReport struct {
Name string // system name/label ("" → "(unnamed)")
Protocol string // "p25" etc.; uppercased in the header
// Network identity.
WACN uint32
SystemID uint32
NAC uint16
LRA uint8
Sites []ReportSite // 1 for a single control channel; N for an aggregated hunt
Bands []ReportBand // frequency bands (band plan), system-wide
}
NetworkReport is the display-ready, protocol-neutral input to RenderNetworkReport — GopherTrunk's equivalent of SDRtrunk's P25 network configuration / activity summary. It is deliberately separate from TopologySnapshot (a wire/JSON type with an Empty() attach contract): adapters resolve every channel to absolute frequencies and flatten the one-site (CLI/daemon) and multi-site (hunt) shapes into the same Sites slice so the renderer stays pure and free of band-plan math.
func ReportFromTopology ¶ added in v0.5.0
func ReportFromTopology(t *TopologySnapshot) NetworkReport
ReportFromTopology builds a single-site NetworkReport from a TopologySnapshot (the CLI and live-daemon path). Uplink frequencies are derived from each channel's matching band-plan transmit offset.
type P25BandPlanEntry ¶ added in v0.2.2
type P25BandPlanEntry struct {
ChannelID uint8 // 0..15; matches the 4-bit IDEN_UP slot index
BaseHz uint64 // downlink base frequency for channel 0, Hz
SpacingHz uint32 // channel-to-channel step, Hz
TxOffsetHz int64 // signed uplink offset (uplink = downlink + offset), Hz
BandwidthHz uint32 // informational; not consulted by BandPlan.Frequency
}
P25BandPlanEntry is one operator-supplied IDEN_UP slot seed for the P25 Phase 1 receiver — mirrors the on-air phase1.IdentifierUpdate fields without the trunking package having to import phase1. The pipeline factory translates each entry into the receiver's runtime shape and calls BandPlan.Apply before the decoder runs.
type P25Phase2Decode ¶ added in v0.2.5
P25Phase2Decode is the protocol-neutral mirror of p25/phase2.MACDecodeConfig: primitive fields so this struct lives in the trunking package without pulling a Phase 2 import. The composer translates back to phase2.MACDecodeConfig at use time.
Trellis / RS / Interleave / Scrambler are numerically aligned to the phase2 enum constants of the same name (TrellisOff = 0, TrellisOn = 1, etc.). The composer round-trips them by casting.
type Patch ¶ added in v0.1.8
type Patch struct {
System string
Protocol string
SuperGroup uint32
Members []uint32
Vendor string
Add bool // true = patch now active, false = patch cancelled
At time.Time
}
Patch is the events.KindPatch payload — a patch add or cancel a trunked system announced.
type PatchGroup ¶ added in v0.1.8
type PatchGroup struct {
SuperGroup uint32
Members []uint32
Vendor string // "motorola" | "harris"
UpdatedAt time.Time
}
PatchGroup associates a P25 super-group / dynamic-regroup talkgroup with the member talkgroups merged into it. A patch makes the member groups share one RF channel, so a call on the super-group physically IS the members' traffic — "following" a patch means attributing the call to every member, not retuning.
type PatchRegistry ¶ added in v0.1.8
type PatchRegistry struct {
// contains filtered or unexported fields
}
PatchRegistry is a thread-safe live table of active patch groups keyed by super-group. The engine maintains one and consults it when dispatching grants so a call on a patched super-group is attributed to its member talkgroups.
func NewPatchRegistry ¶ added in v0.1.8
func NewPatchRegistry() *PatchRegistry
NewPatchRegistry returns an empty registry.
func (*PatchRegistry) Active ¶ added in v0.1.8
func (r *PatchRegistry) Active() []PatchGroup
Active returns a snapshot of every active patch group.
func (*PatchRegistry) Apply ¶ added in v0.1.8
func (r *PatchRegistry) Apply(pg PatchGroup)
Apply records (or replaces) a patch group.
func (*PatchRegistry) Delete ¶ added in v0.1.8
func (r *PatchRegistry) Delete(superGroup uint32)
Delete removes a patch group by its super-group address.
func (*PatchRegistry) MembersOf ¶ added in v0.1.8
func (r *PatchRegistry) MembersOf(group uint32) []uint32
MembersOf returns a copy of the member talkgroups of the patch keyed by group, or nil if group is not an active super-group.
type Protocol ¶
type Protocol uint8
Protocol is the trunking protocol family in use on a System.
const ( ProtocolUnknown Protocol = iota ProtocolP25 // P25 Phase 1 (config "p25" — Phase 2 uses ProtocolP25Phase2) ProtocolDMR // DMR Tier II / III ProtocolNXDN // NXDN ProtocolDPMR // dPMR Mode 3 (digital PMR446 trunking) ProtocolEDACS // EDACS / GE-Marc ProtocolMotorola // Motorola Type II / SmartZone ProtocolLTR // Logic Trunked Radio (LTR / LTR-Net) ProtocolMPT1327 // MPT 1327 (UK / Commonwealth utility trunking) ProtocolP25Phase2 // P25 Phase 2 (H-DQPSK TDMA, config "p25-phase2") ProtocolTETRA // TETRA TMO (π/4-DQPSK, ETSI EN 300 392-2) ProtocolYSF // System Fusion (C4FM, amateur trunked variant — config "ysf") ProtocolDStar // D-STAR (GMSK 4800 bps, amateur — header-only repeater protocol; config "dstar") ProtocolDMRTier2 // DMR Tier II conventional (per-repeater; config "dmr-tier2") ProtocolDMRTier1 // DMR Tier I direct-mode (license-free simplex; config "dmr-tier1") )
func ParseProtocol ¶
ParseProtocol maps a string ("p25", "dmr", "nxdn", "dpmr", "edacs", "motorola", "ltr", "mpt1327", "p25-phase2", "tetra") to a Protocol value.
type RID ¶ added in v0.2.4
type RID struct {
ID uint32 `json:"id"`
Alias string `json:"alias"`
Description string `json:"description,omitempty"`
Tag string `json:"tag,omitempty"` // department / role
Group string `json:"group,omitempty"` // top-level grouping (agency)
Owner string `json:"owner,omitempty"` // operator/badge assigned to the radio
Priority int `json:"priority,omitempty"`
// Lockout marks the radio as stale / decommissioned / known-bad so
// the UI can de-emphasise it. RIDs are not gated like talkgroups
// today — Lockout is informational only.
Lockout bool `json:"lockout,omitempty"`
// Watch flags RIDs of operator interest so they can be surfaced in
// a watch-list UI. Defaults to true on every loader so a plain
// catalogue without a Watch column keeps every RID visible.
Watch bool `json:"watch"`
// Icon is an optional glyph identifier used by the operator UIs to
// render a per-RID icon.
Icon string `json:"icon,omitempty"`
}
RID describes one radio unit (subscriber unit identifier) loaded from disk. RIDs are the per-radio analogue of talkgroups — a way to give operator-meaningful names, owners, and grouping to the numeric SourceID that rides on every grant/affiliation/registration.
The schema intentionally mirrors TalkGroup's fields where it makes sense (Alias↔AlphaTag, Tag, Group, Priority, Lockout, Icon) so the CSV/JSON loaders share the same conventions, and an operator who already maintains a talkgroup catalog can drop a parallel RID file next to it.
type RIDDB ¶ added in v0.2.4
type RIDDB struct {
// contains filtered or unexported fields
}
RIDDB is a thread-safe lookup over loaded RIDs.
func (*RIDDB) Delete ¶ added in v0.2.4
Delete removes the RID with the given id. Returns false if no such RID exists.
func (*RIDDB) LoadCSV ¶ added in v0.2.4
LoadCSV reads RIDs from a CSV. Required column: a numeric Decimal/DEC column with the radio ID. Optional columns (case-insensitive, matched by header): Alias / Alpha Tag, Description, Tag, Group, Owner, Priority, Lockout, Watch, Icon.
func (*RIDDB) LoadCSVFile ¶ added in v0.2.4
LoadCSVFile is a thin wrapper over LoadCSV for a path on disk.
func (*RIDDB) LoadJSON ¶ added in v0.2.4
LoadJSON reads a JSON array of RID records. Records missing the "watch" key resolve to Watch=true so legacy JSON dumps keep every RID visible; explicit `"watch": false` opts a record out of the watch list.
func (*RIDDB) LoadJSONFile ¶ added in v0.2.4
LoadJSONFile is a thin wrapper over LoadJSON for a path on disk.
type ReacquireFunc ¶ added in v0.2.2
ReacquireFunc asks the SDR pool to re-open the device with the given serial and return its fresh Tuner handle. Implementations (typically the daemon's bridge to sdr.Pool.Reacquire) close the stale handle, re-enumerate the driver, open the matching serial, re-apply per-device tuning, and swap the entry in place — see sdr.Pool.Reacquire for the contract.
type RegistrationResponse ¶ added in v0.1.7
type RegistrationResponse uint8
RegistrationResponse encodes the P25 Unit Registration Response value (TIA-102.AABF Table 7-43). The integer values are wire constants — do not renumber.
const ( RegistrationAccepted RegistrationResponse = 0 RegistrationFailed RegistrationResponse = 1 RegistrationDenied RegistrationResponse = 2 RegistrationRefused RegistrationResponse = 3 )
func (RegistrationResponse) String ¶ added in v0.1.7
func (r RegistrationResponse) String() string
type ReportBand ¶ added in v0.5.0
type ReportBand struct {
ChannelID uint8
BaseHz uint64
SpacingHz uint32
BandwidthHz uint32
TxOffsetHz int64
AccessTDMA bool
}
ReportBand is one P25 IDEN_UP band-plan slot.
type ReportChannel ¶ added in v0.5.0
type ReportChannel struct {
ChannelID uint8
ChannelNumber uint16
DownlinkHz uint32
UplinkHz uint32
}
ReportChannel is a control/voice channel with band-plan coordinates and resolved frequencies. Coordinates may be zero (a hunt-discovered channel carries only a resolved frequency); frequencies may be zero (no band plan).
type ReportNeighbor ¶ added in v0.5.0
type ReportNeighbor struct {
RFSS uint8
Site uint8
Channel ReportChannel
}
ReportNeighbor is an adjacent site advertised by a control channel.
type ReportSite ¶ added in v0.5.0
type ReportSite struct {
RFSS uint8
Site uint8
LRA uint8
PrimaryCC ReportChannel
SecondaryCC []ReportChannel
Neighbors []ReportNeighbor
}
ReportSite is one site's identity and control-channel topology.
type ScanMode ¶
type ScanMode uint8
ScanMode controls how Engine.HandleGrant filters incoming grants against the talkgroup database's `Scan` flag.
- ScanModeAll (default): every non-locked-out grant is dispatched, regardless of TalkGroup.Scan. This is the backwards-compatible behavior — pre-scanner configs see no change.
- ScanModeList: only grants whose talkgroup carries Scan==true (or whose grant is flagged Emergency, parallel to the Lockout exception) are dispatched. Unknown talkgroup IDs are dropped because there's no way to know they're scannable.
func ParseScanMode ¶
ParseScanMode is the inverse of String. Empty string maps to the safe default (all); unknown values also map to all so a typo in config doesn't accidentally silence the daemon.
type SiteInfo ¶ added in v0.4.6
type SiteInfo struct {
System string `json:"system"`
RFSSID uint8 `json:"rfss_id"`
SiteID uint8 `json:"site_id"`
ControlChannelHz uint32 `json:"control_channel_hz,omitempty"`
// ControlChannelCarrierOffsetHz is the demod's measured carrier offset (Hz)
// of the locked control carrier relative to the tuned centre, as of LastSeen.
// A large value flags a control lock sitting well off the configured
// frequency — e.g. an adjacent site bleeding through (issue #815).
ControlChannelCarrierOffsetHz int32 `json:"control_channel_carrier_offset_hz,omitempty"`
// ControlChannelTSBKErrorRate is the cumulative percentage of TSBK blocks
// that failed Viterbi/CRC on this site's control channel, as of LastSeen —
// a frame-error rate, not a pre-FEC bit-error rate (issue #858).
// ControlChannelTSBKCount is the total attempts it was measured over; zero
// means no TSBK data yet (fresh lock, or a non-TSBK Phase 2 path).
ControlChannelTSBKErrorRate float64 `json:"control_channel_tsbk_error_rate,omitempty"`
ControlChannelTSBKCount int64 `json:"control_channel_tsbk_count,omitempty"`
WACN uint32 `json:"wacn,omitempty"`
SystemID uint16 `json:"system_id,omitempty"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
SiteInfo records one P25 site the control-channel decoder has identified over the air. It is the row type of the SiteTracker's snapshot — the data behind GET /api/v1/sites (issue #698). Name is not set here; the API layer merges operator-supplied names from the system config onto matching rows.
type SiteTracker ¶ added in v0.4.6
type SiteTracker struct {
// contains filtered or unexported fields
}
SiteTracker maintains a live table of the P25 sites GT has decoded from the control channel. It subscribes to the events bus and folds in every events.KindSiteUpdate (published when the decoder parses an RFSS Status Broadcast), keyed by (system, RFSS, site). Unlike the camped-site snapshot in p25/phase1.NetworkModel — which only knows the current site — the tracker accumulates every site seen as the decoder hops control channels, so a multi-site system surfaces all of its sites over time. Entries never expire: a system's site roster is small and stable, and operators want the full list even for sites that have gone quiet.
func NewSiteTracker ¶ added in v0.4.6
func NewSiteTracker(opts SiteTrackerOptions) (*SiteTracker, error)
NewSiteTracker validates opts and returns a tracker that has already subscribed to the bus.
func (*SiteTracker) Close ¶ added in v0.4.6
func (t *SiteTracker) Close() error
Close releases the bus subscription and waits for Run to drain.
func (*SiteTracker) Len ¶ added in v0.4.6
func (t *SiteTracker) Len() int
Len returns the number of tracked sites.
func (*SiteTracker) Report ¶ added in v0.5.0
func (t *SiteTracker) Report(system string) (string, bool)
Report renders the human-readable network-configuration report for the named system from its most recent topology snapshot. The bool is false when no topology has been observed for the system yet.
func (*SiteTracker) Run ¶ added in v0.4.6
func (t *SiteTracker) Run(ctx context.Context) error
Run drains site-update events until ctx cancels or the bus closes.
func (*SiteTracker) Snapshot ¶ added in v0.4.6
func (t *SiteTracker) Snapshot() []SiteInfo
Snapshot returns every tracked site, ordered by system then RFSS/Site so the listing is stable across calls.
func (*SiteTracker) Topology ¶ added in v0.5.3
func (t *SiteTracker) Topology(system string) (*TopologySnapshot, bool)
Topology returns the most recent topology snapshot for the named system, or (nil, false) when none has been observed yet. observe replaces the stored pointer wholesale rather than mutating it in place (each update carries the decoder's freshly built snapshot), so the returned value is safe to read without further copying. Used by the API to overlay the live neighbour list onto a system's DTO.
type SiteTrackerOptions ¶ added in v0.4.6
type SiteTrackerOptions struct {
Bus *events.Bus
// Now is injectable for tests; defaults to time.Now.
Now func() time.Time
}
SiteTrackerOptions configure a tracker.
type SiteUpdate ¶ added in v0.4.6
type SiteUpdate struct {
System string
RFSSID uint8
SiteID uint8
ControlChannelHz uint32
// ControlChannelCarrierOffsetHz is the demodulator's measured carrier
// offset (signed Hz) of the locked control carrier relative to the tuned
// centre, as of this update. A large value means the locked carrier sits
// well off the configured frequency — e.g. a stronger neighbouring site
// bleeding through at 12.5 kHz spacing (issue #815) — so the reported site
// identity may not belong to the configured frequency. Zero on demod paths
// without an AFC stage (CQPSK) or when the offset is genuinely ~0.
ControlChannelCarrierOffsetHz int32 `json:"control_channel_carrier_offset_hz,omitempty"`
// ControlChannelTSBKErrorRate is the percentage (0..100) of TSBK blocks on
// this control channel that failed Viterbi or CRC, cumulative since the
// decoder started — a frame-error rate, not a pre-FEC bit-error rate. It
// tracks decode quality independently of carrier lock: a well-locked carrier
// at range can still show a high rate (issue #858). ControlChannelTSBKCount
// is the total attempts the rate was measured over (the denominator, and a
// confidence weight); zero means no TSBK has been attempted yet — a fresh
// lock, or a non-TSBK path such as Phase 2 TDMA — and the rate is undefined.
ControlChannelTSBKErrorRate float64 `json:"control_channel_tsbk_error_rate,omitempty"`
ControlChannelTSBKCount int64 `json:"control_channel_tsbk_count,omitempty"`
WACN uint32
SystemID uint16
// Hex renderings of the identity numbers (P25 field convention),
// alongside the decimal fields above so JSON consumers get both. Empty
// when the corresponding value is unknown (zero).
WACNHex string `json:"WACNHex,omitempty"`
SystemIDHex string `json:"SystemIDHex,omitempty"`
RFSSIDHex string `json:"RFSSIDHex,omitempty"`
SiteIDHex string `json:"SiteIDHex,omitempty"`
// Topology, when non-nil, is the full network-configuration snapshot of the
// camped site (identity + primary/secondary control channels + neighbours +
// band plan) as of this update. It is built on the decoder's Process
// goroutine and handed off here, so the SiteTracker can render the live
// network-configuration report without touching the decoder's internals.
Topology *TopologySnapshot
At time.Time
}
SiteUpdate is the payload of an events.KindSiteUpdate event. The P25 control-channel decoder publishes one each time it parses an RFSS Status Broadcast (TSBK 0x3A), naming the site it is currently camped on and the control-channel frequency it heard it on. The SiteTracker accumulates these into the queryable table behind GET /api/v1/sites (issue #698). ControlChannelHz comes from the decoder's tuned frequency — grants carry only the voice ChannelID, not the control channel — so a SiteUpdate is the only place the two are joined.
type System ¶
type System struct {
Name string
Protocol Protocol
ControlChannels []uint32 // candidate CC frequencies in Hz
WACN uint32 // 20-bit Wide-Area Communication Network ID (P25)
SystemID uint16 // 12-bit system identifier (P25 SYSID)
RFSS uint8 // RF SubSystem ID (P25)
Site uint8 // Site ID
// Sites is the operator's optional catalogue of human-readable site
// names, keyed by (RFSS, Site), surfaced through GET /api/v1/sites
// so a discovered P25 site can carry a name instead of an opaque
// ID (issue #698). The decoder does not consult these — they are
// pure presentation metadata merged into the sites listing.
Sites []ConfiguredSite
// TETRAColourCode is the low 30 bits of the extended colour code
// the TETRA scrambler uses to seed its LFSR per ETSI EN 300 392-2
// §8.2.5 ("ec" in the spec). The ccdecoder connector forwards this
// into tetra.ControlChannel.SetColourCode under ChannelCodingOn.
// Zero is valid only for BSCH (§8.2.5.2). For all other channel
// types the colour code is the per-cell secret the descrambler
// needs to recover the type-3 stream — leaving it at zero with
// channel coding on produces garbage. Bits 30..31 are silently
// ignored downstream.
TETRAColourCode uint32
// TETRAChannel selects which TETRA logical channel lives in each
// burst window under ChannelCodingOn. Recognised values:
// "sch/hd" | "sch/f" | "sch/hu" | "bsch" | "aach" (case-insensitive,
// "/" optional). Empty defaults to "sch/hd" — the most common
// signaling carrier for cc.locked / Grant events. Forwarded into
// tetra.ControlChannel.SetExpectedChannel by the ccdecoder
// connector after parsing via tetra.ParseChannelType.
TETRAChannel string
// TETRAChannelCoding gates the full ETSI EN 300 392-2 §8.3.1
// channel-coding chain (descramble + deinterleave + depuncture +
// Viterbi + CRC-16 verify + tail strip). Recognised values
// (case-insensitive): "" / "on" / "true" / "1" → ChannelCodingOn
// (the new default; required for live on-air captures); "off" /
// "false" / "0" → ChannelCodingOff (legacy raw-dibit path, opt-out
// for operators feeding pre-stripped DSD-FME / OP25 fixtures).
// Forwarded into tetra.ControlChannel.SetChannelCoding by the
// ccdecoder connector after parsing via tetra.ParseChannelCoding.
TETRAChannelCoding string
// LTRFCSMode enables CRC-7 FCS verification on the LTR Status
// Ingest path (per DSheirer/sdrtrunk's CRCLTR.java layout).
// Recognised values (case-insensitive): "" / "on" / "true" / "1" →
// FCSOn (the new default; drop Status words whose 7-bit FCS
// trailer doesn't match the CRC over the 24-bit message vector);
// "off" / "false" / "0" → FCSOff (no verification — opt-out for
// pre-stripped fixtures). Forwarded into
// ltr.ControlChannel.SetFCSMode by the ccdecoder connector after
// parsing via ltr.ParseFCSMode.
LTRFCSMode string
// LTRManchesterMode controls Manchester decoding of the LTR
// sub-audible bit stream. Recognised values (case-insensitive):
// "" / "on" / "soft" → ManchesterSoft (the new default —
// majority-decode each pair; matches the dominant on-air
// encoding for sub-audible LTR signaling); "strict" —
// require a mid-bit transition per pair, drop transition-less
// pairs; "off" / "nrz" → ManchesterOff (raw NRZ — opt-out for
// synthesized NRZ fixtures). Forwarded into
// ltr.ControlChannel.SetManchesterMode by the ccdecoder
// connector after parsing via ltr.ParseManchesterMode.
LTRManchesterMode string
// P25BandPlan seeds the Phase 1 receiver's BandPlan with operator-
// supplied IdentifierUpdate entries before the decoder runs.
// Useful for sites that don't broadcast an IDEN_UP TSBK for every
// channel ID their grants reference (issue #345 — Mt Anakie
// broadcasts grants on id=10 but never the matching IDEN_UP, so
// the receiver has nothing to resolve the frequency against).
// Over-the-air IDEN_UPs naturally override these via the same
// BandPlan.Apply path; entries here are the floor, not the ceiling.
// Ignored for non-P25-Phase-1 protocols.
P25BandPlan []P25BandPlanEntry
// DMRBandPlan resolves the LCN in DMR Tier III voice grants to a
// downlink frequency. Required for T3 voice — without it every
// grant is dropped with decode.error stage=no-bandplan. Ignored
// for non-DMR-Tier-III protocols.
DMRBandPlan *DMRBandPlan
// DMRInterleavedVoice opts the system into the experimental 2-slot
// interleaved voice decoder (mirrors config.System.DMRInterleavedVoice).
// When true the DMR Tier III control channel tags its voice grants
// so the composer decodes each timeslot from the carrier's
// interleaved burst stream and routes it to the call by embedded-LC
// talkgroup. Default false keeps the single-slot decoder. Pending a
// real-capture cross-check of the on-air constants — see
// docs/status.md. Ignored for non-DMR protocols.
DMRInterleavedVoice bool
// P25Phase1DemodMode selects the symbol-recovery path for the
// P25 Phase 1 receiver. Recognised values (case-insensitive):
// "" / "c4fm" / "fm" → DemodC4FM (the default — FM
// discriminator + 4-level slicer; matches every previously
// shipping config and works on conventional non-simulcast P25
// transmitters); "cqpsk" / "lsm" / "linear" → DemodCQPSK (the
// linear / LSM path — complex RRC + Gardner + differential
// QPSK; required for simulcast P25 deployments whose control
// channel transmits Linear Simulcast Modulation rather than
// straight C4FM, see issue #275 and TIA-102.BAAA). Forwarded
// into p25phase1rx.Options.DemodMode by the ccdecoder connector
// after parsing via p25phase1rx.ParseDemodMode.
P25Phase1DemodMode string
// P25Phase2TrellisMode enables the 4-state ½-rate trellis FEC
// decoder on the P25 Phase 2 MAC PDU window. Recognised values
// (case-insensitive): "" / "on" / "true" / "1" → TrellisOn (the
// new default — 146 channel dibits via the TIA-102.AABF trellis
// decoder); "off" / "false" / "0" → TrellisOff (legacy 72-dibit
// raw-MAC-PDU path, opt-out for pre-stripped fixtures). Forwarded
// into p25phase2.ControlChannel.SetTrellisMode by the ccdecoder
// connector after parsing via p25phase2.ParseTrellisMode.
P25Phase2TrellisMode string
// P25Phase2RSMode enables the outer Reed-Solomon RS(24, 16, 9)
// verification layer on top of the trellis-decoded MAC PDU.
// Recognised values (case-insensitive): "" / "off" / "false" /
// "0" → RSOff (the default — no outer RS verification; matches
// historical decoder behaviour); "on" / "true" / "1" → RSOn
// (verify RS syndromes per TIA-102.BAAA-A §5.9; drop MAC PDUs
// whose syndromes are non-zero before parsing). Forwarded into
// p25phase2.ControlChannel.SetRSMode by the ccdecoder connector
// after parsing via p25phase2.ParseRSMode.
P25Phase2RSMode string
// P25Phase2InterleaveMode enables the TIA-102.BBAC per-burst block
// deinterleaver applied to the MAC-burst dibits before trellis
// decoding. Recognised values (case-insensitive): "" / "off" /
// "false" / "0" → InterleaveOff (the default); "on" / "true" / "1"
// → InterleaveOn. Forwarded into p25phase2.ControlChannel.
// SetInterleaveMode by the ccdecoder connector after parsing via
// p25phase2.ParseInterleaveMode.
P25Phase2InterleaveMode string
// P25Phase2ScramblerMode enables the PN44 descrambler per
// TIA-102.BBAC-1 §7.2.5 on the trellis-decoded MAC PDU bits.
// Recognised values (case-insensitive): "" / "on" / "true" /
// "1" → ScramblerOn (the default — live Phase 2 traffic is
// always scrambled); "off" / "false" / "0" → ScramblerOff
// (opt-out for unscrambled fixtures). The seed is computed from
// (WACN, SystemID, low 12 bits of Site as the spec's Color
// Code = NAC) per spec equation (5). Forwarded into
// p25phase2.ControlChannel.SetScramblerMode +
// SetScramblerSeed by the ccdecoder connector.
P25Phase2ScramblerMode string
// P25Phase2ClockMode selects the symbol-timing-recovery strategy
// for the P25 Phase 2 receiver. Recognised values (case-
// insensitive): "" / "gardner" / "on" → ClockGardner (the new
// default — non-data-aided Gardner loop; recommended for live
// SDR captures); "naive" / "off" → ClockNaive (decimate every
// sps-th sample; works on sample-aligned synthesized IQ).
// Forwarded into p25phase2rx.Options.ClockMode by the ccdecoder
// connector after parsing via p25phase2rx.ParseClockMode.
P25Phase2ClockMode string
// TETRAClockMode mirrors P25Phase2ClockMode for the TETRA
// receiver. Same recognised values + parser semantics; the
// underlying ClockMode enums in the two receivers share the
// same name + values but are independent types.
TETRAClockMode string
// NXDNViterbiMode enables the K=5 ½-rate Viterbi FEC decoder
// on the NXDN CAC region. Recognised values (case-insensitive):
// "" / "spec" → ViterbiSpec (the new default — full NXDN-TS-1-A
// §4.5.1.1 outbound CAC chain); "on" / "true" / "1" → ViterbiOn
// (intermediate 92-dibit K=5 Viterbi path for older
// MMDVMHost / DSDcc fixtures); "off" / "false" / "0" → ViterbiOff
// (legacy 44-dibit raw-CAC path, opt-out for pre-stripped
// fixtures). Forwarded into nxdn.ControlChannel.SetViterbiMode
// by the ccdecoder connector after parsing via
// nxdn.ParseViterbiMode.
NXDNViterbiMode string
// NXDNDeviationHz overrides the peak frequency deviation (Hz)
// the NXDN receiver's slicer is calibrated against. Spec value
// is 1800 Hz (matches the FM-discriminator output level so live
// captures slice correctly out of the box). Operators with
// on-air captures whose dibit distribution is bimodal (outer
// ±3 dominate, inner ±1 underrepresented) can override here —
// see samples/nxdn/README.md for the calibration recipe.
// Forwarded into nxdnrx.Options.DeviationHz by the ccdecoder
// connector; values <= 0 fall back to the spec default.
NXDNDeviationHz float64
// EDACSBCHMode enables the BCH(40, 28, 2) FEC layer on the
// EDACS CCW. Recognised values (case-insensitive): "" / "on" /
// "true" / "1" → BCHOn (the new default — 40-bit on-wire
// BCH(40, 28, 2) decode + single/double-bit correction); "off" /
// "false" / "0" → BCHOff (legacy pre-stripped 40-bit CCW path,
// opt-out for pre-stripped fixtures). Forwarded into
// edacs.ControlChannel.SetBCHMode by the ccdecoder connector
// after parsing via edacs.ParseBCHMode.
EDACSBCHMode string
// MPT1327BCHMode enables the BCH(63, 38) FEC layer on the MPT
// 1327 codeword. Recognised values (case-insensitive): "" /
// "on" / "true" / "1" → BCHOn (the new default — 64-bit on-wire
// BCH(63, 38) decode); "off" / "false" / "0" → BCHOff (legacy
// 38-bit pre-stripped codeword path, opt-out for pre-stripped
// fixtures). Forwarded into mpt1327.ControlChannel.SetBCHMode
// by the ccdecoder connector after parsing via
// mpt1327.ParseBCHMode.
MPT1327BCHMode string
// MPT1327CWSCTolerance sets the Hamming-distance threshold the
// MPT 1327 Process adapter uses when matching the 16-bit
// Codeword Synchronisation Code. Recognised values
// (case-insensitive): "" → 2-bit tolerance (the new default,
// matches commercial MPT 1327 receivers on noisy on-air
// captures); "0" / "exact" / "off" → exact match (for
// pre-stripped synthesized fixtures); a decimal integer in
// [0, 15]. Forwarded into mpt1327.ControlChannel.SetCWSCTolerance
// by the ccdecoder connector after parsing via
// mpt1327.ParseCWSCTolerance.
MPT1327CWSCTolerance string
// MotorolaBCHMode enables the BCH(64, 16, 11) FEC layer on the
// Motorola Type II OSW. Recognised values (case-insensitive):
// "" / "on" / "true" / "1" → BCHOn (the new default — two
// 64-bit BCH(64, 16, 11) codewords reassembled into the 32-bit
// OSW, with up to 11 bit errors corrected per codeword); "off" /
// "false" / "0" → BCHOff (legacy 32-bit raw-OSW path, opt-out
// for pre-stripped fixtures). Forwarded into
// motorola.ControlChannel.SetBCHMode by the ccdecoder
// connector after parsing via motorola.ParseBCHMode.
MotorolaBCHMode string
// DStarFECMode enables the JARL DV-mode header FEC chain on the
// D-STAR Process adapter. Recognised values (case-insensitive):
// "" / "off" / "false" / "0" → FECOff (the default — reads 328
// info bits straight off the wire, matches synthesized fixtures
// + pre-FEC-stripped inputs); "on" / "true" / "1" → FECOn (660
// on-wire bits → deinterleave 22×30 → PN15 descramble →
// depuncture → K=5 R=1/2 Viterbi → 328 info bits → ParseHeader).
// Forwarded into dstar.ControlChannel.SetFECMode by the
// ccdecoder connector after parsing via dstar.ParseFECMode.
DStarFECMode string
}
System describes one trunked radio system the engine should track.
type TalkGroup ¶
type TalkGroup struct {
ID uint32 `json:"id"`
AlphaTag string `json:"alpha_tag"`
Description string `json:"description,omitempty"`
Tag string `json:"tag,omitempty"` // department / category
Group string `json:"group,omitempty"` // top-level group
Mode string `json:"mode,omitempty"` // D=digital, A=analog, M=mixed
Priority int `json:"priority,omitempty"` // 1 = highest, 10 = lowest, 0 = unset
Lockout bool `json:"lockout,omitempty"`
Scan bool `json:"scan"`
// Stream gates whether completed calls on this talkgroup are
// uploaded to the outbound broadcast aggregators
// (internal/broadcast). Defaults to true on every loader so a
// legacy CSV/JSON without a Stream column keeps streaming every
// call; an explicit "no"/"false" in the CSV (or `"stream": false`
// in JSON) opts a sensitive talkgroup out of all feeds.
Stream bool `json:"stream"`
// Record gates whether calls on this talkgroup are written to
// disk by the per-call WAV recorder. Defaults to true on every
// loader; an explicit "no"/"false" excludes a talkgroup from
// recording (the call is still followed and played live).
Record bool `json:"record"`
// Mute, when true, suppresses this talkgroup's calls from the
// live audio player — the call is still followed, recorded, and
// streamed, just not played on the host's speakers. Defaults to
// false (not muted).
Mute bool `json:"mute,omitempty"`
// Icon is an optional icon name assigned to this talkgroup, used
// by the operator UIs to render a per-talkgroup glyph (the data
// model behind SDRtrunk's Icon Manager). Empty = default icon.
Icon string `json:"icon,omitempty"`
}
TalkGroup describes one talkgroup loaded from disk. The schema follows the Trunk Recorder / RadioReference talkgroup CSV convention.
Scan participates in the engine's per-talkgroup scan list when the engine runs in ScanModeList — only talkgroups with Scan == true get their grants followed (Emergency grants bypass the gate). In ScanModeAll (the default for backwards compat with pre-scanner configs) the field is moot. Defaults to true on every loader so a legacy CSV without a Scan column keeps the existing behavior.
type TalkerAlias ¶ added in v0.1.8
type TalkerAlias struct {
System string // System name, matches trunking.System.Name
Protocol string // "p25-phase2"
SourceID uint32 // radio unit the alias names
Alias string // the reassembled display name
// Unreliable is true when the decode passed CRC but the reassembled
// alias held non-ASCII-printable characters — a hallmark of
// bit-error corruption (#711). Consumers should surface it as
// suspect rather than as a confirmed name.
Unreliable bool
At time.Time
}
TalkerAlias is published on the events bus when a radio's display name (the "talker alias") has been fully reassembled from the multi-fragment vendor MAC PDUs that carry it. Emitted by the P25 Phase 2 decoder. Keyed by SourceID so a consumer can associate it with the active call whose Grant.SourceID matches.
type TalkgroupDB ¶
type TalkgroupDB struct {
// contains filtered or unexported fields
}
TalkgroupDB is a thread-safe lookup over loaded talkgroups.
func (*TalkgroupDB) Add ¶
func (d *TalkgroupDB) Add(tg *TalkGroup)
Add or replace a single talkgroup record.
func (*TalkgroupDB) All ¶
func (d *TalkgroupDB) All() []*TalkGroup
All returns a snapshot of every talkgroup in the DB.
func (*TalkgroupDB) Delete ¶
func (d *TalkgroupDB) Delete(id uint32) bool
Delete removes the talkgroup with the given id. Returns false if no such talkgroup exists.
func (*TalkgroupDB) Len ¶
func (d *TalkgroupDB) Len() int
Len returns the number of loaded talkgroups.
func (*TalkgroupDB) LoadCSV ¶
func (d *TalkgroupDB) LoadCSV(r io.Reader) (int, error)
LoadCSV reads talkgroups from a Trunk-Recorder-style CSV. Required column: a numeric DEC/Decimal column. Optional columns (matched by header, case-insensitive): Alpha Tag, Description, Mode, Tag, Group, Priority, Lockout.
A "Y" / "yes" / "true" value in Lockout sets the flag. Lockout is also inferred when Priority is set to a sentinel "L" value, matching common community CSVs.
func (*TalkgroupDB) LoadCSVFile ¶
func (d *TalkgroupDB) LoadCSVFile(path string) (int, error)
LoadCSVFile is a thin wrapper over LoadCSV for a path on disk.
func (*TalkgroupDB) LoadJSON ¶
func (d *TalkgroupDB) LoadJSON(r io.Reader) (int, error)
LoadJSON reads a JSON array of TalkGroup records. Records missing the "scan" key resolve to Scan=true so legacy JSON dumps keep the "follow every grant" behavior; explicit `"scan": false` turns off participation in the scan list.
func (*TalkgroupDB) Lookup ¶
func (d *TalkgroupDB) Lookup(id uint32) *TalkGroup
Lookup returns the talkgroup record for id, or nil if unknown.
func (*TalkgroupDB) UpdateFields ¶
func (d *TalkgroupDB) UpdateFields(id uint32, fn func(*TalkGroup)) bool
UpdateFields applies fn to the talkgroup with the given id under the write lock. Returns false if no such talkgroup exists. Used by the API to mutate Priority / Lockout without exposing the raw pointer to outside callers.
type TopoBandPlanSlot ¶ added in v0.3.5
type TopoBandPlanSlot struct {
ChannelID uint8 `json:"channel_id" yaml:"channel_id"`
BaseHz uint64 `json:"base_hz" yaml:"base_hz"`
SpacingHz uint32 `json:"spacing_hz" yaml:"spacing_hz"`
BandwidthHz uint32 `json:"bandwidth_hz,omitempty" yaml:"bandwidth_hz,omitempty"`
TxOffsetHz int64 `json:"tx_offset_hz,omitempty" yaml:"tx_offset_hz,omitempty"`
AccessTDMA bool `json:"access_tdma,omitempty" yaml:"access_tdma,omitempty"`
}
TopoBandPlanSlot is one entry of a P25-style band plan (IDEN_UP): a channel ID mapped to a base frequency, channel spacing, and transmit offset.
type TopoChannelRef ¶ added in v0.3.5
type TopoChannelRef struct {
ChannelID uint8 `json:"channel_id" yaml:"channel_id"`
ChannelNumber uint16 `json:"channel_number" yaml:"channel_number"`
FrequencyHz uint32 `json:"frequency_hz,omitempty" yaml:"frequency_hz,omitempty"`
}
TopoChannelRef is a channel identified by its band-plan (id, number) coordinates and, when resolvable, an absolute frequency.
type TopoNeighborRef ¶ added in v0.3.5
type TopoNeighborRef struct {
RFSS uint8 `json:"rfss" yaml:"rfss"`
Site uint8 `json:"site" yaml:"site"`
ChannelID uint8 `json:"channel_id,omitempty" yaml:"channel_id,omitempty"`
ChannelNumber uint16 `json:"channel_number,omitempty" yaml:"channel_number,omitempty"`
FrequencyHz uint32 `json:"frequency_hz,omitempty" yaml:"frequency_hz,omitempty"`
}
TopoNeighborRef is an adjacent site advertised by the control channel.
type TopologyProvider ¶ added in v0.3.5
type TopologyProvider interface {
TopologySnapshot() *TopologySnapshot
}
TopologyProvider is the optional hook a control-channel pipeline implements to expose its accumulated topology after a run. The siglab engine queries it once at EOF and attaches the snapshot to the Result. Returning nil (or an empty snapshot) is fine for protocols/runs that observed no topology.
type TopologySnapshot ¶ added in v0.3.5
type TopologySnapshot struct {
// Display metadata (populated where the decoder knows it). These name the
// run for the human-readable network-configuration report and are not part
// of the topology's Empty() contract.
SystemName string `json:"system_name,omitempty" yaml:"system_name,omitempty"`
Protocol string `json:"protocol,omitempty" yaml:"protocol,omitempty"`
// P25 identity.
WACN uint32 `json:"wacn,omitempty" yaml:"wacn,omitempty"`
SystemID uint32 `json:"system_id,omitempty" yaml:"system_id,omitempty"`
NAC uint16 `json:"nac,omitempty" yaml:"nac,omitempty"` // Network Access Code (P25)
RFSS uint8 `json:"rfss,omitempty" yaml:"rfss,omitempty"`
Site uint8 `json:"site,omitempty" yaml:"site,omitempty"`
LRA uint8 `json:"lra,omitempty" yaml:"lra,omitempty"`
// PrimaryCC is the camped site's primary control channel (P25), with its
// resolved downlink frequency when the band plan was known.
PrimaryCC *TopoChannelRef `json:"primary_cc,omitempty" yaml:"primary_cc,omitempty"`
// Per-protocol identity (populated where applicable).
ColorCode uint8 `json:"color_code,omitempty" yaml:"color_code,omitempty"` // DMR
RAN uint8 `json:"ran,omitempty" yaml:"ran,omitempty"` // NXDN
MCC uint16 `json:"mcc,omitempty" yaml:"mcc,omitempty"` // TETRA
MNC uint16 `json:"mnc,omitempty" yaml:"mnc,omitempty"` // TETRA
LocationArea uint16 `json:"location_area,omitempty" yaml:"location_area,omitempty"` // TETRA LA / NXDN location
// Secondary control channels advertised for the camped site.
Secondary []TopoChannelRef `json:"secondary,omitempty" yaml:"secondary,omitempty"`
// Neighbors are adjacent sites the control channel advertised.
Neighbors []TopoNeighborRef `json:"neighbors,omitempty" yaml:"neighbors,omitempty"`
// BandPlan maps channel IDs to frequencies (P25 IDEN_UP and equivalents).
BandPlan []TopoBandPlanSlot `json:"band_plan,omitempty" yaml:"band_plan,omitempty"`
}
TopologySnapshot is the protocol-neutral system topology a control-channel decoder accumulates over a run: the system/site identity, the neighbor (adjacent) sites it advertised, and its band plan. The signal-lab engine attaches it to a decode Result, and the hunt accumulator folds it into a discovered system.
It lives in package trunking (not siglab) so the per-protocol control-channel decoders — which siglab imports — can implement TopologyProvider without an import cycle. siglab re-exports these as type aliases for ergonomics.
Why this exists separately from per-event payloads: the identifying fields (WACN/SYSID/RFSS/Site for P25, and the per-protocol equivalents) are accumulated from periodic status broadcasts inside the decoder's network model — they are NOT carried on any per-event payload, so a consumer cannot recover them from the event stream. This snapshot is the bridge.
All fields are optional; a protocol fills in only what it can observe. Capability varies: P25 surfaces full identity + neighbors + band plan; DMR Tier III / EDACS / Motorola can add identity + neighbors; NXDN / TETRA give single-site identity; the rest give minimal identity. Band-plan-from-air is P25-only today.
func (*TopologySnapshot) Empty ¶ added in v0.3.5
func (t *TopologySnapshot) Empty() bool
Empty reports whether the snapshot carries no usable information, so callers can avoid attaching a hollow snapshot to a Result.
type Tuner ¶
Tuner is the subset of sdr.Device the hunter needs. Decoupling from the full Device interface keeps the hunter testable without an IQ source.
type UnitActivity ¶ added in v0.1.9
type UnitActivity struct {
RadioID uint32 `json:"radio_id"`
Talkgroup uint32 `json:"talkgroup"`
System string `json:"system"`
Protocol string `json:"protocol"`
// Explicit is true when the association came from a decoded
// affiliation message (P25 Group Affiliation Response); false when
// it was observed from a voice-channel grant naming the unit as
// the source. Both are ground truth — a grant proves the unit is
// transmitting on that talkgroup — but the distinction is useful
// in the UI.
Explicit bool `json:"explicit"`
// Registered is true once the unit has been seen in a unit
// registration message.
Registered bool `json:"registered"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
// CallCount counts grants observed for this unit since it entered
// the table — a simple "recurring radio" signal for the RID list
// (issue #376).
CallCount uint64 `json:"call_count"`
// TalkerAlias is the most-recently-decoded over-the-air talker
// alias for this unit (the radio's own display name), distinct
// from any operator-assigned alias in the RIDDB.
TalkerAlias string `json:"talker_alias,omitempty"`
TalkerAliasAt time.Time `json:"talker_alias_at,omitempty"`
// TalkerAliasUnreliable is true when the stored alias decode passed
// CRC but held non-ASCII-printable characters (bit-error
// corruption, #711); a consumer should render it as suspect.
TalkerAliasUnreliable bool `json:"talker_alias_unreliable,omitempty"`
}
UnitActivity records the last-observed talkgroup activity of one radio unit. It is the row type of the affiliation tracker's snapshot — the "who is on which talkgroup" view SDRtrunk surfaces.
type UnitRegistration ¶ added in v0.1.7
type UnitRegistration struct {
System string // System name, matches trunking.System.Name
Protocol string // "p25" / "dmr" / "nxdn"
SourceID uint32 // radio unit's WUID
WACN uint32 // 20-bit Wide Area Communications Network ID
SystemID uint16 // 12-bit system identifier
Response RegistrationResponse // accepted / failed / denied / refused
// RFSSID and SiteID identify the P25 site that handled this
// registration — the radio's actual serving site, so a genuine
// RID→site location fix for mobility tracking (issue #698). NAC is
// the control channel's Network Access Code (Phase 2: the NSB Color
// Code); not unique per site, so consumers must key on (RFSSID,
// SiteID). All three stay zero on non-P25 registrations and until
// the site's RFSS Status / Network Status broadcast has decoded.
RFSSID uint8
SiteID uint8
NAC uint16
At time.Time
}
UnitRegistration is published on the events bus when a radio unit registers (or attempts to register) on a site. Emitted by P25 control-channel decoders on opcode 0x2C (Unit Registration Response).
type UnitToUnitRequest ¶ added in v0.4.7
type UnitToUnitRequest struct {
System string // System name, matches trunking.System.Name
Protocol string // "p25"
SourceID uint32 // calling unit
TargetID uint32 // called unit being asked to answer
ServiceOptions uint8 // P25 service-options octet (priority / encryption flags)
At time.Time
}
UnitToUnitRequest is published on the events bus when the system asks a radio whether it will answer a private (unit-to-unit) call. Emitted by P25 control-channel decoders on opcode 0x05 (Unit-to-Unit Answer Request). No channel is granted yet — it's the call-setup handshake — so only the two radio IDs (and the service options) are carried.
type VoiceDevice ¶
VoiceDevice is one Voice-role SDR available to the engine. The engine retunes it via the embedded Tuner interface and tracks an optional active call.
type VoicePool ¶
type VoicePool struct {
// contains filtered or unexported fields
}
VoicePool manages the set of Voice-role devices and the call currently (if any) bound to each. It is safe for concurrent use.
func NewVoicePool ¶
func NewVoicePool(devices []*VoiceDevice) *VoicePool
NewVoicePool returns a pool over the supplied devices. The order of devices determines allocation preference (first-fit).
func (*VoicePool) Active ¶
func (p *VoicePool) Active() []*ActiveCall
Active returns a snapshot of every currently-bound call.
func (*VoicePool) ArmEncryptedRelease ¶ added in v0.4.6
ArmEncryptedRelease schedules the metadata-mode teardown of the call bound to serial at time at. It only arms when the call is currently unarmed, so a repeat encryption update keeps the earliest deadline (the metadata window starts when encryption is first known, not on every in-call re-confirmation). No-op when no call is bound. Issue #711.
func (*VoicePool) ArmedCallBySource ¶ added in v0.4.7
func (p *VoicePool) ArmedCallBySource(system string, sourceID uint32) *ActiveCall
ArmedCallBySource returns the active call from system originated by sourceID that is currently armed for a metadata-mode encrypted release (EncReleaseAt set), or nil. The engine uses it to short-circuit the metadata-follow window the moment a system's talker alias completes: the metadata we held the tuner for has arrived, so the call can be torn down early. Only armed calls match, so a completed alias never disturbs a follow-mode or clear call. Evaluated under the pool lock so the read stays consistent with concurrent ArmEncryptedRelease / Touch. Issue #711.
func (*VoicePool) Bind ¶
func (p *VoicePool) Bind(d *VoiceDevice, g Grant, tg *TalkGroup, now time.Time) (*ActiveCall, error)
Bind retunes the device to grant.FrequencyHz and records an active call. Returns an error if the device is already busy or the tune fails. When SetReacquire is wired, a SetCenterFreq failure triggers one reacquire attempt against the SDR pool — the stale handle is swapped for a fresh one and the tune is retried. Recovers from a USB disconnect that happened while the device was idle between calls (issue #345).
func (*VoicePool) Devices ¶
func (p *VoicePool) Devices() []*VoiceDevice
Devices returns a snapshot of the device list.
func (*VoicePool) DisarmEncryptedRelease ¶ added in v0.4.6
DisarmEncryptedRelease cancels any pending metadata-mode teardown on the call bound to serial — used when a later in-call update reveals the operator holds a decryption key for the call, so it should be followed after all. No-op when no call is bound. Issue #711.
func (*VoicePool) EncryptedReleasesDue ¶ added in v0.4.6
func (p *VoicePool) EncryptedReleasesDue(now time.Time) []*ActiveCall
EncryptedReleasesDue returns every active call whose armed encrypted-release deadline has been reached by now. The engine watchdog ends each with EndReasonEncrypted. Evaluated under the pool lock so the deadline read stays consistent with concurrent ArmEncryptedRelease / Touch from the decode pipeline. Issue #711.
func (*VoicePool) FindFree ¶
func (p *VoicePool) FindFree() *VoiceDevice
FindFree returns the first device with no active call, or nil if every device is busy. The pool lock is held only during the scan.
func (*VoicePool) FindFreeForFrequency ¶ added in v0.2.4
func (p *VoicePool) FindFreeForFrequency(hz uint32) *VoiceDevice
FindFreeForFrequency returns the first free device whose Tuner either doesn't implement FrequencyChecker (physical SDR — accepted unconditionally) or reports CanTune(hz)=true (virtual tuner whose wideband window covers the target). Order matches the device list, so the daemon's preference (physical voice SDRs first, virtual taps after) is preserved. Returns nil when every free device rejects the target — the engine then falls back to preemption.
func (*VoicePool) HasCapableDevice ¶ added in v0.2.6
HasCapableDevice reports whether any device in the pool — busy or free — can tune hz. A device with no FrequencyChecker (physical SDR) counts as universally capable. The engine uses this to tell a coverage gap (e.g. every voice device is a wideband tap whose IQ window excludes hz) apart from a genuine all-busy or empty-pool condition, so an out-of-window grant isn't mislogged as an engine bug.
func (*VoicePool) LowestPriorityActive ¶
func (p *VoicePool) LowestPriorityActive() *ActiveCall
LowestPriorityActive returns the active call with the lowest priority among all devices, or nil if no calls are active. Used by the engine when deciding which call to preempt.
func (*VoicePool) LowestPriorityActiveForFrequency ¶ added in v0.2.6
func (p *VoicePool) LowestPriorityActiveForFrequency(hz uint32) *ActiveCall
LowestPriorityActiveForFrequency returns the lowest-priority active call among devices that can tune hz, or nil when no such device has an active call. It mirrors LowestPriorityActive but skips devices whose Tuner rejects hz — preempting one of those would end a call to free a device that then can't bind the incoming grant. Physical SDRs (no FrequencyChecker) are always eligible, so for a pool with no frequency-constrained tuners this matches LowestPriorityActive.
func (*VoicePool) Release ¶
func (p *VoicePool) Release(serial string) *ActiveCall
Release marks the device free. Returns the freed ActiveCall (or nil if the device wasn't busy).
func (*VoicePool) Retune ¶ added in v0.3.7
Retune follows an already-bound call to a new frequency without ending it: it retunes the device to g.FrequencyHz and replaces the stored Grant and LastHeardAt, preserving StartedAt so the call's duration and identity stay continuous. The engine calls this when a control channel moves an in-progress call to a new channel (a real handoff, or a P25 band-plan IdentifierUpdate re-mapping the channel) — the call is matched by (System, GroupID, Timeslot), so following it here is what stops a second tuner being bound for the same talkgroup.
The SetReacquire retry applies exactly as in Bind. Returns an error if the device isn't currently bound or the tune fails after the retry; on error the caller should end the call and rebind a capable device. Note the returned ActiveCall pointer is the same instance Bind handed out, so the engine's mirror sees the updated Grant without any extra bookkeeping.
func (*VoicePool) SetReacquire ¶ added in v0.2.2
func (p *VoicePool) SetReacquire(fn ReacquireFunc)
SetReacquire installs the SDR-pool reacquire callback. After this is set, Bind retries SetCenterFreq once via the callback when the initial tune fails — recovering from a USB disconnect / re- enumerate without dropping the call. Idempotent; passing nil disables the retry (matches the legacy behaviour).
func (*VoicePool) Touch ¶
Touch updates the LastHeardAt timestamp for the given device. The engine watchdog uses this to detect calls that have ended without an explicit release announcement.
func (*VoicePool) UpdateEncryption ¶ added in v0.2.2
UpdateEncryption backfills ALGID/KID on the active call bound to serial — used by the engine when an in-call Encryption Sync arrives after the original grant (P25 Phase 1 LDU2). Returns a copy of the updated Grant for the caller to publish in an enriched event, plus ok=true when a matching call was found. The mutation runs under the pool's mutex so it stays consistent with concurrent Touch / Release.
func (*VoicePool) UpdateSignal ¶ added in v0.6.6
UpdateSignal stamps the call's measured received channel power (dBFS) onto the ActiveCall bound to serial. No-op when no call is bound. A fresh pointer is stored per update so the value is stable once released.
func (*VoicePool) UpdateSource ¶ added in v0.2.5
UpdateSource backfills SourceID + Encrypted on the active call bound to serial — used by the engine when an in-call GROUP_VOICE_CHANNEL_USER PDU arrives on the traffic channel after a compressed grant whose SOURCE_ID / SVC_OPTIONS were absent (e.g. P25 Phase 2 MMR). SourceID is only overwritten when the new value is non-zero so a later compressed-form update doesn't blank out a legitimate source. Encrypted is OR-merged so an in-call PDU can flip a non-encrypted grant to encrypted but never the other way (the spec doesn't define mid-call decryption). Returns a copy of the updated Grant + ok=true when a matching call was found.