8 Commits
56 changed files with 2682 additions and 134 deletions
+226 -3
View File
@@ -96,6 +96,7 @@ const (
keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2) keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001) keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
@@ -244,6 +245,7 @@ const (
keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download) keyExtLoTWWebPassword = "extsvc.lotw.web_password" // LoTW website password (download)
keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull keyExtLoTWLastDownload = "extsvc.lotw.last_download" // YYYY-MM-DD of last confirmation pull
keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull keyExtQRZLastDownload = "extsvc.qrz.last_download" // YYYY-MM-DD of last QRZ confirmation pull
keyExtEQSLLastDownload = "extsvc.eqsl.last_download" // YYYY-MM-DD of last eQSL inbox pull
) )
// QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload // QSLDefaults is the per-user default for the QSL / eQSL / LoTW / upload
@@ -279,6 +281,7 @@ type CATSettings struct {
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server) IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
@@ -4321,7 +4324,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") 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, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) 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)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -4338,6 +4341,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
IcomNetHost: m[keyCATIcomNetHost], IcomNetHost: m[keyCATIcomNetHost],
IcomNetUser: m[keyCATIcomNetUser], IcomNetUser: m[keyCATIcomNetUser],
IcomNetPass: m[keyCATIcomNetPass], IcomNetPass: m[keyCATIcomNetPass],
IcomNetAudio: m[keyCATIcomNetAudio] == "1",
TCIHost: m[keyCATTCIHost], TCIHost: m[keyCATTCIHost],
TCIPort: 40001, TCIPort: 40001,
TCISpots: m[keyCATTCISpots] == "1", TCISpots: m[keyCATTCISpots] == "1",
@@ -4413,6 +4417,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.TCISpots { if s.TCISpots {
tciSpots = "1" tciSpots = "1"
} }
icomNetAudio := "0"
if s.IcomNetAudio {
icomNetAudio = "1"
}
if s.DigitalDefault == "" { if s.DigitalDefault == "" {
s.DigitalDefault = "FT8" s.DigitalDefault = "FT8"
} }
@@ -4429,6 +4437,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost), keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost),
keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser), keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser),
keyCATIcomNetPass: s.IcomNetPass, keyCATIcomNetPass: s.IcomNetPass,
keyCATIcomNetAudio: icomNetAudio,
keyCATTCIHost: strings.TrimSpace(s.TCIHost), keyCATTCIHost: strings.TrimSpace(s.TCIHost),
keyCATTCIPort: strconv.Itoa(s.TCIPort), keyCATTCIPort: strconv.Itoa(s.TCIPort),
keyCATTCISpots: tciSpots, keyCATTCISpots: tciSpots,
@@ -5844,8 +5853,20 @@ func (a *App) TestPTT(cfg AudioSettings) error {
if err := a.pttKey(cfg); err != nil { if err := a.pttKey(cfg); err != nil {
return err return err
} }
// Hold long enough to be observable. OmniRig is async and coalesces rapid
// writes to the same parameter: it stores the desired Tx value and flushes
// on its own poll timer, so a key (PM_TX) followed too quickly by an unkey
// (PM_RX) can land in ONE flush cycle — OmniRig then sends only the last
// value (PM_RX) and the rig never actually transmits (while an already-MOX'd
// rig gets dropped by that lone PM_RX). A longer hold guarantees the key
// command is sent and held before the release. Serial RTS/DTR is instant,
// so it keeps the short window.
hold := 600 * time.Millisecond
if cfg.PTTMethod == "cat" {
hold = 1500 * time.Millisecond
}
gen := a.pttGenNow() gen := a.pttGenNow()
go func() { time.Sleep(600 * time.Millisecond); a.unkeyIfCurrent(gen) }() go func() { time.Sleep(hold); a.unkeyIfCurrent(gen) }()
return nil return nil
} }
@@ -5865,6 +5886,72 @@ func (a *App) DVKStop() {
} }
} }
// AudioStartMonitor pipes live RX audio from the rig into your speakers so you
// hear the radio inside OpsLog. Source = the "From radio" capture device (for a
// USB-connected rig, its "USB Audio CODEC" input); sink = the "Listening"
// device. This is the USB half of the audio feature; the network 50003 stream
// will later Push into the same output path (see audio.Manager.PushMonitorAudio).
func (a *App) AudioStartMonitor() error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
cfg, _ := a.GetAudioSettings()
if strings.TrimSpace(cfg.FromRadio) == "" {
return fmt.Errorf(`no "From radio" capture device set — pick the rig's USB Audio CODEC in Settings → Audio`)
}
applog.Printf("audio: RX monitor start (from=%q → listen=%q)", cfg.FromRadio, cfg.ListeningDevice)
return a.audioMgr.StartMonitor(cfg.FromRadio, cfg.ListeningDevice)
}
// AudioStopMonitor stops the RX monitor passthrough.
func (a *App) AudioStopMonitor() {
if a.audioMgr != nil {
a.audioMgr.StopMonitor()
applog.Printf("audio: RX monitor stopped")
}
}
// AudioMonitorActive reports whether the RX monitor is running (for the toggle).
func (a *App) AudioMonitorActive() bool {
return a.audioMgr != nil && a.audioMgr.MonitorActive()
}
// AudioStartTX keys PTT and pipes your live mic into the rig ("To Radio" device)
// so you can talk through the PC — the USB half of TX voice. PTT uses the
// configured method (CAT/RTS/DTR); if keying fails the audio route isn't started.
func (a *App) AudioStartTX() error {
if a.audioMgr == nil {
return fmt.Errorf("audio not initialized")
}
cfg, _ := a.GetAudioSettings()
if strings.TrimSpace(cfg.ToRadio) == "" {
return fmt.Errorf(`no "To radio" device set — pick the rig's USB Audio CODEC output in Settings → Audio`)
}
if err := a.pttKey(cfg); err != nil { // key first — no point streaming to a rig that isn't transmitting
return err
}
if err := a.audioMgr.StartTXAudio(cfg.RecordingDevice, cfg.ToRadio); err != nil {
a.pttUnkey()
return err
}
applog.Printf("audio: TX start (mic=%q → to-radio=%q, ptt=%q)", cfg.RecordingDevice, cfg.ToRadio, cfg.PTTMethod)
return nil
}
// AudioStopTX stops the TX passthrough and unkeys PTT.
func (a *App) AudioStopTX() {
if a.audioMgr != nil {
a.audioMgr.StopTXAudio()
}
a.pttUnkey()
applog.Printf("audio: TX stopped")
}
// AudioTXActive reports whether the TX passthrough is running (for the toggle).
func (a *App) AudioTXActive() bool {
return a.audioMgr != nil && a.audioMgr.TXAudioActive()
}
// GetLogFilePath returns where the diagnostic log file lives so the user // GetLogFilePath returns where the diagnostic log file lives so the user
// can open it from the Settings UI. Empty when applog hasn't initialised. // can open it from the Settings UI. Empty when applog hasn't initialised.
func (a *App) GetLogFilePath() string { func (a *App) GetLogFilePath() string {
@@ -6819,6 +6906,97 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total)) emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
// (last-download date already stored right after the fetch above) // (last-download date already stored right after the fetch above)
case extsvc.ServiceEQSL:
sinceDate := resolveSince(keyExtEQSLLastDownload)
if sinceDate != "" {
emit("Downloading eQSL Inbox (confirmations received since " + sinceDate + ")…")
} else {
emit("Downloading full eQSL Inbox (all received confirmations)…")
}
adifText, err := extsvc.DownloadEQSLConfirmations(ctx, nil, cfg.EQSL, sinceDate)
if err != nil {
emit("Download failed: " + err.Error())
done(matched, total)
return
}
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
if kerr != nil {
emit("Error reading local log: " + kerr.Error())
done(matched, total)
return
}
// eQSL confirmations aren't ARRL-award-valid (only LoTW + paper QSL are),
// so NEW is judged only against other eQSL confirmations.
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"eqsl_rcvd"})
var items []ConfirmationItem
var unmatched []string
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
q, ok := adif.RecordToQSO(rec)
if !ok {
return nil
}
total++
// eQSL stamps the confirmation date in QSLRDATE (fall back to today).
date := rec["qslrdate"]
if date == "" {
date = time.Now().UTC().Format("20060102")
}
a.enrichContactedFromCty(&q)
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if id, found := keyIDs[key]; found {
if e := a.qso.MarkEQSLConfirmed(ctx, id, date); e == nil {
matched++
}
} else if addNotFound {
q.EQSLSent = "Y"
q.EQSLRcvd = "Y"
q.EQSLRcvdDate = date
if newID, e := a.qso.Add(ctx, q); e == nil {
keyIDs[key] = newID
added++
}
} else {
unmatched = append(unmatched, fmt.Sprintf("%s · %s · %s · %s",
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04Z"), q.Band, q.Mode))
}
dxccNum := 0
if q.DXCC != nil {
dxccNum = *q.DXCC
}
it := ConfirmationItem{
Callsign: q.Callsign,
QSODate: q.QSODate.UTC().Format(time.RFC3339),
Band: q.Band, Mode: q.Mode, Country: q.Country,
}
if dxccNum != 0 {
it.NewDXCC = !sets.DXCC[dxccNum]
it.NewBand = !sets.Band[qso.BandKey(dxccNum, q.Band)]
it.NewSlot = !sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)]
sets.DXCC[dxccNum] = true
sets.Band[qso.BandKey(dxccNum, q.Band)] = true
sets.Slot[qso.SlotKey(dxccNum, q.Band, q.Mode)] = true
}
items = append(items, it)
return nil
})
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "qslmgr:confirmations", items)
}
if perr != nil {
emit("Parse error: " + perr.Error())
}
if addNotFound {
emit(fmt.Sprintf("Matched %d, added %d (of %d eQSL confirmation(s))", matched, added, total))
} else {
emit(fmt.Sprintf("Matched %d of %d eQSL confirmation(s)", matched, total))
}
for _, u := range unmatched {
emit(" ⚠ no local QSO for: " + u)
}
if a.settings != nil {
a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02"))
}
default: default:
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc)) emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
} }
@@ -8492,6 +8670,30 @@ func (a *App) FlexSetAPFLevel(l int) error {
} }
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPFLevel(l) }) return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPFLevel(l) })
} }
func (a *App) FlexSetWNB(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNB(on) })
}
func (a *App) FlexSetWNBLevel(l int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetWNBLevel(l) })
}
func (a *App) FlexSetTXFilter(low, high int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXFilter(low, high) })
}
func (a *App) FlexSetMicProfile(name string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMicProfile(name) })
}
func (a *App) FlexSetCWSpeed(wpm int) error { func (a *App) FlexSetCWSpeed(wpm int) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
@@ -8594,7 +8796,28 @@ func (a *App) reloadCAT() {
case "icom-net": case "icom-net":
// Icom CI-V over the rig's built-in LAN server (remote, no RS-BA1 / Remote // Icom CI-V over the rig's built-in LAN server (remote, no RS-BA1 / Remote
// Utility). Reuses the whole IcomController over the network transport. // Utility). Reuses the whole IcomController over the network transport.
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault)) var audioSink func([]byte)
if s.IcomNetAudio && a.audioMgr != nil {
// Experimental: stream RX audio over 50003. Start the render-only monitor
// sink (no USB capture) and feed each decoded payload into it, so remote
// RX audio plays through the "Listening" device. Decoding is via the PCM
// codec (Opus later). The 50003 payload OFFSET is still pending on-rig
// verification (see icomaudio.go) — hence experimental + opt-in.
acfg, _ := a.GetAudioSettings()
a.audioMgr.StopMonitor() // clear any prior monitor/sink so a re-save restarts cleanly
if err := a.audioMgr.StartMonitorSink(acfg.ListeningDevice); err != nil {
applog.Printf("icom-net audio: cannot start output sink: %v", err)
} else {
codec := audio.NewPCM16Codec()
audioSink = func(payload []byte) {
if pcm, err := codec.Decode(payload); err == nil {
a.audioMgr.PushMonitorAudio(pcm)
}
}
applog.Printf("icom-net audio: RX audio streaming ENABLED (experimental) → %q", acfg.ListeningDevice)
}
}
a.cat.Start(cat.NewIcomNet(s.IcomNetHost, s.IcomNetUser, s.IcomNetPass, s.IcomAddr, s.DigitalDefault, audioSink))
case "tci": case "tci":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any // Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
// TCI-compatible server. // TCI-compatible server.
+16 -4
View File
@@ -366,6 +366,11 @@ export default function App() {
// CAT — receives live rig state via Wails events. // CAT — receives live rig state via Wails events.
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any); const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
// Configured CAT backend ('icom' USB vs 'icom-net'): the live catState.backend
// is "icom" for BOTH, so we track the settings value to tell them apart (used to
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
// rig is off so power-ON can't work).
const [catBackend, setCatBackend] = useState('');
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false }); const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] }); const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
@@ -1352,6 +1357,7 @@ export default function App() {
try { try {
const c = await GetCATSettings(); const c = await GetCATSettings();
if (c.digital_default) digitalDefaultRef.current = c.digital_default; if (c.digital_default) digitalDefaultRef.current = c.digital_default;
setCatBackend(c.backend ?? '');
} catch {} } catch {}
}, []); }, []);
const loadLists = useCallback(async () => { const loadLists = useCallback(async () => {
@@ -3154,7 +3160,7 @@ export default function App() {
case 'icom': case 'icom':
return ( return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border"> <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<IcomPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div> </div>
); );
case 'netcontrol': case 'netcontrol':
@@ -3754,6 +3760,7 @@ export default function App() {
status={agStatus} status={agStatus}
onActivate={agActivate} onActivate={agActivate}
onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }} onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
band={band}
/> />
</div> </div>
)} )}
@@ -4245,7 +4252,7 @@ export default function App() {
is an Icom. */} is an Icom. */}
{catState.backend === 'icom' && ( {catState.backend === 'icom' && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0"> <TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</TabsContent> </TabsContent>
)} )}
@@ -4397,9 +4404,14 @@ export default function App() {
onClose={() => setShowSpotModal(false)} onClose={() => setShowSpotModal(false)}
// Callsign: the QSO-entry call, else the last logged QSO. // Callsign: the QSO-entry call, else the last logged QSO.
defaultCall={callsign.trim() || qsos[0]?.callsign || ''} defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
// Freq: the entry TX freq (kHz), else the last logged QSO's. // Freq (0.1 kHz): the LIVE CAT frequency in Hz when connected — NOT the
// freqMhz display string, which the manual-edit freeze / field locks can
// leave stale (that dropped the sub-kHz: on 14134.5 the frozen "14.134"
// string spotted 14134). Fall back to the entry field, then the last QSO.
defaultFreqKHz={ defaultFreqKHz={
parseFloat(freqMhz) > 0 catState.connected && (catState.freq_hz ?? 0) > 0
? Math.round(((catState.freq_hz ?? 0) / 1000) * 10) / 10
: parseFloat(freqMhz) > 0
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10 ? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0) : (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
} }
+40 -4
View File
@@ -1,14 +1,30 @@
import { Antenna, X } from 'lucide-react'; import { useState } from 'react';
import { Antenna, X, ListFilter } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
export type AGAntenna = { index: number; name: string }; export type AGAntenna = { index: number; name: string; bands?: number };
export type AGStatus = { export type AGStatus = {
connected: boolean; host?: string; last_error?: string; connected: boolean; host?: string; last_error?: string;
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean; port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
antennas: AGAntenna[]; antennas: AGAntenna[];
}; };
// AG_BAND_BITS maps a band name to its bit in the 4O3A Antenna Genius band
// bitmask (standard HF+6m order, bit 0 = 160m). Used to show only the antennas
// configured for the current band, like the native app. If the mapping ever
// proves off on real hardware, the debug log (antgenius: antenna raw …) shows the
// device's actual mask so it can be corrected.
const AG_BAND_BITS: Record<string, number> = {
'160m': 1 << 0, '80m': 1 << 1, '60m': 0, '40m': 1 << 2, '30m': 1 << 3,
'20m': 1 << 4, '17m': 1 << 5, '15m': 1 << 6, '12m': 1 << 7, '10m': 1 << 8, '6m': 1 << 9,
};
function bandBit(band?: string): number {
if (!band) return 0;
return AG_BAND_BITS[band.trim().toLowerCase()] ?? 0;
}
// Format an antenna name: first letter uppercase, the rest lowercase // Format an antenna name: first letter uppercase, the rest lowercase
// (e.g. "DX COMMANDER" → "Dx commander"). // (e.g. "DX COMMANDER" → "Dx commander").
function pretty(name: string): string { function pretty(name: string): string {
@@ -22,13 +38,28 @@ function pretty(name: string): string {
// a port-A button (left) and port-B button (right). Colours: green = selected on // a port-A button (left) and port-B button (right). Colours: green = selected on
// port A, blue = selected on port B, red (pulsing) = that port is transmitting. // port A, blue = selected on port B, red (pulsing) = that port is transmitting.
// Clicking an already-selected port deselects it (port → None). // Clicking an already-selected port deselects it (port → None).
export function AntGeniusPanel({ status, onActivate, onClose }: { export function AntGeniusPanel({ status, onActivate, onClose, band }: {
status: AGStatus; status: AGStatus;
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
onClose: () => void; onClose: () => void;
band?: string; // current operating band (e.g. "20m") for the band filter
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const list = status.antennas ?? []; const allAntennas = status.antennas ?? [];
// Band filter (persisted, default ON): show only antennas whose band mask
// includes the current band — matching the native 4O3A app. Antennas with an
// unknown mask (0) always show, and if the filter would hide EVERYTHING (e.g. a
// band we can't map, or masks the device didn't send) we fall back to the full
// list so the panel is never mysteriously empty.
const [bandFilter, setBandFilter] = useState(() => localStorage.getItem('opslog.agBandFilter') !== '0');
const toggleBandFilter = () => setBandFilter((v) => { const n = !v; localStorage.setItem('opslog.agBandFilter', n ? '1' : '0'); return n; });
const bit = bandBit(band);
let list = allAntennas;
if (bandFilter && bit !== 0) {
const filtered = allAntennas.filter((a) => !a.bands || (a.bands & bit) !== 0);
if (filtered.length > 0) list = filtered;
}
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => { const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B'; const letter = port === 1 ? 'A' : 'B';
@@ -57,6 +88,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} /> <Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span> <span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
<span className="flex-1" /> <span className="flex-1" />
<button type="button" onClick={toggleBandFilter}
title={bandFilter ? t('agp.filterOnHint', { band: band || '' }) : t('agp.filterOffHint')}
className={cn('transition-colors', bandFilter ? 'text-primary' : 'text-muted-foreground/50 hover:text-muted-foreground')}>
<ListFilter className="size-3.5" />
</button>
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider"> <span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} /> <span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span> <span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
+8 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react'; import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
@@ -134,6 +134,12 @@ interface Props {
export function FilterBuilder({ open, initial, onApply, onClose }: Props) { export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Field dropdown sorted alphabetically by translated label (the FIELDS array is
// grouped by category for maintenance; the operator wants it A→Z to find things).
const sortedFields = useMemo(
() => FIELDS.map((f) => ({ f, txt: t(f.label) })).sort((a, b) => a.txt.localeCompare(b.txt)),
[t],
);
const [conditions, setConditions] = useState<FilterCondition[]>([]); const [conditions, setConditions] = useState<FilterCondition[]>([]);
const [match, setMatch] = useState<'AND' | 'OR'>('AND'); const [match, setMatch] = useState<'AND' | 'OR'>('AND');
const [presets, setPresets] = useState<Record<string, QueryFilter>>({}); const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
@@ -237,7 +243,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
}}> }}>
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72"> <SelectContent className="max-h-72">
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{t(f.label)}</SelectItem>)} {sortedFields.map(({ f, txt }) => <SelectItem key={f.value} value={f.value}>{txt}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}> <Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
+135 -59
View File
@@ -7,6 +7,7 @@ import {
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
@@ -25,6 +26,8 @@ type FlexState = {
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[]; rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number; split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
wnb: boolean; wnb_level: number;
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
mode?: string; mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number; apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
@@ -42,6 +45,7 @@ const ZERO: FlexState = {
mon: false, mon_level: 0, mic_level: 0, atu_memories: false, mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false, rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0, cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0, apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
amp_available: false, amp_operate: false, amp_available: false, amp_operate: false,
@@ -143,27 +147,28 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
// `display` overrides the numeric readout; `segColor` colours segments by their // `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak). // 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26; const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title }: { function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string; label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string; segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
}) { }) {
const span = hi - lo; const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0; const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS); const lit = Math.round((pct / 100) * METER_SEGMENTS);
return ( return (
<div onClick={onClick} title={title} <div onClick={onClick} title={title}
className={cn('rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0', className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2',
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}> onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
<div className="flex items-baseline justify-between gap-1 mb-1.5"> <div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90"> <span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : ( {display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></> <>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)} )}
</span> </span>
</div> </div>
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */} {/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]"> <div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => { {Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit; const on = i < lit;
const frac = i / METER_SEGMENTS; const frac = i / METER_SEGMENTS;
@@ -176,7 +181,7 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
); );
})} })}
</div> </div>
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>} {extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div> </div>
); );
} }
@@ -262,6 +267,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0]) ? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
: -1; : -1;
// Compact header readouts for PA voltage + temperature (numbers, not bars) — the
// user wanted these in the header to save vertical space in the Meters card.
const hdrMeters = st.meters || [];
const hdrFind = (name: string) => hdrMeters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const voltM = hdrFind('13.8') || hdrFind('VOLT');
const patempM = hdrFind('PATEMP') || hdrFind('PA TEMP');
return ( return (
<div className="h-full min-h-0 overflow-auto bg-background"> <div className="h-full min-h-0 overflow-auto bg-background">
<div className="max-w-5xl mx-auto p-3 space-y-3"> <div className="max-w-5xl mx-auto p-3 space-y-3">
@@ -274,6 +286,23 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span> <span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
{/* Compact PA voltage + temperature readouts (numbers) to save space. */}
{!off && (voltM || patempM) && (
<div className="flex items-center gap-4 mr-1 font-mono tabular-nums">
{voltM && (
<span className="flex flex-col items-end leading-none">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.voltage')}</span>
<span className="text-sm font-bold">{voltM.value.toFixed(1)}<span className="text-[9px] text-slate-400 ml-0.5">V</span></span>
</span>
)}
{patempM && (
<span className="flex flex-col items-end leading-none">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.paTemp')}</span>
<span className={cn('text-sm font-bold', patempM.value >= 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}<span className="text-[9px] text-slate-400 ml-0.5">°C</span></span>
</span>
)}
</div>
)}
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider', <span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
!st.available ? 'bg-muted text-muted-foreground' !st.available ? 'bg-muted text-muted-foreground'
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}> : st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
@@ -315,6 +344,58 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</div> </div>
)} )}
{/* Live meters (UDP VITA-49 stream) — placed right under the slices. */}
<Card icon={Gauge} title={t('flxp.meters')}>
{(() => {
const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
// Mic input level + speech-compression (voltage & PA temp live in the
// header now). All already in the meter stream.
const mic = meters.find((m) => /MIC/i.test(m.name || '') && !(m.src || '').toUpperCase().includes('AMP'));
const comp = radio('COMPPEAK') || radio('COMP');
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const sv = (dbm + 127) / 6; // S0 = -127 dBm
if (sv >= 9) {
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
}
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression (dB of gain reduction).
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
].filter(Boolean);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
);
})()}
</Card>
{off && ( {off && (
<div className="text-center text-sm text-muted-foreground py-6"> <div className="text-center text-sm text-muted-foreground py-6">
{t('flxp.waiting')} {t('flxp.waiting')}
@@ -391,6 +472,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} /> <Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
</div> </div>
{/* TX audio bandwidth — low/high cut (Hz). Typed locally, committed on
blur/Enter so we don't spam a command per keystroke; the hold guard
keeps the poll from clobbering the field mid-edit. */}
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.txFilter')}</span>
{(() => {
const editTX = (patch: Partial<FlexState>) => { hold.current['tx_filter_low'] = Date.now() + 8000; hold.current['tx_filter_high'] = Date.now() + 8000; setSt((p) => ({ ...p, ...patch })); };
const commitTX = () => { hold.current['tx_filter_low'] = Date.now() + 900; hold.current['tx_filter_high'] = Date.now() + 900; FlexSetTXFilter(st.tx_filter_low || 0, st.tx_filter_high || 0).catch(() => {}); };
const inp = 'w-[70px] h-8 rounded-md border border-input bg-background px-2 text-xs font-mono tabular-nums disabled:opacity-40';
return (<>
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_low || 0} className={inp}
onChange={(e) => editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })}
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
<span className="text-muted-foreground text-xs"></span>
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_high || 0} className={inp}
onChange={(e) => editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })}
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
</>);
})()}
</div>
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */}
{!!st.mic_profiles && st.mic_profiles.length > 0 && (
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.micProfile')}</span>
<select disabled={off} value={st.mic_profile || ''}
onChange={(e) => { const v = e.target.value; hold.current['mic_profile'] = Date.now() + 2000; setSt((p) => ({ ...p, mic_profile: v })); FlexSetMicProfile(v).catch(() => {}); }}
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-2 text-xs">
{!st.mic_profiles.includes(st.mic_profile || '') && <option value=""></option>}
{st.mic_profiles.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
)}
</div> </div>
) : ( ) : (
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */ /* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
@@ -464,6 +578,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706" <LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))} onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} /> onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2" <LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))} onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} /> onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
@@ -560,71 +678,29 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span> <span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
)} )}
</div> </div>
</Card> {/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
)} and shown compact so they sit with the amp controls. */}
{/* Live meters (UDP VITA-49 stream) */}
<Card icon={Gauge} title={t('flxp.meters')}>
{(() => { {(() => {
const meters = st.meters || []; const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10); const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim())); && !/^(RL|DRV)$/i.test((m.name || '').trim()));
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626' if (off || amp.length === 0) return null;
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const sv = (dbm + 127) / 6; // S0 = -127 dBm
if (sv >= 9) {
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
}
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
].filter(Boolean);
return ( return (
<> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
{amp.length > 0 && (
<div className="border-t border-border/60 pt-2 mt-1">
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">{t('flxp.amplifierHdr')}</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{amp.map((m) => { {amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) { if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />; return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
} }
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />; const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={acc} />;
})} })}
</div> </div>
</div>
)}
</>
); );
})()} })()}
</Card> </Card>
)}
</div> </div>
</div> </div>
); );
+25 -4
View File
@@ -58,6 +58,21 @@ const BANDS: { l: string; hz: number }[] = [
// SSB by frequency and the rig's data variant for digital modes. // SSB by frequency and the rig's data variant for digital modes.
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM']; const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
function attOptions(model?: string): { v: string; l: string }[] {
const m = (model ?? '').toUpperCase();
const OFF = { v: '0', l: 'OFF' };
if (/(7610|7700|7800|7850|7851)/.test(m)) {
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
}
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
}
// fmtVFO renders a Hz frequency the way an Icom front panel does: // fmtVFO renders a Hz frequency the way an Icom front panel does:
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00". // MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
function fmtVFO(hz?: number): string { function fmtVFO(hz?: number): string {
@@ -534,7 +549,7 @@ function ScopePanadapter() {
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are // Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
// read every cache cycle; DSP set-controls are optimistic and reconcile on the // read every cache cycle; DSP set-controls are optimistic and reconcile on the
// next poll. Front-panel knob changes for DSP show after ↻ Refresh. // next poll. Front-panel knob changes for DSP show after ↻ Refresh.
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) { export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
const { t } = useI18n(); const { t } = useI18n();
const [st, setSt] = useState<IcomState>(ZERO); const [st, setSt] = useState<IcomState>(ZERO);
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
@@ -637,8 +652,12 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null} {st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* Radio power ON / OFF. Manual by design — the app never wakes the rig {/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
on connect; ON sends the wake preamble then the rig boots ~15 s. */} unpowered while the rig is off, so power-ON can't reach it (OFF works
but ON doesn't); hiding both avoids a dead button. On the network the
rig's LAN server stays alive in standby, so both work. */}
{isNetwork && (
<>
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')} <button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20"> className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
<Power className="size-3.5" /> ON <Power className="size-3.5" /> ON
@@ -647,6 +666,8 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20"> className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
<Power className="size-3.5" /> OFF <Power className="size-3.5" /> OFF
</button> </button>
</>
)}
</div> </div>
</div> </div>
@@ -804,7 +825,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} /> onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
</Row> </Row>
<Row label="Att"> <Row label="Att">
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]} <Segmented value={String(st.att)} options={attOptions(cat?.rig)}
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} /> onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
</Row> </Row>
<Row label={t('icmp.filter')}> <Row label={t('icmp.filter')}>
+1 -1
View File
@@ -351,7 +351,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return ( return (
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}> <Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0"> <DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="flex-row items-baseline gap-2"> <DialogHeader className="flex-row items-baseline gap-2">
<DialogTitle>{t('qedit.title')}</DialogTitle> <DialogTitle>{t('qedit.title')}</DialogTitle>
<span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span> <span className="font-mono text-xs text-muted-foreground">#{draft.id} {draft.callsign}</span>
@@ -274,6 +274,12 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
headerTooltip: t('rqg.awardTip', { name: a.name }), headerTooltip: t('rqg.awardTip', { name: a.name }),
width: 110, width: 110,
cellClass: 'text-[11px]', cellClass: 'text-[11px]',
// Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows
// them whenever the saved column state doesn't cover them — a newly-added
// award, an empty cache, or a rebuild that runs before the saved state is
// re-applied — which is why "all award columns" kept reappearing. The user's
// saved state (a column they explicitly showed) still overrides this.
hide: true,
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '', valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
})); }));
return [...base, ...awards]; return [...base, ...awards];
+103 -3
View File
@@ -21,6 +21,8 @@ import {
GetEmailSettings, SaveEmailSettings, TestEmail, GetEmailSettings, SaveEmailSettings, TestEmail,
QSLGetEmailTemplates, QSLSaveEmailTemplates, QSLGetEmailTemplates, QSLSaveEmailTemplates,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus, GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
AudioStartMonitor, AudioStopMonitor, AudioMonitorActive,
AudioStartTX, AudioStopTX, AudioTXActive,
ListClusterServers, SaveClusterServer, DeleteClusterServer, ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect, GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer, ConnectClusterServer, DisconnectClusterServer,
@@ -759,6 +761,20 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
); );
} }
// Known Icom models paired with their FACTORY default CI-V address. Picking a
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
const ICOM_MODELS: { name: string; addr: number }[] = [
{ name: 'IC-705', addr: 0xA4 },
{ name: 'IC-7300', addr: 0x94 },
{ name: 'IC-7610', addr: 0x98 },
{ name: 'IC-7700', addr: 0x88 },
{ name: 'IC-7800', addr: 0x80 },
{ name: 'IC-9100', addr: 0x7C },
{ name: 'IC-9700', addr: 0xA2 },
];
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) { export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station'); const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
@@ -796,7 +812,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ 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,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', 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, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', digital_default: 'FT8',
}); });
@@ -863,6 +879,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]); const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 }); const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const [dvkErr, setDvkErr] = useState(''); const [dvkErr, setDvkErr] = useState('');
const [monitorOn, setMonitorOn] = useState(false);
const [txOn, setTxOn] = useState(false);
useEffect(() => {
AudioMonitorActive().then(setMonitorOn).catch(() => {});
AudioTXActive().then(setTxOn).catch(() => {});
}, []);
const toggleMonitor = async () => {
try {
if (monitorOn) { await AudioStopMonitor(); setMonitorOn(false); }
else { await AudioStartMonitor(); setMonitorOn(true); }
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
};
const toggleTX = async () => {
try {
if (txOn) { await AudioStopTX(); setTxOn(false); }
else { await AudioStartTX(); setTxOn(true); }
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
};
// General behaviour prefs (mirrored to the DB so they travel with data/). // General behaviour prefs (mirrored to the DB so they travel with data/).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0'); const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
@@ -2034,12 +2068,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1 col-span-2"> <div className="space-y-1">
<Label>{t('cat.icomModel')}</Label>
<Select
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>{t('cat.civAddr')}</Label> <Label>{t('cat.civAddr')}</Label>
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')} <Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} /> onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
<p className="text-xs text-muted-foreground">{t('cat.civHint')}</p>
</div> </div>
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.civHint')}</p>
</> </>
)} )}
{catCfg.backend === 'icom-net' && ( {catCfg.backend === 'icom-net' && (
@@ -2049,6 +2095,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''} <Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''}
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} /> onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} />
</div> </div>
<div className="space-y-1">
<Label>{t('cat.icomModel')}</Label>
<Select
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.civAddr')}</Label> <Label>{t('cat.civAddr')}</Label>
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')} <Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
@@ -2065,6 +2123,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} /> onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
</div> </div>
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p> <p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={!!(catCfg as any).icom_net_audio}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, icom_net_audio: !!c } as any))} />
<span>
{t('cat.icomNetAudio')}
<span className="block text-[11px] text-muted-foreground">{t('cat.icomNetAudioHint')}</span>
</span>
</label>
</> </>
)} )}
{catCfg.backend === 'tci' && ( {catCfg.backend === 'tci' && (
@@ -3747,6 +3813,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '} <strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
<strong>To Radio</strong> = where voice-keyer messages are transmitted. <strong>To Radio</strong> = where voice-keyer messages are transmitted.
</p> </p>
<div className="flex items-center gap-3">
<Button
variant={monitorOn ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={toggleMonitor}
disabled={!monitorOn && !audioCfg.from_radio}
title="Hear the rig's RX audio (From Radio) through your Listening device"
>
{monitorOn ? ' Stop listening' : ' Listen to radio'}
</Button>
<span className="text-[11px] text-muted-foreground">
{monitorOn
? 'RX monitor running From Radio Listening device.'
: 'Live-monitor the rig here (USB codec now; network audio later).'}
</span>
</div>
<div className="flex items-center gap-3">
<Button
variant={txOn ? 'destructive' : 'outline'}
size="sm"
className="h-8"
onClick={toggleTX}
disabled={!txOn && !audioCfg.to_radio}
title="Key PTT and pipe your live mic into the rig (To Radio device)"
>
{txOn ? ' Stop talking (TX)' : '🎙 Talk to radio (TX)'}
</Button>
<span className="text-[11px] text-muted-foreground">
{txOn
? 'TRANSMITTING mic To Radio, PTT keyed. Click to stop.'
: 'Live mic rig with PTT (USB now; network TX later).'}
</span>
</div>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl"> <div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
+10 -6
View File
@@ -134,8 +134,10 @@ const en: Dict = {
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', 'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
'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.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 rigs 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.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.', '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.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)', '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)',
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).', 'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
@@ -191,9 +193,9 @@ const en: Dict = {
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL", 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope 50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?', 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope 50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
'rst.clickToFill': 'Click to set RST tx from the signal', 'rst.clickToFill': 'Click to set RST tx from the signal',
'qrz.openTitle': 'Open {call} on QRZ.com', 'qrz.openTitle': 'Open {call} on QRZ.com',
@@ -327,8 +329,10 @@ const fr: Dict = {
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', 'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
'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.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 laudio RX par le réseau (expérimental)',
'cat.icomNetAudioHint': 'Écoute laudio 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.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.', '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 ladresse 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.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)', '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)',
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).", 'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
@@ -378,9 +382,9 @@ const fr: Dict = {
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL", 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher que la bande courante',
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope 50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?', 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope 50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com', 'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // 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). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.19.1'; export const APP_VERSION = '0.19.2';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+20
View File
@@ -36,6 +36,18 @@ export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>; export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AudioMonitorActive():Promise<boolean>;
export function AudioStartMonitor():Promise<void>;
export function AudioStartTX():Promise<void>;
export function AudioStopMonitor():Promise<void>;
export function AudioStopTX():Promise<void>;
export function AudioTXActive():Promise<boolean>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>; export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>; export function AwardFields():Promise<Array<string>>;
@@ -190,6 +202,8 @@ export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>; export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMicProfile(arg1:string):Promise<void>;
export function FlexSetMon(arg1:boolean):Promise<void>; export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>; export function FlexSetMonLevel(arg1:number):Promise<void>;
@@ -218,6 +232,8 @@ export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>; export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetTXSlice(arg1:number):Promise<void>; export function FlexSetTXSlice(arg1:number):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>; export function FlexSetTunePower(arg1:number):Promise<void>;
@@ -228,6 +244,10 @@ export function FlexSetVoxDelay(arg1:number):Promise<void>;
export function FlexSetVoxLevel(arg1:number):Promise<void>; export function FlexSetVoxLevel(arg1:number):Promise<void>;
export function FlexSetWNB(arg1:boolean):Promise<void>;
export function FlexSetWNBLevel(arg1:number):Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>; export function FlexTune(arg1:boolean):Promise<void>;
export function GetActiveProfile():Promise<profile.Profile>; export function GetActiveProfile():Promise<profile.Profile>;
+40
View File
@@ -34,6 +34,30 @@ export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3); return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
} }
export function AudioMonitorActive() {
return window['go']['main']['App']['AudioMonitorActive']();
}
export function AudioStartMonitor() {
return window['go']['main']['App']['AudioStartMonitor']();
}
export function AudioStartTX() {
return window['go']['main']['App']['AudioStartTX']();
}
export function AudioStopMonitor() {
return window['go']['main']['App']['AudioStopMonitor']();
}
export function AudioStopTX() {
return window['go']['main']['App']['AudioStopTX']();
}
export function AudioTXActive() {
return window['go']['main']['App']['AudioTXActive']();
}
export function AwardCellQSOs(arg1, arg2, arg3) { export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3); return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
} }
@@ -342,6 +366,10 @@ export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1); return window['go']['main']['App']['FlexSetMic'](arg1);
} }
export function FlexSetMicProfile(arg1) {
return window['go']['main']['App']['FlexSetMicProfile'](arg1);
}
export function FlexSetMon(arg1) { export function FlexSetMon(arg1) {
return window['go']['main']['App']['FlexSetMon'](arg1); return window['go']['main']['App']['FlexSetMon'](arg1);
} }
@@ -398,6 +426,10 @@ export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1); return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
} }
export function FlexSetTXFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
}
export function FlexSetTXSlice(arg1) { export function FlexSetTXSlice(arg1) {
return window['go']['main']['App']['FlexSetTXSlice'](arg1); return window['go']['main']['App']['FlexSetTXSlice'](arg1);
} }
@@ -418,6 +450,14 @@ export function FlexSetVoxLevel(arg1) {
return window['go']['main']['App']['FlexSetVoxLevel'](arg1); return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
} }
export function FlexSetWNB(arg1) {
return window['go']['main']['App']['FlexSetWNB'](arg1);
}
export function FlexSetWNBLevel(arg1) {
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
}
export function FlexTune(arg1) { export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1); return window['go']['main']['App']['FlexTune'](arg1);
} }
+16
View File
@@ -117,6 +117,7 @@ export namespace antgenius {
export class Antenna { export class Antenna {
index: number; index: number;
name: string; name: string;
bands: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Antenna(source); return new Antenna(source);
@@ -126,6 +127,7 @@ export namespace antgenius {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.index = source["index"]; this.index = source["index"];
this.name = source["name"]; this.name = source["name"];
this.bands = source["bands"];
} }
} }
export class Status { export class Status {
@@ -583,6 +585,10 @@ export namespace cat {
mon: boolean; mon: boolean;
mon_level: number; mon_level: number;
mic_level: number; mic_level: number;
tx_filter_low: number;
tx_filter_high: number;
mic_profile?: string;
mic_profiles?: string[];
atu_status?: string; atu_status?: string;
atu_memories: boolean; atu_memories: boolean;
rx_avail: boolean; rx_avail: boolean;
@@ -603,6 +609,8 @@ export namespace cat {
nr_level: number; nr_level: number;
anf: boolean; anf: boolean;
anf_level: number; anf_level: number;
wnb: boolean;
wnb_level: number;
mode?: string; mode?: string;
cw_speed: number; cw_speed: number;
cw_pitch: number; cw_pitch: number;
@@ -640,6 +648,10 @@ export namespace cat {
this.mon = source["mon"]; this.mon = source["mon"];
this.mon_level = source["mon_level"]; this.mon_level = source["mon_level"];
this.mic_level = source["mic_level"]; this.mic_level = source["mic_level"];
this.tx_filter_low = source["tx_filter_low"];
this.tx_filter_high = source["tx_filter_high"];
this.mic_profile = source["mic_profile"];
this.mic_profiles = source["mic_profiles"];
this.atu_status = source["atu_status"]; this.atu_status = source["atu_status"];
this.atu_memories = source["atu_memories"]; this.atu_memories = source["atu_memories"];
this.rx_avail = source["rx_avail"]; this.rx_avail = source["rx_avail"];
@@ -660,6 +672,8 @@ export namespace cat {
this.nr_level = source["nr_level"]; this.nr_level = source["nr_level"];
this.anf = source["anf"]; this.anf = source["anf"];
this.anf_level = source["anf_level"]; this.anf_level = source["anf_level"];
this.wnb = source["wnb"];
this.wnb_level = source["wnb_level"];
this.mode = source["mode"]; this.mode = source["mode"];
this.cw_speed = source["cw_speed"]; this.cw_speed = source["cw_speed"];
this.cw_pitch = source["cw_pitch"]; this.cw_pitch = source["cw_pitch"];
@@ -1349,6 +1363,7 @@ export namespace main {
icom_net_host: string; icom_net_host: string;
icom_net_user: string; icom_net_user: string;
icom_net_pass: string; icom_net_pass: string;
icom_net_audio: boolean;
tci_host: string; tci_host: string;
tci_port: number; tci_port: number;
tci_spots: boolean; tci_spots: boolean;
@@ -1374,6 +1389,7 @@ export namespace main {
this.icom_net_host = source["icom_net_host"]; this.icom_net_host = source["icom_net_host"];
this.icom_net_user = source["icom_net_user"]; this.icom_net_user = source["icom_net_user"];
this.icom_net_pass = source["icom_net_pass"]; this.icom_net_pass = source["icom_net_pass"];
this.icom_net_audio = source["icom_net_audio"];
this.tci_host = source["tci_host"]; this.tci_host = source["tci_host"];
this.tci_port = source["tci_port"]; this.tci_port = source["tci_port"];
this.tci_spots = source["tci_spots"]; this.tci_spots = source["tci_spots"];
+21 -3
View File
@@ -32,9 +32,13 @@ const (
) )
// Antenna is one configured antenna (index + name as stored on the device). // Antenna is one configured antenna (index + name as stored on the device).
// Bands is the device's band bitmask for the antenna (which bands it covers);
// 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like
// the native 4O3A app.
type Antenna struct { type Antenna struct {
Index int `json:"index"` Index int `json:"index"`
Name string `json:"name"` Name string `json:"name"`
Bands int `json:"bands"`
} }
// Status is the snapshot the UI renders. // Status is the snapshot the UI renders.
@@ -65,6 +69,8 @@ type Client struct {
statusMu sync.RWMutex statusMu sync.RWMutex
status Status status Status
antennas map[int]string // index → name (rebuilt into status.Antennas) antennas map[int]string // index → name (rebuilt into status.Antennas)
antBands map[int]int // index → band bitmask (which bands the antenna covers)
antRawN int // one-shot: how many raw antenna lines we've logged
stop chan struct{} stop chan struct{}
running bool running bool
@@ -80,6 +86,7 @@ func New(host string, port int, password string) *Client {
password: strings.TrimSpace(password), password: strings.TrimSpace(password),
stop: make(chan struct{}), stop: make(chan struct{}),
antennas: map[int]string{}, antennas: map[int]string{},
antBands: map[int]int{},
status: Status{Host: host}, status: Status{Host: host},
} }
} }
@@ -356,13 +363,24 @@ func (c *Client) parseAntenna(msg string) {
// The device stores spaces as underscores in names. // The device stores spaces as underscores in names.
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " ")) name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
} }
// Band bitmask: which bands this antenna is configured for. The device sends
// it as "band=<mask>" (a bitmask). Kept so the UI can show only band-relevant
// antennas (like the native app). Log the first few raw antenna lines so the
// exact field name + bit values can be confirmed on real hardware.
bands := kvInt(msg, "band")
c.statusMu.Lock() c.statusMu.Lock()
if c.antRawN < 8 {
c.antRawN++
applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands)
}
if name != "" && !isPlaceholderName(name) { if name != "" && !isPlaceholderName(name) {
c.antennas[id] = name c.antennas[id] = name
c.antBands[id] = bands
} else { } else {
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
delete(c.antBands, id)
} }
c.status.Antennas = sortedAntennas(c.antennas) c.status.Antennas = sortedAntennas(c.antennas, c.antBands)
c.status.Connected = true c.status.Connected = true
c.statusMu.Unlock() c.statusMu.Unlock()
} }
@@ -431,10 +449,10 @@ func isPlaceholderName(name string) bool {
return err == nil return err == nil
} }
func sortedAntennas(m map[int]string) []Antenna { func sortedAntennas(m map[int]string, bands map[int]int) []Antenna {
out := make([]Antenna, 0, len(m)) out := make([]Antenna, 0, len(m))
for idx, name := range m { for idx, name := range m {
out = append(out, Antenna{Index: idx, Name: name}) out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]})
} }
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index }) sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
return out return out
+57
View File
@@ -0,0 +1,57 @@
package audio
import "fmt"
// Codec converts between the wire payload of a network audio stream (Icom 50003)
// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the
// capture/render engine and the pcmRing use). It exists so the transport code
// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough;
// tomorrow an Opus codec implements the same two methods and drops in unchanged.
// This mirrors how civTransport abstracts the CAT byte stream from its transport.
type Codec interface {
// Name is a short label for logs/UI.
Name() string
// Decode turns one received audio payload into internal PCM. The returned
// slice is freshly allocated (the caller may retain it).
Decode(payload []byte) ([]byte, error)
// Encode turns internal PCM into a payload to transmit.
Encode(pcm []byte) ([]byte, error)
}
// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed"
// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit
// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode
// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a
// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later.
//
// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio
// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a
// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in
// the TX path before Encode; keeping the codec rate-agnostic keeps that concern
// in one place.
type pcm16Codec struct{}
// NewPCM16Codec returns the uncompressed 16-bit PCM codec.
func NewPCM16Codec() Codec { return pcm16Codec{} }
func (pcm16Codec) Name() string { return "pcm16" }
func (pcm16Codec) Decode(payload []byte) ([]byte, error) {
// Icom PCM payloads are whole 16-bit samples; an odd length means a truncated
// packet — trim the stray byte rather than emit a half-sample click.
n := len(payload) &^ 1
if n != len(payload) {
if n == 0 {
return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload))
}
}
out := make([]byte, n)
copy(out, payload[:n])
return out, nil
}
func (pcm16Codec) Encode(pcm []byte) ([]byte, error) {
out := make([]byte, len(pcm))
copy(out, pcm)
return out, nil
}
+37
View File
@@ -0,0 +1,37 @@
package audio
import "testing"
func TestPCM16CodecRoundTrip(t *testing.T) {
c := NewPCM16Codec()
in := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0x7f}
enc, err := c.Encode(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
dec, err := c.Decode(enc)
if err != nil {
t.Fatalf("decode: %v", err)
}
if string(dec) != string(in) {
t.Fatalf("round-trip mismatch: got % X want % X", dec, in)
}
}
func TestPCM16CodecTrimsOddByte(t *testing.T) {
c := NewPCM16Codec()
dec, err := c.Decode([]byte{0x10, 0x20, 0x30}) // 3 bytes = 1 sample + stray
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(dec) != 2 {
t.Fatalf("expected the stray byte trimmed to 2, got %d", len(dec))
}
}
func TestPCM16CodecRejectsSingleByte(t *testing.T) {
c := NewPCM16Codec()
if _, err := c.Decode([]byte{0x10}); err == nil {
t.Fatalf("expected an error for a sub-sample payload")
}
}
+149
View File
@@ -5,6 +5,7 @@ package audio
import ( import (
"fmt" "fmt"
"runtime" "runtime"
"sync"
"time" "time"
"unsafe" "unsafe"
@@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
} }
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
// render stream. Producers (a USB-codec capture, or a decoded network audio
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
// without touching the render side, mirroring the civTransport split on the CAT
// side. On overflow the oldest audio is dropped to keep latency bounded; on
// underrun Pull simply returns short and the render loop pads with silence.
type pcmRing struct {
mu sync.Mutex
buf []byte
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
}
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
func newPCMRing(maxBytes int) *pcmRing {
if maxBytes <= 0 {
maxBytes = bytesPerSec // 1 s default
}
return &pcmRing{max: maxBytes}
}
// Push appends samples, dropping the oldest audio if the backlog would exceed
// the cap (a slow/absent consumer never makes the producer block or grow without
// bound). A short glitch beats runaway latency for live monitoring.
func (r *pcmRing) Push(p []byte) {
if len(p) == 0 {
return
}
r.mu.Lock()
r.buf = append(r.buf, p...)
if len(r.buf) > r.max {
drop := len(r.buf) - r.max
r.buf = append(r.buf[:0], r.buf[drop:]...)
}
r.mu.Unlock()
}
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
// when empty. The render loop pads any shortfall with silence.
func (r *pcmRing) pull(maxBytes int) []byte {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.buf) == 0 || maxBytes <= 0 {
return nil
}
n := maxBytes
if n > len(r.buf) {
n = len(r.buf)
}
out := make([]byte, n)
copy(out, r.buf[:n])
r.buf = append(r.buf[:0], r.buf[n:]...)
return out
}
// renderStream continuously renders PCM pulled from src to a device until stop
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
// writes silence rather than glitching, keeping the WASAPI clock steady so live
// monitor audio flows smoothly even when the source stalls briefly. Runs on a
// COM-initialised, OS-locked thread.
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := coInit(); err != nil {
return fmt.Errorf("CoInitialize: %w", err)
}
defer ole.CoUninitialize()
dev, err := openDevice(wca.ERender, deviceID)
if err != nil {
return err
}
defer dev.Release()
var ac *wca.IAudioClient
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
return fmt.Errorf("activate render: %w", err)
}
defer ac.Release()
frameBytes := ch * bits / 8
if frameBytes <= 0 {
return fmt.Errorf("bad audio format")
}
wfx := &wca.WAVEFORMATEX{
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
WBitsPerSample: uint16(bits), CbSize: 0,
}
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
return fmt.Errorf("initialize render: %w", err)
}
var bufFrames uint32
if err := ac.GetBufferSize(&bufFrames); err != nil {
return err
}
var arc *wca.IAudioRenderClient
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
return fmt.Errorf("get render service: %w", err)
}
defer arc.Release()
// feed fills up to `frames` render frames: as much real audio as the ring
// has, the remainder silence (so the buffer stays full and the clock steady).
feed := func(frames int) error {
if frames <= 0 {
return nil
}
var data *byte
if err := arc.GetBuffer(uint32(frames), &data); err != nil {
return err
}
dst := unsafe.Slice(data, frames*frameBytes)
got := src.pull(frames * frameBytes)
n := copy(dst, got)
for i := n; i < len(dst); i++ {
dst[i] = 0 // silence-fill the shortfall
}
arc.ReleaseBuffer(uint32(frames), 0)
return nil
}
if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch
return err
}
if err := ac.Start(); err != nil {
return fmt.Errorf("start render: %w", err)
}
defer ac.Stop()
for {
select {
case <-stop:
return nil
default:
}
var padding uint32
ac.GetCurrentPadding(&padding)
if err := feed(int(bufFrames - padding)); err != nil {
return err
}
time.Sleep(8 * time.Millisecond)
}
}
+130
View File
@@ -15,6 +15,9 @@ type Manager struct {
recStop chan struct{} recStop chan struct{}
recDone chan recResult recDone chan recResult
playStop chan struct{} playStop chan struct{}
monStop chan struct{} // RX monitor passthrough (capture → render)
monRing *pcmRing // live audio hand-off, also fed by the network stream
txStop chan struct{} // TX audio passthrough (mic → rig)
onChange func() // fired on any record/playback state transition onChange func() // fired on any record/playback state transition
} }
@@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() {
m.notify() m.notify()
} }
} }
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
//
// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio
// CODEC" capture endpoint) to outputDev (your speakers/headset) through a
// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring
// is later fed by the network 50003 stream instead of a USB capture — the render
// half is transport-agnostic. inputDev "" = system default capture.
func (m *Manager) StartMonitor(inputDev, outputDev string) error {
return m.startMonitor(inputDev, outputDev, true)
}
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
// producer — the network 50003 stream — can feed decoded RX PCM via
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
func (m *Manager) StartMonitorSink(outputDev string) error {
return m.startMonitor("", outputDev, false)
}
// startMonitor wires the RX monitor: always a render loop pulling from monRing;
// when capture is true it also captures inputDev into that ring (USB monitor).
// When false the ring is fed only by PushMonitorAudio (network audio).
func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
m.mu.Lock()
if m.monStop != nil {
m.mu.Unlock()
return fmt.Errorf("monitor already running")
}
stop := make(chan struct{})
ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor
m.monStop, m.monRing = stop, ring
m.mu.Unlock()
if capture {
// Producer: capture the rig's USB audio into the ring.
go func() {
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
}()
}
// Consumer: render the ring to the output device at the internal 16 kHz mono.
go func() {
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
}()
m.notify()
return nil
}
// StopMonitor stops the RX monitor passthrough.
func (m *Manager) StopMonitor() {
m.mu.Lock()
stop := m.monStop
m.monStop, m.monRing = nil, nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
// MonitorActive reports whether the RX monitor passthrough is running.
func (m *Manager) MonitorActive() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.monStop != nil
}
// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the
// active monitor's output — the hook the network 50003 audio stream uses to play
// decoded RX through the very same output path a USB capture feeds. No-op when no
// monitor is running. Keeps the unexported ring inside the package.
func (m *Manager) PushMonitorAudio(pcm []byte) {
m.mu.Lock()
ring := m.monRing
m.mu.Unlock()
if ring != nil {
ring.Push(pcm)
}
}
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
//
// StartTXAudio pipes your live microphone (micDev) into the rig's audio input
// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint),
// so you talk through the PC. It is the mirror of StartMonitor (same ring +
// capture + render primitives, source/sink swapped). PTT keying is the caller's
// job (the app layer keys PTT before this and unkeys after) so this stays a pure
// audio route. The captured 16 kHz mono stream is also the exact shape the future
// network 50003 TX will encode and send — so Phase 5 reuses this capture side.
func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
m.mu.Lock()
if m.txStop != nil {
m.mu.Unlock()
return fmt.Errorf("TX audio already running")
}
stop := make(chan struct{})
ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency
m.txStop = stop
m.mu.Unlock()
go func() {
_ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) })
}()
go func() {
_ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring)
}()
m.notify()
return nil
}
// StopTXAudio stops the TX mic→rig passthrough.
func (m *Manager) StopTXAudio() {
m.mu.Lock()
stop := m.txStop
m.txStop = nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
// TXAudioActive reports whether the TX mic→rig passthrough is running.
func (m *Manager) TXAudioActive() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.txStop != nil
}
+11
View File
@@ -275,6 +275,11 @@ type FlexTXState struct {
Mon bool `json:"mon"` Mon bool `json:"mon"`
MonLevel int `json:"mon_level"` MonLevel int `json:"mon_level"`
MicLevel int `json:"mic_level"` MicLevel int `json:"mic_level"`
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
MicProfile string `json:"mic_profile,omitempty"`
MicProfiles []string `json:"mic_profiles,omitempty"`
ATUStatus string `json:"atu_status,omitempty"` ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"` ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls. // Active RX slice DSP controls.
@@ -296,6 +301,8 @@ type FlexTXState struct {
NRLevel int `json:"nr_level"` NRLevel int `json:"nr_level"`
ANF bool `json:"anf"` ANF bool `json:"anf"`
ANFLevel int `json:"anf_level"` ANFLevel int `json:"anf_level"`
WNB bool `json:"wnb"`
WNBLevel int `json:"wnb_level"`
// CW / mode-specific controls. // CW / mode-specific controls.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…) Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"` CWSpeed int `json:"cw_speed"`
@@ -344,6 +351,8 @@ type FlexController interface {
SetMon(bool) error SetMon(bool) error
SetMonLevel(int) error SetMonLevel(int) error
SetMic(int) error SetMic(int) error
SetTXFilter(low, high int) error // transmit-audio bandwidth (Hz)
SetMicProfile(string) error // load a SmartSDR mic profile by name
ATUStart() error ATUStart() error
ATUBypass() error ATUBypass() error
SetATUMemories(bool) error SetATUMemories(bool) error
@@ -365,6 +374,8 @@ type FlexController interface {
SetANFLevel(int) error SetANFLevel(int) error
SetAPF(bool) error SetAPF(bool) error
SetAPFLevel(int) error SetAPFLevel(int) error
SetWNB(bool) error
SetWNBLevel(int) error
// CW keyer + mode-specific controls. // CW keyer + mode-specific controls.
SetCWSpeed(int) error SetCWSpeed(int) error
SetCWPitch(int) error SetCWPitch(int) error
+2
View File
@@ -313,6 +313,8 @@ func ModelName(addr byte) string {
return "IC-7300" return "IC-7300"
case 0x98: case 0x98:
return "IC-7610" return "IC-7610"
case 0x7C:
return "IC-9100"
case 0xA2: case 0xA2:
return "IC-9700" return "IC-9700"
case 0xA4: case 0xA4:
+141 -10
View File
@@ -38,6 +38,8 @@ type Flex struct {
slices map[int]*flexSlice slices map[int]*flexSlice
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab) tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
amp flexAmp // external amplifier (PowerGenius XL) state amp flexAmp // external amplifier (PowerGenius XL) state
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
micProfile string // currently loaded mic profile
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly) txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
lastStateSig string // last logged derived-state signature (log only on change) lastStateSig string // last logged derived-state signature (log only on change)
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power). boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
@@ -85,6 +87,8 @@ type flexSlice struct {
anfLevel int anfLevel int
apf bool // CW audio peaking filter apf bool // CW audio peaking filter
apfLevel int apfLevel int
wnb bool // wideband noise blanker
wnbLevel int
filterLo int // slice filter low cut (Hz) filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz) filterHi int // slice filter high cut (Hz)
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A) rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
@@ -108,6 +112,8 @@ type flexTX struct {
mon bool mon bool
monLevel int monLevel int
micLevel int micLevel int
filterLow int // TX filter low cut (Hz)
filterHigh int // TX filter high cut (Hz)
atuStatus string atuStatus string
atuMemories bool atuMemories bool
// CW keyer params (set via the top-level "cw" commands). // CW keyer params (set via the top-level "cw" commands).
@@ -209,6 +215,8 @@ func (f *Flex) Connect() error {
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state) f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default) f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
f.send("sub profile all") // mic/global/tx profiles (for the mic-profile dropdown)
f.send("profile mic info") // request the current mic profile list + selection
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below) f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
f.startMeters(conn) // open the UDP VITA-49 stream for live meters f.startMeters(conn) // open the UDP VITA-49 stream for live meters
if f.spotsEnabled { if f.spotsEnabled {
@@ -445,6 +453,12 @@ func (f *Flex) handleStatus(payload string) {
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay) f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
case "mic_level", "miclevel": case "mic_level", "miclevel":
f.tx.micLevel = atoiDefault(val, f.tx.micLevel) f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
// TX filter: the transmit STATUS reports the passband as lo/hi (the
// SET command is filter_low/filter_high — a SmartSDR quirk).
case "lo", "filter_low":
f.tx.filterLow = atoiDefault(val, f.tx.filterLow)
case "hi", "filter_high":
f.tx.filterHigh = atoiDefault(val, f.tx.filterHigh)
} }
} }
f.mu.Unlock() f.mu.Unlock()
@@ -526,6 +540,35 @@ func (f *Flex) handleStatus(payload string) {
} }
f.mu.Unlock() f.mu.Unlock()
} }
// Mic-profile object — "profile mic list=A^B^C" (available profiles) and
// "profile mic current=<name>" (loaded one). Names can contain spaces, so
// values are taken from the raw payload after the key. Logged once so the
// exact field names are confirmable on real hardware.
if len(fields) >= 2 && fields[0] == "profile" && fields[1] == "mic" {
debugLog.Printf("Flex: profile status: %s", payload)
if i := strings.Index(payload, "list="); i >= 0 {
var profs []string
for _, p := range strings.Split(payload[i+len("list="):], "^") {
if p = strings.TrimSpace(p); p != "" {
profs = append(profs, p)
}
}
f.mu.Lock()
f.micProfiles = profs
f.mu.Unlock()
}
// The loaded profile arrives as current=<name> (some firmwares:
// selection=<name>); accept either.
for _, key := range []string{"current=", "selection="} {
if i := strings.Index(payload, key); i >= 0 {
cur := strings.TrimSpace(payload[i+len(key):])
f.mu.Lock()
f.micProfile = cur
f.mu.Unlock()
break
}
}
}
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …). // Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
if len(fields) >= 1 && fields[0] == "interlock" { if len(fields) >= 1 && fields[0] == "interlock" {
f.mu.Lock() f.mu.Lock()
@@ -744,6 +787,10 @@ func (f *Flex) handleStatus(payload string) {
s.apf = val == "1" s.apf = val == "1"
case "apf_level": case "apf_level":
s.apfLevel = atoiDefault(val, s.apfLevel) s.apfLevel = atoiDefault(val, s.apfLevel)
case "wnb":
s.wnb = val == "1"
case "wnb_level":
s.wnbLevel = atoiDefault(val, s.wnbLevel)
case "filter_lo": case "filter_lo":
s.filterLo = atoiDefault(val, s.filterLo) s.filterLo = atoiDefault(val, s.filterLo)
case "filter_hi": case "filter_hi":
@@ -803,24 +850,44 @@ func (f *Flex) ReadState() (RigState, error) {
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in // band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
// use. Caller holds f.mu. // use. Caller holds f.mu.
func (f *Flex) mainSliceLocked() (int, *flexSlice) { func (f *Flex) mainSliceLocked() (int, *flexSlice) {
best, bestS := 1<<30, (*flexSlice)(nil) // Iterate in ASCENDING index order — NEVER map-iteration order, which Go
for idx, s := range f.slices { // randomises. When two slices transiently BOTH report active=1 (e.g. an
// external controller like DXHunter activates a slice on another band while
// ours still holds active, before SmartSDR sends active=0 to the old one),
// map order returned a RANDOM active slice each call → the operating frequency
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
// Deterministic order = the lowest-indexed active slice wins, stably.
firstInUse := -1
for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if !s.inUse { if !s.inUse {
continue continue
} }
if firstInUse < 0 {
firstInUse = idx
}
if s.active { if s.active {
return idx, s return idx, s
} }
if idx < best {
best, bestS = idx, s
} }
} if firstInUse >= 0 {
if bestS != nil { return firstInUse, f.slices[firstInUse]
return best, bestS
} }
return -1, nil return -1, nil
} }
// sortedSliceIdxLocked returns the slice indices in ascending order so every
// slice-selection helper is deterministic (map iteration is randomised). Caller
// holds f.mu.
func (f *Flex) sortedSliceIdxLocked() []int {
idxs := make([]int, 0, len(f.slices))
for idx := range f.slices {
idxs = append(idxs, idx)
}
sort.Ints(idxs)
return idxs
}
// activeSliceIndexLocked returns the slice index to send commands to (the main // activeSliceIndexLocked returns the slice index to send commands to (the main
// slice, else 0). Caller holds f.mu. // slice, else 0). Caller holds f.mu.
func (f *Flex) activeSliceIndexLocked() int { func (f *Flex) activeSliceIndexLocked() int {
@@ -841,8 +908,8 @@ func sliceLetter(idx int) string {
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil. // txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
// Caller holds f.mu. // Caller holds f.mu.
func (f *Flex) txSliceLocked() *flexSlice { func (f *Flex) txSliceLocked() *flexSlice {
for _, s := range f.slices { for _, idx := range f.sortedSliceIdxLocked() {
if s.inUse && s.tx { if s := f.slices[idx]; s.inUse && s.tx {
return s return s
} }
} }
@@ -871,7 +938,8 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt { if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt {
rx = main rx = main
} else { } else {
for _, s := range f.slices { for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt { if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
rx = s rx = s
break break
@@ -927,6 +995,15 @@ func (f *Flex) SetFrequency(hz int64) error {
f.mu.Lock() f.mu.Lock()
idx := f.activeSliceIndexLocked() idx := f.activeSliceIndexLocked()
connected := f.conn != nil connected := f.conn != nil
// Optimistically update the active slice's cached freq NOW, before the radio
// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
// jumped to the new band while the slice cache — which the FlexPanel and the
// Ultrabeam follow loop read — still showed the old one, so the antenna chased
// the stale value. The real echo confirms/corrects this a moment later.
if s := f.slices[idx]; s != nil {
s.freqHz = hz
}
f.mu.Unlock() f.mu.Unlock()
if !connected { if !connected {
return fmt.Errorf("flex: not connected") return fmt.Errorf("flex: not connected")
@@ -952,6 +1029,13 @@ func (f *Flex) SetMode(mode string) error {
if fm == "" { if fm == "" {
return fmt.Errorf("flex: unsupported mode %q", mode) return fmt.Errorf("flex: unsupported mode %q", mode)
} }
// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
// panel reflects it immediately instead of lagging the radio's echo.
f.mu.Lock()
if s := f.slices[idx]; s != nil {
s.mode = fm
}
f.mu.Unlock()
// "slice s <rx> mode=<m>" — set command per the SmartSDR API. // "slice s <rx> mode=<m>" — set command per the SmartSDR API.
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm)) f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
return nil return nil
@@ -1127,6 +1211,10 @@ func (f *Flex) FlexState() FlexTXState {
Mon: f.tx.mon, Mon: f.tx.mon,
MonLevel: f.tx.monLevel, MonLevel: f.tx.monLevel,
MicLevel: f.tx.micLevel, MicLevel: f.tx.micLevel,
TXFilterLow: f.tx.filterLow,
TXFilterHigh: f.tx.filterHigh,
MicProfile: f.micProfile,
MicProfiles: f.micProfiles,
ATUStatus: f.tx.atuStatus, ATUStatus: f.tx.atuStatus,
ATUMemories: f.tx.atuMemories, ATUMemories: f.tx.atuMemories,
// CW keyer (defaults applied so the sliders show sane values pre-read). // CW keyer (defaults applied so the sliders show sane values pre-read).
@@ -1177,6 +1265,8 @@ func (f *Flex) FlexState() FlexTXState {
st.ANFLevel = rx.anfLevel st.ANFLevel = rx.anfLevel
st.APF = rx.apf st.APF = rx.apf
st.APFLevel = rx.apfLevel st.APFLevel = rx.apfLevel
st.WNB = rx.wnb
st.WNBLevel = rx.wnbLevel
st.FilterLo = rx.filterLo st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi st.FilterHi = rx.filterHi
st.RXAnt = rx.rxAnt st.RXAnt = rx.rxAnt
@@ -1354,6 +1444,8 @@ func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) } func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) } func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) } func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
// ── CW keyer controls (top-level "cw" commands) ── // ── CW keyer controls (top-level "cw" commands) ──
@@ -1571,6 +1663,45 @@ func (f *Flex) SetMic(l int) error {
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l }) return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
} }
// SetTXFilter sets the transmit-audio bandwidth (low + high cut, in Hz). SmartSDR
// clamps to legal values per mode; we just pass them through in one command so
// both edges move together.
func (f *Flex) SetTXFilter(low, high int) error {
if low < 0 {
low = 0
}
if high < low {
high = low
}
// Guard both status field names (the echo comes back as lo/hi, not
// filter_low/high) so the radio's lagging echo doesn't snap the inputs back.
f.mu.Lock()
f.txSetAt["hi"] = time.Now()
f.mu.Unlock()
return f.txSet(fmt.Sprintf("transmit set filter_low=%d filter_high=%d", low, high), "lo",
func(t *flexTX) { t.filterLow, t.filterHigh = low, high })
}
// SetMicProfile loads a SmartSDR mic profile by name (quoted, as names may hold
// spaces). Optimistically caches the selection.
func (f *Flex) SetMicProfile(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("flex: empty mic profile")
}
f.mu.Lock()
connected := f.conn != nil
if connected {
f.micProfile = name
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
f.send(fmt.Sprintf("profile mic load \"%s\"", name))
return nil
}
func (f *Flex) ATUStart() error { func (f *Flex) ATUStart() error {
if !f.connected() { if !f.connected() {
return fmt.Errorf("flex: not connected") return fmt.Errorf("flex: not connected")
+219
View File
@@ -0,0 +1,219 @@
package cat
// icomaudio.go — the NETWORK AUDIO stream (UDP 50003) for the Icom LAN protocol.
// It is the third stream alongside control (50001) and CI-V (50002): once the
// control login + conninfo (with rxenable=1) authorize audio, the rig streams RX
// audio here as data packets. This file dials/handshakes/keeps-alive that socket
// exactly like the CI-V stream (icomnet.go) — those parts are byte-for-byte the
// PROVEN transport — and hands each received audio payload to a sink callback
// (the app decodes it via an audio.Codec and plays it through the RX monitor).
//
// Reuses icomnet.go's helpers (icnCtrl, icnHandshake, icnPingReply, icnRecv,
// icnLocalID, icnLE) and the same seq/retransmit discipline.
//
// ⚠️ PAYLOAD OFFSET PENDING ON-RIG VERIFICATION. The stream framing (handshake,
// ping, idle, retransmit, common 16-byte header) is identical to CI-V and proven.
// The AUDIO data packet's inner layout — where the PCM starts and the datalen
// field — is reconstructed from wfview's audio_packet (ident@0x10, datalen@0x12,
// sendseq@0x14, audio@0x16) but NOT yet confirmed against a real 50003 capture.
// audioPump logs the first few raw packets (icaDumpFirst) so the offset can be
// confirmed/corrected on the first on-rig test without a packet capture, the same
// way the CI-V/scope framing was iterated. Nothing here can destabilize CAT: the
// audio stream is opt-in and entirely separate from control/CI-V.
import (
"net"
"sync"
"sync/atomic"
"time"
)
// icaAudioOffset is where the PCM payload begins inside an audio data packet
// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 +
// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change
// is a one-line edit.
const icaAudioOffset = 0x16
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
// offset verification. After the layout is confirmed on a real rig this can go to
// 0 (or the const above corrected).
const icaDumpFirst = 6
// icomAudio is the connected audio stream. RX only for now (Phase 4); TX (Phase
// 5) will add an encode+send path mirroring icomNet.Write.
type icomAudio struct {
conn *net.UDPConn
aID, aRemote uint32
sink func([]byte) // receives each raw audio payload (app decodes + plays)
// Receive-side retransmit (audio is a heavy stream, like the scope): track the
// rig's data-packet send seq and ask it to resend gaps, or the rig drops the
// session. Same mechanism as icomNet. Owned solely by audioPump → no lock.
rxHaveSeq bool
rxLastSeq uint16
rxMissing map[uint16]int
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
lastRx atomic.Int64 // UnixNano of last packet (liveness)
done chan struct{}
closeOnce sync.Once
}
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
func (a *icomAudio) Close() {
a.closeOnce.Do(func() {
close(a.done)
for i := 0; i < 3; i++ {
_, _ = a.conn.Write(icnCtrl(0x05, 0, a.aID, a.aRemote)) // disconnect
time.Sleep(15 * time.Millisecond)
}
_ = a.conn.Close()
debugLog.Printf("icom audio: stream closed")
})
}
// dialIcomAudio opens the audio UDP stream to rig:50003, binding LOCAL :50003
// (mirroring the civ stream's local :50002). The control conninfo (rxenable=1,
// audioport=50003) must already have authorized it. sink receives each raw audio
// payload. cancel aborts a slow dial (Stop/Start).
func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*icomAudio, error) {
araddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50003"))
if err != nil {
return nil, err
}
conn, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50003}, araddr)
if err != nil {
debugLog.Printf("icom audio: cannot bind local :50003 (Remote Utility running?): %v", err)
return nil, err
}
aID := icnLocalID(conn)
aRemote, err := icnHandshake(conn, aID, cancel)
if err != nil {
_ = conn.Close()
debugLog.Printf("icom audio: handshake FAILED: %v", err)
return nil, err
}
_ = conn.SetReadBuffer(1 << 20)
a := &icomAudio{
conn: conn, aID: aID, aRemote: aRemote,
sink: sink,
rxMissing: make(map[uint16]int),
done: make(chan struct{}),
}
a.markRx()
debugLog.Printf("icom audio: stream up (rig id 0x%08X) — awaiting RX audio", aRemote)
go a.audioPump()
return a, nil
}
// audioPump drains the audio socket: replies to pings, sends idle keepalives,
// requests retransmits for lost packets, and hands each audio payload to sink.
func (a *icomAudio) audioPump() {
buf := make([]byte, 8192)
lastIdle := time.Now()
lastReq := time.Now()
for {
select {
case <-a.done:
return
default:
}
_ = a.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
if k, err := a.conn.Read(buf); err == nil && k >= 16 {
a.markRx()
switch typ := icnLE.Uint16(buf[4:]); {
case typ == 0x07: // ping
_, _ = a.conn.Write(icnPingReply(buf[:k], a.aID, a.aRemote))
case typ == 0x01: // retransmit request from the rig (we send no tracked audio yet)
case typ == 0x05: // rig-initiated disconnect
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
case typ == 0x00 && k > icaAudioOffset: // audio data packet
a.trackRxSeq(icnLE.Uint16(buf[6:]))
if a.dumped < icaDumpFirst {
a.dumped++
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
}
if a.sink != nil {
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
a.sink(payload)
}
}
}
if time.Since(lastIdle) > 100*time.Millisecond {
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
lastIdle = time.Now()
}
if time.Since(lastReq) > 100*time.Millisecond {
a.sendRetransmitReq()
lastReq = time.Now()
}
}
}
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
// the audio stream owns its own seq state with no shared locking.
func (a *icomAudio) trackRxSeq(seq uint16) {
if !a.rxHaveSeq {
a.rxHaveSeq = true
a.rxLastSeq = seq
return
}
switch d := int16(seq - a.rxLastSeq); {
case d == 0:
case d < 0:
delete(a.rxMissing, seq)
case d == 1:
a.rxLastSeq = seq
case int(d) <= icnMaxMissing:
for f := a.rxLastSeq + 1; f != seq; f++ {
a.rxMissing[f] = 0
}
a.rxLastSeq = seq
default:
a.rxMissing = make(map[uint16]int)
a.rxLastSeq = seq
}
}
func (a *icomAudio) sendRetransmitReq() {
if len(a.rxMissing) == 0 {
return
}
if len(a.rxMissing) > icnMaxMissing {
a.rxMissing = make(map[uint16]int)
return
}
var seqs []uint16
for s, cnt := range a.rxMissing {
if cnt >= 4 {
delete(a.rxMissing, s)
continue
}
a.rxMissing[s] = cnt + 1
seqs = append(seqs, s)
}
switch {
case len(seqs) == 0:
return
case len(seqs) == 1:
_, _ = a.conn.Write(icnCtrl(0x01, seqs[0], a.aID, a.aRemote))
default:
b := make([]byte, 16+4*len(seqs))
icnLE.PutUint32(b[0:], uint32(len(b)))
icnLE.PutUint16(b[4:], 0x01)
icnLE.PutUint32(b[8:], a.aID)
icnLE.PutUint32(b[12:], a.aRemote)
off := 16
for _, s := range seqs {
icnLE.PutUint16(b[off:], s)
icnLE.PutUint16(b[off+2:], s)
off += 4
}
_, _ = a.conn.Write(b)
}
}
+38 -8
View File
@@ -36,7 +36,13 @@ var icnBE = binary.BigEndian
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network // NewIcomNet builds an (unconnected) Icom backend whose transport is the network
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1 // stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
// credentials. Reuses the whole IcomSerial controller — only `open` differs. // credentials. Reuses the whole IcomSerial controller — only `open` differs.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *IcomSerial { //
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
// non-nil the conninfo asks the rig to stream audio and each received payload is
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
// so enabling it can't affect freq/mode/DSP control.
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
if civAddr <= 0 || civAddr > 0xFF { if civAddr <= 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610 civAddr = 0x98 // IC-7610
} }
@@ -57,7 +63,7 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *Ic
b.dialMu.Lock() b.dialMu.Lock()
cancel := b.dialCancel cancel := b.dialCancel
b.dialMu.Unlock() b.dialMu.Unlock()
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel) return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
} }
return b return b
} }
@@ -130,6 +136,10 @@ type icomNet struct {
// but link fine" (stay connected) from "link dead" (reconnect). See Alive(). // but link fine" (stay connected) from "link dead" (reconnect). See Alive().
lastRx atomic.Int64 lastRx atomic.Int64
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
// Torn down alongside the CI-V/control streams in Close.
audio *icomAudio
done chan struct{} done chan struct{}
closeOnce sync.Once closeOnce sync.Once
} }
@@ -232,6 +242,9 @@ var icnTrace = false
func (n *icomNet) Close() error { func (n *icomNet) Close() error {
n.closeOnce.Do(func() { n.closeOnce.Do(func() {
close(n.done) close(n.done)
if n.audio != nil {
n.audio.Close()
}
// Tell the rig we're leaving so it frees its SINGLE control session at // Tell the rig we're leaving so it frees its SINGLE control session at
// once. If it never gets a disconnect it holds the session for minutes and // once. If it never gets a disconnect it holds the session for minutes and
// refuses every new login — which is why a lost link (or a hard app exit) // refuses every new login — which is why a lost link (or a hard app exit)
@@ -475,8 +488,9 @@ func (n *icomNet) resend(seq uint16) {
// ------------------------- connect ------------------------- // ------------------------- connect -------------------------
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) { func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr) wantAudio := audioSink != nil
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
// ---- control stream (50001): handshake → login → token → conninfo ---- // ---- control stream (50001): handshake → login → token → conninfo ----
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001")) craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
if err != nil { if err != nil {
@@ -562,7 +576,11 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
rigMAC = make([]byte, 6) rigMAC = make([]byte, 6)
} }
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003)) var rxEnable byte
if wantAudio {
rxEnable = 0x01 // ask the rig to stream RX audio on 50003
}
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
cTracked++ cTracked++
cInner++ cInner++
drainEnd := time.Now().Add(500 * time.Millisecond) drainEnd := time.Now().Add(500 * time.Millisecond)
@@ -634,6 +652,18 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
go n.ctrlPump() go n.ctrlPump()
go n.civPump() go n.civPump()
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
// CAT works without audio, so we log and continue rather than tear down a
// perfectly good control/CI-V session.
if wantAudio {
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
} else {
n.audio = a
}
}
return n, nil return n, nil
} }
@@ -772,7 +802,7 @@ func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) [
return b return b
} }
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte { func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
b := make([]byte, 0x90) b := make([]byte, 0x90)
icnLE.PutUint32(b[0:], 0x90) icnLE.PutUint32(b[0:], 0x90)
icnLE.PutUint16(b[6:], seq) icnLE.PutUint16(b[6:], seq)
@@ -788,8 +818,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
copy(b[0x2a:0x30], rigMAC) copy(b[0x2a:0x30], rigMAC)
copy(b[0x40:0x60], []byte("IC-7610")) copy(b[0x40:0x60], []byte("IC-7610"))
copy(b[0x60:0x70], icnPasscode(user)) copy(b[0x60:0x70], icnPasscode(user))
b[0x70] = 0x00 // rxenable (audio off — CI-V only) b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
b[0x71] = 0x00 // txenable b[0x71] = 0x00 // txenable (Phase 5)
b[0x72] = 0x10 // rxcodec b[0x72] = 0x10 // rxcodec
b[0x73] = 0x04 // txcodec b[0x73] = 0x04 // txcodec
icnBE.PutUint32(b[0x74:], 16000) icnBE.PutUint32(b[0x74:], 16000)
+19 -4
View File
@@ -210,17 +210,25 @@ func (b *IcomSerial) Connect() error {
go b.netScopeFeeder(sc.ScopeChan(), b.readerDone) go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
} }
// Best-effort model identification: ask the rig for its own CI-V address. // Best-effort model identification: ask the rig for its own CI-V address. The
// 0x19 ID read returns the rig's FACTORY default address (e.g. 0x94 for an
// IC-7300) regardless of the address it's currently OPERATING on — so it's the
// reliable model signal even when the user runs the rig at a non-default CI-V
// address (a common trick to make CAT "just work" at 0x98).
idAddr := b.rigAddr // fallback: the configured address if the ID read fails
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil { if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool { if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00 return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
}); err == nil { }); err == nil {
b.model = civ.ModelName(f.Data[1]) b.model = civ.ModelName(f.Data[1])
idAddr = f.Data[1]
} }
} }
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub // Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
// selector byte; single-scope rigs (IC-7300…) do not. // selector byte; single-scope rigs (IC-7300…) do not. Decide from the
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2 // IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must
// still parse single-scope frames (this was the "scope blank on the 7300" bug).
b.dualScope = idAddr == 0x98 || idAddr == 0xA2
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network // Defer the DSP snapshot until the rig actually answers CI-V. Over the network
// the rig may still be booting (or off) at Connect, so an immediate readDSP // the rig may still be booting (or off) at Connect, so an immediate readDSP
// would time out and leave every control at 0 / off with no retry. ReadState // would time out and leave every control at 0 / off with no retry. ReadState
@@ -590,7 +598,7 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
} }
continue continue
} }
if rawN < 4 { if rawN < 24 {
rawN++ rawN++
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data) applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
} }
@@ -696,6 +704,13 @@ func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader // and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
// routes every 0x27 frame to scopeLoop. // routes every 0x27 frame to scopeLoop.
func (b *IcomSerial) SetScope(on bool) error { func (b *IcomSerial) SetScope(on bool) error {
if on {
// Context for the scope-diagnostic log: which rig + whether we expect the
// dual-scope (main/sub) frame layout. If the IC-7300 (single scope) streams
// but nothing shows, compare its `icom scope raw` frames against this.
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
}
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and // Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
// continue rather than abort the second command. // continue rather than abort the second command.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil { if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
+10
View File
@@ -414,6 +414,16 @@ func (o *OmniRig) SetPTT(on bool) error {
debugLog.Printf("OmniRig.SetPTT error: %v", err) debugLog.Printf("OmniRig.SetPTT error: %v", err)
return fmt.Errorf("set Tx=%s: %w", name, err) return fmt.Errorf("set Tx=%s: %w", name, err)
} }
// Read the Tx param straight back. OmniRig is async — this may still show the
// previous value for a poll cycle — but if a key/unkey NEVER changes it, the
// write was coalesced or the rig isn't honouring PM_TX/PM_RX (wrong .ini).
if v, err := oleutil.GetProperty(o.rig, "Tx"); err == nil {
txState := "PM_RX"
if v.Val&pmTX != 0 {
txState = "PM_TX"
}
debugLog.Printf("OmniRig.SetPTT: Tx readback = 0x%X (%s)", v.Val, txState)
}
return nil return nil
} }
+85
View File
@@ -53,6 +53,91 @@ func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string)
return msg, nil return msg, nil
} }
// eQSL Inbox download endpoint + base host. DownloadInBox.cfm builds an ADIF of
// the account's *received* eQSLs (confirmations) and returns an HTML page that
// links to the generated .adi file; we then fetch that file (two-step API).
const (
eqslDownloadInboxURL = "https://www.eQSL.cc/qslcard/DownloadInBox.cfm"
eqslBaseURL = "https://www.eQSL.cc"
)
// eqslAdiHrefRe pulls the generated .adi path out of the DownloadInBox reply,
// e.g. `<A HREF="/qslcard/downloadedfiles/xxxx.adi">`.
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href="(/[^"]+\.adi)"`)
// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs =
// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since
// then, incremental "last download"); qthNick comes from the config when the
// account has several QTH profiles.
func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
if user == "" || strings.TrimSpace(cfg.Password) == "" {
return "", fmt.Errorf("eqsl: username/password not set")
}
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
q := url.Values{}
q.Set("UserName", user)
q.Set("Password", cfg.Password)
if nick := strings.TrimSpace(cfg.QTHNickname); nick != "" {
q.Set("QTHNickname", nick)
}
if s := eqslRcvdSince(since); s != "" {
q.Set("RcvdSince", s) // eQSL wants YYYYMMDDHHMM
}
// Step 1: ask eQSL to build the inbox ADIF; the reply is HTML linking to it.
page, err := eqslGet(ctx, client, eqslDownloadInboxURL+"?"+q.Encode())
if err != nil {
return "", err
}
if reason := authErrEQSL(page); reason != "" {
return "", fmt.Errorf("eqsl: %s", reason)
}
m := eqslAdiHrefRe.FindStringSubmatch(page)
if m == nil {
// eQSL reports problems inline ("Error: …", or "no QSLs" wording).
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslReason(page))
}
// Step 2: fetch the generated .adi file.
adif, err := eqslGet(ctx, client, eqslBaseURL+m[1])
if err != nil {
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
}
return adif, nil
}
// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on
// a non-200 status.
func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return "", fmt.Errorf("eqsl: build request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("eqsl: request failed: %w", err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("eqsl: http %d", resp.StatusCode)
}
return string(b), nil
}
// eqslRcvdSince converts the app's "YYYY-MM-DD" into eQSL's RcvdSince format
// "YYYYMMDDHHMM" (midnight). Empty in → empty out (full pull).
func eqslRcvdSince(since string) string {
s := strings.ReplaceAll(strings.TrimSpace(since), "-", "")
if len(s) == 8 { // YYYYMMDD → append 0000 (00:00)
return s + "0000"
}
return s
}
// authErrEQSL returns a reason when the response signals bad credentials, else // authErrEQSL returns a reason when the response signals bad credentials, else
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd". // "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
func authErrEQSL(body string) string { func authErrEQSL(body string) string {
+13
View File
@@ -1890,6 +1890,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
return nil return nil
} }
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET eqsl_rcvd = 'Y', eqsl_rcvd_date = ?,
updated_at = ? WHERE id = ?`,
date, db.NowISO(), id)
if err != nil {
return fmt.Errorf("mark eqsl confirmed %d: %w", id, err)
}
return nil
}
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO // MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string. // after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error { func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.19.1" appVersion = "0.19.2"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+23
View File
@@ -0,0 +1,23 @@
# Amplifiers and Switches
## PowerGenius XL (4O3A) amplifier
Direct TCP connection (Settings → PowerGenius):
- **Operate / Standby** and **fault** display.
- **Fan-mode** selector (Standard / Contest / Broadcast).
- When a Flex is connected, the amp controls + its **FWD / ID / TEMP** meters
appear in the FlexRadio panel's Amplifier card. See [[FlexRadio]].
## Antenna Genius (4O3A) antenna switch
Over TCP / GSCP — a docked **A/B antenna-switch** widget:
- One row per configured antenna, with a **Port A** and **Port B** button.
- Colours: green = selected on port A, blue = selected on port B, red (pulsing) =
that port is transmitting. Clicking a selected port deselects it (→ None).
- **Band filter** (funnel icon in the header): shows only the antennas configured
for the current band, like the native app. Toggle it off to see all antennas.
Antennas with an unknown band mask are always shown.
Configure the host / password in Settings → Antenna Genius.
+36
View File
@@ -0,0 +1,36 @@
# Audio and Keyers
## CW keyer
A CW keyer with **macros** and F-key macros. The keyer **engine** is selectable
(Settings → CW Keyer):
- **WinKeyer** — K1EL WK1/2/3 over a COM port.
- **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the
remote link too. (Requires the Icom CAT backend; set break-in to SEMI/FULL or
the rig keys the sidetone but stays in RX.)
- **TCI** — for TCI backends.
## Digital Voice Keyer (DVK)
Record **F1F6** voice messages and transmit them. Set the audio devices, PTT
method and gains in Settings → Audio.
- **PTT** method: CAT, RTS, DTR, or none (VOX).
- **Test PTT** keys the rig briefly. On CAT/OmniRig the key is held ~1.5 s so the
transmit command is actually sent (OmniRig is asynchronous and coalesces rapid
writes).
## QSO audio recording
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO WAV
(`CALL_YYYYMMDD_HHMMSS.wav`), mixing RX + mic. Configure the recordings folder,
format (WAV / MP3), pre-roll and gains in Settings → Audio.
## RX audio monitor (USB rigs)
Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB
Audio CODEC" input) through your speakers, so you hear the radio inside OpsLog.
**Talk to radio** keys PTT and pipes your mic to the rig's audio input. (Network
audio for remote Icom is a work in progress — see
[[Remote Icom over the Internet]].)
+47
View File
@@ -0,0 +1,47 @@
# Awards
OpsLog has a flexible awards engine. Award **definitions** and reference lists are
shared **globally** across profiles.
## Built-in awards
DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French
departments), and more — tracked **worked / confirmed / validated** by band and
mode.
## How matching works
An award scans a **QSO field** for a reference, by one of:
- **code** — the field value is the reference (e.g. state = `NY`).
- **description** — the reference's name appears in the field (substring, case-
insensitive but **space-sensitive** — "Hongkong" won't match "Hong Kong").
- **pattern** — a per-reference regex is tested against the field.
Plus **OR rules** (also match if any of these hit), a **leading/trailing** strip,
a **prefix** added to found references, and a **dynamic** mode (any value counts,
like POTA).
> **Tip — Hong Kong / Macau style mismatches:** if the data says "Hong Kong" but
> the reference description is "Hongkong", either fix the description to match, or
> set that OR-search's *Match by* to **pattern** so the reference's own regex
> (e.g. `\bHong Kong\b`) is used. In *description* mode the per-reference pattern
> is ignored.
## Live detection & manual refs
- References are detected **live** as you enter a callsign.
- You can **manually assign** a reference to a QSO (stored in an ADIF extra so it
survives export/import — see [[Import and Export ADIF]]).
## Reference lists & display
- **Import reference lists** for totals and names.
- Per award, choose what the Recent-QSOs column shows: **reference**, **name**,
or **both**. Award columns are opt-in per the Columns picker
([[Recent QSOs and Filters]]).
## Rescan
**Rescan** re-pulls the logbook and recomputes — it picks up fresh LoTW / QRZ /
eQSL confirmations (see [[QSL Management]]).
+49
View File
@@ -0,0 +1,49 @@
# CAT Control
OpsLog has **four native CAT backends** (Settings → CAT). Each auto-reconnects
and connects *non-blocking*, so a powered-off radio never freezes the app.
| Backend | Use it for |
|---------|-----------|
| **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). |
| **FlexRadio (SmartSDR)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
| **Icom CI-V** | Any Icom over **USB** *or* over the internet via its **LAN server**. → [[Icom]] · [[Remote Icom over the Internet]] |
| **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. |
Once connected, frequency / band / mode follow the radio, clicking a cluster spot
tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
## OmniRig
Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM
port / baud in OmniRig's own settings. Works with anything OmniRig supports; PTT
keying via CAT is supported where the rig's OmniRig profile exposes it.
## FlexRadio
Enter the radio's IP (UDP discovery assists). You get a full SmartSDR-style
control tab — see **[[FlexRadio]]**.
## Icom CI-V (USB)
- **Rig model** — pick your model from the dropdown; it sets the correct **CI-V
address** automatically (or choose *Other* and type the hex address).
- **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF**
on the rig).
- The model choice also tailors the console (e.g. attenuator steps: 20 dB on an
IC-7300, 6/12/18 dB on an IC-7610).
Full console details: **[[Icom]]**.
## TCI
Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and,
optionally, panorama spots.
## Notes
- The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency.
- **Per-band Flex RX/TX antennas** can be configured and are applied
automatically on band change (Settings → FlexRadio).
- Split, RIT/XIT and other rig-specific features are exposed on each backend's
control tab where supported.
+27
View File
@@ -0,0 +1,27 @@
# Contest Logging
**Contest tab.** Pick a contest and an exchange, and OpsLog handles the rest.
## Setup
- **Contest** — from the built-in ADIF `CONTEST_ID` list.
- **Exchange** — a **running serial** (auto-incrementing) or a **fixed exchange**.
- **Window** — start / end times; QSOs outside are flagged.
## While operating
- OpsLog auto-fills `CONTEST_ID` and the **sent / received serials** (`STX` /
`SRX`).
- **Dupe** detection flags a call already worked in the contest.
- A live **scoreboard** tracks your progress.
## Export
Export the contest log to **Cabrillo** for submission (Recent QSOs → export), or
to ADIF. See [[Import and Export ADIF]].
## Tip
Use **Bulk edit field** (right-click in Recent QSOs) to fix a contest field
across many QSOs at once — e.g. set `CONTEST_ID` or an exchange string on an
imported batch.
+40
View File
@@ -0,0 +1,40 @@
# DX Cluster and Spots
## Servers
Add multiple cluster servers (Settings). They auto-reconnect; one is the
**master** used for sending commands. The Cluster tab and the Main-view cluster
pane share the same live feed.
## Filter sidebar
A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane):
- **Callsign** search, **hide worked**, **group duplicates**.
- Filter by **band / mode / status / source**.
## Per-spot status and tuning
Each spot is coloured by **status** relative to your log:
- **new** (entity never worked), **new-band**, **new-slot**, **worked**.
Click a spot to **tune the rig** (fills the callsign into the entry strip). A
multi-band **Band Map** shows panadapter-style strips per band.
**POTA** spots are tagged with their park reference (via `api.pota.app`).
## Sending a spot
Use the **Send Spot** window to announce a DX spot on the master cluster:
callsign + frequency + a message. The frequency defaults to the **live CAT
frequency** (full resolution, including sub-kHz).
## Spot alerts
**Tools → Alert management** — Log4OM-style alert rules on **call / country /
band / mode / spotter**, with **sound**, **visual** and **e-mail** notification.
Active alerts appear as a discreet bell in the entry strip; click it for the
list. Alerts are kept briefly and only shown when active.
See also: [[Maps and Antennas]] for turning the beam to a spot.
+54
View File
@@ -0,0 +1,54 @@
# FlexRadio
When the CAT backend is a **FlexRadio (SmartSDR)**, OpsLog shows a full
SmartSDR-style control panel (as a Main-view pane or the FlexRadio tab). It talks
the radio's TCP API directly and streams live meters over UDP (VITA-49).
## Slices (multi-slice)
FlexRadios can run several receiver slices (A/B/C/D…). OpsLog shows **every
in-use slice** as a row of buttons under the header:
- Click a slice to make it the **active** slice — the main frequency, mode, DSP
and spot-clicks all follow it.
- Click a slice's **TX** badge to move the transmitter onto it.
- **Split** is detected only when the TX slice and a same-band RX slice form a
genuine split; independent slices on other bands are not shown as split.
Everything you do targets the **active** slice, so a second slice monitoring
another band never hijacks your main frequency.
## Header
Model + RX/TX state, plus compact **PA voltage** and **PA temperature** readouts
(temperature turns amber/red as it climbs).
## Transmit
RF power, tune power, **TUNE**, **MOX**, **SPLIT**, speech processor (NOR / DX /
DX+), VOX (+ level + delay), monitor (+ level), mic gain, **TX filter** low/high
cut, and the **mic profile** selector. On CW the panel switches to keyer controls
(speed / pitch / break-in / sidetone / filter).
## Receive (active slice)
RX/TX antenna, AGC mode + threshold, audio level, and the noise/notch filters:
**NB**, **WNB** (wideband noise blanker), **NR**, **ANF**, and **APF** on CW —
each a toggle plus level. Filter-width presets per mode.
## Amplifier (PowerGenius XL)
If a PowerGenius XL is detected, its **OPERATE / STANDBY**, **fan mode** and
**fault** show in an Amplifier card, with the amp's FWD / ID / TEMP meters
inline. See [[Amplifiers and Switches]].
## Meters
Live UDP meters under the slices: **S-meter** (S-units, click → fill RST),
**forward power** (W), **SWR**, **mic** level (40…+10 dB, red = overdrive) and
**compression**. PA voltage and temperature are in the header.
## Antennas per band
Configure Flex **RX/TX antennas per band** (Settings → FlexRadio); OpsLog applies
them automatically on band change.
+59
View File
@@ -0,0 +1,59 @@
# Getting Started
This walks you from a fresh install to your first logged QSO.
## 1. Set your station
**Settings → Station**: enter your **callsign**, **grid locator** and **name**.
These feed callsign lookups, the map, awards, the QSL card and the "my station"
ADIF fields on every QSO.
## 2. (Optional) Connect a radio
**Settings → CAT**, pick a backend and fill its fields — see [[CAT Control]].
Once connected, the frequency, band and mode fill in automatically as you tune,
and clicking a cluster spot tunes the rig.
You can log perfectly well **without** a radio — just type the band, mode and
frequency.
## 3. The entry strip
The strip across the top is the heart of OpsLog (Log4OM-style):
- **Callsign** — type it and the contacted entity's **flag** appears next to the
RST fields; the country, CQ/ITU zones and continent resolve offline from
`cty.dat`. If QRZ/HamQTH lookup is configured, name / QTH / grid auto-fill and
a photo shows in the F2 tab.
- **RST sent / rcvd**, **Name**, **QTH**, **Grid**.
- **Band / Mode / Frequency** (TX and, for split, RX).
- **Start / End time**, **Comment / Note**.
Press **Enter** in any text field to **log** the QSO. The contact drops into
**Recent QSOs** below.
> Mode comes from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
> the frequency.
## 4. Look around
- **Recent QSOs** — your log; double-click a row to edit every field.
See [[Recent QSOs and Filters]].
- **Worked-before matrix** — as you type a call, see if/when you worked that
entity, per band/mode slot.
- **Main view** — two configurable panes (Settings → General → *Main view*): a
great-circle map, the cluster, the FlexRadio or Icom console, Net control, and
more. See [[Maps and Antennas]].
## 5. Profiles
Every setting is **per-profile**. A profile can log to the local SQLite file or
a shared **MySQL** database (multi-operator). Create one per call / station /
event. See [[Profiles and Databases]].
## Next steps
- Chase spots → [[DX Cluster and Spots]]
- Track awards → [[Awards]]
- Send confirmations → [[QSL Management]]
- Design a card → [[QSL Card Designer]]
+41
View File
@@ -0,0 +1,41 @@
# OpsLog
A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB
*and* remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert
Electronics), a DX cluster with spot alerts, awards tracking, maps, contest
logging, QSL management and a QSL-card designer.
Built with **Wails v2** (Go backend + React/TypeScript frontend), **pure Go**
(no CGO). SQLite holds the configuration; the logbook can be the local SQLite
file *or* a **shared MySQL** database so several operators run one log. Fully
themeable and bilingual (English / French).
Developed by **F4BPO**.
## New here? Read these first
1. **[[Installation]]** — download / build and first launch.
2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a
profile.
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, TCI).
## What OpsLog does
| Area | Pages |
|------|-------|
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] |
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
| Confirmations | [[Awards]] · [[QSL Management]] · [[QSL Card Designer]] |
| Configuration | [[Settings and Data]] · [[Security]] · [[Profiles and Databases]] |
| Something wrong? | [[Troubleshooting]] |
## Design in one line
Everything you configure (settings, profiles, rigs, cluster nodes, award lists,
QSL templates, lookup cache) lives in a **local SQLite** file so the UI is
instant. Only the **QSOs** live where the active profile points them — local
SQLite or a shared MySQL. See [[Profiles and Databases]].
+55
View File
@@ -0,0 +1,55 @@
# Icom
When the CAT backend is **Icom CI-V** (USB or network), OpsLog shows a full
RS-BA1-style console — the same panel whether you're on USB or remote.
For setup, see [[CAT Control]]; for the internet link, see
[[Remote Icom over the Internet]].
## Choosing your model
Pick your model from the **Rig model** dropdown in Settings → CAT — it sets the
CI-V address and tailors the console to the radio (e.g. attenuator steps).
The model is also auto-detected from the radio's CI-V ID.
## Console
- **Twin VFO readout** (MAIN / SUB): big tabular frequency, mode badge, band, and
RIT/ΔTX offset, with a **mode-button row** (SSB / CW / RTTY / PSK / AM / FM).
- **Spectrum scope + waterfall**: ON/OFF, CTR/FIX, double-click to tune, and
**◀ ⊙ ▶** to centre the scope on the current frequency (±50 kHz) and pan.
- **Live meters** always visible: S-meter (click → fill RST), power in watts, SWR.
- **Receive DSP:** AF / RF gain, squelch, AGC, preamp, **attenuator** (steps match
the model — e.g. 20 dB on an IC-7300, 6/12/18 dB on an IC-7610), filter
(FIL1/2/3), NB, NR, ANF, and — **on CW only** — the **APF** (audio peak filter).
- **Passband / notch:** Twin PBT (inner / outer) and manual notch + position.
- **Transmit:** RF power, MOX, TUNE, **split with automatic offset** (+5 kHz SSB /
+1 kHz CW), monitor. On **voice modes only**: mic gain, compressor, VOX (+ gain
+ anti-VOX). Controls that don't apply to the current mode are hidden.
- **Bands & antenna:** one-touch band buttons, ANT1/ANT2.
- **Clarifiers:** RIT and ΔTX, wheel / ± tuning (Ctrl + ← / → nudges RIT).
## Power ON / OFF
- **Network:** ON and OFF buttons are shown — the radio's LAN server answers even
in standby, so ON works.
- **USB:** the buttons are **hidden**. Over USB the CI-V interface is unpowered
while the rig is off, so a power-ON command can't reach it — turn the radio on
physically.
The app never wakes the rig on connect (manual by design).
## CW keying
CW can run through the **radio's own keyer** over CI-V — no extra hardware, and
it works over the remote link too. See [[Audio and Keyers]].
## Model-specific notes
- **IC-7300 / IC-705 / IC-9100:** single-receiver, single-step attenuator
(20 dB); the IC-9100 is USB-only (no LAN server).
- **IC-7610 / IC-9700:** dual scope (main/sub); stepped attenuator; LAN server
for remote.
- If the **scope is blank on a single-scope rig run at a non-default CI-V
address**, OpsLog now detects the model from the CI-V ID, not the configured
address, so it decodes single-scope frames correctly.
+32
View File
@@ -0,0 +1,32 @@
# Import and Export ADIF
OpsLog is **ADIF 3.1.7 compliant**: a full field dictionary, 30 promoted columns,
a generic "extra fields" editor for anything not promoted, and standard/all
export modes.
## Import
Import an `.adi` / `.adif` file into the active profile's logbook. On import,
OpsLog parses every field, maps standard fields to columns and keeps unknown but
valid ADIF fields in a per-QSO **extras** store (editable later). Country / zones
are enriched from `cty.dat` where missing.
## Export
- **Whole log**, **selected rows**, or the **filtered view** (no limit) — see
[[Recent QSOs and Filters]].
- **Standard** mode exports the common promoted fields; **All** mode also exports
every extra field.
- **Cabrillo** export for contest submission.
## Round-trip safety
Award references you assign manually are stored in an ADIF extra field
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does not
lose your award-reference assignments.
## Tips
- To move a batch of QSOs between logs, export selected → import into the other
profile.
- Use **Find duplicates** (Tools) after importing to catch overlaps.
+44
View File
@@ -0,0 +1,44 @@
# Installation
OpsLog is a single Windows executable — no installer, no runtime to add. Pure Go
+ an embedded WebView2 (present on Windows 10/11 by default).
## Download and run
1. Grab the latest `OpsLog.exe` from the project's **Releases** page.
2. Put it in its own folder (it will create a `data/` folder next to itself).
3. Double-click to run. On first launch you pick a **language** (English /
French) and OpsLog creates its local database.
> **Where is my data?** A `data/` folder is created next to the executable,
> holding the SQLite config + local logbook, QSL templates, recordings and
> backups. Keep the exe and `data/` together, or back up `data/`. See
> [[Settings and Data]].
## Building from source (developers)
OpsLog is built with **Wails v2.11**.
```bash
# Dev — Vite hot-reload; Go methods reachable at http://localhost:34115
wails dev
# Production build → build/bin/OpsLog.exe
wails build
# Regenerate Go↔TS bindings after changing exported App methods
wails generate module
```
Prerequisites: Go, Node.js, and the Wails CLI (`~/go/bin/wails.exe`). No CGO /
C toolchain is needed.
## First-run checklist
- **Settings → General** — language, theme, and the two Main-view panes.
- **Settings → Station** — your callsign, grid, name (used for lookups, awards,
QSL cards and the map).
- **Settings → CAT** — connect your radio ([[CAT Control]]).
- **Settings → Lookup** — QRZ.com / HamQTH credentials for callsign lookup.
Continue with **[[Getting Started]]**.
+45
View File
@@ -0,0 +1,45 @@
# Logging Basics
## The entry strip
The entry strip logs a QSO from any `<input>` inside it — press **Enter** to
save. Fields: callsign, RST tx/rx, name, QTH, grid, band, mode, TX/RX frequency
(split), start/end time, comment and note.
- **Callsign** drives everything: the **flag**, offline DXCC resolution (country,
CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area
handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides.
- **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the
photo and a QRZ.com tab. Configure it in Settings → Lookup.
- **ESC** clears the callsign (and stops CW).
## Editing a QSO
Double-click any row in **Recent QSOs** (or Worked-before) to open the full
editor — every ADIF field across tabs: QSO info, contacted station's details,
award refs, QSL info, contest, sat/prop, my station, and a generic **extra
fields** editor for any ADIF field not promoted to a column.
## Bulk operations
Select rows in Recent QSOs, right-click:
- **Fix country & zones** from `cty.dat`, or **update from QRZ / ClubLog**.
- **Bulk edit field** — set one field across many QSOs (QSL/upload status, my
station, contest, propagation, contacted-station refs, comment…). Great for
flipping a batch to *Requested* before an upload.
- **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo,
**delete**.
## Find duplicates
**Tools → Find duplicates** groups QSOs by same **call + band + mode** (optionally
same day or same minute) and lets you pick which to delete.
## Notes
- **Worked-before matrix** shows prior contacts with the entity per band/mode
slot as you type.
- Awards references are detected **live** as you enter a call (see [[Awards]]).
See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]]
+38
View File
@@ -0,0 +1,38 @@
# Maps and Antennas
## Main view
The Main view is **two configurable panes** (per profile, Settings → General →
*Main view*). Choose from: great-circle map, locator (street) map, the cluster
grid, the worked-before grid, recent QSOs, the **FlexRadio** controls, the
**Icom** console, or the **Net control** panel.
## Great-circle map
- Short/long-path **distance & azimuth** to the worked station.
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite (all
labelled).
- The **antenna beam lobe(s)** are drawn from the rotor azimuth.
## Locator (street) map
An OSM street map centred on the contacted grid square — handy for local /
portable contacts. No API key.
## Rotor compass
An azimuthal-equidistant **click-to-turn** compass driven by **PstRotator**. The
current heading and target are shown; click a bearing to turn the rotor.
## Ultrabeam
Ultrabeam antennas are supported with three modes — **Normal**, **180° reverse**,
**Bidirectional**:
- The **radiating direction** is shown in green and the **mechanical boom** in
grey, on both the compass and the map, so you always know where the antenna
actually points.
- The antenna **follows the rig frequency** (band change → retune), and retunes
immediately when you click a spot.
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch).
+42
View File
@@ -0,0 +1,42 @@
# Multi-Operator Live Status
For a **multi-op special-event call** on a shared MySQL logbook (e.g. TM74TFR),
OpsLog can publish who is on the air, on which band/mode/frequency, as a live
page you embed on the station's **QRZ.com** bio.
## Enable it
1. Point the profile's logbook at a **shared MySQL** database
([[Profiles and Databases]]).
2. **Settings → General → Publish live operator status.**
Each OpsLog instance then heartbeats its current activity (operator call, band,
frequency, mode) into a `live_status` table every ~15 s. OpsLog only **writes**
to the database — it is not a web server.
## The web page
A small PHP renderer, `docs/livestatus/tm74-status.php`, reads that table and
produces a live page/image. Put it on **your own** web server (the one reachable
from the internet):
1. Edit the credentials at the top: `$DB_HOST`, `$DB_PORT`, `$DB_NAME`,
`$DB_USER`, `$DB_PASS` — pointing at the **same** MySQL as OpsLog's logbook.
2. Embed it on QRZ (QRZ strips `<script>`/`<iframe>`, so use an image):
```html
<img src="https://your-server/tm74-status.php?img=1">
```
or link the real-time page: `https://your-server/tm74-status.php`.
## Troubleshooting the PHP page
- **`Connection refused`** — MySQL is refusing the connection. Most common cause:
the server's `bind-address` is `127.0.0.1` (localhost only). Set it to the LAN
IP / `0.0.0.0`, grant the `opslog` user for the web server's IP, and open the
firewall.
- **Runs on a non-standard MySQL port** — set `$DB_PORT` and pass it as the 5th
`mysqli` argument (the shipped script already does).
- Redeploy the current `tm74-status.php` if you edited an old copy — the shipped
version fails gracefully instead of throwing a PHP fatal error.
+30
View File
@@ -0,0 +1,30 @@
# Net Control
**Directed-net logging** (Tools → Net) — run a directed net or round-table and
log the whole thing at once, Log4OM-style.
## How it works
- **Roster** (right) — the net's registered stations, stored globally in
`nets.json` (shared across profiles). Add contacts with a QRZ/HamQTH prefill.
- **On-air** (left) — the active session (in memory): put a station on the air
(check it in), and log the whole net when done.
- Frequency / band / mode of each logged QSO come from **live CAT** at log time
(falling back to the last known values if CAT is off).
## Working a net
1. Open / create a net and **Open** it.
2. Double-click a roster station (or use *Put on air*) to put it on the air —
this starts its QSO (time on).
3. Double-click an on-air row to open the full QSO editor (edit any field).
4. **Log & end** a station to write its QSO to the logbook (time off = now).
## Worked-before
Click a station (on air or in the roster) to see prior contacts with it in the
**worked-before** panel below the on-air column. The panel height is adjustable
(drag its top edge).
> The active session is in-memory only — closing the app mid-net drops
> un-logged on-air stations.
+35
View File
@@ -0,0 +1,35 @@
# Profiles and Databases
## Profiles
Every setting in OpsLog is **per-profile**: station identity, CAT, lookup, QSL
defaults, awards display, antennas, and which logbook to use. Create one profile
per callsign / station / event.
Switch or manage profiles in Settings. The active profile decides which logbook
your QSOs go to.
## Config vs. logbook — two different stores
| Store | What | Where |
|-------|------|-------|
| **Config** | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` |
| **Logbook** | your QSOs | where the active profile points — local SQLite **or** shared MySQL |
Keeping config local means the UI is instant even when the logbook is a far-away
MySQL server.
## Shared MySQL logbook (multi-operator)
Point a profile's logbook at a **MySQL** database so several operators run **one
log** — e.g. a multi-op special-event call. Configure the host / database /
credentials in the profile's database settings.
This is also what powers [[Multi-Operator Live Status]] (each instance heartbeats
its activity into a `live_status` table).
## Backups
Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless,
copying the `data/` folder backs up your config and local logbook. See
[[Settings and Data]].
+35
View File
@@ -0,0 +1,35 @@
# QSL Card Designer
**Tools → QSL Card Designer…** turns a few photos into a polished eQSL card.
## Design flow
1. **Pick 16 photos** (jpeg/png). OpsLog analyses them offline (detail /
luminance grid) and proposes **3 designs** — callsign placed in the calmest
zone of the best photo, operator name, CQ/ITU zones + locator line, country
flag, the other photos as bordered inserts, and a per-QSO confirmation box.
2. **Fine-tune**: click an element to select, drag to move; change font / style
preset (gel gold, gel silver, classic white outline, script, flat) and the
per-preset knobs on the right. The **QSO info box** has opacity, background
colour, corner radius, title and text-colour controls.
3. **Save** the template. Photos are copied into
`data/qsl/templates/<id>/` (the originals can move afterwards). One template
can be the **default per profile**.
## Sending
Right-click a QSO → **Send eQSL by e-mail**. The card is rendered with that QSO's
data, rasterised to a ≤ 800 KB JPEG, archived in `data/qsl/outbox/`, and sent via
the configured SMTP account to the address from the QRZ/HamQTH lookup. On success
the QSO is stamped `EQSL_SENT=Y`.
The e-mail subject/body templates live in the designer, with
`{CALL} {DATE} {BAND} {MODE} {MYCALL}` variables.
## Fonts & flags
Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all OFL,
embedded); Cooper Black is offered when MS Office installed it. Flags:
flag-icons (MIT), embedded for the commonly-worked DXCC entities.
See also: [[QSL Management]].
+44
View File
@@ -0,0 +1,44 @@
# QSL Management
**Tools → QSL Manager.** Upload and download confirmations for the online QSL
services, and manage paper QSL.
## Services
| Service | Upload | Download confirmations |
|---------|:------:|:----------------------:|
| **LoTW** | ✔ (via TQSL) | ✔ |
| **QRZ.com** | ✔ | ✔ |
| **eQSL** | ✔ | ✔ |
| **ClubLog** | ✔ (batched ADIF) | — |
| **HRDLog** | ✔ | — |
| **POTA** | hunter-log sync | ✔ (park tags) |
| **Paper** | — | set sent/received + via |
## Uploading
Pick a service, find the QSOs (by callsign or a filter), select and upload.
**Bulk edit field** (right-click in Recent QSOs) flips a batch's upload status to
*Requested* first, if you gate uploads that way.
## Downloading confirmations
Pick a service and a **date window** (All / Since last download / a date), then
**Download confirmations**. OpsLog matches each confirmation to your log
(call + minute + band + mode), stamps it (`LOTW_RCVD`, `EQSL_RCVD`,
`QRZ download`…), and optionally **adds not-found** QSOs. Downloading
auto-refreshes the **award** stats.
- **LoTW** and **paper QSL** are the award-valid confirmations for ARRL awards;
eQSL / QRZ count only against their own confirmations.
- **LoTW upload** goes through **TQSL** (ARRL's signer) — set the TQSL path and
station location in Settings.
## E-mail eQSL
Right-click a QSO → **Send eQSL by e-mail** via the configured SMTP account. The
[[QSL Card Designer]] card is rendered with that QSO's data and sent to the
address found by the QRZ/HamQTH lookup.
> Outlook/Hotmail disable basic-auth SMTP — use **Gmail with an app password**,
> or a Microsoft app password.
+45
View File
@@ -0,0 +1,45 @@
# Publishing this wiki to GitHub
These files are the source for the **OpsLog GitHub Wiki**. GitHub stores a wiki
as its own git repository (`<repo>.wiki.git`), so publishing means pushing these
`.md` files into it.
## Automatic — via the release script (recommended)
The release task (`.vscode/release.ps1`, VS Code → *Tasks: Run Task* → *Release
OpsLog*) **publishes the wiki automatically** at the end of every release: it
clones `github.com/GregTroar/OpsLog.wiki.git` to a temp folder, copies every page
from this `wiki/` folder (except this guide), and pushes only if something
changed. Auth reuses your GitHub git credential (set up by `gh auth login`).
**One-time:** on GitHub, open the repo → **Wiki** tab → *Create the first page*
Save. This initialises the wiki repo so the script's clone succeeds.
## Manual (if you want to push between releases)
```bash
# One-time: clone the wiki repo NEXT TO the project (not inside it)
git clone https://github.com/GregTroar/OpsLog.wiki.git
# Then, to update:
cp wiki/*.md ../OpsLog.wiki/ # (skip README-PUBLISHING.md if you like)
cd ../OpsLog.wiki
git add . && git commit -m "Update wiki" && git push
```
The pages appear immediately under the repository's **Wiki** tab.
## How the pages work
- Each `.md` file is one wiki page. The **file name** becomes the page title and
URL: `Getting-Started.md` → page *Getting Started* at `.../wiki/Getting-Started`.
- **`_Sidebar.md`** renders as the navigation sidebar on every page.
- **`_Footer.md`** renders at the bottom of every page.
- Link between pages with `[[Page Name]]` (wiki-link) or a normal relative link
`[text](Getting-Started)`.
- `Home.md` is the wiki landing page.
## Keep it in sync with the README
The wiki mirrors [`README.md`](../README.md). When you add a feature, update the
matching wiki page and the README together so they don't drift.
+33
View File
@@ -0,0 +1,33 @@
# Recent QSOs and Filters
## The Recent QSOs grid
The main log table. Double-click a row to edit; right-click for bulk actions
(see [[Logging Basics]]). Columns are configurable — click **Columns** to choose
which are visible; your selection is saved (per profile).
### Award columns
Each defined award can show a column with the reference the QSO counts for. These
columns are **hidden by default** and opt-in from the Columns picker — turn on
only the awards you're chasing. Your choice persists across restarts.
## Advanced filter builder
Click the filter button to open the **QSO filter builder** (Log4OM-style):
- Add one or more **conditions**: *field · operator · value*.
- Operators: equals, not-equal, contains, starts/ends with, greater/less
(or equal), is empty, is not empty.
- Join conditions with **ALL (AND)** or **ANY (OR)**.
- Save named **presets** (they travel with the `data/` folder).
- The field dropdown is sorted alphabetically to find things fast.
The filtered view can be exported: **selected rows** or the **whole filtered
view** (no row limit) → ADIF or Cabrillo.
## Worked-before matrix
As you type a callsign, the worked-before grid shows prior contacts with that
**entity**, arranged by band/mode slot, so you instantly see what's new (new
DXCC / new band / new slot) versus already worked.
+55
View File
@@ -0,0 +1,55 @@
# Remote Icom over the Internet
OpsLog speaks the Icom radio's **built-in network protocol** directly, so it
**replaces both the Icom Remote Utility and RS-BA1**. Radios with a LAN server —
**IC-7610, IC-9700, IC-705, IC-7760, IC-905** — can be operated over the
LAN/internet with the full [[Icom]] console.
> The **IC-9100 has no LAN server** — it is USB-only.
## On the radio (one-time)
In the radio's **Network** menu:
1. Enable the network / *Remote settings*.
2. Set a **Network User1** name and password.
3. Note the radio's **IP address** and its **CI-V address**.
4. Make sure the Icom **Remote Utility is closed** on every PC — OpsLog binds the
same local UDP ports, so they can't both run.
## In OpsLog
Settings → CAT → backend **Icom CI-V (network / remote)**:
- **Host** — the radio's IP or hostname.
- **User / Password** — the Network User1 credentials.
- **Rig model / CI-V address** — pick the model.
Save. OpsLog logs in, obtains a session **token** (auto-renewed so control never
drops after ~2 minutes), opens the **CI-V tunnel**, and the whole console works
over the net — freq / mode / DSP / TX / RIT / CW / scope.
## How it stays solid
- **Token renewal** every ~45 s keeps the session alive.
- **Receive-side retransmit**: under the heavy panadapter stream, lost UDP
packets are re-requested, so the link doesn't drop when the scope is on.
- **Standby-tolerant**: the radio's server answers pings even when the radio is
off, so the session doesn't flap — the console + power ON button stay reachable.
- **Manual power** ON / OFF (the app never wakes the rig on connect).
## Audio
Network **audio** is a work in progress. For now, use the radio in **USB + a
voice link** (e.g. Mumble) alongside the network CAT, or run OpsLog on the
station PC via remote desktop. See [[Audio and Keyers]] for the USB-monitor
option.
## Troubleshooting
- **"Login rejected"** → wrong Network User1 name/password.
- **Can't bind local port / dial fails** → the Icom **Remote Utility** is still
running; close it.
- **Link drops when the scope turns on** → should be fixed by receive-side
retransmit; if it persists, check the log (`icom net:` lines) and network
quality. See [[Troubleshooting]].
+24
View File
@@ -0,0 +1,24 @@
# Security
## Secret vault
Opt-in **passphrase encryption** of the stored passwords (QRZ, cluster, eQSL,
SMTP, Icom network, etc.) using **AES-GCM + PBKDF2**.
- Enable it in Settings; you set a passphrase.
- Encrypted values are marked `enc:v1:` and are **portable** (they travel with
the `data/` folder).
- A single **unlock prompt at launch** decrypts them for the session.
If you forget the passphrase, the encrypted secrets can't be recovered — re-enter
them.
## What OpsLog sends where
- **Callsign lookups** go to QRZ.com / HamQTH with your credentials.
- **QSL uploads/downloads** go to the services you configure (LoTW via TQSL, QRZ,
eQSL, ClubLog, HRDLog).
- **Telemetry** is a once-a-day anonymous heartbeat (random install ID + version
+ OS — no callsign, no QSO data). Opt out in Preferences.
- Everything else stays local (or on your own MySQL / web server for the
multi-op live status).
+51
View File
@@ -0,0 +1,51 @@
# Settings and Data
## Where everything lives
A `data/` folder is created **next to `OpsLog.exe`**:
- **Config** (SQLite): settings, profiles, rigs/antennas, cluster nodes, lookup
cache, award lists, QSL templates. Always local, so the UI is instant.
- **Local logbook** (SQLite): QSOs, when the profile logs locally.
- `data/qsl/templates/…` — QSL card templates and their photos.
- `data/qsl/outbox/…` — sent QSL card JPEGs.
- Recordings folder (configurable) — per-QSO WAV/MP3.
- Backups.
Back up your setup by copying `data/` (and keep it with the exe). See
[[Profiles and Databases]] for local-vs-MySQL logbooks.
## Settings map
| Section | What |
|---------|------|
| **General** | language, theme, Main-view panes, live-status publish |
| **Station** | your callsign, grid, name, address (my-station ADIF) |
| **CAT** | radio backend + connection ([[CAT Control]]) |
| **Lookup** | QRZ.com / HamQTH credentials |
| **FlexRadio** | per-band RX/TX antennas |
| **Audio** | DVK / recorder devices, PTT, gains, RX monitor |
| **CW Keyer** | WinKeyer / Icom / TCI engine + macros |
| **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA |
| **Email** | SMTP for eQSL-by-email |
| **PowerGenius / Antenna Genius / Rotator / Ultrabeam** | station hardware |
| **Backup** | database + ADIF backup at shutdown |
## Appearance & language
Four themes (Warm light, Warm dark, Graphite dark, High contrast) plus **Auto**
(follows the OS). Full **English / French** UI with a first-run flag chooser and
a switcher in Settings → General.
## Integrations (outbound UDP)
Push the current frequency to **PstRotator**, radio info in **N1MM `RadioInfo`**
format, or an **ADIF record on each logged QSO** — so external tools stay in sync.
## Other
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at launch,
skipping any already running.
- **Update check** at startup (toggleable).
- **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID +
version + OS; no callsign or QSO data). Opt out in Preferences.
+58
View File
@@ -0,0 +1,58 @@
# Troubleshooting
## Where's the log file?
OpsLog writes a diagnostic log. The path is shown in Settings (and can be opened
from there). When reporting a problem, include the relevant lines — they're
prefixed by subsystem (`Flex:`, `icom net:`, `icom scope`, `antgenius:`,
`cluster:`, `qslmgr:log`, `audio:`…).
## CAT
- **Radio not connecting** — check the backend fields (COM port + baud for USB;
IP/credentials for network). OpsLog connects non-blocking, so it won't freeze —
watch the CAT status.
- **Icom: no CAT at all over USB** — the CI-V address must match the radio; pick
your **model** from the dropdown (it sets the address) and set *CI-V USB Echo
Back* **OFF** on the rig.
- **Icom scope blank on an IC-7300** — this was a bug where the scope decoder was
chosen from the configured CI-V address; it now uses the model detected from the
CI-V ID, so a single-scope rig run at address `0x98` decodes correctly. Update
to the latest build.
- **Icom attenuator button does nothing** — the dB steps are model-specific; make
sure the right model is selected (IC-7300 = 20 dB, IC-7610 = 6/12/18 dB).
## Remote Icom
- **Login rejected** → wrong Network User1 name/password.
- **Can't bind local port** → the Icom **Remote Utility is still running**
close it.
- **Link drops with the scope on** → receive-side retransmit should hold it; if
not, check network quality and the `icom net:` log lines. See
[[Remote Icom over the Internet]].
## FlexRadio
- **A control reads 0 / stale at startup** — some SmartSDR status fields use short
names (e.g. the TX filter is reported as `lo`/`hi`, not `filter_low/high`). If a
value never populates, capture the `Flex: … status` log line and report it.
- **Antenna wrong band on a spot click** — fixed: `SetFrequency`/`SetMode` update
the slice cache immediately so the panel and the Ultrabeam follow don't chase a
stale frequency.
## Multi-op live status PHP
`Connection refused` from the PHP page is almost always MySQL `bind-address =
127.0.0.1` or a firewall/grant issue — see
[[Multi-Operator Live Status]].
## DVK / PTT
- **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the async
OmniRig write is actually sent; if it still doesn't key, the rig's OmniRig
profile may not expose CAT TX keying — use RTS/DTR or VOX.
## Still stuck?
Open an issue with: what you did, what happened, your radio/model, and the
relevant log lines.
+2
View File
@@ -0,0 +1,2 @@
---
**OpsLog** — a modern ham-radio logger by **F4BPO** · [Home](Home) · [Troubleshooting](Troubleshooting) · A French README is in `README.fr.md`.
+37
View File
@@ -0,0 +1,37 @@
### OpsLog Wiki
**Start here**
- [[Home]]
- [[Installation]]
- [[Getting Started]]
**Logging**
- [[Logging Basics]]
- [[Recent QSOs and Filters]]
- [[Import and Export ADIF]]
- [[Profiles and Databases]]
**Radio control (CAT)**
- [[CAT Control]]
- [[FlexRadio]]
- [[Icom]]
- [[Remote Icom over the Internet]]
**Operating**
- [[DX Cluster and Spots]]
- [[Maps and Antennas]]
- [[Amplifiers and Switches]]
- [[Audio and Keyers]]
- [[Contest Logging]]
- [[Net Control]]
- [[Multi-Operator Live Status]]
**QSL & Awards**
- [[Awards]]
- [[QSL Management]]
- [[QSL Card Designer]]
**Reference**
- [[Settings and Data]]
- [[Security]]
- [[Troubleshooting]]