A reflector that has moved to 3.0 will not accept a station that always announces 2.0, and a 2.x reflector still needs one explicit plaintext session. Co-authored-by: Cursor <cursoragent@cursor.com>
228 lines
5.1 KiB
Go
228 lines
5.1 KiB
Go
// Package station is the headset station. It loads the config, keeps one
|
|
// reflector session, and exposes the state the window shows.
|
|
package station
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.neitzel.de/konrad/funknetz/internal/opus"
|
|
)
|
|
|
|
// Options selects the config file and the clock used for heartbeats and retries.
|
|
type Options struct {
|
|
ConfigPath string
|
|
Clock Clock
|
|
}
|
|
|
|
// State is what the window shows.
|
|
type State struct {
|
|
Connection string `json:"connection"`
|
|
Detail string `json:"detail"`
|
|
Callsign string `json:"callsign"`
|
|
TalkGroup int `json:"talkGroup"`
|
|
Protocol string `json:"protocol"`
|
|
Talker string `json:"talker"`
|
|
Transmitting bool `json:"transmitting"`
|
|
}
|
|
|
|
// Station is the seam tests and the window use.
|
|
type Station struct {
|
|
cfg loaded
|
|
clock Clock
|
|
|
|
mu sync.Mutex
|
|
state State
|
|
wake chan struct{}
|
|
buttonHeld bool
|
|
spaceHeld bool
|
|
fatal string
|
|
retryReason string
|
|
protocol string
|
|
remoteTalker string
|
|
roomBusy bool
|
|
audioHeard bool
|
|
lastAudio time.Time
|
|
flushWaiting bool
|
|
flushAt time.Time
|
|
rxSeq uint16
|
|
secure bool
|
|
udpWait bool
|
|
ivPrefix []byte
|
|
udpKey []byte
|
|
rxCounter uint32
|
|
txCounter uint32
|
|
mic []int16
|
|
play []int16
|
|
clientID uint16
|
|
seq uint16
|
|
udp *net.UDPConn
|
|
udpPeer *net.UDPAddr
|
|
lastUDPSend time.Time
|
|
enc *opus.Encoder
|
|
dec *opus.Decoder
|
|
|
|
logFile *os.File
|
|
logPath string
|
|
logNotice string
|
|
up bool
|
|
outage bool
|
|
sending bool
|
|
sendStart time.Time
|
|
talkCall string
|
|
talkStart time.Time
|
|
}
|
|
|
|
// New loads the config at opt.ConfigPath. A missing file is created mode 0600
|
|
// and New returns an error so the caller can exit before opening a window.
|
|
func New(opt Options) (*Station, error) {
|
|
cfg, err := loadConfig(opt.ConfigPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clock := opt.Clock
|
|
if clock == nil {
|
|
clock = wallClock{}
|
|
}
|
|
detail := "Idle"
|
|
if cfg.certFatal != "" {
|
|
detail = cfg.certFatal
|
|
} else if cfg.logNotice != "" {
|
|
detail = cfg.logNotice
|
|
}
|
|
s := &Station{
|
|
cfg: cfg,
|
|
clock: clock,
|
|
wake: make(chan struct{}, 1),
|
|
logPath: cfg.logPath,
|
|
logNotice: cfg.logNotice,
|
|
fatal: cfg.certFatal,
|
|
state: State{
|
|
Connection: "Disconnected",
|
|
Detail: detail,
|
|
Callsign: cfg.callsign,
|
|
TalkGroup: cfg.talkGroup,
|
|
},
|
|
}
|
|
if cfg.enableLogging {
|
|
s.openLog()
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// State returns the latest window state.
|
|
func (s *Station) State() State {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.state
|
|
}
|
|
|
|
// Wait blocks until pred is true or ctx ends.
|
|
func (s *Station) Wait(ctx context.Context, pred func(State) bool) (State, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for !pred(s.state) {
|
|
s.mu.Unlock()
|
|
select {
|
|
case <-ctx.Done():
|
|
s.mu.Lock()
|
|
return State{}, ctx.Err()
|
|
case <-s.wake:
|
|
s.mu.Lock()
|
|
}
|
|
}
|
|
return s.state, nil
|
|
}
|
|
|
|
// HoldButton reports that the on-screen button is held.
|
|
func (s *Station) HoldButton() { s.setButton(true) }
|
|
|
|
// ReleaseButton reports that the on-screen button was released.
|
|
func (s *Station) ReleaseButton() { s.setButton(false) }
|
|
|
|
// HoldSpace reports that Space is held.
|
|
func (s *Station) HoldSpace() { s.setSpace(true) }
|
|
|
|
// ReleaseSpace reports that Space was released.
|
|
func (s *Station) ReleaseSpace() { s.setSpace(false) }
|
|
|
|
func (s *Station) setButton(held bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
was := s.transmitting()
|
|
s.buttonHeld = held
|
|
s.afterHold(was)
|
|
}
|
|
|
|
func (s *Station) setSpace(held bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
was := s.transmitting()
|
|
s.spaceHeld = held
|
|
s.afterHold(was)
|
|
}
|
|
|
|
func (s *Station) afterHold(was bool) {
|
|
if was && !s.transmitting() {
|
|
s.endTransmit()
|
|
}
|
|
s.publish()
|
|
}
|
|
|
|
// WriteMicrophone accepts 16 kHz mono samples. Samples are sent only while
|
|
// this station is allowed to talk; otherwise they are discarded.
|
|
func (s *Station) WriteMicrophone(pcm []int16) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !s.transmitting() || s.enc == nil || s.udp == nil {
|
|
s.mic = nil
|
|
return
|
|
}
|
|
s.mic = append(s.mic, pcm...)
|
|
for len(s.mic) >= opus.FrameSamples {
|
|
frame := append([]int16(nil), s.mic[:opus.FrameSamples]...)
|
|
s.mic = append([]int16(nil), s.mic[opus.FrameSamples:]...)
|
|
pkt, err := s.enc.Encode(frame)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// A lost datagram ends the session when the receive timer expires.
|
|
_ = s.sendUDP(udpAudio, appendBlob(nil, pkt))
|
|
}
|
|
}
|
|
|
|
// TakePlayback returns PCM queued for the speaker since the last take.
|
|
func (s *Station) TakePlayback() []int16 {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.transmitting() {
|
|
s.play = nil
|
|
return nil
|
|
}
|
|
out := s.play
|
|
s.play = nil
|
|
return out
|
|
}
|
|
|
|
// WaitPlayback waits until at least n samples are queued, then returns those samples.
|
|
func (s *Station) WaitPlayback(ctx context.Context, n int) ([]int16, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for len(s.play) < n {
|
|
s.mu.Unlock()
|
|
select {
|
|
case <-ctx.Done():
|
|
s.mu.Lock()
|
|
return nil, ctx.Err()
|
|
case <-s.wake:
|
|
s.mu.Lock()
|
|
}
|
|
}
|
|
out := append([]int16(nil), s.play[:n]...)
|
|
s.play = append([]int16(nil), s.play[n:]...)
|
|
return out, nil
|
|
}
|