chore: release v0.19.4
This commit is contained in:
@@ -89,6 +89,8 @@ const (
|
||||
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
||||
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
||||
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
||||
keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter
|
||||
keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal
|
||||
keyCATPollMs = "cat.poll_ms"
|
||||
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||
@@ -277,6 +279,8 @@ type CATSettings struct {
|
||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
||||
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
|
||||
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
|
||||
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
||||
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
||||
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
|
||||
@@ -453,6 +457,8 @@ type App struct {
|
||||
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
||||
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
||||
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
||||
catFlexDecodeSecs int // decode spot display duration (seconds)
|
||||
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
|
||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||
liveBand string
|
||||
@@ -4460,7 +4466,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -4471,6 +4477,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
FlexHost: m[keyCATFlexHost],
|
||||
FlexPort: 4992,
|
||||
FlexSpots: m[keyCATFlexSpots] == "1",
|
||||
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
||||
FlexDecodeSecs: 120,
|
||||
IcomPort: m[keyCATIcomPort],
|
||||
IcomBaud: 115200,
|
||||
IcomAddr: 0x98, // IC-7610 default
|
||||
@@ -4488,6 +4496,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||
out.FlexPort = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
|
||||
out.FlexDecodeSecs = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
|
||||
out.TCIPort = n
|
||||
}
|
||||
@@ -4549,6 +4560,13 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if s.FlexSpots {
|
||||
flexSpots = "1"
|
||||
}
|
||||
flexDecodeSpots := "0"
|
||||
if s.FlexDecodeSpots {
|
||||
flexDecodeSpots = "1"
|
||||
}
|
||||
if s.FlexDecodeSecs < 10 || s.FlexDecodeSecs > 3600 {
|
||||
s.FlexDecodeSecs = 120
|
||||
}
|
||||
tciSpots := "0"
|
||||
if s.TCISpots {
|
||||
tciSpots = "1"
|
||||
@@ -4567,6 +4585,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||
keyCATFlexSpots: flexSpots,
|
||||
keyCATFlexDecodeSpots: flexDecodeSpots,
|
||||
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||
@@ -7855,6 +7875,24 @@ func (a *App) consumeUDPEvents() {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case ev.DecodeCall != "":
|
||||
// A WSJT-X decode (heard station). Render it on the FlexRadio
|
||||
// panadapter when the option is on; green + SNR comment, auto-expiring
|
||||
// after the configured duration. De-duped per call in the Flex backend.
|
||||
if a.catFlexDecodeSpots && a.cat != nil {
|
||||
secs := a.catFlexDecodeSecs
|
||||
if secs <= 0 {
|
||||
secs = 120
|
||||
}
|
||||
a.cat.SendSpot(cat.SpotInfo{
|
||||
FreqHz: ev.DecodeFreqHz,
|
||||
Callsign: ev.DecodeCall,
|
||||
Mode: ev.Mode,
|
||||
Color: "#FF34C759", // green — distinct from cluster orange
|
||||
Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR),
|
||||
LifetimeSec: secs,
|
||||
})
|
||||
}
|
||||
case ev.LoggedADIF != "":
|
||||
applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF))
|
||||
wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{
|
||||
@@ -8966,6 +9004,8 @@ func (a *App) reloadCAT() {
|
||||
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
||||
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
|
||||
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
|
||||
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
||||
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
||||
if !s.Enabled {
|
||||
a.cat.Stop()
|
||||
return
|
||||
|
||||
@@ -815,7 +815,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8',
|
||||
@@ -2055,6 +2055,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||||
{t('cat.flexSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span>
|
||||
</label>
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!catCfg.flex_decode_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_decode_spots: !!c }))} />
|
||||
{t('cat.flexDecodeSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSpotsHint')}</span>
|
||||
</label>
|
||||
{catCfg.flex_decode_spots && (
|
||||
<div className="col-span-2 flex items-center gap-2 pl-6">
|
||||
<Label className="text-sm">{t('cat.flexDecodeSecs')}</Label>
|
||||
<Input type="number" className="w-24" min={10} max={3600}
|
||||
value={catCfg.flex_decode_secs || 120}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} />
|
||||
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'icom' && (
|
||||
|
||||
@@ -139,7 +139,7 @@ const en: Dict = {
|
||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||
'cat.icomNetAudioHint': 'Play the rig’s received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
|
||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)",
|
||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
|
||||
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
||||
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
||||
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
|
||||
@@ -338,7 +338,7 @@ const fr: Dict = {
|
||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||
'cat.icomNetAudioHint': 'Écoute l’audio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
|
||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)",
|
||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
|
||||
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer l’adresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
||||
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
||||
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.19.3';
|
||||
export const APP_VERSION = '0.19.4';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
@@ -1378,6 +1378,8 @@ export namespace main {
|
||||
flex_host: string;
|
||||
flex_port: number;
|
||||
flex_spots: boolean;
|
||||
flex_decode_spots: boolean;
|
||||
flex_decode_secs: number;
|
||||
icom_port: string;
|
||||
icom_baud: number;
|
||||
icom_addr: number;
|
||||
@@ -1404,6 +1406,8 @@ export namespace main {
|
||||
this.flex_host = source["flex_host"];
|
||||
this.flex_port = source["flex_port"];
|
||||
this.flex_spots = source["flex_spots"];
|
||||
this.flex_decode_spots = source["flex_decode_spots"];
|
||||
this.flex_decode_secs = source["flex_decode_secs"];
|
||||
this.icom_port = source["icom_port"];
|
||||
this.icom_baud = source["icom_baud"];
|
||||
this.icom_addr = source["icom_addr"];
|
||||
|
||||
@@ -210,6 +210,7 @@ type SpotInfo struct {
|
||||
Mode string
|
||||
Color string
|
||||
Comment string
|
||||
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
||||
}
|
||||
|
||||
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||
|
||||
+21
-3
@@ -60,6 +60,7 @@ type Flex struct {
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||
|
||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||
@@ -157,7 +158,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
return &Flex{
|
||||
host: strings.TrimSpace(host), port: port,
|
||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
}
|
||||
@@ -352,6 +353,7 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.spotCall[idx] = call
|
||||
f.spotIdx[idx] = true
|
||||
f.spotByCall[strings.ToUpper(call)] = idx
|
||||
}
|
||||
}
|
||||
// A successful "slice create" for split returns the new slice's index;
|
||||
@@ -1071,8 +1073,23 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
if color == "" {
|
||||
color = "#FFFFA500" // opaque orange default
|
||||
}
|
||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d",
|
||||
float64(s.FreqHz)/1e6, call, color, time.Now().Unix())
|
||||
life := s.LifetimeSec
|
||||
if life <= 0 {
|
||||
life = 1800 // default 30 min (cluster spots)
|
||||
}
|
||||
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
|
||||
// spotted gets its previous spot removed first — one live spot per call,
|
||||
// refreshed, instead of a pile that all expire independently.
|
||||
f.mu.Lock()
|
||||
if old, ok := f.spotByCall[strings.ToUpper(s.Callsign)]; ok {
|
||||
delete(f.spotByCall, strings.ToUpper(s.Callsign))
|
||||
delete(f.spotCall, old)
|
||||
delete(f.spotIdx, old)
|
||||
f.send(fmt.Sprintf("spot remove %d", old))
|
||||
}
|
||||
f.mu.Unlock()
|
||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||
if m := flexEncode(s.Mode); m != "" {
|
||||
cmd += " mode=" + m
|
||||
}
|
||||
@@ -1118,6 +1135,7 @@ func (f *Flex) ClearSpots() error {
|
||||
f.mu.Lock()
|
||||
f.spotIdx = map[int]bool{}
|
||||
f.spotCall = map[int]string{}
|
||||
f.spotByCall = map[string]int{}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
|
||||
@@ -43,9 +43,15 @@ type Event struct {
|
||||
|
||||
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
||||
DXGrid string // ServiceWSJT (Status)
|
||||
Mode string // ServiceWSJT (Status)
|
||||
Mode string // ServiceWSJT (Status/Decode)
|
||||
FreqHz int64 // ServiceWSJT (Status)
|
||||
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
||||
|
||||
// A WSJT-X Decode (heard station) to render on the panadapter.
|
||||
DecodeCall string // transmitting (DE) callsign
|
||||
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||
DecodeSNR int // reported SNR (dB)
|
||||
DecodeCQ bool // the decode was a CQ
|
||||
}
|
||||
|
||||
// Server is a single inbound UDP listener.
|
||||
@@ -57,6 +63,8 @@ type Server struct {
|
||||
done chan struct{}
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
|
||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||
}
|
||||
|
||||
func newServer(cfg Config, out chan<- Event) *Server {
|
||||
@@ -175,9 +183,29 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name)
|
||||
return
|
||||
}
|
||||
// Status carries the current dial frequency; remember it so Decode audio
|
||||
// offsets can be turned into RF frequencies for the panadapter.
|
||||
if w.FreqHz > 0 && !w.IsDecode {
|
||||
s.mu.Lock()
|
||||
s.lastDialHz = w.FreqHz
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if w.IsDecode {
|
||||
s.mu.Lock()
|
||||
dial := s.lastDialHz
|
||||
s.mu.Unlock()
|
||||
if dial <= 0 {
|
||||
return // no dial freq yet (Status not seen) → can't place the spot
|
||||
}
|
||||
ev.DecodeCall = w.DecodeCall
|
||||
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||
ev.DecodeSNR = w.SNR
|
||||
ev.DecodeCQ = w.IsCQ
|
||||
ev.Mode = w.Mode
|
||||
break
|
||||
}
|
||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||
ev.DXCall = w.DXCall
|
||||
@@ -241,7 +269,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
return
|
||||
}
|
||||
// Empty events are useless; skip.
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" {
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
|
||||
return
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -37,18 +37,27 @@ const (
|
||||
)
|
||||
|
||||
// WSJTEvent is the parsed, typed result of decoding a single packet.
|
||||
// One of (DXCall, LoggedADIF) is non-empty depending on the message.
|
||||
// One of (DXCall, LoggedADIF, DecodeCall) is non-empty depending on the message.
|
||||
type WSJTEvent struct {
|
||||
DXCall string // current "DX Call" field in the WSJT app
|
||||
DXGrid string // optional grid for that call
|
||||
DXCall string // current "DX Call" field in the WSJT app (Status)
|
||||
DXGrid string // optional grid for that call (Status)
|
||||
Mode string // FT8 / FT4 / …
|
||||
FreqHz int64 // current dial freq when available
|
||||
FreqHz int64 // current dial freq when available (Status)
|
||||
LoggedADIF string // full ADIF text when message is LoggedADIF
|
||||
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
|
||||
|
||||
// Decode (type 2): the transmitting station heard on the band. FreqHz is NOT
|
||||
// set here (Decode carries only the audio offset); the caller adds the last
|
||||
// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
|
||||
IsDecode bool
|
||||
DecodeCall string // the sender (DE) callsign extracted from the message text
|
||||
DeltaFreqHz int64 // audio offset within the passband (Hz)
|
||||
SNR int // reported signal-to-noise (dB)
|
||||
IsCQ bool // the decode was a CQ call
|
||||
}
|
||||
|
||||
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
|
||||
// don't care about (heartbeat, decode lines, clears, etc.).
|
||||
// don't care about (heartbeat, clears, etc.).
|
||||
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
if len(pkt) < 12 {
|
||||
return WSJTEvent{}, false, fmt.Errorf("packet too short")
|
||||
@@ -143,6 +152,56 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
|
||||
return ev, true, nil
|
||||
|
||||
case wsjtMsgDecode:
|
||||
// Decode payload (v2):
|
||||
// bool is_new
|
||||
// quint32 time (ms since midnight)
|
||||
// qint32 snr
|
||||
// double delta_time (seconds)
|
||||
// quint32 delta_frequency (Hz, audio offset in the passband)
|
||||
// QUtf8 mode
|
||||
// QUtf8 message (the decoded text, e.g. "CQ K1ABC FN42")
|
||||
// bool low_confidence
|
||||
// bool off_air
|
||||
var b uint8
|
||||
if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
var t32, df uint32
|
||||
var snr int32
|
||||
if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &snr); err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
var dt float64
|
||||
if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
mode, err := readQString(r)
|
||||
if err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
msg, err := readQString(r)
|
||||
if err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
call, isCQ := wsjtSender(msg)
|
||||
if call == "" {
|
||||
return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
|
||||
}
|
||||
ev.IsDecode = true
|
||||
ev.DecodeCall = call
|
||||
ev.IsCQ = isCQ
|
||||
ev.DeltaFreqHz = int64(df)
|
||||
ev.SNR = int(snr)
|
||||
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
return ev, true, nil
|
||||
|
||||
case wsjtMsgLoggedADIF:
|
||||
// Payload: a single QString containing the ADIF record.
|
||||
adif, err := readQString(r)
|
||||
@@ -155,6 +214,64 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
return WSJTEvent{}, false, nil
|
||||
}
|
||||
|
||||
// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and
|
||||
// whether it was a CQ. Grammar:
|
||||
//
|
||||
// CQ [modifier] <de_call> [grid] → de_call, isCQ=true
|
||||
// <to_call> <de_call> [report|…] → de_call, isCQ=false
|
||||
//
|
||||
// Returns "" for free-text / telemetry / hashed-call messages we can't resolve.
|
||||
func wsjtSender(message string) (call string, isCQ bool) {
|
||||
f := strings.Fields(strings.ToUpper(strings.TrimSpace(message)))
|
||||
if len(f) == 0 {
|
||||
return "", false
|
||||
}
|
||||
if f[0] == "CQ" {
|
||||
// Skip an optional modifier after CQ (DX / a region like NA / a zone like
|
||||
// 020) — it never looks like a callsign (no letter+digit mix).
|
||||
idx := 1
|
||||
if len(f) > 2 && !looksLikeCall(f[1]) {
|
||||
idx = 2
|
||||
}
|
||||
if idx < len(f) && looksLikeCall(f[idx]) {
|
||||
return f[idx], true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
// Standard exchange: the DE (sender) call is the second token.
|
||||
if len(f) >= 2 && looksLikeCall(f[1]) {
|
||||
return f[1], false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// looksLikeCall is a loose callsign test: 3–12 chars of A–Z/0–9//, with at least
|
||||
// one letter AND one digit. Rejects the fixed exchange tokens (RR73/RRR/73) that
|
||||
// would otherwise pass.
|
||||
func looksLikeCall(s string) bool {
|
||||
switch s {
|
||||
case "RR73", "RRR", "73":
|
||||
return false
|
||||
}
|
||||
if len(s) < 3 || len(s) > 12 {
|
||||
return false
|
||||
}
|
||||
hasL, hasD := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z':
|
||||
hasL = true
|
||||
case c >= '0' && c <= '9':
|
||||
hasD = true
|
||||
case c == '/':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasL && hasD
|
||||
}
|
||||
|
||||
// readQString reads a Qt QString as written by QDataStream: an int32 byte
|
||||
// length (or -1 for null) followed by the UTF-8 bytes.
|
||||
func readQString(r *bytes.Reader) (string, error) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package udp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWSJTSender(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
wantCall string
|
||||
wantCQ bool
|
||||
}{
|
||||
{"CQ K1ABC FN42", "K1ABC", true},
|
||||
{"CQ DX W2XYZ EM12", "W2XYZ", true}, // modifier "DX" skipped
|
||||
{"CQ NA VE3ABC FN03", "VE3ABC", true}, // region modifier skipped
|
||||
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true},// zone modifier skipped
|
||||
{"W2XYZ K1ABC -10", "K1ABC", false}, // exchange → sender is 2nd call
|
||||
{"W2XYZ K1ABC R-10", "K1ABC", false},
|
||||
{"W2XYZ K1ABC RR73", "K1ABC", false},
|
||||
{"F4BPO K1ABC/P 73", "K1ABC/P", false},// portable call kept
|
||||
{"CQ F/DL1ABC JO31", "F/DL1ABC", true},// compound prefix
|
||||
// Non-callsign / free text → no sender.
|
||||
{"TNX 73 GL", "", false},
|
||||
{"K1ABC RR73", "", false}, // only one call + a token → 2nd token not a call
|
||||
{"", "", false},
|
||||
{"CQ CQ CQ", "", true}, // CQ but no resolvable call
|
||||
}
|
||||
for _, c := range cases {
|
||||
call, cq := wsjtSender(c.msg)
|
||||
if call != c.wantCall || cq != c.wantCQ {
|
||||
t.Errorf("wsjtSender(%q) = (%q,%v), want (%q,%v)", c.msg, call, cq, c.wantCall, c.wantCQ)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.19.3"
|
||||
appVersion = "0.19.4"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user