Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c39204301 | ||
|
|
0c3089344b | ||
|
|
6ab606fa54 | ||
|
|
96390110f8 | ||
|
|
6f2f9236b0 | ||
|
|
efb61107fe | ||
|
|
16e780c2df | ||
|
|
f3bf0b2f5c | ||
|
|
1f74e4d234 | ||
|
|
19c2de5f61 | ||
|
|
5ae2bad549 | ||
|
|
e487aa78f3 | ||
|
|
b9079fe4e2 | ||
|
|
206a6bff09 | ||
|
|
6ee31ae45d | ||
|
|
521f8266cf | ||
|
|
1f5e5759cc | ||
|
|
f665d666d6 | ||
|
|
5f9ee9efa2 | ||
|
|
44d276c841 | ||
|
|
e656c79589 | ||
|
|
86cbe94cae | ||
|
|
b9b005ea36 | ||
|
|
2c47c5d052 | ||
|
|
446888a330 | ||
|
|
128364260b | ||
|
|
daf38554a5 | ||
|
|
39f095ae75 | ||
|
|
ea8e954eef | ||
|
|
6db90abcad |
@@ -41,12 +41,14 @@ import (
|
|||||||
"hamlog/internal/netctl"
|
"hamlog/internal/netctl"
|
||||||
"hamlog/internal/operating"
|
"hamlog/internal/operating"
|
||||||
"hamlog/internal/pota"
|
"hamlog/internal/pota"
|
||||||
|
"hamlog/internal/lotwusers"
|
||||||
"hamlog/internal/powergenius"
|
"hamlog/internal/powergenius"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
|
"hamlog/internal/solar"
|
||||||
"hamlog/internal/ultrabeam"
|
"hamlog/internal/ultrabeam"
|
||||||
"hamlog/internal/winkeyer"
|
"hamlog/internal/winkeyer"
|
||||||
|
|
||||||
@@ -87,6 +89,8 @@ const (
|
|||||||
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
||||||
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
||||||
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
||||||
|
keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
|
keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal
|
||||||
keyCATPollMs = "cat.poll_ms"
|
keyCATPollMs = "cat.poll_ms"
|
||||||
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
keyCATDelayMs = "cat.delay_ms" // pause between commands
|
||||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||||
@@ -96,6 +100,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 +249,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
|
||||||
@@ -273,12 +279,15 @@ type CATSettings struct {
|
|||||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||||
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
||||||
|
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
|
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
|
||||||
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
|
||||||
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
|
||||||
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
|
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
|
||||||
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
|
||||||
@@ -417,6 +426,8 @@ type App struct {
|
|||||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||||
|
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||||
|
lotwUsers *lotwusers.Manager // LoTW user-activity list (badge next to the callsign)
|
||||||
|
|
||||||
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
||||||
// session (in-memory only — active stations currently in QSO).
|
// session (in-memory only — active stations currently in QSO).
|
||||||
@@ -446,6 +457,8 @@ type App struct {
|
|||||||
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
||||||
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
||||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
||||||
|
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
|
catFlexDecodeSecs int // decode spot display duration (seconds)
|
||||||
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
|
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
|
||||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||||
liveBand string
|
liveBand string
|
||||||
@@ -763,8 +776,38 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
|
// cty.dat lacks (DXpeditions). Loaded from cache if present; downloaded on
|
||||||
// demand. Resolution applied only when the user enables it.
|
// demand. Resolution applied only when the user enables it.
|
||||||
a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir)
|
a.clublog = clublog.NewManager(clublogAppAPIKey, dataDir)
|
||||||
|
|
||||||
|
// LoTW user-activity list (who's a LoTW user + last upload). Loads the cached
|
||||||
|
// CSV if present, and auto-downloads in the background when it's missing or
|
||||||
|
// older than a DAY — the list carries each station's last-upload date, so a
|
||||||
|
// stale cache would misreport recency (a station that uploaded 2 days ago must
|
||||||
|
// show as recent, which only works if we refresh roughly daily). Manual
|
||||||
|
// refresh is in Settings → LoTW.
|
||||||
|
a.lotwUsers = lotwusers.NewManager(dataDir)
|
||||||
go func() {
|
go func() {
|
||||||
if err := a.clublog.EnsureLoaded(); err == nil {
|
if a.lotwUsers.Count() == 0 || time.Since(a.lotwUsers.Updated()) > 24*time.Hour {
|
||||||
|
if n, err := a.lotwUsers.Download(context.Background()); err == nil {
|
||||||
|
applog.Printf("lotwusers: auto-downloaded %d LoTW users", n)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "lotwusers:updated")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
applog.Printf("lotwusers: auto-download failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_ = a.clublog.EnsureLoaded()
|
||||||
|
// Auto-refresh a missing/stale country file (ClubLog adds date-ranged
|
||||||
|
// exceptions constantly — e.g. event calls like IR0WWA get new SARDINIA
|
||||||
|
// periods). Without this a stale cache resolves recent event QSOs to the
|
||||||
|
// base country. Best-effort; the on-disk copy still serves if it fails.
|
||||||
|
if a.clublog.NeedsRefresh(3 * 24 * time.Hour) {
|
||||||
|
if err := a.clublog.Download(context.Background()); err != nil {
|
||||||
|
applog.Printf("clublog: auto-refresh failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.clublog.Loaded() {
|
||||||
d, n := a.clublog.Info()
|
d, n := a.clublog.Info()
|
||||||
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
|
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
|
||||||
}
|
}
|
||||||
@@ -871,6 +914,16 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Live space-weather (solar flux, sunspots, A/K indices) for the header strip
|
||||||
|
// and per-QSO stamping. Refreshes in the background; pushes a UI event on each
|
||||||
|
// update so the strip re-reads.
|
||||||
|
a.solar = solar.NewManager(func() {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "solar:update")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
a.solar.Start()
|
||||||
|
|
||||||
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
|
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
|
||||||
a.audioMgr = audio.NewManager(func() {
|
a.audioMgr = audio.NewManager(func() {
|
||||||
st := a.dvkStatus()
|
st := a.dvkStatus()
|
||||||
@@ -1113,6 +1166,9 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
a.udp.StopAll()
|
a.udp.StopAll()
|
||||||
}
|
}
|
||||||
|
if a.solar != nil {
|
||||||
|
a.solar.Stop()
|
||||||
|
}
|
||||||
// Stop CAT so the backend disconnects cleanly. Critical for the Icom network
|
// Stop CAT so the backend disconnects cleanly. Critical for the Icom network
|
||||||
// backend: without this the rig never gets a disconnect and holds its single
|
// backend: without this the rig never gets a disconnect and holds its single
|
||||||
// control session for minutes, refusing every new login (even from the Icom
|
// control session for minutes, refusing every new login (even from the Icom
|
||||||
@@ -1640,6 +1696,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
||||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||||
a.applyQSLDefaults(&q)
|
a.applyQSLDefaults(&q)
|
||||||
|
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
|
||||||
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
||||||
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
||||||
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
||||||
@@ -1663,6 +1720,94 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySolar stamps the current space-weather onto a QSO before it's saved: the
|
||||||
|
// standard ADIF SFI / A_INDEX / K_INDEX fields (only when the QSO doesn't already
|
||||||
|
// carry them), and the sunspot number as an APP_OPSLOG_SSN extra (SSN has no
|
||||||
|
// standard ADIF field). No-op when the feed hasn't loaded yet.
|
||||||
|
func (a *App) applySolar(q *qso.QSO) {
|
||||||
|
if a.solar == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := a.solar.Get()
|
||||||
|
if !d.OK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.SFI == nil {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(d.SFI), 64); err == nil {
|
||||||
|
q.SFI = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.AIndex == nil {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(d.AIndex), 64); err == nil {
|
||||||
|
q.AIndex = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.KIndex == nil {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(d.KIndex), 64); err == nil {
|
||||||
|
q.KIndex = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ssn := strings.TrimSpace(d.SSN); ssn != "" {
|
||||||
|
if q.Extras == nil {
|
||||||
|
q.Extras = map[string]string{}
|
||||||
|
}
|
||||||
|
if _, ok := q.Extras["APP_OPSLOG_SSN"]; !ok {
|
||||||
|
q.Extras["APP_OPSLOG_SSN"] = ssn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSolarData returns the latest space-weather snapshot for the header strip.
|
||||||
|
func (a *App) GetSolarData() solar.Data {
|
||||||
|
if a.solar == nil {
|
||||||
|
return solar.Data{}
|
||||||
|
}
|
||||||
|
return a.solar.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshSolar forces an immediate re-fetch of the space-weather feed.
|
||||||
|
func (a *App) RefreshSolar() {
|
||||||
|
if a.solar != nil {
|
||||||
|
go a.solar.Refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoTWUserInfo reports whether a callsign is a LoTW user and how recently it
|
||||||
|
// uploaded — drives the colour-coded LoTW badge in the entry strip.
|
||||||
|
func (a *App) LoTWUserInfo(callsign string) lotwusers.Info {
|
||||||
|
if a.lotwUsers == nil {
|
||||||
|
return lotwusers.Info{DaysAgo: -1}
|
||||||
|
}
|
||||||
|
return a.lotwUsers.Lookup(callsign)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoTWUsersStatus is the loaded-list summary for Settings (count + last refresh).
|
||||||
|
type LoTWUsersStatus struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Updated string `json:"updated,omitempty"` // RFC3339, empty if never
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLoTWUsersStatus returns how many LoTW callsigns are loaded and when.
|
||||||
|
func (a *App) GetLoTWUsersStatus() LoTWUsersStatus {
|
||||||
|
if a.lotwUsers == nil {
|
||||||
|
return LoTWUsersStatus{}
|
||||||
|
}
|
||||||
|
st := LoTWUsersStatus{Count: a.lotwUsers.Count()}
|
||||||
|
if u := a.lotwUsers.Updated(); !u.IsZero() {
|
||||||
|
st.Updated = u.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadLoTWUsers fetches ARRL's LoTW user-activity list and caches it.
|
||||||
|
// Returns the number of callsigns loaded.
|
||||||
|
func (a *App) DownloadLoTWUsers() (int, error) {
|
||||||
|
if a.lotwUsers == nil {
|
||||||
|
return 0, fmt.Errorf("not initialized")
|
||||||
|
}
|
||||||
|
return a.lotwUsers.Download(a.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// StationInfoComputed bundles the data we resolve live from the
|
// StationInfoComputed bundles the data we resolve live from the
|
||||||
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
|
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
|
||||||
// lat/lon. Used by the Settings UI to show the "what will be stamped on
|
// lat/lon. Used by the Settings UI to show the "what will be stamped on
|
||||||
@@ -2906,8 +3051,10 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
|
|||||||
defs := a.awardDefs()
|
defs := a.awardDefs()
|
||||||
metas := a.awardRefMetas(defs)
|
metas := a.awardRefMetas(defs)
|
||||||
fieldByCode := map[string]string{}
|
fieldByCode := map[string]string{}
|
||||||
|
dispByCode := map[string]string{}
|
||||||
for _, d := range defs {
|
for _, d := range defs {
|
||||||
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
|
||||||
|
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
|
||||||
}
|
}
|
||||||
nameOf := func(field, ref string) string {
|
nameOf := func(field, ref string) string {
|
||||||
switch field {
|
switch field {
|
||||||
@@ -2933,10 +3080,22 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
|
|||||||
if !rf.Worked {
|
if !rf.Worked {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// DXCC's ref is a number → show the country name instead.
|
// Per-award display choice: ref (default), name (description), or
|
||||||
|
// both. DXCC keeps showing the country name under the default.
|
||||||
label := rf.Ref
|
label := rf.Ref
|
||||||
if dxccField && rf.Name != "" {
|
switch dispByCode[code] {
|
||||||
label = rf.Name
|
case "name":
|
||||||
|
if rf.Name != "" {
|
||||||
|
label = rf.Name
|
||||||
|
}
|
||||||
|
case "both":
|
||||||
|
if rf.Name != "" {
|
||||||
|
label = rf.Ref + " — " + rf.Name
|
||||||
|
}
|
||||||
|
default: // "" or "ref"
|
||||||
|
if dxccField && rf.Name != "" {
|
||||||
|
label = rf.Name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
refs = append(refs, label)
|
refs = append(refs, label)
|
||||||
}
|
}
|
||||||
@@ -3615,6 +3774,24 @@ var bulkFieldColumns = map[string]string{
|
|||||||
"my_antenna": "my_antenna",
|
"my_antenna": "my_antenna",
|
||||||
"my_sig": "my_sig",
|
"my_sig": "my_sig",
|
||||||
"my_sig_info": "my_sig_info",
|
"my_sig_info": "my_sig_info",
|
||||||
|
// Contest
|
||||||
|
"contest_id": "contest_id",
|
||||||
|
"srx_string": "srx_string",
|
||||||
|
"stx_string": "stx_string",
|
||||||
|
"arrl_sect": "arrl_sect",
|
||||||
|
"precedence": "precedence",
|
||||||
|
"class": "class",
|
||||||
|
// Propagation / satellite
|
||||||
|
"prop_mode": "prop_mode",
|
||||||
|
"sat_name": "sat_name",
|
||||||
|
"sat_mode": "sat_mode",
|
||||||
|
// Contacted station activation refs / SIG
|
||||||
|
"pota_ref": "pota_ref",
|
||||||
|
"sota_ref": "sota_ref",
|
||||||
|
"wwff_ref": "wwff_ref",
|
||||||
|
"iota": "iota",
|
||||||
|
"sig": "sig",
|
||||||
|
"sig_info": "sig_info",
|
||||||
// Misc text
|
// Misc text
|
||||||
"comment": "comment",
|
"comment": "comment",
|
||||||
"notes": "notes",
|
"notes": "notes",
|
||||||
@@ -4289,7 +4466,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, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, 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
|
||||||
}
|
}
|
||||||
@@ -4300,12 +4477,15 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
FlexHost: m[keyCATFlexHost],
|
FlexHost: m[keyCATFlexHost],
|
||||||
FlexPort: 4992,
|
FlexPort: 4992,
|
||||||
FlexSpots: m[keyCATFlexSpots] == "1",
|
FlexSpots: m[keyCATFlexSpots] == "1",
|
||||||
|
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
||||||
|
FlexDecodeSecs: 120,
|
||||||
IcomPort: m[keyCATIcomPort],
|
IcomPort: m[keyCATIcomPort],
|
||||||
IcomBaud: 115200,
|
IcomBaud: 115200,
|
||||||
IcomAddr: 0x98, // IC-7610 default
|
IcomAddr: 0x98, // IC-7610 default
|
||||||
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",
|
||||||
@@ -4316,6 +4496,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||||
out.FlexPort = n
|
out.FlexPort = n
|
||||||
}
|
}
|
||||||
|
if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
|
||||||
|
out.FlexDecodeSecs = n
|
||||||
|
}
|
||||||
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
|
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
|
||||||
out.TCIPort = n
|
out.TCIPort = n
|
||||||
}
|
}
|
||||||
@@ -4377,10 +4560,21 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.FlexSpots {
|
if s.FlexSpots {
|
||||||
flexSpots = "1"
|
flexSpots = "1"
|
||||||
}
|
}
|
||||||
|
flexDecodeSpots := "0"
|
||||||
|
if s.FlexDecodeSpots {
|
||||||
|
flexDecodeSpots = "1"
|
||||||
|
}
|
||||||
|
if s.FlexDecodeSecs < 10 || s.FlexDecodeSecs > 3600 {
|
||||||
|
s.FlexDecodeSecs = 120
|
||||||
|
}
|
||||||
tciSpots := "0"
|
tciSpots := "0"
|
||||||
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"
|
||||||
}
|
}
|
||||||
@@ -4391,12 +4585,15 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||||
keyCATFlexSpots: flexSpots,
|
keyCATFlexSpots: flexSpots,
|
||||||
|
keyCATFlexDecodeSpots: flexDecodeSpots,
|
||||||
|
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
||||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||||
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,
|
||||||
@@ -4980,6 +5177,8 @@ func (a *App) NetLookup(callsign string) netctl.Station {
|
|||||||
if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil {
|
if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil {
|
||||||
st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country
|
st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country
|
||||||
st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ
|
st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ
|
||||||
|
st.Grid, st.Address, st.State, st.Cnty = lr.Grid, lr.Address, lr.State, lr.County
|
||||||
|
st.Cont, st.Lat, st.Lon, st.Email = lr.Continent, lr.Lat, lr.Lon, lr.Email
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
@@ -5070,6 +5269,16 @@ func (a *App) NetActivate(callsign string) (qso.QSO, error) {
|
|||||||
for _, st := range net.Stations {
|
for _, st := range net.Stations {
|
||||||
if strings.EqualFold(st.Callsign, call) {
|
if strings.EqualFold(st.Callsign, call) {
|
||||||
q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country
|
q.Name, q.QTH, q.Country = st.Name, st.QTH, st.Country
|
||||||
|
q.Grid, q.Address, q.State, q.County = st.Grid, st.Address, st.State, st.Cnty
|
||||||
|
q.Continent, q.Email = st.Cont, st.Email
|
||||||
|
if st.Lat != 0 {
|
||||||
|
lat := st.Lat
|
||||||
|
q.Lat = &lat
|
||||||
|
}
|
||||||
|
if st.Lon != 0 {
|
||||||
|
lon := st.Lon
|
||||||
|
q.Lon = &lon
|
||||||
|
}
|
||||||
if st.DXCC != 0 {
|
if st.DXCC != 0 {
|
||||||
d := st.DXCC
|
d := st.DXCC
|
||||||
q.DXCC = &d
|
q.DXCC = &d
|
||||||
@@ -5451,7 +5660,20 @@ func (a *App) applyClublogException(q *qso.QSO, force bool) bool {
|
|||||||
}
|
}
|
||||||
e, ok := a.clublog.Resolve(q.Callsign, date)
|
e, ok := a.clublog.Resolve(q.Callsign, date)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
// No exception COVERS this QSO's date. If the call nonetheless HAS a
|
||||||
|
// date-ranged exception (e.g. G1T = Scotland only from 2024-02-21), then
|
||||||
|
// cty.dat's date-blind "=G1T → Scotland" override is WRONG for an older
|
||||||
|
// QSO — resolve it by ClubLog's date-aware PREFIX table instead (G1 →
|
||||||
|
// England for a 2012 contact). Ordinary calls (no exception) are left to
|
||||||
|
// cty.dat.
|
||||||
|
if a.clublog.HasException(q.Callsign) {
|
||||||
|
if pe, pok := a.clublog.ResolvePrefix(q.Callsign, date); pok {
|
||||||
|
e, ok = pe, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
q.Country = titleEntity(e.Entity)
|
q.Country = titleEntity(e.Entity)
|
||||||
if e.Cont != "" {
|
if e.Cont != "" {
|
||||||
@@ -5800,8 +6022,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5821,6 +6055,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 {
|
||||||
@@ -6541,12 +6841,14 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
} else {
|
} else {
|
||||||
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
|
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
|
||||||
}
|
}
|
||||||
|
emit(fmt.Sprintf("Window: since=%q → resolved=%q (scope owncall=%q)", since, sinceDate, ownCall))
|
||||||
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
|
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
emit("Download failed: " + err.Error())
|
emit("Download failed: " + err.Error())
|
||||||
done(matched, total)
|
done(matched, total)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024))
|
||||||
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
|
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
|
||||||
if kerr != nil {
|
if kerr != nil {
|
||||||
emit("Error reading local log: " + kerr.Error())
|
emit("Error reading local log: " + kerr.Error())
|
||||||
@@ -6669,7 +6971,14 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
}
|
}
|
||||||
emit("ADIF head: " + snip)
|
emit("ADIF head: " + snip)
|
||||||
}
|
}
|
||||||
keyIDs, _ := a.qso.DedupeKeyIDs(ctx)
|
// Scope to THIS profile's / forced callsign so a QRZ logbook holding
|
||||||
|
// several of the operator's calls (F4BPO, TM2Q…) only confirms/adds QSOs
|
||||||
|
// for the active call. Windowed + phone-mode-tolerant matching (same as
|
||||||
|
// eQSL/LoTW).
|
||||||
|
qrzOwner := a.uploadOwnerCall(extsvc.ServiceQRZ)
|
||||||
|
mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner)
|
||||||
|
const qrzMatchWindow = 10 * time.Minute
|
||||||
|
qrzSkippedOtherCall := 0
|
||||||
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
|
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
|
||||||
// judged only against other QRZ confirmations.
|
// judged only against other QRZ confirmations.
|
||||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
|
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
|
||||||
@@ -6706,6 +7015,13 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
|
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Skip a QSO logged under a DIFFERENT one of the operator's callsigns.
|
||||||
|
if qrzOwner != "" {
|
||||||
|
if rc := strings.ToUpper(strings.TrimSpace(rec["station_callsign"])); rc != "" && rc != qrzOwner {
|
||||||
|
qrzSkippedOtherCall++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
total++
|
total++
|
||||||
date := rec["qrzcom_qso_download_date"]
|
date := rec["qrzcom_qso_download_date"]
|
||||||
if date == "" {
|
if date == "" {
|
||||||
@@ -6714,8 +7030,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
a.enrichContactedFromCty(&q)
|
a.enrichContactedFromCty(&q)
|
||||||
line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s",
|
line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s",
|
||||||
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode)
|
q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode)
|
||||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow)
|
||||||
id, found := keyIDs[key]
|
|
||||||
switch {
|
switch {
|
||||||
case found:
|
case found:
|
||||||
if alreadyQrz[id] {
|
if alreadyQrz[id] {
|
||||||
@@ -6729,8 +7044,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
q.QRZComUploadStatus = "Y"
|
q.QRZComUploadStatus = "Y"
|
||||||
q.QRZComDownloadStatus = "Y"
|
q.QRZComDownloadStatus = "Y"
|
||||||
q.QRZComDownloadDate = date
|
q.QRZComDownloadDate = date
|
||||||
|
if q.StationCallsign == "" {
|
||||||
|
q.StationCallsign = qrzOwner
|
||||||
|
}
|
||||||
if newID, e := a.qso.Add(ctx, q); e == nil {
|
if newID, e := a.qso.Add(ctx, q); e == nil {
|
||||||
keyIDs[key] = newID
|
mIdx.Add(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), newID)
|
||||||
added++
|
added++
|
||||||
emit(line + " ### ADDED ###")
|
emit(line + " ### ADDED ###")
|
||||||
}
|
}
|
||||||
@@ -6773,8 +7091,128 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
||||||
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))
|
||||||
|
if qrzSkippedOtherCall > 0 {
|
||||||
|
emit(fmt.Sprintf("Skipped %d QSO(s) logged under another of your callsigns (scoped to %s).", qrzSkippedOtherCall, qrzOwner))
|
||||||
|
}
|
||||||
// (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
|
||||||
|
}
|
||||||
|
// Scope everything to THIS profile's / forced callsign so an eQSL account
|
||||||
|
// holding several of the operator's calls (F4BPO, TM2Q…) only confirms/adds
|
||||||
|
// QSOs for the active call.
|
||||||
|
eqslOwner := a.uploadOwnerCall(extsvc.ServiceEQSL)
|
||||||
|
// Tolerant match index: an eQSL confirmation carries the OTHER station's
|
||||||
|
// logged time, often a minute or two off ours — so we match within a
|
||||||
|
// ±10-minute window. The mode must still match, except the phone sidebands
|
||||||
|
// SSB/USB/LSB are treated as one (a station may confirm USB where we logged
|
||||||
|
// SSB); FT8/FT4/CW/… stay exact.
|
||||||
|
mIdx, kerr := a.qso.BuildMatchIndex(ctx, eqslOwner)
|
||||||
|
if kerr != nil {
|
||||||
|
emit("Error reading local log: " + kerr.Error())
|
||||||
|
done(matched, total)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const eqslMatchWindow = 10 * time.Minute
|
||||||
|
skippedOtherCall := 0
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
// Skip a confirmation logged under a DIFFERENT one of the operator's
|
||||||
|
// callsigns (the eQSL account may hold several) — it belongs to that
|
||||||
|
// call's log, not this one.
|
||||||
|
if eqslOwner != "" {
|
||||||
|
if rc := strings.ToUpper(strings.TrimSpace(rec["station_callsign"])); rc != "" && rc != eqslOwner {
|
||||||
|
skippedOtherCall++
|
||||||
|
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)
|
||||||
|
id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), eqslMatchWindow)
|
||||||
|
if 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 q.StationCallsign == "" {
|
||||||
|
q.StationCallsign = eqslOwner // stamp the active call on adds
|
||||||
|
}
|
||||||
|
if newID, e := a.qso.Add(ctx, q); e == nil {
|
||||||
|
mIdx.Add(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), 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 skippedOtherCall > 0 {
|
||||||
|
emit(fmt.Sprintf("Skipped %d confirmation(s) logged under another of your callsigns (scoped to %s).", skippedOtherCall, eqslOwner))
|
||||||
|
}
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
@@ -7022,7 +7460,10 @@ func (a *App) uploadOwnerCall(svc extsvc.Service) string {
|
|||||||
case extsvc.ServiceHRDLog:
|
case extsvc.ServiceHRDLog:
|
||||||
owner = cfg.HRDLog.Callsign
|
owner = cfg.HRDLog.Callsign
|
||||||
case extsvc.ServiceEQSL:
|
case extsvc.ServiceEQSL:
|
||||||
owner = cfg.EQSL.Username
|
// eQSL has no Force-callsign field; its Username is just the account login
|
||||||
|
// (which may hold several of the operator's calls). Scope by the ACTIVE
|
||||||
|
// PROFILE's callsign instead — the operating identity — via the fallback
|
||||||
|
// below, so a download on the F4BPO profile only touches F4BPO QSOs.
|
||||||
}
|
}
|
||||||
owner = strings.ToUpper(strings.TrimSpace(owner))
|
owner = strings.ToUpper(strings.TrimSpace(owner))
|
||||||
if owner == "" && a.profiles != nil {
|
if owner == "" && a.profiles != nil {
|
||||||
@@ -7434,6 +7875,24 @@ func (a *App) consumeUDPEvents() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case ev.DecodeCall != "":
|
||||||
|
// A WSJT-X decode (heard station). Render it on the FlexRadio
|
||||||
|
// panadapter when the option is on; green + SNR comment, auto-expiring
|
||||||
|
// after the configured duration. De-duped per call in the Flex backend.
|
||||||
|
if a.catFlexDecodeSpots && a.cat != nil {
|
||||||
|
secs := a.catFlexDecodeSecs
|
||||||
|
if secs <= 0 {
|
||||||
|
secs = 120
|
||||||
|
}
|
||||||
|
a.cat.SendSpot(cat.SpotInfo{
|
||||||
|
FreqHz: ev.DecodeFreqHz,
|
||||||
|
Callsign: ev.DecodeCall,
|
||||||
|
Mode: ev.Mode,
|
||||||
|
Color: "#FF34C759", // green — distinct from cluster orange
|
||||||
|
Comment: fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR),
|
||||||
|
LifetimeSec: secs,
|
||||||
|
})
|
||||||
|
}
|
||||||
case ev.LoggedADIF != "":
|
case ev.LoggedADIF != "":
|
||||||
applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF))
|
applog.Printf("udp: emit udp:logged_qso (%d bytes ADIF)\n", len(ev.LoggedADIF))
|
||||||
wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{
|
wruntime.EventsEmit(a.ctx, "udp:logged_qso", map[string]any{
|
||||||
@@ -7748,9 +8207,54 @@ func (a *App) SetCATFrequency(hz int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
|
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
|
||||||
}
|
}
|
||||||
|
// Re-tune the Ultrabeam right away on a DELIBERATE freq set (spot click, band
|
||||||
|
// button, memory recall) instead of waiting for the ~1.5 s follow poll + the
|
||||||
|
// CAT echo — the reason it used to only follow after you nudged the VFO.
|
||||||
|
go a.ultrabeamFollowNow(hz)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ultrabeamFollowNow re-tunes the Ultrabeam to freqHz at once (best-effort),
|
||||||
|
// honouring the same enabled/follow/in-range/step-deadband rules as the follow
|
||||||
|
// loop. Called from SetCATFrequency so a spot click moves the antenna instantly.
|
||||||
|
func (a *App) ultrabeamFollowNow(freqHz int64) {
|
||||||
|
c := a.ultrabeam
|
||||||
|
if c == nil || freqHz <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := a.GetUltrabeamSettings()
|
||||||
|
if err != nil || !s.Enabled || !s.Follow {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step := s.StepKHz
|
||||||
|
if step <= 0 {
|
||||||
|
step = 50
|
||||||
|
}
|
||||||
|
st, err := c.GetStatus()
|
||||||
|
if err != nil || st == nil || !st.Connected {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.FreqMin > 0 && st.FreqMax > 0 {
|
||||||
|
mhz := freqHz / 1_000_000
|
||||||
|
if mhz < int64(st.FreqMin) || mhz > int64(st.FreqMax) {
|
||||||
|
return // outside the antenna's tunable range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
khz := int(freqHz / 1000)
|
||||||
|
diff := khz - st.Frequency
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if st.Frequency > 0 && diff < step {
|
||||||
|
return // within the deadband — don't chase a tiny QSY
|
||||||
|
}
|
||||||
|
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
||||||
|
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
||||||
|
} else {
|
||||||
|
applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d)", khz, st.Direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are
|
// SetCATMode sets the rig's mode. ADIF mode names (SSB / CW / FT8 / …) are
|
||||||
// translated to backend-specific values by the backend itself.
|
// translated to backend-specific values by the backend itself.
|
||||||
func (a *App) SetCATMode(mode string) error {
|
func (a *App) SetCATMode(mode string) error {
|
||||||
@@ -8270,6 +8774,22 @@ func (a *App) FlexSetSplit(on bool) error {
|
|||||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlexSetActiveSlice focuses a slice (A/B/C/D…) so all commands target it.
|
||||||
|
func (a *App) FlexSetActiveSlice(idx int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetActiveSlice(idx) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexSetTXSlice makes a slice the transmitter (e.g. TX on the active slice).
|
||||||
|
func (a *App) FlexSetTXSlice(idx int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXSlice(idx) })
|
||||||
|
}
|
||||||
|
|
||||||
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
|
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
|
||||||
const keyFlexBandAnt = "flex.band_antennas"
|
const keyFlexBandAnt = "flex.band_antennas"
|
||||||
|
|
||||||
@@ -8387,6 +8907,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")
|
||||||
@@ -8460,6 +9004,8 @@ func (a *App) reloadCAT() {
|
|||||||
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
||||||
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
|
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
|
||||||
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
|
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
|
||||||
|
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
||||||
|
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
a.cat.Stop()
|
a.cat.Stop()
|
||||||
return
|
return
|
||||||
@@ -8489,7 +9035,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.
|
||||||
|
|||||||
+10
-2
@@ -122,12 +122,20 @@ func (a *App) QSLGenerateProposals(photoPaths []string) ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QSLListTemplates returns all saved templates (defaults first).
|
// QSLListTemplates returns the templates for the ACTIVE profile (its own plus any
|
||||||
|
// shared/global ones), defaults first — so the default is per-profile, not a
|
||||||
|
// single global list shared by every profile.
|
||||||
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
func (a *App) QSLListTemplates() ([]QSLTemplateInfo, error) {
|
||||||
if a.qslTemplates == nil {
|
if a.qslTemplates == nil {
|
||||||
return nil, fmt.Errorf("db not initialized")
|
return nil, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
recs, err := a.qslTemplates.List(a.ctx)
|
var recs []qslcard.Record
|
||||||
|
var err error
|
||||||
|
if p, e := a.profiles.Active(a.ctx); e == nil {
|
||||||
|
recs, err = a.qslTemplates.ListFor(a.ctx, p.ID)
|
||||||
|
} else {
|
||||||
|
recs, err = a.qslTemplates.List(a.ctx)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
||||||
|
$DB_PORT = 3306; // MySQL port — change if your server listens elsewhere
|
||||||
$DB_NAME = 'opslog'; // database name
|
$DB_NAME = 'opslog'; // database name
|
||||||
$DB_USER = 'opslog';
|
$DB_USER = 'opslog';
|
||||||
$DB_PASS = 'CHANGE_ME';
|
$DB_PASS = 'CHANGE_ME';
|
||||||
@@ -27,7 +28,7 @@ $STALE_SECONDS = 120; // an operator is "active" if seen within this wi
|
|||||||
// instead of a fatal "table doesn't exist".
|
// instead of a fatal "table doesn't exist".
|
||||||
mysqli_report(MYSQLI_REPORT_OFF);
|
mysqli_report(MYSQLI_REPORT_OFF);
|
||||||
|
|
||||||
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME, $DB_PORT);
|
||||||
if ($mysqli->connect_errno) {
|
if ($mysqli->connect_errno) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
exit('DB error');
|
exit('DB error');
|
||||||
|
|||||||
+176
-17
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
AlertCircle, Antenna, Bell, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
GetCATSettings,
|
GetCATSettings,
|
||||||
|
GetSolarData,
|
||||||
|
LoTWUserInfo,
|
||||||
OperatingDefaultForBand,
|
OperatingDefaultForBand,
|
||||||
LogUDPLoggedADIF,
|
LogUDPLoggedADIF,
|
||||||
ListCountries,
|
ListCountries,
|
||||||
@@ -366,6 +368,35 @@ 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('');
|
||||||
|
// Live space-weather (solar flux / sunspots / A / K) for the header strip.
|
||||||
|
// Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow
|
||||||
|
// fallback poll. These same numbers are stamped onto each logged QSO.
|
||||||
|
const [solar, setSolar] = useState<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => { GetSolarData().then((d) => setSolar(d ?? null)).catch(() => {}); };
|
||||||
|
load();
|
||||||
|
const off = EventsOn('solar:update', load);
|
||||||
|
const id = window.setInterval(load, 60 * 60 * 1000); // hourly fallback; the event refreshes it live
|
||||||
|
return () => { off(); window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
|
||||||
|
// debounced. Drives a colour-coded LoTW badge by last-upload recency.
|
||||||
|
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const c = callsign.trim();
|
||||||
|
if (!c) { setLotwInfo(null); return; }
|
||||||
|
const run = () => { LoTWUserInfo(c).then((r) => setLotwInfo(r as any)).catch(() => setLotwInfo(null)); };
|
||||||
|
const t = window.setTimeout(run, 300);
|
||||||
|
// Re-run when the background auto-download finishes (so a call typed before
|
||||||
|
// the list loaded gets its badge without re-typing).
|
||||||
|
const off = EventsOn('lotwusers:updated', run);
|
||||||
|
return () => { window.clearTimeout(t); off(); };
|
||||||
|
}, [callsign]);
|
||||||
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: [] });
|
||||||
@@ -793,6 +824,22 @@ export default function App() {
|
|||||||
|
|
||||||
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
|
const [alertsOpen, setAlertsOpen] = useState(false); // Alert management modal
|
||||||
|
|
||||||
|
// Persistent spot-alert tray: keep the last few fired alerts visible (they used
|
||||||
|
// to vanish with the 3 s toast, lost if you were on another window). Each stays
|
||||||
|
// ~10 min and is clickable to tune the rig to its freq/mode.
|
||||||
|
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
|
||||||
|
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
|
||||||
|
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
|
||||||
|
const ALERT_TTL_MS = 10 * 60 * 1000;
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setRecentAlerts((prev) => { const cut = Date.now() - ALERT_TTL_MS; const n = prev.filter((a) => a.at >= cut); return n.length === prev.length ? prev : n; });
|
||||||
|
}, 20000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
// Close the (now-hidden) dropdown when the last alert clears/expires.
|
||||||
|
useEffect(() => { if (recentAlerts.length === 0) setAlertsPanelOpen(false); }, [recentAlerts.length]);
|
||||||
|
|
||||||
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
|
||||||
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
|
||||||
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
|
||||||
@@ -994,11 +1041,15 @@ export default function App() {
|
|||||||
// list once, then compute each shown QSO's reference per award and attach it
|
// list once, then compute each shown QSO's reference per award and attach it
|
||||||
// to the rows (the grids render one hideable column per award).
|
// to the rows (the grids render one hideable column per award).
|
||||||
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
|
||||||
|
// Bumped whenever award definitions are saved so the grid columns AND the
|
||||||
|
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
|
||||||
|
// changing it updates ALL contacts (old included) with no restart.
|
||||||
|
const [awardsVersion, setAwardsVersion] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
GetAwardDefs().then((defs: any[]) =>
|
GetAwardDefs().then((defs: any[]) =>
|
||||||
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}, []);
|
}, [awardsVersion]);
|
||||||
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
|
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
|
||||||
@@ -1006,7 +1057,7 @@ export default function App() {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
|
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [qsos, awardCols.length]);
|
}, [qsos, awardCols.length, awardsVersion]);
|
||||||
const qsosWithAwards = useMemo(
|
const qsosWithAwards = useMemo(
|
||||||
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
|
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
|
||||||
[qsos, qsoAwardRefs],
|
[qsos, qsoAwardRefs],
|
||||||
@@ -1018,7 +1069,7 @@ export default function App() {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
|
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [wb, awardCols.length]);
|
}, [wb, awardCols.length, awardsVersion]);
|
||||||
const wbWithAwards = useMemo(
|
const wbWithAwards = useMemo(
|
||||||
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
|
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
|
||||||
[wb, wbAwardRefs],
|
[wb, wbAwardRefs],
|
||||||
@@ -1197,9 +1248,17 @@ export default function App() {
|
|||||||
if (p?.visual) {
|
if (p?.visual) {
|
||||||
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
|
const call = String(p?.call ?? ''); const band = String(p?.band ?? ''); const rule = String(p?.rule ?? 'alert');
|
||||||
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`);
|
showToast(`🔔 ${rule}: ${call}${band ? ` on ${band}` : ''}${p?.country ? ` — ${p.country}` : ''}`);
|
||||||
|
// Also keep it in the persistent tray (clickable, ~10 min).
|
||||||
|
const a: RecentAlert = {
|
||||||
|
id: Date.now() + Math.random(), rule, call, band,
|
||||||
|
mode: String(p?.mode ?? ''), freq_hz: Number(p?.freq_hz ?? 0),
|
||||||
|
country: String(p?.country ?? ''), comment: String(p?.comment ?? ''), at: Date.now(),
|
||||||
|
};
|
||||||
|
setRecentAlerts((prev) => [a, ...prev.filter((x) => !(x.call === a.call && x.band === a.band))].slice(0, 3));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { off(); };
|
return () => { off(); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||||
@@ -1324,6 +1383,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 () => {
|
||||||
@@ -2425,7 +2485,7 @@ export default function App() {
|
|||||||
setRefsDownloading(false);
|
setRefsDownloading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCtyDat() {
|
async function refreshCtyDat() {
|
||||||
if (ctyRefreshing) return;
|
if (ctyRefreshing) return;
|
||||||
setCtyRefreshing(true);
|
setCtyRefreshing(true);
|
||||||
@@ -2697,6 +2757,66 @@ export default function App() {
|
|||||||
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
// LoTW-user badge: shown only when the entered call is a LoTW user, coloured by
|
||||||
|
// last-upload recency (green < 1 week · amber 1–4 weeks · red > 30 days). The
|
||||||
|
// tooltip shows the exact last-upload date. Needs the list downloaded once
|
||||||
|
// (Settings → LoTW); until then no badge appears.
|
||||||
|
const lotwBlock = (lotwInfo && lotwInfo.is_user) ? (() => {
|
||||||
|
const d = lotwInfo.days_ago ?? -1;
|
||||||
|
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
|
||||||
|
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
||||||
|
: 'border-danger text-danger bg-danger/15';
|
||||||
|
return (
|
||||||
|
<div className="self-end shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
||||||
|
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1.5 text-[10px] font-extrabold tracking-wide leading-none', cls)}>
|
||||||
|
LoTW
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null;
|
||||||
|
// A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
|
||||||
|
// row instead of the intrusive floating cards. It lights + shows a count when
|
||||||
|
// alerts are pending; click it to see the last 3 (each clickable to tune).
|
||||||
|
const hasAlerts = recentAlerts.length > 0;
|
||||||
|
// Only show the bell when there are pending alerts — hidden otherwise.
|
||||||
|
const alertLedBlock = !hasAlerts ? null : (
|
||||||
|
<div className="relative self-end mb-0.5 shrink-0">
|
||||||
|
<button type="button" onClick={() => setAlertsPanelOpen((o) => !o)}
|
||||||
|
title={hasAlerts ? t('alert.pending', { n: recentAlerts.length }) : t('alert.noneShort')}
|
||||||
|
className={cn('relative inline-flex size-8 items-center justify-center rounded-full border transition-colors',
|
||||||
|
hasAlerts ? 'border-warning bg-warning/15 text-warning' : 'border-border bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
<Bell className="size-4" />
|
||||||
|
{hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning animate-ping" />}
|
||||||
|
{hasAlerts && <span className="absolute -right-1 -top-1 inline-flex size-2.5 rounded-full bg-warning" />}
|
||||||
|
</button>
|
||||||
|
{alertsPanelOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setAlertsPanelOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-9 z-50 w-72 rounded-lg border border-border bg-card shadow-xl overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/60 bg-muted/30">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{t('alert.recent')}</span>
|
||||||
|
{hasAlerts && <button type="button" className="text-[11px] text-muted-foreground hover:text-foreground" onClick={() => setRecentAlerts([])}>{t('alert.clear')}</button>}
|
||||||
|
</div>
|
||||||
|
{!hasAlerts ? (
|
||||||
|
<div className="px-3 py-4 text-center text-xs text-muted-foreground">{t('alert.noneShort')}</div>
|
||||||
|
) : recentAlerts.map((a) => (
|
||||||
|
<button key={a.id} type="button" title={t('alert.tuneHint')}
|
||||||
|
onClick={() => { handleSpotClick({ comment: a.comment, freq_hz: a.freq_hz, band: a.band, dx_call: a.call }); setAlertsPanelOpen(false); }}
|
||||||
|
className="block w-full text-left px-3 py-2 border-b border-border/30 last:border-0 hover:bg-warning/10 transition-colors">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-bold text-sm truncate">{a.call}</span>
|
||||||
|
{a.freq_hz > 0 && <span className="ml-auto shrink-0 text-[11px] font-mono tabular-nums text-warning">{(a.freq_hz / 1e6).toFixed(3)}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground truncate">
|
||||||
|
{a.rule}{a.band ? ` · ${a.band}` : ''}{a.mode ? ` · ${a.mode}` : ''}{a.country ? ` · ${a.country}` : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
// Compact-strip Country (stacked label) + a narrow Comment.
|
// Compact-strip Country (stacked label) + a narrow Comment.
|
||||||
const countryBlockSm = (
|
const countryBlockSm = (
|
||||||
<div className="flex flex-col w-36">
|
<div className="flex flex-col w-36">
|
||||||
@@ -3083,7 +3203,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':
|
||||||
@@ -3145,11 +3265,13 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
) : (
|
) : (
|
||||||
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
<header className="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
||||||
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
||||||
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||||
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
|
<span className="font-bold text-[15px] tracking-tight leading-none">OpsLog</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground leading-none cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Menubar menus={menus} onAction={handleMenu} />
|
<Menubar menus={menus} onAction={handleMenu} />
|
||||||
@@ -3366,6 +3488,34 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Space-weather / propagation — compact, in the header. Live from N0NBH
|
||||||
|
(hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
|
||||||
|
onto each logged QSO. Always renders one element so the grid columns
|
||||||
|
stay aligned (empty span until the feed loads). */}
|
||||||
|
{solar && solar.ok ? (
|
||||||
|
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
||||||
|
title={`${t('prop.title')}${solar.updated ? ' · ' + solar.updated : ''}`}>
|
||||||
|
{(() => {
|
||||||
|
const geo = String(solar.geomag_field || '').toUpperCase();
|
||||||
|
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
|
||||||
|
: /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
|
||||||
|
const it = (label: string, val: any, cls = 'text-foreground') => (
|
||||||
|
<span className="inline-flex items-baseline gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
|
||||||
|
<span className={cn('font-bold text-[12px]', cls)}>{(val ?? '') === '' ? '—' : val}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (<>
|
||||||
|
{it('SFI', solar.sfi)}
|
||||||
|
{it('SSN', solar.ssn)}
|
||||||
|
{it('A', solar.a_index)}
|
||||||
|
{it('K', solar.k_index)}
|
||||||
|
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
|
||||||
|
</>);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : <span />}
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||||
<Clock className="size-3" />
|
<Clock className="size-3" />
|
||||||
{utcNow}<span className="text-[10px]">UTC</span>
|
{utcNow}<span className="text-[10px]">UTC</span>
|
||||||
@@ -3579,13 +3729,16 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
|
{/* Row 2: Name fixed to the Band/Mode/Country column width (300px) so
|
||||||
its right edge lines up with that column below; QTH grows to fill. */}
|
its right edge lines up with that column below; QTH grows to fill.
|
||||||
<div className="flex gap-2 items-end">
|
gap-4 matches Row 3 so QTH's left edge aligns with Comment/Note. */}
|
||||||
|
<div className="flex gap-4 items-end">
|
||||||
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
<div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
|
||||||
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
<Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
|
||||||
</div>
|
</div>
|
||||||
{qthBlock}
|
{qthBlock}
|
||||||
{gridBlock}
|
{gridBlock}
|
||||||
|
{lotwBlock}
|
||||||
|
{alertLedBlock}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
{/* Row 3: tight left detail column (Band/Mode/Country) and
|
||||||
@@ -3679,6 +3832,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>
|
||||||
)}
|
)}
|
||||||
@@ -4148,7 +4302,7 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
|
||||||
<AwardsPanel onEditQSO={openEdit} />
|
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{contestTabEnabled && (
|
{contestTabEnabled && (
|
||||||
@@ -4170,7 +4324,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -4322,11 +4476,16 @@ 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(parseFloat(freqMhz) * 1000 * 10) / 10
|
? Math.round(((catState.freq_hz ?? 0) / 1000) * 10) / 10
|
||||||
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
|
: parseFloat(freqMhz) > 0
|
||||||
|
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
|
||||||
|
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
|
||||||
}
|
}
|
||||||
defaultMode={mode || qsos[0]?.mode || ''}
|
defaultMode={mode || qsos[0]?.mode || ''}
|
||||||
targetName={
|
targetName={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type RefMeta = { code: string; count: number; updated_at: string; can_update: bo
|
|||||||
export type AwardDef = {
|
export type AwardDef = {
|
||||||
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
|
||||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||||
|
ref_display?: string; // grid column shows: ref | name | both
|
||||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
||||||
or_rules?: AwardOrRule[];
|
or_rules?: AwardOrRule[];
|
||||||
@@ -301,6 +302,16 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
<Field2 label={t('awed.awardUrl')}><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
<Field2 label={t('awed.referenceUrl')}><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
|
||||||
|
<Field2 label={t('awed.refDisplay')}>
|
||||||
|
<Select value={cur.ref_display || 'ref'} onValueChange={(v) => patch({ ref_display: v })}>
|
||||||
|
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ref">{t('awed.refDisplayRef')}</SelectItem>
|
||||||
|
<SelectItem value="name">{t('awed.refDisplayName')}</SelectItem>
|
||||||
|
<SelectItem value="both">{t('awed.refDisplayBoth')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field2>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
<Field2 label={t('awed.validFrom')}><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
|
||||||
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
<Field2 label={t('awed.validTo')}><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
|
||||||
@@ -354,8 +365,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional OR searches: a QSO earns a reference if the
|
{/* Fallback searches: tried in order, only while nothing
|
||||||
primary rule OR any of these match. */}
|
has matched yet — the first that hits wins (short-circuit),
|
||||||
|
so a value already resolved by the primary rule isn't
|
||||||
|
re-derived differently by a later one. */}
|
||||||
<div className="border-t pt-2.5 space-y-2">
|
<div className="border-t pt-2.5 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
|
|||||||
|
|
||||||
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
|
||||||
|
|
||||||
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
|
||||||
// Computed results are cached per award code — each award is scanned only the
|
// Computed results are cached per award code — each award is scanned only the
|
||||||
@@ -220,7 +220,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
{t('awp.rescan')}
|
{t('awp.rescan')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
|
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
|
||||||
{/* Quick selector — scales when there are many awards. */}
|
{/* Quick selector — scales when there are many awards. */}
|
||||||
{awardList.length > 0 && (
|
{awardList.length > 0 && (
|
||||||
<div className="px-2 py-2 border-b border-border/40">
|
<div className="px-2 py-2 border-b border-border/40">
|
||||||
|
|||||||
@@ -49,6 +49,24 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||||
|
// Contest
|
||||||
|
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
||||||
|
{ id: 'stx_string', label: 'bulk.fStxString', group: 'Contest', kind: 'text' },
|
||||||
|
{ id: 'arrl_sect', label: 'bulk.fArrlSect', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
{ id: 'precedence', label: 'bulk.fPrecedence', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
{ id: 'class', label: 'bulk.fClass', group: 'Contest', kind: 'text', upper: true },
|
||||||
|
// Propagation / satellite
|
||||||
|
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
|
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
||||||
|
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
||||||
|
// Contacted station (activation refs / SIG)
|
||||||
|
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
||||||
|
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
||||||
|
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
||||||
// Misc
|
// Misc
|
||||||
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
||||||
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
||||||
@@ -65,11 +83,14 @@ const STATUS_VALUES: { v: string; label: string }[] = [
|
|||||||
{ v: '_', label: 'bulk.statusBlank' },
|
{ v: '_', label: 'bulk.statusBlank' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
||||||
// Maps the internal group key → its i18n label key.
|
// Maps the internal group key → its i18n label key.
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
'QSL / upload': 'bulk.groupQsl',
|
'QSL / upload': 'bulk.groupQsl',
|
||||||
'My station': 'bulk.groupMyStation',
|
'My station': 'bulk.groupMyStation',
|
||||||
|
'Contacted station': 'bulk.groupContacted',
|
||||||
|
'Contest': 'bulk.groupContest',
|
||||||
|
'Propagation': 'bulk.groupPropagation',
|
||||||
'Misc': 'bulk.groupMisc',
|
'Misc': 'bulk.groupMisc',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,9 +151,12 @@ export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|||||||
{GROUPS.map((g) => (
|
{GROUPS.map((g) => (
|
||||||
<div key={g}>
|
<div key={g}>
|
||||||
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
||||||
{FIELDS.filter((f) => f.group === g).map((f) => (
|
{FIELDS.filter((f) => f.group === g)
|
||||||
<SelectItem key={f.id} value={f.id}>{t(f.label)}</SelectItem>
|
.map((f) => ({ f, txt: t(f.label) }))
|
||||||
))}
|
.sort((a, b) => a.txt.localeCompare(b.txt))
|
||||||
|
.map(({ f, txt }) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>{txt}</SelectItem>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -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 })}>
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import {
|
|||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
|
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,13 +26,17 @@ 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;
|
||||||
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
||||||
meters?: Meter[];
|
meters?: Meter[];
|
||||||
|
slices?: FlexSlice[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
||||||
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
||||||
|
|
||||||
const ZERO: FlexState = {
|
const ZERO: FlexState = {
|
||||||
@@ -40,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,
|
||||||
@@ -141,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;
|
||||||
@@ -174,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -260,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">
|
||||||
@@ -272,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')}>
|
||||||
@@ -280,6 +311,91 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Slices A/B/C/D — every in-use receiver. Click one to make it the
|
||||||
|
ACTIVE slice: the main frequency, mode, DSP and spot-clicks all follow
|
||||||
|
it. The active slice is highlighted; the TX slice is flagged. */}
|
||||||
|
{!!st.slices && st.slices.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{st.slices.map((sl) => (
|
||||||
|
<button key={sl.index} type="button" disabled={off}
|
||||||
|
onClick={() => FlexSetActiveSlice(sl.index).catch(() => {})}
|
||||||
|
title={t('flxp.sliceHint')}
|
||||||
|
className={cn('flex items-center gap-2 rounded-lg border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||||||
|
sl.active ? 'border-info bg-info/10 ring-1 ring-info' : 'border-border bg-card hover:bg-muted')}>
|
||||||
|
<span className={cn('inline-flex size-6 items-center justify-center rounded-md text-sm font-extrabold',
|
||||||
|
sl.active ? 'bg-info text-info-foreground' : 'bg-muted text-muted-foreground')}>{sl.letter}</span>
|
||||||
|
<span className="flex items-baseline gap-1.5">
|
||||||
|
<span className="font-mono font-bold tabular-nums text-sm leading-none">{(sl.freq_hz / 1e6).toFixed(3)}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground uppercase leading-none">{sl.mode}</span>
|
||||||
|
{sl.band ? <span className="text-[10px] text-muted-foreground leading-none">{sl.band}</span> : null}
|
||||||
|
</span>
|
||||||
|
{/* TX badge — click a non-TX slice's badge to move TX onto it
|
||||||
|
(e.g. transmit on the active slice). stopPropagation so it
|
||||||
|
doesn't also fire the outer "make active" click. */}
|
||||||
|
<span role="button" tabIndex={-1}
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (!sl.tx && !off) FlexSetTXSlice(sl.index).catch(() => {}); }}
|
||||||
|
title={sl.tx ? t('flxp.txSlice') : t('flxp.setTxSlice')}
|
||||||
|
className={cn('rounded text-[10px] font-bold px-1',
|
||||||
|
sl.tx ? 'bg-danger text-danger-foreground' : 'border border-danger/50 text-danger cursor-pointer hover:bg-danger/10')}>
|
||||||
|
TX
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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')}
|
||||||
@@ -356,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). */
|
||||||
@@ -429,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))} />
|
||||||
@@ -525,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>
|
||||||
|
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||||
|
and shown compact so they sit with the amp controls. */}
|
||||||
|
{(() => {
|
||||||
|
const meters = st.meters || [];
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
|
if (off || amp.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
{amp.map((m) => {
|
||||||
|
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||||
|
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" />;
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live meters (UDP VITA-49 stream) */}
|
|
||||||
<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');
|
|
||||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
|
||||||
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
|
||||||
: /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 (
|
|
||||||
<>
|
|
||||||
<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) => {
|
|
||||||
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
|
||||||
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} 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)} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,16 +652,22 @@ 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
|
||||||
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
but ON doesn't); hiding both avoids a dead button. On the network the
|
||||||
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">
|
rig's LAN server stays alive in standby, so both work. */}
|
||||||
<Power className="size-3.5" /> ON
|
{isNetwork && (
|
||||||
</button>
|
<>
|
||||||
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||||||
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-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||||||
<Power className="size-3.5" /> OFF
|
<Power className="size-3.5" /> ON
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
||||||
|
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
|
||||||
|
</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')}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { hamlogGridTheme } from '@/lib/gridTheme';
|
import { hamlogGridTheme } from '@/lib/gridTheme';
|
||||||
import { AgGridReact } from 'ag-grid-react';
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
|
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus, History } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
|
||||||
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
|
||||||
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
|
||||||
|
WorkedBefore,
|
||||||
} from '@/../wailsjs/go/main/App';
|
} from '@/../wailsjs/go/main/App';
|
||||||
import { netctl } from '@/../wailsjs/go/models';
|
import { netctl } from '@/../wailsjs/go/models';
|
||||||
|
|
||||||
@@ -67,6 +68,36 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
const activeGrid = useRef<any>(null);
|
const activeGrid = useRef<any>(null);
|
||||||
const rosterGrid = useRef<any>(null);
|
const rosterGrid = useRef<any>(null);
|
||||||
|
|
||||||
|
// Worked-before for the clicked station (see if/when we contacted it before).
|
||||||
|
const [wbCall, setWbCall] = useState('');
|
||||||
|
const [wb, setWb] = useState<any>(null);
|
||||||
|
const [wbBusy, setWbBusy] = useState(false);
|
||||||
|
const wbReq = useRef(0);
|
||||||
|
// Resizable height (drag the handle on top). Persisted.
|
||||||
|
const [wbHeight, setWbHeight] = useState(() => {
|
||||||
|
const v = parseInt(localStorage.getItem('opslog.netWbHeight') || '', 10);
|
||||||
|
return v >= 80 && v <= 600 ? v : 176;
|
||||||
|
});
|
||||||
|
useEffect(() => { localStorage.setItem('opslog.netWbHeight', String(wbHeight)); }, [wbHeight]);
|
||||||
|
const startWbResize = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startY = e.clientY, startH = wbHeight;
|
||||||
|
const onMove = (ev: MouseEvent) => setWbHeight(Math.max(80, Math.min(600, startH - (ev.clientY - startY))));
|
||||||
|
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
const showWorkedBefore = useCallback(async (call: string, dxcc = 0) => {
|
||||||
|
const c = (call ?? '').trim().toUpperCase();
|
||||||
|
setWbCall(c);
|
||||||
|
if (!c) { setWb(null); return; }
|
||||||
|
const req = ++wbReq.current;
|
||||||
|
setWbBusy(true);
|
||||||
|
try { const r = await WorkedBefore(c, dxcc); if (wbReq.current === req) setWb(r); }
|
||||||
|
catch { if (wbReq.current === req) setWb(null); }
|
||||||
|
finally { if (wbReq.current === req) setWbBusy(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isOpen = openId !== '' && openId === selId;
|
const isOpen = openId !== '' && openId === selId;
|
||||||
|
|
||||||
const refreshNets = useCallback(async () => {
|
const refreshNets = useCallback(async () => {
|
||||||
@@ -173,6 +204,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
...c, callsign: (r.callsign || call).toUpperCase(),
|
...c, callsign: (r.callsign || call).toUpperCase(),
|
||||||
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
name: r.name || c.name, qth: r.qth || c.qth, country: r.country || c.country,
|
||||||
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
dxcc: r.dxcc || c.dxcc, itu: r.itu || c.itu, cq: r.cq || c.cq,
|
||||||
|
grid: r.grid || c.grid, address: r.address || c.address, state: r.state || c.state,
|
||||||
|
cnty: r.cnty || c.cnty, cont: r.cont || c.cont, lat: r.lat || c.lat, lon: r.lon || c.lon,
|
||||||
|
email: r.email || c.email,
|
||||||
}));
|
}));
|
||||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
finally { setLooking(false); }
|
finally { setLooking(false); }
|
||||||
@@ -266,6 +300,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
rowData={active}
|
rowData={active}
|
||||||
columnDefs={activeCols}
|
columnDefs={activeCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
|
||||||
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
@@ -281,6 +316,61 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Worked-before for the clicked station — click a row (on air or
|
||||||
|
roster) to see if/when we contacted it before. Lives INSIDE the
|
||||||
|
on-air column so resizing only splits this column; the roster on
|
||||||
|
the right keeps its full height. */}
|
||||||
|
<div className="shrink-0 flex flex-col" style={{ height: wbHeight }}>
|
||||||
|
<div className="h-1.5 shrink-0 cursor-ns-resize border-t border-border/60 hover:bg-primary/40 transition-colors" onMouseDown={startWbResize} title={t('ncp.wbResize')} />
|
||||||
|
<div className="flex-1 min-h-0 bg-card flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
|
<History className="size-3.5" />
|
||||||
|
{t('ncp.workedBefore')}
|
||||||
|
{wbCall && <span className="font-mono text-foreground normal-case">{wbCall}</span>}
|
||||||
|
{wb && wb.count > 0 && (
|
||||||
|
<span className="ml-auto font-normal normal-case text-foreground">
|
||||||
|
{wb.count} QSO · {t('ncp.wbFirst')} {String(wb.first ?? '').slice(0, 10)} · {t('ncp.wbLast')} {String(wb.last ?? '').slice(0, 10)}
|
||||||
|
{wb.dxcc_name ? ` · ${wb.dxcc_name} (${wb.dxcc_count})` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
{!wbCall ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbHint')}</div>
|
||||||
|
) : wbBusy ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">…</div>
|
||||||
|
) : !wb || wb.count === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">{t('ncp.wbNone')} {wbCall}</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-muted/30 text-muted-foreground">
|
||||||
|
<tr className="text-left">
|
||||||
|
<th className="px-3 py-1 font-semibold">{t('ncp.colDate')}</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">{t('ncp.colTimeOn')}</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">{t('ncp.colBand')}</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">{t('ncp.colMode')}</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">RST</th>
|
||||||
|
<th className="px-2 py-1 font-semibold">{t('ncp.colComment')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{((wb.entries ?? []) as any[]).map((q, i) => (
|
||||||
|
<tr key={q.id ?? i} className="border-t border-border/20 hover:bg-muted/30">
|
||||||
|
<td className="px-3 py-1 font-mono">{String(q.qso_date ?? '').slice(0, 10)}</td>
|
||||||
|
<td className="px-2 py-1 font-mono">{String(q.time_on ?? '').slice(0, 5)}</td>
|
||||||
|
<td className="px-2 py-1">{q.band}</td>
|
||||||
|
<td className="px-2 py-1">{q.mode}</td>
|
||||||
|
<td className="px-2 py-1 font-mono">{(q.rst_sent ?? '')}/{(q.rst_rcvd ?? '')}</td>
|
||||||
|
<td className="px-2 py-1 truncate max-w-[360px]">{q.comment}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* NET USERS / roster (right) */}
|
{/* NET USERS / roster (right) */}
|
||||||
@@ -298,6 +388,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
columnDefs={rosterCols}
|
columnDefs={rosterCols}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
|
||||||
|
onRowClicked={(e) => e.data && showWorkedBefore(e.data.callsign, (e.data as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
getRowId={(p) => String((p.data as any).callsign)}
|
getRowId={(p) => String((p.data as any).callsign)}
|
||||||
|
|||||||
@@ -187,6 +187,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
// WPX, …) are derived from the QSO by the backend and shown read-only.
|
||||||
const awardFieldRef = useRef<Record<string, string>>({});
|
const awardFieldRef = useRef<Record<string, string>>({});
|
||||||
const [awardRefs, setAwardRefs] = useState('');
|
const [awardRefs, setAwardRefs] = useState('');
|
||||||
|
// The refs present when the editor opened, so on save we can tell which ones
|
||||||
|
// were REMOVED and strip their in-field tokens (see applyAwardRefs).
|
||||||
|
const seedAwardRefsRef = useRef('');
|
||||||
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
|
||||||
|
|
||||||
// Load award definitions once, then seed the editable manual refs from the QSO.
|
// Load award definitions once, then seed the editable manual refs from the QSO.
|
||||||
@@ -207,6 +210,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
.filter((r: any) => r.pickable)
|
.filter((r: any) => r.pickable)
|
||||||
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
|
||||||
.join(';');
|
.join(';');
|
||||||
|
seedAwardRefsRef.current = seed;
|
||||||
setAwardRefs(seed);
|
setAwardRefs(seed);
|
||||||
} catch { /* leave manual refs empty on failure */ }
|
} catch { /* leave manual refs empty on failure */ }
|
||||||
})
|
})
|
||||||
@@ -327,7 +331,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
// the dedicated columns, then route the picked refs back onto the payload
|
// the dedicated columns, then route the picked refs back onto the payload
|
||||||
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
|
||||||
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
|
||||||
applyAwardRefs(out, awardRefs, awardFieldRef.current);
|
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
|
||||||
onSave(out);
|
onSave(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,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];
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
|
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
@@ -341,6 +344,9 @@ function TreeNodeView({
|
|||||||
// Select and slamming the open dropdown shut on any ambient re-render.
|
// Select and slamming the open dropdown shut on any ambient re-render.
|
||||||
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
|
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
|
||||||
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
|
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
|
||||||
|
'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
|
||||||
|
'light-sage': { bg: '#eef1ec', card: '#f8faf6', accent: '#2f855a' },
|
||||||
|
'dim-slate': { bg: '#343b47', card: '#3d4552', accent: '#fb923c' },
|
||||||
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
|
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
|
||||||
'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
|
'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
|
||||||
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
||||||
@@ -759,6 +765,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');
|
||||||
@@ -795,8 +815,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [bandDraft, setBandDraft] = useState('');
|
const [bandDraft, setBandDraft] = useState('');
|
||||||
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, flex_decode_spots: false, flex_decode_secs: 120,
|
||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '',
|
icom_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 +883,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');
|
||||||
@@ -962,6 +1000,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [clublogTesting, setClublogTesting] = useState(false);
|
const [clublogTesting, setClublogTesting] = useState(false);
|
||||||
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [lotwTesting, setLotwTesting] = useState(false);
|
const [lotwTesting, setLotwTesting] = useState(false);
|
||||||
|
// LoTW user-activity list (for the callsign badge): count + last refresh.
|
||||||
|
const [lotwUsers, setLotwUsers] = useState<{ count: number; updated?: string }>({ count: 0 });
|
||||||
|
const [lotwUsersBusy, setLotwUsersBusy] = useState(false);
|
||||||
|
useEffect(() => { GetLoTWUsersStatus().then((s) => setLotwUsers(s as any)).catch(() => {}); }, []);
|
||||||
|
const downloadLotwUsers = async () => {
|
||||||
|
setLotwUsersBusy(true);
|
||||||
|
try { await DownloadLoTWUsers(); const s = await GetLoTWUsersStatus(); setLotwUsers(s as any); }
|
||||||
|
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||||||
|
finally { setLotwUsersBusy(false); }
|
||||||
|
};
|
||||||
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -2007,6 +2055,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||||||
{t('cat.flexSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span>
|
{t('cat.flexSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!catCfg.flex_decode_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_decode_spots: !!c }))} />
|
||||||
|
{t('cat.flexDecodeSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSpotsHint')}</span>
|
||||||
|
</label>
|
||||||
|
{catCfg.flex_decode_spots && (
|
||||||
|
<div className="col-span-2 flex items-center gap-2 pl-6">
|
||||||
|
<Label className="text-sm">{t('cat.flexDecodeSecs')}</Label>
|
||||||
|
<Input type="number" className="w-24" min={10} max={3600}
|
||||||
|
value={catCfg.flex_decode_secs || 120}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'icom' && (
|
{catCfg.backend === 'icom' && (
|
||||||
@@ -2034,12 +2095,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 +2122,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 +2150,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' && (
|
||||||
@@ -3485,6 +3578,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* LoTW user list — powers the colour-coded "LoTW" badge next to the
|
||||||
|
entered callsign (green < 1 week · amber 1–4 weeks · red > 30 days). */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t('lotw.usersTitle')}</Label>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('lotw.usersHint')}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadLotwUsers} disabled={lotwUsersBusy}>
|
||||||
|
<ArrowDown className="size-3.5" /> {lotwUsersBusy ? t('lotw.usersDownloading') : t('lotw.usersDownload')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{lotwUsers.count > 0
|
||||||
|
? t('lotw.usersLoaded', { n: lotwUsers.count.toLocaleString(), date: lotwUsers.updated ? new Date(lotwUsers.updated).toLocaleDateString() : '?' })
|
||||||
|
: t('lotw.usersNone')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : extSvcTab === 'pota' ? (
|
) : extSvcTab === 'pota' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
@@ -3747,6 +3857,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">
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ const QSO_FIELD_WEIGHT: Record<string, number> = {
|
|||||||
|
|
||||||
// QSOBoxView renders the confirmation box with per-QSO values.
|
// QSOBoxView renders the confirmation box with per-QSO values.
|
||||||
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
|
||||||
|
const fg = box.fg || '#14243a'; // text colour (field labels use it at 0.6 opacity)
|
||||||
const avail = box.w - 56;
|
const avail = box.w - 56;
|
||||||
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
|
||||||
let cursor = 28;
|
let cursor = 28;
|
||||||
@@ -304,24 +305,24 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
|
||||||
<text x={28} y={22} fontSize={34} fontWeight={700} fill="#1b2a3d"
|
<text x={28} y={22} fontSize={34} fontWeight={700} fill={fg}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{box.title}
|
{box.title}
|
||||||
</text>
|
</text>
|
||||||
{cols.map(({ f, x }) => (
|
{cols.map(({ f, x }) => (
|
||||||
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
|
||||||
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
|
<text fontSize={19} fill={fg} opacity={0.6} letterSpacing={1.5}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
|
<text y={26} fontSize={28} fontWeight={700} fill={fg}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
|
||||||
{values[f] ?? ''}
|
{values[f] ?? ''}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
{box.footer && (
|
{box.footer && (
|
||||||
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill="#3c4d63"
|
<text x={28} y={box.h - 18} fontSize={24} fontStyle="italic" fill={fg} opacity={0.85}
|
||||||
fontFamily="'Segoe UI', Arial, sans-serif">
|
fontFamily="'Segoe UI', Arial, sans-serif">
|
||||||
{box.footer}
|
{box.footer}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -170,6 +170,35 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
|
|||||||
onChange={(w) => onPatchBox({ w })} />
|
onChange={(w) => onPatchBox({ w })} />
|
||||||
<NumberField label="Height" value={box.h} min={120} max={500}
|
<NumberField label="Height" value={box.h} min={120} max={500}
|
||||||
onChange={(h) => onPatchBox({ h })} />
|
onChange={(h) => onPatchBox({ h })} />
|
||||||
|
<NumberField label="Opacity %" value={Math.round((box.bg_opacity ?? 1) * 100)} min={0} max={100}
|
||||||
|
onChange={(v) => onPatchBox({ bg_opacity: Math.max(0, Math.min(100, v)) / 100 })} />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Background</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||||
|
value={/^#[0-9a-fA-F]{6}$/.test(box.bg ?? '') ? box.bg : '#000000'}
|
||||||
|
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||||
|
<Input className="h-7 w-32 font-mono text-xs" value={box.bg ?? ''}
|
||||||
|
onChange={(ev) => onPatchBox({ bg: ev.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text color</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="color" className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-0"
|
||||||
|
value={/^#[0-9a-fA-F]{6}$/.test(box.fg ?? '') ? (box.fg as string) : '#14243a'}
|
||||||
|
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||||
|
<Input className="h-7 w-32 font-mono text-xs" value={box.fg ?? ''} placeholder="#14243a"
|
||||||
|
onChange={(ev) => onPatchBox({ fg: ev.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NumberField label="Corner radius" value={box.radius ?? 0} min={0} max={80}
|
||||||
|
onChange={(radius) => onPatchBox({ radius })} />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Title</Label>
|
||||||
|
<Input className="h-7 w-44 font-mono text-xs" value={box.title ?? ''}
|
||||||
|
onChange={(ev) => onPatchBox({ title: ev.target.value })} />
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Label className="text-xs text-muted-foreground">Footer</Label>
|
<Label className="text-xs text-muted-foreground">Footer</Label>
|
||||||
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
<Input className="h-7 w-44 font-mono text-xs" value={box.footer}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface QSOBox {
|
|||||||
h: number;
|
h: number;
|
||||||
bg: string;
|
bg: string;
|
||||||
bg_opacity: number;
|
bg_opacity: number;
|
||||||
|
fg?: string; // text colour (default dark); set it when using a dark background
|
||||||
radius: number;
|
radius: number;
|
||||||
title: string;
|
title: string;
|
||||||
fields: string[];
|
fields: string[];
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a
|
||||||
|
// text field — the inverse of appendTokens. Used when a picked reference is
|
||||||
|
// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it;
|
||||||
|
// otherwise the ref is re-derived from the field and reappears on reopen.
|
||||||
|
function removeTokens(existing: string | undefined, refs: string): string {
|
||||||
|
let out = existing ?? '';
|
||||||
|
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig');
|
||||||
|
out = out.replace(re, ' ');
|
||||||
|
}
|
||||||
|
return out.replace(/\s{2,}/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
|
||||||
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
|
||||||
// The award engine honours these regardless of how the award matches, so a
|
// The award engine honours these regardless of how the award matches, so a
|
||||||
@@ -47,8 +60,24 @@ const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
|
|||||||
// data lives in its conventional place and exports correctly. Awards that match
|
// data lives in its conventional place and exports correctly. Awards that match
|
||||||
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
// a free-text field by description/pattern (address/qth/name/custom) rely solely
|
||||||
// on the override — we don't pollute the text field with a code.
|
// on the override — we don't pollute the text field with a code.
|
||||||
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
|
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>, prevAwardRefs?: string) {
|
||||||
const byCode = parseAwardRefs(awardRefs);
|
const byCode = parseAwardRefs(awardRefs);
|
||||||
|
// References the operator REMOVED since the editor opened: for in-field awards
|
||||||
|
// (note/comment) strip the token from the field too, so a deleted DDFM/etc.
|
||||||
|
// ref doesn't get re-derived from the leftover token and reappear on reopen.
|
||||||
|
if (prevAwardRefs != null) {
|
||||||
|
const prev = parseAwardRefs(prevAwardRefs);
|
||||||
|
for (const [code, prevRef] of Object.entries(prev)) {
|
||||||
|
const field = fieldOf[code] || code.toLowerCase();
|
||||||
|
if (field !== 'note' && field !== 'notes' && field !== 'comment') continue;
|
||||||
|
const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean));
|
||||||
|
const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase()));
|
||||||
|
if (removed.length === 0) continue;
|
||||||
|
const rm = removed.join(',');
|
||||||
|
if (field === 'comment') payload.comment = removeTokens(payload.comment, rm);
|
||||||
|
else payload.notes = removeTokens(payload.notes, rm);
|
||||||
|
}
|
||||||
|
}
|
||||||
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
|
||||||
const overrides: string[] = [];
|
const overrides: string[] = [];
|
||||||
for (const [code, ref] of Object.entries(byCode)) {
|
for (const [code, ref] of Object.entries(byCode)) {
|
||||||
|
|||||||
+32
-18
@@ -13,6 +13,8 @@ type Dict = Record<string, string>;
|
|||||||
|
|
||||||
const en: Dict = {
|
const en: Dict = {
|
||||||
// Menu bar
|
// Menu bar
|
||||||
|
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
||||||
|
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||||
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
||||||
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
||||||
@@ -21,6 +23,7 @@ const en: Dict = {
|
|||||||
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
'tools.qslManager': 'QSL Manager…', 'tools.qslDesigner': 'QSL Card Designer…',
|
||||||
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
'tools.winkeyer': 'WinKeyer CW keyer', 'tools.dvk': 'Digital Voice Keyer', 'tools.cwDecoder': 'CW decoder (RX audio)',
|
||||||
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
'tools.net': 'NET Control', 'tools.alerts': 'Alert management…', 'tools.contest': 'Contest mode',
|
||||||
|
'alert.tuneHint': 'Click to tune the rig to this spot (freq + mode) and fill the call', 'alert.dismiss': 'Dismiss', 'alert.pending': '{n} recent spot alert(s) — click to view', 'alert.noneShort': 'No recent alerts', 'alert.recent': 'Recent alerts', 'alert.clear': 'Clear',
|
||||||
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
'menu.help': 'Help', 'help.about': 'About OpsLog', 'tools.duplicates': 'Find duplicates…',
|
||||||
// Duplicates modal
|
// Duplicates modal
|
||||||
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
|
'dup.title': 'Find duplicates', 'dup.scanning': 'Scanning the log…', 'dup.none': 'No duplicates found. 🎉',
|
||||||
@@ -44,7 +47,8 @@ const en: Dict = {
|
|||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||||
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||||
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.dark-warm': 'Warm dark',
|
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
|
||||||
|
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
|
||||||
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
|
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
|
||||||
// Settings navigation (sidebar groups + section names)
|
// Settings navigation (sidebar groups + section names)
|
||||||
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
||||||
@@ -133,8 +137,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.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.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||||
'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.icomNetAudioHint': 'Play the rig’s received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
|
||||||
|
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
|
||||||
|
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
||||||
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
'cat.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).',
|
||||||
@@ -143,6 +149,7 @@ const en: Dict = {
|
|||||||
'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.',
|
'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.',
|
||||||
// External services (repeated labels)
|
// External services (repeated labels)
|
||||||
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
||||||
|
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
||||||
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
||||||
// Profiles panel
|
// Profiles panel
|
||||||
'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile',
|
'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile',
|
||||||
@@ -190,16 +197,16 @@ 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 F1–F6.', '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 F1–F6.', '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.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',
|
||||||
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
||||||
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
|
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
|
||||||
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
|
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
|
||||||
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
||||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
||||||
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
||||||
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
||||||
@@ -207,10 +214,10 @@ const en: Dict = {
|
|||||||
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
||||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Additional searches', 'awed.orAlsoMatch': '— also match the reference if any of these hit', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||||
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
||||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||||
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
||||||
'qslm.leave': '— leave —', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}',
|
'qslm.leave': '— leave —', 'qslm.yes': 'Yes', 'qslm.no': 'No', 'qslm.requested': 'Requested', 'qslm.ignore': 'Ignore', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Electronic', 'qslm.svcPota': 'POTA hunter log', 'qslm.svcPaper': 'Paper QSL', 'qslm.sentRequested': 'Requested', 'qslm.sentNo': 'No', 'qslm.sentQueued': 'Queued', 'qslm.sentYes': 'Yes (already sent)', 'qslm.sentInvalid': 'Invalid', 'qslm.sentBlank': '— blank —', 'qslm.qsoUpdated': '{n} QSO updated.', 'qslm.service': 'Service', 'qslm.callsign': 'Callsign', 'qslm.callsignScopeTitle': "Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)", 'qslm.syncHunterLog': 'Sync hunter log', 'qslm.onlyMyCallTitle': "Only sync hunts made under your active profile's callsign — skip QSOs you made under another call (e.g. XV9Q, NQ2H) that aren't in this logbook", 'qslm.onlyMyCall': 'Only my profile callsign', 'qslm.addMissingTitle': "Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)", 'qslm.addMissing': 'Add not-found QSOs to my log', 'qslm.potaToken': 'Token in Settings → External services → POTA.', 'qslm.callsignPlaceholder': 'e.g. DL1ABC', 'qslm.search': 'Search', 'qslm.paperHint': 'Find a callsign, then set QSL sent/received + via + date on the selection.', 'qslm.sentStatus': 'Sent status', 'qslm.selectRequired': 'Select required', 'qslm.potaSummaryShort': '{updated} updated · {added} added · {already} already · {unmatched} unmatched', 'qslm.potaOtherCall': ' · {n} other call', 'qslm.paperCount': '{total} QSO · {selected} selected', 'qslm.filter': 'Filter', 'qslm.filterAll': 'All', 'qslm.filterNew': 'New (any)', 'qslm.filterNewDxcc': 'New DXCC', 'qslm.filterNewBand': 'New band', 'qslm.filterNewSlot': 'New slot', 'qslm.results': 'Results', 'qslm.log': 'Log', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} found · {selected} selected', 'qslm.paperEmpty': 'Search a callsign to list its QSOs, then set QSL status below.', 'qslm.potaEmpty': 'Click "Sync hunter log" to fetch your pota.app log and stamp park references.', 'qslm.potaSyncing': 'Syncing with pota.app…', 'qslm.potaSummary': '{updated} QSO updated · {added} added to log · {already} already tagged · {unmatched} unmatched (of {fetched} hunter-log entries).', 'qslm.potaSkipped': ' {n} hunt(s) made under another callsign were skipped', 'qslm.potaKeptOnly': ' (kept only {call})', 'qslm.potaRescan': 'Rescan the POTA award to count the new references.', 'qslm.thActivator': 'Activator', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Band', 'qslm.thPark': 'Park', 'qslm.thWhyUnmatched': 'Why unmatched', 'qslm.openToFix': 'Open this QSO to fix it', 'qslm.starting': 'starting…', 'qslm.working': 'working…', 'qslm.noNewConf': 'No new confirmations.', 'qslm.noConfMatch': 'No confirmations match this filter.', 'qslm.thCallsign': 'Callsign', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Country', 'qslm.thNew': 'New?', 'qslm.newDxcc': 'NEW DXCC', 'qslm.newBand': 'NEW BAND', 'qslm.newSlot': 'NEW SLOT', 'qslm.uploadEmpty': 'Pick a service + sent status, then "Select required".', 'qslm.qslReceived': 'QSL received', 'qslm.qslRcvdDateTitle': 'QSL received date', 'qslm.qslSent': 'QSL sent', 'qslm.qslSentDateTitle': 'QSL sent date', 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'e.g. paid 3€', 'qslm.comment': 'Comment', 'qslm.commentPlaceholder': 'comment', 'qslm.applyToSelected': 'Apply to {n} selected', 'qslm.downloadTitle': 'Fetch confirmations from the service and update received status', 'qslm.downloadConf': 'Download confirmations', 'qslm.downloadRangeTitle': 'How far back to download', 'qslm.sinceLast': 'Since last download', 'qslm.sinceDate': 'Since date…', 'qslm.sinceAll': 'All', 'qslm.sinceDateTitleQrz': 'QRZ: filters by QSO date (no server-side received-date filter)', 'qslm.sinceDateTitleLotw': 'LoTW: confirmations received since this date', 'qslm.addNotFoundTitle': "Insert confirmed QSOs that aren't in your log yet", 'qslm.addNotFound': 'Add not-found', 'qslm.uploadTo': 'Upload {n} to {service}',
|
||||||
'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…',
|
'qedit.qslDash': '—', 'qedit.qslYes': 'Yes', 'qedit.qslNo': 'No', 'qedit.qslRequested': 'Requested', 'qedit.qslIgnore': 'Ignore', 'qedit.statusModified': 'Modified', 'qedit.confQslPaper': 'QSL (paper)', 'qedit.callsignRequired': 'Callsign required', 'qedit.lookupError': 'Lookup: {msg}', 'qedit.title': 'Edit QSO', 'qedit.editFieldsFor': 'Edit fields for QSO #{id}', 'qedit.tabQsoInfo': 'QSO Info', 'qedit.tabContact': "Contact's details", 'qedit.tabAwards': 'Award Refs', 'qedit.tabQsl': 'QSL Info', 'qedit.tabContest': 'Contest', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'My Station', 'qedit.tabMoreAdif': 'More ADIF', 'qedit.tabAdifFields': 'ADIF fields', 'qedit.callsign': 'Callsign', 'qedit.fetchTitle': 'Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…', 'qedit.fetch': 'Fetch', 'qedit.name': 'Name', 'qedit.band': 'Band', 'qedit.rxBand': 'RX Band', 'qedit.mode': 'Mode', 'qedit.country': 'Country', 'qedit.dxccTitle': 'DXCC entity # — set automatically from Country', 'qedit.txFreq': 'TX Freq', 'qedit.rxFreq': 'RX Freq', 'qedit.qsoStart': 'QSO Start (UTC)', 'qedit.qsoEnd': 'QSO End (UTC)', 'qedit.grid': 'Grid', 'qedit.comment': 'Comment', 'qedit.note': 'Note', 'qedit.county': 'County', 'qedit.state': 'State', 'qedit.continent': 'Continent', 'qedit.address': 'Address', 'qedit.email': 'E-mail address', 'qedit.qslMsg': 'QSL Msg', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Computed (automatic)', 'qedit.computedHint': "Derived from this QSO's fields (DXCC, zones, prefix, notes…). Not editable here.", 'qedit.noneYet': 'None yet.', 'qedit.manageConf': 'Manage Confirmation', 'qedit.sent': 'Sent', 'qedit.received': 'Received', 'qedit.dateSent': 'Date sent', 'qedit.dateReceived': 'Date received', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Pick a channel, edit it — the table on the right updates live. Everything is written when you click', 'qedit.saveChanges': 'Save changes', 'qedit.thType': 'Type', 'qedit.contestId': 'Contest ID', 'qedit.rcvdExchange': 'rcvd exchange', 'qedit.sentExchange': 'sent exchange', 'qedit.check': 'Check', 'qedit.precedence': 'Precedence', 'qedit.arrlSection': 'ARRL section', 'qedit.propMode': 'Propagation mode', 'qedit.satName': 'Satellite name', 'qedit.satMode': 'Satellite mode', 'qedit.antAz': 'Antenna AZ (°)', 'qedit.antEl': 'Antenna EL (°)', 'qedit.antPath': 'Antenna path', 'qedit.myStationHint': 'These override the active station profile for this QSO only.', 'qedit.stationCallsign': 'Station callsign', 'qedit.operator': 'Operator', 'qedit.myGrid': 'My grid', 'qedit.gridExt': 'Grid ext', 'qedit.cqZone': 'CQ zone', 'qedit.ituZone': 'ITU zone', 'qedit.sotaRef': 'SOTA ref', 'qedit.potaRef': 'POTA ref', 'qedit.street': 'Street', 'qedit.city': 'City', 'qedit.postal': 'Postal', 'qedit.rig': 'Rig', 'qedit.antenna': 'Antenna', 'qedit.specialActivity': 'Special activity', 'qedit.sigInfo': 'SIG info', 'qedit.wwffRef': 'WWFF ref', 'qedit.region': 'Region', 'qedit.powerWeather': 'Power & space weather', 'qedit.rxPower': 'RX power (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'A index', 'qedit.kIndex': 'K index', 'qedit.identityClubs': 'Identity & clubs', 'qedit.contactedOp': 'Contacted op', 'qedit.formerCall': 'Former call (EQ_CALL)', 'qedit.class': 'Class', 'qedit.flagsCredits': 'Flags & credits', 'qedit.qsoComplete': 'QSO complete', 'qedit.qsoRandom': 'QSO random', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Credit granted', 'qedit.creditSubmitted': 'Credit submitted', 'qedit.myStationAdif': 'My station (ADIF)', 'qedit.myName': 'My name', 'qedit.myWwffRef': 'My WWFF ref', 'qedit.myArrlSect': 'My ARRL sect', 'qedit.mySig': 'My SIG', 'qedit.mySigInfo': 'My SIG info', 'qedit.myDarcDok': 'My DARC DOK', 'qedit.myVuccGrids': 'My VUCC grids', 'qedit.delete': 'Delete', 'qedit.cancel': 'Cancel', 'qedit.saving': 'Saving…',
|
||||||
// Grids column headers (recent / worked-before / cluster)
|
// Grids column headers (recent / worked-before / cluster)
|
||||||
@@ -220,6 +227,8 @@ const en: Dict = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fr: Dict = {
|
const fr: Dict = {
|
||||||
|
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
||||||
|
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||||
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
||||||
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
||||||
@@ -228,6 +237,7 @@ const fr: Dict = {
|
|||||||
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
'tools.qslManager': 'Gestionnaire QSL…', 'tools.qslDesigner': 'Créateur de carte QSL…',
|
||||||
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
'tools.winkeyer': 'Manipulateur CW WinKeyer', 'tools.dvk': 'Manipulateur vocal numérique', 'tools.cwDecoder': 'Décodeur CW (audio RX)',
|
||||||
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
'tools.net': 'Contrôle de NET', 'tools.alerts': 'Gestion des alertes…', 'tools.contest': 'Mode contest',
|
||||||
|
'alert.tuneHint': 'Cliquer pour accorder la radio sur ce spot (fréq + mode) et remplir l\'indicatif', 'alert.dismiss': 'Fermer', 'alert.pending': '{n} alerte(s) de spot récente(s) — cliquer pour voir', 'alert.noneShort': 'Aucune alerte récente', 'alert.recent': 'Alertes récentes', 'alert.clear': 'Effacer',
|
||||||
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
'menu.help': 'Aide', 'help.about': 'À propos d\'OpsLog', 'tools.duplicates': 'Trouver les doublons…',
|
||||||
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
'dup.title': 'Trouver les doublons', 'dup.scanning': 'Analyse du journal…', 'dup.none': 'Aucun doublon trouvé. 🎉',
|
||||||
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
|
'dup.hint': 'Chaque groupe est le même contact enregistré plusieurs fois (indicatif + bande + mode). Le premier (le plus ancien) est laissé décoché ; coche ceux à supprimer.',
|
||||||
@@ -247,7 +257,8 @@ const fr: Dict = {
|
|||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||||
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
||||||
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.dark-warm': 'Sombre chaud',
|
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
|
||||||
|
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
|
||||||
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
|
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
|
||||||
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
||||||
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
||||||
@@ -325,8 +336,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.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.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
'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.icomNetAudioHint': 'Écoute l’audio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
|
||||||
|
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
|
||||||
|
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer l’adresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
||||||
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
'cat.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).",
|
||||||
@@ -334,6 +347,7 @@ const fr: Dict = {
|
|||||||
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
||||||
'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.",
|
'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.",
|
||||||
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
||||||
|
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
||||||
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
||||||
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
||||||
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
|
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
|
||||||
@@ -376,24 +390,24 @@ 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 F1–F6.', '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 F1–F6.', '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 n’afficher 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.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',
|
||||||
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
||||||
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
|
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
|
||||||
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
||||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
||||||
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
||||||
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
||||||
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
||||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches supplémentaires', 'awed.orAlsoMatch': "— correspond aussi à la référence si l'une d'elles est trouvée", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||||
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
||||||
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
||||||
'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…',
|
'qedit.qslDash': '—', 'qedit.qslYes': 'Oui', 'qedit.qslNo': 'Non', 'qedit.qslRequested': 'Demandé', 'qedit.qslIgnore': 'Ignorer', 'qedit.statusModified': 'Modifié', 'qedit.confQslPaper': 'QSL (papier)', 'qedit.callsignRequired': 'Indicatif requis', 'qedit.lookupError': 'Recherche : {msg}', 'qedit.title': 'Modifier le QSO', 'qedit.editFieldsFor': 'Modifier les champs du QSO #{id}', 'qedit.tabQsoInfo': 'Infos QSO', 'qedit.tabContact': 'Détails du contact', 'qedit.tabAwards': 'Réf. diplômes', 'qedit.tabQsl': 'Infos QSL', 'qedit.tabContest': 'Concours', 'qedit.tabSat': 'Sat / Prop', 'qedit.tabMyStation': 'Ma station', 'qedit.tabMoreAdif': "Plus d'ADIF", 'qedit.tabAdifFields': 'Champs ADIF', 'qedit.callsign': 'Indicatif', 'qedit.fetchTitle': 'Rechercher cet indicatif (QRZ.com / HamQTH) et actualiser nom, pays, locator, zones…', 'qedit.fetch': 'Rechercher', 'qedit.name': 'Nom', 'qedit.band': 'Bande', 'qedit.rxBand': 'Bande RX', 'qedit.mode': 'Mode', 'qedit.country': 'Pays', 'qedit.dxccTitle': "Entité DXCC n° — définie automatiquement d'après le pays", 'qedit.txFreq': 'Fréq. TX', 'qedit.rxFreq': 'Fréq. RX', 'qedit.qsoStart': 'Début QSO (UTC)', 'qedit.qsoEnd': 'Fin QSO (UTC)', 'qedit.grid': 'Locator', 'qedit.comment': 'Commentaire', 'qedit.note': 'Note', 'qedit.county': 'Comté', 'qedit.state': 'État', 'qedit.continent': 'Continent', 'qedit.address': 'Adresse', 'qedit.email': 'Adresse e-mail', 'qedit.qslMsg': 'Message QSL', 'qedit.qslVia': 'QSL Via', 'qedit.computedAuto': 'Calculé (automatique)', 'qedit.computedHint': 'Dérivé des champs de ce QSO (DXCC, zones, préfixe, notes…). Non modifiable ici.', 'qedit.noneYet': "Aucun pour l'instant.", 'qedit.manageConf': 'Gérer la confirmation', 'qedit.sent': 'Envoyé', 'qedit.received': 'Reçu', 'qedit.dateSent': "Date d'envoi", 'qedit.dateReceived': 'Date de réception', 'qedit.via': 'Via', 'qedit.viaPlaceholder': 'BUREAU / DIRECT / manager…', 'qedit.qslPanelHint': 'Choisissez un canal, modifiez-le — le tableau de droite se met à jour en direct. Tout est enregistré quand vous cliquez sur', 'qedit.saveChanges': 'Enregistrer', 'qedit.thType': 'Type', 'qedit.contestId': 'ID concours', 'qedit.rcvdExchange': 'échange reçu', 'qedit.sentExchange': 'échange envoyé', 'qedit.check': 'Check', 'qedit.precedence': 'Précédence', 'qedit.arrlSection': 'Section ARRL', 'qedit.propMode': 'Mode de propagation', 'qedit.satName': 'Nom du satellite', 'qedit.satMode': 'Mode satellite', 'qedit.antAz': 'Azimut antenne (°)', 'qedit.antEl': 'Élévation antenne (°)', 'qedit.antPath': "Chemin d'antenne", 'qedit.myStationHint': 'Ces valeurs remplacent le profil de station actif pour ce QSO uniquement.', 'qedit.stationCallsign': 'Indicatif de la station', 'qedit.operator': 'Opérateur', 'qedit.myGrid': 'Mon locator', 'qedit.gridExt': 'Ext. locator', 'qedit.cqZone': 'Zone CQ', 'qedit.ituZone': 'Zone ITU', 'qedit.sotaRef': 'Réf. SOTA', 'qedit.potaRef': 'Réf. POTA', 'qedit.street': 'Rue', 'qedit.city': 'Ville', 'qedit.postal': 'Code postal', 'qedit.rig': 'Équipement', 'qedit.antenna': 'Antenne', 'qedit.specialActivity': 'Activité spéciale', 'qedit.sigInfo': 'Info SIG', 'qedit.wwffRef': 'Réf. WWFF', 'qedit.region': 'Région', 'qedit.powerWeather': 'Puissance et météo spatiale', 'qedit.rxPower': 'Puissance RX (W)', 'qedit.distance': 'Distance (km)', 'qedit.aIndex': 'Indice A', 'qedit.kIndex': 'Indice K', 'qedit.identityClubs': 'Identité et clubs', 'qedit.contactedOp': 'Opérateur contacté', 'qedit.formerCall': 'Ancien indicatif (EQ_CALL)', 'qedit.class': 'Classe', 'qedit.flagsCredits': 'Indicateurs et crédits', 'qedit.qsoComplete': 'QSO complet', 'qedit.qsoRandom': 'QSO aléatoire', 'qedit.silentKey': 'Silent key', 'qedit.creditGranted': 'Crédit accordé', 'qedit.creditSubmitted': 'Crédit soumis', 'qedit.myStationAdif': 'Ma station (ADIF)', 'qedit.myName': 'Mon nom', 'qedit.myWwffRef': 'Ma réf. WWFF', 'qedit.myArrlSect': 'Ma section ARRL', 'qedit.mySig': 'Mon SIG', 'qedit.mySigInfo': 'Mon info SIG', 'qedit.myDarcDok': 'Mon DARC DOK', 'qedit.myVuccGrids': 'Mes locators VUCC', 'qedit.delete': 'Supprimer', 'qedit.cancel': 'Annuler', 'qedit.saving': 'Enregistrement…',
|
||||||
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé',
|
'rqg.c.qso_date': 'Date UTC', 'rqg.c.qso_date_off': 'Date fin', 'rqg.c.callsign': 'Indicatif', 'rqg.c.band': 'Bande', 'rqg.c.band_rx': 'Bande RX', 'rqg.c.mode': 'Mode', 'rqg.c.submode': 'Sous-mode', 'rqg.c.freq_hz': 'Fréq (TX)', 'rqg.h.freq_hz': 'Fréq', 'rqg.c.freq_rx_hz': 'Fréq (RX)', 'rqg.h.freq_rx_hz': 'Fréq RX', 'rqg.c.rst_sent': 'RST env', 'rqg.c.rst_rcvd': 'RST reçu', 'rqg.c.tx_pwr': 'Puiss. TX', 'rqg.c.name': 'Nom', 'rqg.c.qth': 'QTH', 'rqg.c.address': 'Adresse', 'rqg.c.country': 'Pays', 'rqg.c.state': 'État', 'rqg.c.cnty': 'Comté', 'rqg.c.cont': 'Continent', 'rqg.h.cont': 'Cont', 'rqg.c.grid': 'Locator', 'rqg.c.gridsquare_ext': 'Ext. loc.', 'rqg.h.gridsquare_ext': 'ExtLoc', 'rqg.c.vucc_grids': 'Locators VUCC', 'rqg.h.vucc_grids': 'VUCC', 'rqg.c.dxcc': 'DXCC #', 'rqg.c.cqz': 'CQZ', 'rqg.c.ituz': 'ITU', 'rqg.c.iota': 'IOTA', 'rqg.c.sota_ref': 'Réf. SOTA', 'rqg.h.sota_ref': 'SOTA', 'rqg.c.pota_ref': 'Réf. POTA', 'rqg.h.pota_ref': 'POTA', 'rqg.c.age': 'Âge', 'rqg.c.lat': 'Lat', 'rqg.c.lon': 'Lon', 'rqg.c.email': 'E-mail', 'rqg.c.web': 'Web', 'rqg.c.qsl_sent': 'QSL env', 'rqg.c.qsl_rcvd': 'QSL reçu', 'rqg.c.qsl_sent_date': 'Date env QSL', 'rqg.h.qsl_sent_date': 'QSL env.', 'rqg.c.qsl_rcvd_date': 'Date reçu QSL', 'rqg.h.qsl_rcvd_date': 'QSL reçu', 'rqg.c.qsl_via': 'QSL via', 'rqg.c.qsl_msg': 'Msg QSL', 'rqg.c.qslmsg_rcvd': 'Msg QSL reçu', 'rqg.c.lotw_sent': 'LoTW env', 'rqg.c.lotw_rcvd': 'LoTW reçu', 'rqg.c.lotw_sent_date': 'Date env LoTW', 'rqg.h.lotw_sent_date': 'LoTW env.', 'rqg.c.lotw_rcvd_date': 'Date reçu LoTW', 'rqg.h.lotw_rcvd_date': 'LoTW reçu', 'rqg.c.eqsl_sent': 'eQSL env', 'rqg.c.eqsl_rcvd': 'eQSL reçu', 'rqg.c.eqsl_sent_date': 'Date env eQSL', 'rqg.h.eqsl_sent_date': 'eQSL env.', 'rqg.c.eqsl_rcvd_date': 'Date reçu eQSL', 'rqg.h.eqsl_rcvd_date': 'eQSL reçu', 'rqg.c.opslog_qsl_card_sent': 'QSL OpsLog', 'rqg.c.opslog_recording_sent': 'Enreg. envoyé', 'rqg.h.opslog_recording_sent': 'Enr. env', 'rqg.c.clublog_sent': 'ClubLog env', 'rqg.c.clublog_sent_date': 'Date env ClubLog', 'rqg.h.clublog_sent_date': 'ClubLog env.', 'rqg.c.hrdlog_sent': 'HRDLog env', 'rqg.c.hrdlog_sent_date': 'Date env HRDLog', 'rqg.h.hrdlog_sent_date': 'HRDLog env.', 'rqg.c.qrz_sent': 'QRZ.com env', 'rqg.c.qrz_rcvd': 'QRZ.com reçu', 'rqg.c.qrz_sent_date': 'Date env QRZ.com', 'rqg.h.qrz_sent_date': 'QRZ.com env.', 'rqg.c.qrz_rcvd_date': 'Date reçu QRZ.com', 'rqg.h.qrz_rcvd_date': 'QRZ.com reçu', 'rqg.c.contest_id': 'ID concours', 'rqg.h.contest_id': 'Concours', 'rqg.c.srx': 'SRX', 'rqg.c.stx': 'STX', 'rqg.c.srx_string': 'Chaîne SRX', 'rqg.h.srx_string': 'SRX str', 'rqg.c.stx_string': 'Chaîne STX', 'rqg.h.stx_string': 'STX str', 'rqg.c.check': 'Check', 'rqg.c.precedence': 'Précédence', 'rqg.c.arrl_sect': 'Section ARRL', 'rqg.h.arrl_sect': 'Sect. ARRL', 'rqg.c.prop_mode': 'Mode prop.', 'rqg.h.prop_mode': 'Prop', 'rqg.c.sat_name': 'Nom sat.', 'rqg.h.sat_name': 'Sat', 'rqg.c.sat_mode': 'Mode sat.', 'rqg.c.ant_az': 'Azimut ant.', 'rqg.h.ant_az': 'Az', 'rqg.c.ant_el': 'Élévation ant.', 'rqg.h.ant_el': 'Él', 'rqg.c.ant_path': 'Chemin ant.', 'rqg.h.ant_path': 'Chemin', 'rqg.c.station_callsign': 'Indicatif station', 'rqg.h.station_callsign': 'Station', 'rqg.c.operator': 'Opérateur', 'rqg.c.my_grid': 'Mon locator', 'rqg.c.my_country': 'Mon pays', 'rqg.h.my_country': 'Mon pays', 'rqg.c.my_state': 'Mon état', 'rqg.c.my_cnty': 'Mon comté', 'rqg.h.my_cnty': 'Mon comté', 'rqg.c.my_iota': 'Mon IOTA', 'rqg.c.my_sota': 'Mon SOTA', 'rqg.c.my_pota': 'Mon POTA', 'rqg.c.my_dxcc': 'Mon DXCC', 'rqg.h.my_dxcc': 'Mon DXCC#', 'rqg.c.my_cq_zone': 'Ma zone CQ', 'rqg.h.my_cq_zone': 'Ma CQZ', 'rqg.c.my_itu_zone': 'Ma zone ITU', 'rqg.h.my_itu_zone': 'Ma ITU', 'rqg.c.my_lat': 'Ma lat', 'rqg.c.my_lon': 'Ma lon', 'rqg.c.my_street': 'Ma rue', 'rqg.h.my_street': 'Rue', 'rqg.c.my_city': 'Ma ville', 'rqg.h.my_city': 'Ville', 'rqg.c.my_zip': 'Mon code postal', 'rqg.h.my_zip': 'CP', 'rqg.c.my_rig': 'Mon équipement', 'rqg.c.my_antenna': 'Mon antenne', 'rqg.h.my_antenna': 'Mon ant.', 'rqg.c.comment': 'Commentaire', 'rqg.c.notes': 'Notes', 'rqg.c.created': 'Créé', 'rqg.h.created': 'Créé le', 'rqg.c.updated': 'Mis à jour', 'rqg.h.updated': 'Mis à jour le', 'rqg.grpQso': 'QSO', 'rqg.grpContacted': 'Station contactée', 'rqg.grpQsl': 'QSL', 'rqg.grpLotw': 'LoTW', 'rqg.grpEqsl': 'eQSL', 'rqg.grpUploads': 'Envois', 'rqg.grpContest': 'Concours', 'rqg.grpProp': 'Propagation', 'rqg.grpMyStation': 'Ma station', 'rqg.grpMisc': 'Divers', 'rqg.grpAwards': 'Diplômes', 'rqg.awardTip': '{name} — référence comptée pour ce QSO', 'rqg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'rqg.clearFilters': 'Effacer les filtres', 'rqg.columns': 'Colonnes', 'rqg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau des QSO récents. Votre sélection est enregistrée.', 'rqg.all': 'tout', 'rqg.none': 'aucun', 'rqg.resetDefaults': 'Réinitialiser', 'rqg.done': 'Terminé',
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { writeUiPref } from './uiPref';
|
|||||||
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
// CSS variables in style.css key off of. 'auto' follows the OS light/dark
|
||||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||||
// travels with the data/ folder like the language).
|
// travels with the data/ folder like the language).
|
||||||
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
||||||
|
|
||||||
// Selectable, concrete themes (excludes 'auto') in display order.
|
// Selectable, concrete themes (excludes 'auto') in display order.
|
||||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||||
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast',
|
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LS_KEY = 'opslog.theme';
|
export const LS_KEY = 'opslog.theme';
|
||||||
|
|||||||
@@ -92,6 +92,207 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */
|
||||||
|
[data-theme="light-cool"] {
|
||||||
|
--background: #f4f6f8; /* cool blue-white */
|
||||||
|
--foreground: #0f172a; /* slate-900 */
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #0f172a;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #0f172a;
|
||||||
|
--primary: #2563eb; /* blue-600 */
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #e2e8f0; /* slate-200 */
|
||||||
|
--secondary-foreground: #0f172a;
|
||||||
|
--muted: #e6ebf1; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #475569; /* slate-600 — readable on muted */
|
||||||
|
--accent: #dbeafe; /* blue-100 */
|
||||||
|
--accent-foreground: #1e3a8a; /* blue-900 */
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #d3dae2; /* cool slate border */
|
||||||
|
--input: #d3dae2;
|
||||||
|
--ring: #3b82f6; /* blue-500 focus ring */
|
||||||
|
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #ca8a04;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #e11d48;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
|
--info: #0284c7;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #6ee7b7;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #e2e8f0;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b6c2d1;
|
||||||
|
--scrollbar-thumb-hover: #8fa1b5;
|
||||||
|
--card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */
|
||||||
|
[data-theme="light-sage"] {
|
||||||
|
--background: #eef1ec; /* pale sage */
|
||||||
|
--foreground: #1a2420; /* deep pine-ink */
|
||||||
|
--card: #f8faf6; /* lifted off-white with a green cast */
|
||||||
|
--card-foreground: #1a2420;
|
||||||
|
--popover: #f8faf6;
|
||||||
|
--popover-foreground: #1a2420;
|
||||||
|
--primary: #2f855a; /* deep sage green */
|
||||||
|
--primary-foreground: #f0fdf4;
|
||||||
|
--secondary: #dde5db;
|
||||||
|
--secondary-foreground: #1a2420;
|
||||||
|
--muted: #dde5db; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #445048; /* muted pine — readable on muted */
|
||||||
|
--accent: #d7e8d5; /* soft green */
|
||||||
|
--accent-foreground: #1e4634; /* forest-900 */
|
||||||
|
--destructive: #b91c1c;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #cdd8ce; /* soft sage border */
|
||||||
|
--input: #cdd8ce;
|
||||||
|
--ring: #38a169; /* green focus ring */
|
||||||
|
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #ca8a04;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #e11d48;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
|
--info: #0284c7;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #6ee7b7;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #dde5db;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b3c2b3;
|
||||||
|
--scrollbar-thumb-hover: #91a591;
|
||||||
|
--card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */
|
||||||
|
[data-theme="dim-slate"] {
|
||||||
|
--background: #343b47; /* medium slate — light text, not deep dark */
|
||||||
|
--foreground: #eceef2;
|
||||||
|
--card: #3d4552; /* lifted panel */
|
||||||
|
--card-foreground: #eceef2;
|
||||||
|
--popover: #3d4552;
|
||||||
|
--popover-foreground: #eceef2;
|
||||||
|
--primary: #fb923c; /* orange-400 — brand warmth, pops on slate */
|
||||||
|
--primary-foreground: #241206;
|
||||||
|
--secondary: #454e5c;
|
||||||
|
--secondary-foreground: #eceef2;
|
||||||
|
--muted: #414a58; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #b4bcc9; /* readable on muted */
|
||||||
|
--accent: #495468; /* hover / selection tint */
|
||||||
|
--accent-foreground: #dce3ef;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #1a0808;
|
||||||
|
--destructive-muted: #3f1f1f;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #4e5867;
|
||||||
|
--input: #4e5867;
|
||||||
|
--ring: #fdba74; /* orange-300 focus ring */
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #06231a;
|
||||||
|
--success-muted: #1c3d34;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #2a5f4c;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #241a06;
|
||||||
|
--warning-muted: #41371a;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #5e4d22;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #242006;
|
||||||
|
--caution-muted: #403b1a;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #5b5222;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #260a11;
|
||||||
|
--danger-muted: #421f28;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #63303c;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #061f2b;
|
||||||
|
--info-muted: #16384a;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #1d5670;
|
||||||
|
|
||||||
|
/* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2f7d55;
|
||||||
|
--mx-dx-conf: #6366f1;
|
||||||
|
--mx-dx-work: #3f3f8a;
|
||||||
|
--mx-none: #4a5262;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #565f70;
|
||||||
|
--scrollbar-thumb-hover: #6b7588;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
||||||
[data-theme="dark-warm"] {
|
[data-theme="dark-warm"] {
|
||||||
--background: #221d18;
|
--background: #221d18;
|
||||||
|
|||||||
@@ -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.18';
|
export const APP_VERSION = '0.19.4';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+36
@@ -11,12 +11,14 @@ import {awardref} from '../models';
|
|||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {solar} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
import {contest} from '../models';
|
import {contest} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
|
import {lotwusers} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
import {netctl} from '../models';
|
import {netctl} from '../models';
|
||||||
|
|
||||||
@@ -36,6 +38,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>>;
|
||||||
@@ -126,6 +140,8 @@ export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
|||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function DownloadLoTWUsers():Promise<number>;
|
||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
|
||||||
@@ -172,6 +188,8 @@ export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetActiveSlice(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||||
@@ -188,6 +206,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>;
|
||||||
@@ -216,6 +236,10 @@ 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 FlexSetTunePower(arg1:number):Promise<void>;
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||||
@@ -224,6 +248,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>;
|
||||||
@@ -294,6 +322,8 @@ export function GetListsSettings():Promise<main.ListsSettings>;
|
|||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
export function GetLogbookRevision():Promise<string>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
@@ -320,6 +350,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
|||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
|
export function GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
@@ -462,6 +494,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
|||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
@@ -568,6 +602,8 @@ export function QuitApp():Promise<void>;
|
|||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -214,6 +238,10 @@ export function DownloadConfirmations(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadLoTWUsers() {
|
||||||
|
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||||
|
}
|
||||||
|
|
||||||
export function DuplicateProfile(arg1, arg2) {
|
export function DuplicateProfile(arg1, arg2) {
|
||||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -306,6 +334,10 @@ export function FlexSetATUMemories(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetActiveSlice(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetActiveSlice'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetAudioLevel(arg1) {
|
export function FlexSetAudioLevel(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
||||||
}
|
}
|
||||||
@@ -338,6 +370,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);
|
||||||
}
|
}
|
||||||
@@ -394,6 +430,14 @@ 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) {
|
||||||
|
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1) {
|
export function FlexSetTunePower(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||||
}
|
}
|
||||||
@@ -410,6 +454,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);
|
||||||
}
|
}
|
||||||
@@ -550,6 +602,10 @@ export function GetLiveStatusEnabled() {
|
|||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLoTWUsersStatus() {
|
||||||
|
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLogFilePath() {
|
export function GetLogFilePath() {
|
||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
@@ -602,6 +658,10 @@ export function GetSecretStatus() {
|
|||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSolarData() {
|
||||||
|
return window['go']['main']['App']['GetSolarData']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStartupStatus() {
|
export function GetStartupStatus() {
|
||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
@@ -886,6 +946,10 @@ export function ListUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LoTWUserInfo(arg1) {
|
||||||
|
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1) {
|
export function LogUDPLoggedADIF(arg1) {
|
||||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1098,6 +1162,10 @@ export function RefreshCtyDat() {
|
|||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshSolar() {
|
||||||
|
return window['go']['main']['App']['RefreshSolar']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ReloadUDPIntegrations() {
|
export function ReloadUDPIntegrations() {
|
||||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -250,6 +252,7 @@ export namespace award {
|
|||||||
valid_from?: string;
|
valid_from?: string;
|
||||||
valid_to?: string;
|
valid_to?: string;
|
||||||
alias?: string;
|
alias?: string;
|
||||||
|
ref_display?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
field: string;
|
field: string;
|
||||||
match_by?: string;
|
match_by?: string;
|
||||||
@@ -289,6 +292,7 @@ export namespace award {
|
|||||||
this.valid_from = source["valid_from"];
|
this.valid_from = source["valid_from"];
|
||||||
this.valid_to = source["valid_to"];
|
this.valid_to = source["valid_to"];
|
||||||
this.alias = source["alias"];
|
this.alias = source["alias"];
|
||||||
|
this.ref_display = source["ref_display"];
|
||||||
this.type = source["type"];
|
this.type = source["type"];
|
||||||
this.field = source["field"];
|
this.field = source["field"];
|
||||||
this.match_by = source["match_by"];
|
this.match_by = source["match_by"];
|
||||||
@@ -541,9 +545,34 @@ export namespace cat {
|
|||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class FlexSliceInfo {
|
||||||
|
index: number;
|
||||||
|
letter: string;
|
||||||
|
freq_hz: number;
|
||||||
|
mode?: string;
|
||||||
|
band?: string;
|
||||||
|
active: boolean;
|
||||||
|
tx: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlexSliceInfo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.index = source["index"];
|
||||||
|
this.letter = source["letter"];
|
||||||
|
this.freq_hz = source["freq_hz"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.active = source["active"];
|
||||||
|
this.tx = source["tx"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class FlexTXState {
|
export class FlexTXState {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
slices?: FlexSliceInfo[];
|
||||||
rf_power: number;
|
rf_power: number;
|
||||||
tune_power: number;
|
tune_power: number;
|
||||||
tune: boolean;
|
tune: boolean;
|
||||||
@@ -556,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;
|
||||||
@@ -576,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;
|
||||||
@@ -600,6 +635,7 @@ export namespace cat {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.available = source["available"];
|
this.available = source["available"];
|
||||||
this.model = source["model"];
|
this.model = source["model"];
|
||||||
|
this.slices = this.convertValues(source["slices"], FlexSliceInfo);
|
||||||
this.rf_power = source["rf_power"];
|
this.rf_power = source["rf_power"];
|
||||||
this.tune_power = source["tune_power"];
|
this.tune_power = source["tune_power"];
|
||||||
this.tune = source["tune"];
|
this.tune = source["tune"];
|
||||||
@@ -612,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"];
|
||||||
@@ -632,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"];
|
||||||
@@ -1112,6 +1154,27 @@ export namespace lookup {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace lotwusers {
|
||||||
|
|
||||||
|
export class Info {
|
||||||
|
is_user: boolean;
|
||||||
|
last_upload?: string;
|
||||||
|
days_ago: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Info(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.is_user = source["is_user"];
|
||||||
|
this.last_upload = source["last_upload"];
|
||||||
|
this.days_ago = source["days_ago"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace main {
|
export namespace main {
|
||||||
|
|
||||||
export class AntGeniusSettings {
|
export class AntGeniusSettings {
|
||||||
@@ -1315,12 +1378,15 @@ export namespace main {
|
|||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
|
flex_decode_spots: boolean;
|
||||||
|
flex_decode_secs: number;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
icom_baud: number;
|
icom_baud: number;
|
||||||
icom_addr: number;
|
icom_addr: number;
|
||||||
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;
|
||||||
@@ -1340,12 +1406,15 @@ export namespace main {
|
|||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
this.flex_spots = source["flex_spots"];
|
||||||
|
this.flex_decode_spots = source["flex_decode_spots"];
|
||||||
|
this.flex_decode_secs = source["flex_decode_secs"];
|
||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
this.icom_baud = source["icom_baud"];
|
this.icom_baud = source["icom_baud"];
|
||||||
this.icom_addr = source["icom_addr"];
|
this.icom_addr = source["icom_addr"];
|
||||||
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"];
|
||||||
@@ -1694,6 +1763,20 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class LoTWUsersStatus {
|
||||||
|
count: number;
|
||||||
|
updated?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new LoTWUsersStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.count = source["count"];
|
||||||
|
this.updated = source["updated"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class LookupSettings {
|
export class LookupSettings {
|
||||||
qrz_user: string;
|
qrz_user: string;
|
||||||
qrz_password: string;
|
qrz_password: string;
|
||||||
@@ -2291,6 +2374,14 @@ export namespace netctl {
|
|||||||
dxcc?: number;
|
dxcc?: number;
|
||||||
itu?: number;
|
itu?: number;
|
||||||
cq?: number;
|
cq?: number;
|
||||||
|
grid?: string;
|
||||||
|
address?: string;
|
||||||
|
state?: string;
|
||||||
|
cnty?: string;
|
||||||
|
cont?: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
email?: string;
|
||||||
groups?: string;
|
groups?: string;
|
||||||
sig?: string;
|
sig?: string;
|
||||||
sig_info?: string;
|
sig_info?: string;
|
||||||
@@ -2308,6 +2399,14 @@ export namespace netctl {
|
|||||||
this.dxcc = source["dxcc"];
|
this.dxcc = source["dxcc"];
|
||||||
this.itu = source["itu"];
|
this.itu = source["itu"];
|
||||||
this.cq = source["cq"];
|
this.cq = source["cq"];
|
||||||
|
this.grid = source["grid"];
|
||||||
|
this.address = source["address"];
|
||||||
|
this.state = source["state"];
|
||||||
|
this.cnty = source["cnty"];
|
||||||
|
this.cont = source["cont"];
|
||||||
|
this.lat = source["lat"];
|
||||||
|
this.lon = source["lon"];
|
||||||
|
this.email = source["email"];
|
||||||
this.groups = source["groups"];
|
this.groups = source["groups"];
|
||||||
this.sig = source["sig"];
|
this.sig = source["sig"];
|
||||||
this.sig_info = source["sig_info"];
|
this.sig_info = source["sig_info"];
|
||||||
@@ -3238,6 +3337,66 @@ export namespace qso {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace solar {
|
||||||
|
|
||||||
|
export class Data {
|
||||||
|
sfi: string;
|
||||||
|
ssn: string;
|
||||||
|
a_index: string;
|
||||||
|
k_index: string;
|
||||||
|
xray: string;
|
||||||
|
geomag_field: string;
|
||||||
|
aurora: string;
|
||||||
|
muf: string;
|
||||||
|
updated: string;
|
||||||
|
source: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
// Go type: time
|
||||||
|
fetched_at: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Data(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.sfi = source["sfi"];
|
||||||
|
this.ssn = source["ssn"];
|
||||||
|
this.a_index = source["a_index"];
|
||||||
|
this.k_index = source["k_index"];
|
||||||
|
this.xray = source["xray"];
|
||||||
|
this.geomag_field = source["geomag_field"];
|
||||||
|
this.aurora = source["aurora"];
|
||||||
|
this.muf = source["muf"];
|
||||||
|
this.updated = source["updated"];
|
||||||
|
this.source = source["source"];
|
||||||
|
this.ok = source["ok"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace udp {
|
export namespace udp {
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+131
-1
@@ -15,7 +15,10 @@ type Manager struct {
|
|||||||
recStop chan struct{}
|
recStop chan struct{}
|
||||||
recDone chan recResult
|
recDone chan recResult
|
||||||
playStop chan struct{}
|
playStop chan struct{}
|
||||||
onChange func() // fired on any record/playback state transition
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
type recResult struct {
|
type recResult struct {
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
+58
-46
@@ -13,6 +13,7 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -55,6 +56,11 @@ type Def struct {
|
|||||||
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
|
||||||
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
|
||||||
Alias string `json:"alias,omitempty"`
|
Alias string `json:"alias,omitempty"`
|
||||||
|
// RefDisplay picks what the grid's award column shows for a match: "" or "ref"
|
||||||
|
// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
|
||||||
|
// the prefecture name), "both" = "ref — name". DXCC always shows the country
|
||||||
|
// name under "ref" (unchanged).
|
||||||
|
RefDisplay string `json:"ref_display,omitempty"`
|
||||||
|
|
||||||
// --- Type & matching ---
|
// --- Type & matching ---
|
||||||
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
|
||||||
@@ -68,10 +74,13 @@ type Def struct {
|
|||||||
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
|
||||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
||||||
|
|
||||||
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
|
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||||
// earns a reference if the primary match OR any of these match. Lets a
|
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||||
// French department (DDFM) be found from "D74" in the note AND from a postal
|
// yields a reference wins and the rest are skipped (short-circuit, like a
|
||||||
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
|
// chain of "else if"). Lets a Chinese province (WAPC) be found first from the
|
||||||
|
// province NAME in the address, and only if that fails, from a city regex
|
||||||
|
// (e.g. "\bJiangyin\b" → JS) — so a QSO already resolved by name isn't also
|
||||||
|
// re-tagged, possibly differently, by a later rule.
|
||||||
OrRules []OrRule `json:"or_rules,omitempty"`
|
OrRules []OrRule `json:"or_rules,omitempty"`
|
||||||
|
|
||||||
// --- Scope ---
|
// --- Scope ---
|
||||||
@@ -252,6 +261,21 @@ type RefMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||||
|
// compileAwardRE compiles an award / reference regex. Award patterns are run
|
||||||
|
// over free-text log fields (QTH, NOTES) whose capitalization is arbitrary — a
|
||||||
|
// log may hold "Tokyo", "TOKYO" or "TOKIO" — so we match case-insensitively by
|
||||||
|
// default. An author who set their own flag group (e.g. "(?s)") is respected.
|
||||||
|
func compileAwardRE(p string) (*regexp.Regexp, error) {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return nil, errors.New("empty pattern")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(p, "(?") {
|
||||||
|
p = "(?i)" + p
|
||||||
|
}
|
||||||
|
return regexp.Compile(p)
|
||||||
|
}
|
||||||
|
|
||||||
func NewRefList(metas []RefMeta) refList {
|
func NewRefList(metas []RefMeta) refList {
|
||||||
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
||||||
for _, m := range metas {
|
for _, m := range metas {
|
||||||
@@ -260,7 +284,7 @@ func NewRefList(metas []RefMeta) refList {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||||
if re, err := regexp.Compile(p); err == nil {
|
if re, err := compileAwardRE(p); err == nil {
|
||||||
m.re = re
|
m.re = re
|
||||||
rl.withPattern = append(rl.withPattern, code)
|
rl.withPattern = append(rl.withPattern, code)
|
||||||
}
|
}
|
||||||
@@ -292,7 +316,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
perr := make([]string, len(defs))
|
perr := make([]string, len(defs))
|
||||||
for i := range defs {
|
for i := range defs {
|
||||||
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
||||||
re, err := regexp.Compile(p)
|
re, err := compileAwardRE(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
perr[i] = "bad pattern: " + err.Error()
|
perr[i] = "bad pattern: " + err.Error()
|
||||||
} else {
|
} else {
|
||||||
@@ -427,24 +451,14 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
|||||||
}
|
}
|
||||||
var re *regexp.Regexp
|
var re *regexp.Regexp
|
||||||
if p := strings.TrimSpace(d.Pattern); p != "" {
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||||
if c, err := regexp.Compile(p); err == nil {
|
if c, err := compileAwardRE(p); err == nil {
|
||||||
re = c
|
re = c
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl := NewRefList(metas)
|
rl := NewRefList(metas)
|
||||||
found := candidates(&d, re, q, rl, len(metas) > 0)
|
return candidates(&d, re, q, rl, len(metas) > 0)
|
||||||
// Merge operator-assigned references (manual override). These let the
|
|
||||||
// operator tag a QSO for an award whose field/description matching can't
|
|
||||||
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
|
|
||||||
// province NAME, so a contact whose address doesn't spell it out needs the
|
|
||||||
// province picked by hand. For a predefined award the override is still
|
|
||||||
// validated against its reference list.
|
|
||||||
if man := manualRefs(q, d.Code); len(man) > 0 {
|
|
||||||
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
||||||
@@ -479,30 +493,6 @@ func manualRefs(q *qso.QSO, code string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
|
|
||||||
// When the award is predefined, only references present and valid in its list
|
|
||||||
// are kept (so a typo can't invent a reference).
|
|
||||||
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, c := range found {
|
|
||||||
seen[normalizeRef(c)] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, c := range manual {
|
|
||||||
c = normalizeRef(c)
|
|
||||||
if _, dup := seen[c]; dup {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if predefined {
|
|
||||||
if m, ok := rl.byCode[c]; !ok || !m.Valid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen[c] = struct{}{}
|
|
||||||
found = append(found, c)
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||||
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||||
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
||||||
@@ -560,6 +550,14 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
|||||||
found = append(found, nc.code)
|
found = append(found, nc.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A reference may also declare its own regex to broaden matching beyond
|
||||||
|
// its plain name (e.g. Tokyo's `\bTok[iy]o\b` also catches "TOKIO"). Test
|
||||||
|
// those too — the name match and the pattern are OR'd.
|
||||||
|
for _, code := range rl.withPattern {
|
||||||
|
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
|
||||||
|
found = append(found, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
case predefined && !exact:
|
case predefined && !exact:
|
||||||
// "Search reference inside the field": look up each token of the field in
|
// "Search reference inside the field": look up each token of the field in
|
||||||
// the list — O(tokens), not O(all references) — plus test the few
|
// the list — O(tokens), not O(all references) — plus test the few
|
||||||
@@ -591,19 +589,33 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
|||||||
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
|
||||||
predefined := hasList && !d.Dynamic
|
predefined := hasList && !d.Dynamic
|
||||||
|
|
||||||
// Primary search, then each OR rule — a QSO earns a reference if any matches.
|
// Primary search first; the OR rules are ordered FALLBACKS — try the next
|
||||||
|
// only while nothing has matched yet, and stop at the first that yields a
|
||||||
|
// reference (short-circuit). So a province already found by NAME isn't also
|
||||||
|
// re-derived, possibly differently, from a later city-regex rule.
|
||||||
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
||||||
for i := range d.OrRules {
|
for i := 0; len(found) == 0 && i < len(d.OrRules); i++ {
|
||||||
r := &d.OrRules[i]
|
r := &d.OrRules[i]
|
||||||
var rre *regexp.Regexp
|
var rre *regexp.Regexp
|
||||||
if p := strings.TrimSpace(r.Pattern); p != "" {
|
if p := strings.TrimSpace(r.Pattern); p != "" {
|
||||||
c, err := regexp.Compile(p)
|
c, err := compileAwardRE(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // skip a rule with a bad regex rather than failing the award
|
continue // skip a rule with a bad regex rather than failing the award
|
||||||
}
|
}
|
||||||
rre = c
|
rre = c
|
||||||
}
|
}
|
||||||
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
|
found = searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
|
||||||
|
// the operator tag a QSO for an award whose field/description matching can't
|
||||||
|
// auto-detect the reference — e.g. WAPC scans ADDRESS for a province NAME, so
|
||||||
|
// a contact whose address doesn't spell it out needs the province picked by
|
||||||
|
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
||||||
|
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
||||||
|
// predefined award the ref is still validated against the list below.
|
||||||
|
for _, c := range manualRefs(q, d.Code) {
|
||||||
|
found = append(found, normalizeRef(c))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !predefined {
|
if !predefined {
|
||||||
|
|||||||
@@ -128,6 +128,146 @@ func TestComputeMatchByDescription(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OR rules are ordered fallbacks: the primary match wins and short-circuits the
|
||||||
|
// rules; when the primary finds nothing, the first OR rule that hits wins and
|
||||||
|
// later rules are skipped. DDFM-style: a French department from the NOTE first,
|
||||||
|
// else captured from a postal code in the address (Prefix "D" → "D74").
|
||||||
|
func TestComputeOrRuleFallback(t *testing.T) {
|
||||||
|
def := Def{Code: "DDFM", Type: TypeQSOFields, Field: "notes", Pattern: `\b(D\d{2})\b`,
|
||||||
|
DXCCFilter: []int{227}, Valid: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "address", MatchBy: "pattern", Pattern: `\b(\d{2})\d{3}\b`, Prefix: "D"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"DDFM": {
|
||||||
|
{Code: "D74", Name: "Haute-Savoie", Valid: true},
|
||||||
|
{Code: "D75", Name: "Paris", Valid: true},
|
||||||
|
}}
|
||||||
|
worked := func(q qso.QSO) []string {
|
||||||
|
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
|
||||||
|
var out []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
out = append(out, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// Primary (note "D74") matches → the postal OR rule is skipped, so the
|
||||||
|
// address's 75001 does NOT also add D75. Exactly the user's ask: first
|
||||||
|
// condition wins, no fall-through.
|
||||||
|
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "worked D74", Address: "75001 Paris"}); len(got) != 1 || got[0] != "D74" {
|
||||||
|
t.Errorf("primary wins: got %v, want [D74] (no postal fall-through to D75)", got)
|
||||||
|
}
|
||||||
|
// No note dept → fall through to the OR rule → postal 74140 → D74.
|
||||||
|
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "", Address: "74140 Annecy"}); len(got) != 1 || got[0] != "D74" {
|
||||||
|
t.Errorf("OR fallback: got %v, want [D74]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user's WAPC cascade: (1) province NAME in QTH, else (2) province NAME in
|
||||||
|
// ADDRESS, else (3) match-by-pattern in QTH, which runs each reference's OWN
|
||||||
|
// regex (city lists). Confirms the ordered fallback + that a match-by-pattern
|
||||||
|
// rule with an empty rule-pattern triggers the per-reference regexes.
|
||||||
|
func TestComputeProvinceCascade(t *testing.T) {
|
||||||
|
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{318}, Valid: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "address", MatchBy: "description"},
|
||||||
|
{Field: "qth", MatchBy: "pattern"}, // empty pattern → per-reference regexes
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAPC": {
|
||||||
|
{Code: "JS", Name: "Jiangsu", Pattern: `\bJiangyin\b`, Valid: true},
|
||||||
|
{Code: "ZJ", Name: "Zhejiang", Pattern: `\bHangzhou\b`, Valid: true},
|
||||||
|
}}
|
||||||
|
worked := func(q qso.QSO) []string {
|
||||||
|
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
|
||||||
|
var out []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
out = append(out, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name, qth, addr string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"name in qth", "Wuxi, Jiangsu", "", "JS"},
|
||||||
|
{"name in address", "Wuxi", "Jiangsu Province", "JS"},
|
||||||
|
{"city regex (Jiangyin) in qth", "Jiangyin", "", "JS"},
|
||||||
|
{"city regex (Hangzhou) in qth", "Hangzhou", "", "ZJ"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := worked(qso.QSO{Callsign: "BA1A", DXCC: ip(318), QTH: c.qth, Address: c.addr})
|
||||||
|
if len(got) != 1 || got[0] != c.want {
|
||||||
|
t.Errorf("%s: got %v, want [%s]", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manually-assigned override (ManualRefsKey) must count in Compute — not just
|
||||||
|
// MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes
|
||||||
|
// no QSO field) sticks after the operator picks the province by hand. The engine
|
||||||
|
// only auto-detects "Jiangsu" when the address spells it out; here it doesn't, so
|
||||||
|
// the ref exists solely as the override.
|
||||||
|
func TestComputeManualOverride(t *testing.T) {
|
||||||
|
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "address", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{318}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
// Address doesn't name the province → only the override tags it.
|
||||||
|
{Callsign: "BA1ABC", Band: "20m", DXCC: ip(318), Address: "Beijing Rd 5",
|
||||||
|
Extras: map[string]string{ManualRefsKey: "WAPC@JS"}},
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAPC": {
|
||||||
|
{Code: "JS", Name: "Jiangsu", Valid: true},
|
||||||
|
{Code: "GD", Name: "Guangdong", Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 1 {
|
||||||
|
t.Fatalf("WAPC worked = %d, want 1 (Jiangsu via manual override); got %v", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
var worked []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
worked = append(worked, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(worked) != 1 || worked[0] != "JS" {
|
||||||
|
t.Errorf("worked refs = %v, want [JS]", worked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An override for a ref NOT in the predefined list is rejected (no typo refs).
|
||||||
|
qsos[0].Extras[ManualRefsKey] = "WAPC@ZZ"
|
||||||
|
if w := Compute([]Def{def}, qsos, refMetas, nil)[0].Worked; w != 0 {
|
||||||
|
t.Errorf("bogus override ZZ: worked = %d, want 0", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A description-match award must also honour a REFERENCE's own regex (used to
|
||||||
|
// broaden matching past the plain name) AND match case-insensitively — a log
|
||||||
|
// QTH "ITABASHIKU, TOKIO" (uppercase, "Tokio" spelling) must count for Tokyo
|
||||||
|
// via its `\bTok[iy]o\b` pattern, which the plain name "Tokyo" can't catch.
|
||||||
|
func TestComputeDescriptionRefPattern(t *testing.T) {
|
||||||
|
def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "JA1LZB", Band: "10m", DXCC: ip(339), QTH: "ITABASHIKU, TOKIO"}, // pattern + case
|
||||||
|
{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "osaka pref"}, // lowercase name
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAJA": {
|
||||||
|
{Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true},
|
||||||
|
{Code: "27", Name: "Osaka", Valid: true},
|
||||||
|
{Code: "01", Name: "Hokkaido", Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 2 {
|
||||||
|
t.Errorf("WAJA worked = %d, want 2 (Tokyo via pattern + Osaka via lowercase name); got %v", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
|
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
|
||||||
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
|
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
|
||||||
// 4-char prefix of GRIDSQUARE.
|
// 4-char prefix of GRIDSQUARE.
|
||||||
|
|||||||
+34
-5
@@ -205,11 +205,12 @@ func (m *Manager) SetPTT(on bool) error {
|
|||||||
// the backend picks a default when it's empty. (Status-based colouring can be
|
// the backend picks a default when it's empty. (Status-based colouring can be
|
||||||
// driven later by setting Color per spot.)
|
// driven later by setting Color per spot.)
|
||||||
type SpotInfo struct {
|
type SpotInfo struct {
|
||||||
FreqHz int64
|
FreqHz int64
|
||||||
Callsign string
|
Callsign string
|
||||||
Mode string
|
Mode string
|
||||||
Color string
|
Color string
|
||||||
Comment string
|
Comment string
|
||||||
|
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||||
@@ -245,9 +246,24 @@ func (m *Manager) SendSpot(s SpotInfo) {
|
|||||||
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
|
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
|
||||||
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
||||||
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
||||||
|
// FlexSliceInfo identifies one FlexRadio receiver slice (A/B/C/D…) for the
|
||||||
|
// panel, so the operator sees every slice and which one is active/TX.
|
||||||
|
type FlexSliceInfo struct {
|
||||||
|
Index int `json:"index"` // 0-based slice index
|
||||||
|
Letter string `json:"letter"` // A, B, C, D…
|
||||||
|
FreqHz int64 `json:"freq_hz"`
|
||||||
|
Mode string `json:"mode,omitempty"` // ADIF mode
|
||||||
|
Band string `json:"band,omitempty"`
|
||||||
|
Active bool `json:"active"` // the focused/operating slice
|
||||||
|
TX bool `json:"tx"` // this slice transmits
|
||||||
|
}
|
||||||
|
|
||||||
type FlexTXState struct {
|
type FlexTXState struct {
|
||||||
Available bool `json:"available"` // backend is Flex and handshaked
|
Available bool `json:"available"` // backend is Flex and handshaked
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
|
// Slices lists every in-use receiver slice (A/B/C/D…) so the panel can show
|
||||||
|
// them all and highlight the active one. The active slice drives everything.
|
||||||
|
Slices []FlexSliceInfo `json:"slices,omitempty"`
|
||||||
RFPower int `json:"rf_power"`
|
RFPower int `json:"rf_power"`
|
||||||
TunePower int `json:"tune_power"`
|
TunePower int `json:"tune_power"`
|
||||||
Tune bool `json:"tune"` // tune carrier active
|
Tune bool `json:"tune"` // tune carrier active
|
||||||
@@ -260,6 +276,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.
|
||||||
@@ -281,6 +302,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"`
|
||||||
@@ -329,6 +352,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
|
||||||
@@ -339,6 +364,8 @@ type FlexController interface {
|
|||||||
SetMute(bool) error
|
SetMute(bool) error
|
||||||
SetRXAntenna(string) error
|
SetRXAntenna(string) error
|
||||||
SetTXAntenna(string) error
|
SetTXAntenna(string) error
|
||||||
|
SetActiveSlice(int) error // focus slice idx so commands target it
|
||||||
|
SetTXSlice(int) error // make slice idx the transmitter (tx=1)
|
||||||
SetSplit(bool) error
|
SetSplit(bool) error
|
||||||
SetNB(bool) error
|
SetNB(bool) error
|
||||||
SetNBLevel(int) error
|
SetNBLevel(int) error
|
||||||
@@ -348,6 +375,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+353
-106
@@ -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).
|
||||||
@@ -58,6 +60,7 @@ type Flex struct {
|
|||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
|
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||||
|
|
||||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||||
@@ -85,6 +88,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 +113,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).
|
||||||
@@ -151,7 +158,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
@@ -209,6 +216,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 {
|
||||||
@@ -344,6 +353,7 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.spotCall[idx] = call
|
f.spotCall[idx] = call
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
|
f.spotByCall[strings.ToUpper(call)] = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A successful "slice create" for split returns the new slice's index;
|
// A successful "slice create" for split returns the new slice's index;
|
||||||
@@ -445,6 +455,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 +542,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 +789,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":
|
||||||
@@ -774,21 +823,19 @@ func (f *Flex) ReadState() (RigState, error) {
|
|||||||
if !f.gotHandle {
|
if !f.gotHandle {
|
||||||
return st, nil // connected TCP but radio hasn't handshaked yet
|
return st, nil // connected TCP but radio hasn't handshaked yet
|
||||||
}
|
}
|
||||||
rx, tx := f.pickSlicesLocked()
|
main, rxS, txSplit := f.operatingLocked()
|
||||||
if rx == nil && tx == nil {
|
if main == nil {
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
if tx == nil {
|
// Main frequency/mode = the ACTIVE slice (what the operator is on). Only a
|
||||||
tx = rx
|
// genuine same-band split adds a separate TX freq; then ADIF convention wants
|
||||||
}
|
// FreqHz = TX and RxFreqHz = RX.
|
||||||
if rx == nil {
|
st.FreqHz = main.freqHz
|
||||||
rx = tx
|
st.Mode = flexModeToADIF(main.mode)
|
||||||
}
|
if rxS != nil && txSplit != nil {
|
||||||
st.FreqHz = tx.freqHz
|
|
||||||
st.Mode = flexModeToADIF(tx.mode)
|
|
||||||
if rx.freqHz != tx.freqHz {
|
|
||||||
st.Split = true
|
st.Split = true
|
||||||
st.RxFreqHz = rx.freqHz
|
st.RxFreqHz = rxS.freqHz
|
||||||
|
st.FreqHz = txSplit.freqHz
|
||||||
}
|
}
|
||||||
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
||||||
if sig != f.lastStateSig {
|
if sig != f.lastStateSig {
|
||||||
@@ -798,76 +845,163 @@ func (f *Flex) ReadState() (RigState, error) {
|
|||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the
|
// mainSliceLocked is the operator's slice: the ACTIVE (focused) in-use slice, or
|
||||||
// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice
|
// the lowest-indexed in-use slice when none is flagged active. EVERYTHING the
|
||||||
// (preferring the active/focused one), NOT simply the active slice: tuning the
|
// user does — freq/mode display, RX DSP, tuning, mode changes, spot clicks —
|
||||||
// TX slice makes it the active/focused slice, which would otherwise collapse RX
|
// follows this slice, so a second independent slice (e.g. monitoring another
|
||||||
// onto TX and hide the split. Caller holds f.mu.
|
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
|
||||||
func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) {
|
// use. Caller holds f.mu.
|
||||||
idxs := make([]int, 0, len(f.slices))
|
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||||
for i, s := range f.slices {
|
// Iterate in ASCENDING index order — NEVER map-iteration order, which Go
|
||||||
if s.inUse {
|
// randomises. When two slices transiently BOTH report active=1 (e.g. an
|
||||||
idxs = append(idxs, i)
|
// 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
|
||||||
sort.Ints(idxs)
|
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||||
var active, txS, nonTx, first *flexSlice
|
// Deterministic order = the lowest-indexed active slice wins, stably.
|
||||||
for _, i := range idxs {
|
firstInUse := -1
|
||||||
s := f.slices[i]
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
if first == nil {
|
s := f.slices[idx]
|
||||||
first = s
|
|
||||||
}
|
|
||||||
if s.active {
|
|
||||||
active = s
|
|
||||||
}
|
|
||||||
if s.tx {
|
|
||||||
txS = s
|
|
||||||
} else if nonTx == nil {
|
|
||||||
nonTx = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx = txS
|
|
||||||
if tx == nil {
|
|
||||||
if active != nil {
|
|
||||||
tx = active
|
|
||||||
} else {
|
|
||||||
tx = first
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// RX = the receive slice: the active one if it isn't the TX slice, else the
|
|
||||||
// first non-TX slice; fall back to TX (simplex) when there's only one slice.
|
|
||||||
switch {
|
|
||||||
case active != nil && active != tx:
|
|
||||||
rx = active
|
|
||||||
case nonTx != nil:
|
|
||||||
rx = nonTx
|
|
||||||
default:
|
|
||||||
rx = tx
|
|
||||||
}
|
|
||||||
return rx, tx
|
|
||||||
}
|
|
||||||
|
|
||||||
// activeSliceIndexLocked returns the slice index to send commands to (the active
|
|
||||||
// slice, else the lowest in-use index, else 0). Caller holds f.mu.
|
|
||||||
func (f *Flex) activeSliceIndexLocked() int {
|
|
||||||
best, found := 1<<30, false
|
|
||||||
for idx, s := range f.slices {
|
|
||||||
if !s.inUse {
|
if !s.inUse {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s.active {
|
if firstInUse < 0 {
|
||||||
return idx
|
firstInUse = idx
|
||||||
}
|
}
|
||||||
if idx < best {
|
if s.active {
|
||||||
best, found = idx, true
|
return idx, s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found {
|
if firstInUse >= 0 {
|
||||||
return best
|
return firstInUse, f.slices[firstInUse]
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// slice, else 0). Caller holds f.mu.
|
||||||
|
func (f *Flex) activeSliceIndexLocked() int {
|
||||||
|
if idx, _ := f.mainSliceLocked(); idx >= 0 {
|
||||||
|
return idx
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sliceLetter maps a slice index to its SmartSDR letter (0→A, 1→B, …).
|
||||||
|
func sliceLetter(idx int) string {
|
||||||
|
if idx < 0 || idx > 25 {
|
||||||
|
return fmt.Sprintf("%d", idx)
|
||||||
|
}
|
||||||
|
return string(rune('A' + idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
|
||||||
|
// Caller holds f.mu.
|
||||||
|
func (f *Flex) txSliceLocked() *flexSlice {
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
if s := f.slices[idx]; s.inUse && s.tx {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
|
||||||
|
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
|
||||||
|
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
|
||||||
|
// freq) AND the SAME split class (both PHONE, or both CW) — detected from the
|
||||||
|
// pair itself, NOT from which slice is active (the TX slice often steals focus
|
||||||
|
// right after "slice create", which must NOT read as "no split"). A slice on
|
||||||
|
// another band is an independent receiver, ignored; so is a same-band slice in a
|
||||||
|
// different mode (SSB + FT8) or a data mode (FT8/FT4/RTTY never split).
|
||||||
|
// Caller holds f.mu.
|
||||||
|
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
||||||
|
_, main = f.mainSliceLocked()
|
||||||
|
txS := f.txSliceLocked()
|
||||||
|
if txS == nil {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
bt := BandFromHz(txS.freqHz)
|
||||||
|
if bt == "" {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
// Split only applies to PHONE/CW; a data-mode TX slice never splits.
|
||||||
|
ct := flexSplitClass(txS.mode)
|
||||||
|
if ct == "" {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
// A split partner is an in-use, same-band slice at a different freq in the
|
||||||
|
// SAME split class (so SSB + FT8 on one band is NOT a split).
|
||||||
|
sameSplit := func(s *flexSlice) bool {
|
||||||
|
return s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
|
||||||
|
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct
|
||||||
|
}
|
||||||
|
// RX = the active slice when it qualifies, else the first other qualifying
|
||||||
|
// same-band slice.
|
||||||
|
if sameSplit(main) {
|
||||||
|
rx = main
|
||||||
|
} else {
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
if s := f.slices[idx]; sameSplit(s) {
|
||||||
|
rx = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rx == nil {
|
||||||
|
return main, nil, nil // tx slice alone (simplex) or no same-mode partner → not split
|
||||||
|
}
|
||||||
|
return main, rx, txS
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveSlice focuses slice idx on the radio so every subsequent command
|
||||||
|
// (freq / mode / DSP / spot click) targets it. Lets the operator pick the
|
||||||
|
// operating slice from OpsLog (like SliceLogger's A/B selector).
|
||||||
|
func (f *Flex) SetActiveSlice(idx int) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
_, exists := f.slices[idx]
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("flex: no slice %d", idx)
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("slice s %d active=1", idx))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTXSlice makes slice idx the transmitter (tx=1) — e.g. "put TX on the active
|
||||||
|
// slice" so you transmit where you're listening. Only one slice can be TX; the
|
||||||
|
// radio clears the flag on the others.
|
||||||
|
func (f *Flex) SetTXSlice(idx int) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
_, exists := f.slices[idx]
|
||||||
|
connected := f.conn != nil
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("flex: no slice %d", idx)
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("slice s %d tx=1", idx))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetFrequency(hz int64) error {
|
func (f *Flex) SetFrequency(hz int64) error {
|
||||||
if hz <= 0 {
|
if hz <= 0 {
|
||||||
return fmt.Errorf("flex: invalid frequency")
|
return fmt.Errorf("flex: invalid frequency")
|
||||||
@@ -875,6 +1009,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")
|
||||||
@@ -900,6 +1043,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
|
||||||
@@ -923,8 +1073,23 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if color == "" {
|
if color == "" {
|
||||||
color = "#FFFFA500" // opaque orange default
|
color = "#FFFFA500" // opaque orange default
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d",
|
life := s.LifetimeSec
|
||||||
float64(s.FreqHz)/1e6, call, color, time.Now().Unix())
|
if life <= 0 {
|
||||||
|
life = 1800 // default 30 min (cluster spots)
|
||||||
|
}
|
||||||
|
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
|
||||||
|
// spotted gets its previous spot removed first — one live spot per call,
|
||||||
|
// refreshed, instead of a pile that all expire independently.
|
||||||
|
f.mu.Lock()
|
||||||
|
if old, ok := f.spotByCall[strings.ToUpper(s.Callsign)]; ok {
|
||||||
|
delete(f.spotByCall, strings.ToUpper(s.Callsign))
|
||||||
|
delete(f.spotCall, old)
|
||||||
|
delete(f.spotIdx, old)
|
||||||
|
f.send(fmt.Sprintf("spot remove %d", old))
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||||
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
if m := flexEncode(s.Mode); m != "" {
|
if m := flexEncode(s.Mode); m != "" {
|
||||||
cmd += " mode=" + m
|
cmd += " mode=" + m
|
||||||
}
|
}
|
||||||
@@ -970,6 +1135,7 @@ func (f *Flex) ClearSpots() error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.spotIdx = map[int]bool{}
|
f.spotIdx = map[int]bool{}
|
||||||
f.spotCall = map[int]string{}
|
f.spotCall = map[int]string{}
|
||||||
|
f.spotByCall = map[string]int{}
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
@@ -1048,19 +1214,10 @@ func clampLevel(v int) int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// rxSliceLocked returns the active RX slice and its index (-1 when none), using
|
// rxSliceLocked returns the operator's (main/active) slice and its index — the
|
||||||
// the same RX-selection rule as ReadState. Caller holds f.mu.
|
// slice every RX-DSP control and read targets. Caller holds f.mu.
|
||||||
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
|
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
|
||||||
rx, _ := f.pickSlicesLocked()
|
return f.mainSliceLocked()
|
||||||
if rx == nil {
|
|
||||||
return -1, nil
|
|
||||||
}
|
|
||||||
for i, s := range f.slices {
|
|
||||||
if s == rx {
|
|
||||||
return i, rx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1, rx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlexState returns a snapshot of the radio's transmit/ATU state plus the active
|
// FlexState returns a snapshot of the radio's transmit/ATU state plus the active
|
||||||
@@ -1084,6 +1241,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).
|
||||||
@@ -1093,10 +1254,31 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
CWSidetone: f.tx.cwSidetone,
|
CWSidetone: f.tx.cwSidetone,
|
||||||
CWMonLevel: f.tx.cwMonLevel,
|
CWMonLevel: f.tx.cwMonLevel,
|
||||||
}
|
}
|
||||||
if rx, tx := f.pickSlicesLocked(); rx != nil && tx != nil && rx != tx && rx.freqHz != tx.freqHz {
|
if _, rxS, txSplit := f.operatingLocked(); rxS != nil && txSplit != nil {
|
||||||
st.Split = true
|
st.Split = true
|
||||||
st.RXFreqHz = rx.freqHz
|
st.RXFreqHz = rxS.freqHz
|
||||||
st.TXFreqHz = tx.freqHz
|
st.TXFreqHz = txSplit.freqHz
|
||||||
|
}
|
||||||
|
// Every in-use slice (A/B/C/D…) so the panel shows them all and highlights the
|
||||||
|
// active/TX one — the active slice drives everything the operator does.
|
||||||
|
sidx := make([]int, 0, len(f.slices))
|
||||||
|
for i, s := range f.slices {
|
||||||
|
if s.inUse {
|
||||||
|
sidx = append(sidx, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Ints(sidx)
|
||||||
|
for _, i := range sidx {
|
||||||
|
s := f.slices[i]
|
||||||
|
st.Slices = append(st.Slices, FlexSliceInfo{
|
||||||
|
Index: i,
|
||||||
|
Letter: sliceLetter(i),
|
||||||
|
FreqHz: s.freqHz,
|
||||||
|
Mode: flexModeToADIF(s.mode),
|
||||||
|
Band: BandFromHz(s.freqHz),
|
||||||
|
Active: s.active,
|
||||||
|
TX: s.tx,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if _, rx := f.rxSliceLocked(); rx != nil {
|
if _, rx := f.rxSliceLocked(); rx != nil {
|
||||||
st.RXAvail = true
|
st.RXAvail = true
|
||||||
@@ -1113,6 +1295,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
|
||||||
@@ -1229,35 +1413,40 @@ func (f *Flex) SetSplit(on bool) error {
|
|||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
}
|
}
|
||||||
rx, tx := f.pickSlicesLocked()
|
// Split is built AROUND THE ACTIVE slice, and "already split" uses the SAME
|
||||||
|
// same-band-pair rule as the button state (operatingLocked) — otherwise two
|
||||||
|
// INDEPENDENT slices on different bands look "already split" and SPLIT ON does
|
||||||
|
// nothing (the bug the user hit: A on 20m + B on 80m).
|
||||||
|
main, rxS, txS := f.operatingLocked()
|
||||||
rxIdx, txIdx := -1, -1
|
rxIdx, txIdx := -1, -1
|
||||||
for i, s := range f.slices {
|
for i, s := range f.slices {
|
||||||
if s == rx {
|
if s == rxS {
|
||||||
rxIdx = i
|
rxIdx = i
|
||||||
}
|
}
|
||||||
if s == tx {
|
if s == txS {
|
||||||
txIdx = i
|
txIdx = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alreadySplit := rx != nil && tx != nil && rx != tx
|
alreadySplit := rxS != nil && txS != nil
|
||||||
var rxFreq int64
|
var baseFreq int64
|
||||||
var rxMode, rxAnt string
|
var baseMode, baseAnt string
|
||||||
if rx != nil {
|
if main != nil {
|
||||||
rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt
|
baseFreq, baseMode, baseAnt = main.freqHz, main.mode, main.rxAnt
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
if on {
|
if on {
|
||||||
if alreadySplit || rx == nil {
|
if alreadySplit || main == nil {
|
||||||
return nil // already split (or no slice)
|
return nil // already split, or no active slice
|
||||||
}
|
}
|
||||||
offset := int64(5000)
|
offset := int64(5000)
|
||||||
if strings.HasPrefix(strings.ToUpper(rxMode), "CW") {
|
if strings.HasPrefix(strings.ToUpper(baseMode), "CW") {
|
||||||
offset = 1000
|
offset = 1000
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode)
|
// Create the TX slice at the ACTIVE slice's freq + offset (same band).
|
||||||
if rxAnt != "" {
|
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(baseFreq+offset)/1e6, baseMode)
|
||||||
cmd += " ant=" + rxAnt
|
if baseAnt != "" {
|
||||||
|
cmd += " ant=" + baseAnt
|
||||||
}
|
}
|
||||||
if seq := f.send(cmd); seq > 0 {
|
if seq := f.send(cmd); seq > 0 {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -1270,10 +1459,10 @@ func (f *Flex) SetSplit(on bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if txIdx >= 0 {
|
if txIdx >= 0 {
|
||||||
f.send(fmt.Sprintf("slice remove %d", txIdx))
|
f.send(fmt.Sprintf("slice remove %d", txIdx)) // drop the extra TX slice
|
||||||
}
|
}
|
||||||
if rxIdx >= 0 {
|
if rxIdx >= 0 {
|
||||||
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx))
|
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx)) // RX slice transmits again
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1285,6 +1474,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) ──
|
||||||
|
|
||||||
@@ -1502,6 +1693,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")
|
||||||
@@ -1721,6 +1951,23 @@ func parseFloatDefault(s string, def float64) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
||||||
|
// flexSplitClass groups a raw Flex slice mode into a split-capable class. A
|
||||||
|
// genuine split (one RX freq, one TX freq, SAME mode) only makes sense for PHONE
|
||||||
|
// and CW pileups. Data modes (FT8/FT4/RTTY all report as DIGU/DIGL/RTTY on a
|
||||||
|
// Flex) and digital voice never operate split, so they return "" (not
|
||||||
|
// split-capable). Two same-band slices whose classes differ (e.g. SSB + FT8) are
|
||||||
|
// independent operations, not a split.
|
||||||
|
func flexSplitClass(rawMode string) string {
|
||||||
|
switch flexModeToADIF(rawMode) {
|
||||||
|
case "SSB", "AM", "FM":
|
||||||
|
return "PHONE"
|
||||||
|
case "CW":
|
||||||
|
return "CW"
|
||||||
|
default: // DATA, RTTY, DIGITALVOICE, "" → never split
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func flexModeToADIF(m string) string {
|
func flexModeToADIF(m string) string {
|
||||||
switch strings.ToUpper(strings.TrimSpace(m)) {
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||||
case "USB", "LSB":
|
case "USB", "LSB":
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A genuine split is two same-band slices in the SAME split class (both PHONE or
|
||||||
|
// both CW). Two same-band slices in different modes (SSB + FT8), or in a data
|
||||||
|
// mode (FT8/FT4/RTTY = DIGU/DIGL), must NOT read as split.
|
||||||
|
func TestOperatingSplitMode(t *testing.T) {
|
||||||
|
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||||
|
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
slices map[int]*flexSlice
|
||||||
|
wantSplit bool
|
||||||
|
}{
|
||||||
|
{"phone split (both USB)", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(14_200_000, "USB", false, true),
|
||||||
|
}, true},
|
||||||
|
{"CW split (both CW)", map[int]*flexSlice{
|
||||||
|
0: mk(7_010_000, "CW", true, false),
|
||||||
|
1: mk(7_025_000, "CW", false, true),
|
||||||
|
}, true},
|
||||||
|
{"SSB + FT8 same band → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(14_074_000, "DIGU", false, true),
|
||||||
|
}, false},
|
||||||
|
{"both FT8 (DIGU) same band → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_074_000, "DIGU", true, false),
|
||||||
|
1: mk(14_080_000, "DIGU", false, true),
|
||||||
|
}, false},
|
||||||
|
{"different bands → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(7_100_000, "LSB", false, true),
|
||||||
|
}, false},
|
||||||
|
{"simplex (single slice) → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, true),
|
||||||
|
}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
f := &Flex{slices: c.slices}
|
||||||
|
_, rx, tx := f.operatingLocked()
|
||||||
|
gotSplit := rx != nil && tx != nil
|
||||||
|
if gotSplit != c.wantSplit {
|
||||||
|
t.Errorf("%s: split=%v, want %v", c.name, gotSplit, c.wantSplit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,23 @@ func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
|
|||||||
return Exception{}, false
|
return Exception{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasException reports whether the callsign has ANY full-call exception (at any
|
||||||
|
// date). Used to tell a genuinely date-sensitive call (G1T: Scotland only from
|
||||||
|
// 2024) — where cty.dat's date-blind exact-call override is wrong for older
|
||||||
|
// QSOs — from an ordinary call cty.dat handles fine.
|
||||||
|
func (db *DB) HasException(call string) bool {
|
||||||
|
if db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
for _, key := range candidates(c) {
|
||||||
|
if len(db.exceptions[key]) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// candidates yields the call and a version with one trailing affix removed.
|
// candidates yields the call and a version with one trailing affix removed.
|
||||||
func candidates(c string) []string {
|
func candidates(c string) []string {
|
||||||
out := []string{c}
|
out := []string{c}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package clublog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func d(s string) time.Time {
|
||||||
|
t, _ := time.Parse("2006-01-02", s)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// G1T is a contest call: Scotland ONLY from 2024-02-21; before that it's England
|
||||||
|
// by prefix. cty.dat's date-blind "=G1T → Scotland" override is wrong for a 2012
|
||||||
|
// QSO — the date-aware ClubLog cascade must give England then, Scotland now.
|
||||||
|
func TestG1TDatedException(t *testing.T) {
|
||||||
|
db := &DB{
|
||||||
|
exceptions: map[string][]Exception{
|
||||||
|
"G1T": {{Call: "G1T", Entity: "Scotland", ADIF: 279, Start: d("2024-02-21")}},
|
||||||
|
},
|
||||||
|
prefixes: map[string][]Exception{
|
||||||
|
"G": {{Entity: "England", ADIF: 223}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2012 QSO: exception doesn't cover it, but the call HAS an exception →
|
||||||
|
// caller should use the prefix (England).
|
||||||
|
if _, ok := db.Resolve("G1T", d("2012-07-29")); ok {
|
||||||
|
t.Fatalf("2012: exception should NOT cover a pre-2024 date")
|
||||||
|
}
|
||||||
|
if !db.HasException("G1T") {
|
||||||
|
t.Fatalf("HasException(G1T) should be true")
|
||||||
|
}
|
||||||
|
pe, pok := db.ResolvePrefix("G1T", d("2012-07-29"))
|
||||||
|
if !pok || pe.Entity != "England" {
|
||||||
|
t.Fatalf("2012 prefix: got %q ok=%v, want England", pe.Entity, pok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2025 QSO: the exception covers it → Scotland.
|
||||||
|
e, ok := db.Resolve("G1T", d("2025-01-01"))
|
||||||
|
if !ok || e.Entity != "Scotland" {
|
||||||
|
t.Fatalf("2025: got %q ok=%v, want Scotland", e.Entity, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordinary call with no exception → HasException false (caller leaves it
|
||||||
|
// to cty.dat).
|
||||||
|
if db.HasException("G4ABC") {
|
||||||
|
t.Fatalf("HasException(G4ABC) should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,17 @@ func (m *Manager) Info() (date string, count int) {
|
|||||||
return m.db.Date(), m.db.Count()
|
return m.db.Date(), m.db.Count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NeedsRefresh reports whether the cached country file is missing or older than
|
||||||
|
// maxAge — so the caller re-downloads it to pick up newly-added date-ranged
|
||||||
|
// exceptions (e.g. an event call's fresh SARDINIA period).
|
||||||
|
func (m *Manager) NeedsRefresh(maxAge time.Duration) bool {
|
||||||
|
fi, err := os.Stat(m.Path())
|
||||||
|
if err != nil {
|
||||||
|
return true // missing
|
||||||
|
}
|
||||||
|
return time.Since(fi.ModTime()) > maxAge
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
|
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
|
||||||
func (m *Manager) EnsureLoaded() error {
|
func (m *Manager) EnsureLoaded() error {
|
||||||
if m.Loaded() {
|
if m.Loaded() {
|
||||||
@@ -125,3 +136,19 @@ func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) {
|
|||||||
}
|
}
|
||||||
return db.Resolve(call, date)
|
return db.Resolve(call, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolvePrefix resolves a call by ClubLog's date-ranged PREFIX table.
|
||||||
|
func (m *Manager) ResolvePrefix(call string, date time.Time) (Exception, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
db := m.db
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return db.ResolvePrefix(call, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasException reports whether the call has any full-call exception (any date).
|
||||||
|
func (m *Manager) HasException(call string) bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
db := m.db
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return db.HasException(call)
|
||||||
|
}
|
||||||
|
|||||||
+13
-5
@@ -339,17 +339,25 @@ func normalizeCallsign(s string) string {
|
|||||||
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
|
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
|
||||||
// Ecuador). This is the same class of rule as KG4 and /MM.
|
// Ecuador). This is the same class of rule as KG4 and /MM.
|
||||||
if areaDigit != 0 {
|
if areaDigit != 0 {
|
||||||
main = replaceFirstDigit(main, areaDigit)
|
main = replaceAreaDigit(main, areaDigit)
|
||||||
}
|
}
|
||||||
return main
|
return main
|
||||||
}
|
}
|
||||||
|
|
||||||
// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to
|
// replaceAreaDigit substitutes the CALL-AREA digit of a call with d (used to
|
||||||
// apply a "/N" call-area change). Returns the call unchanged if it has no digit.
|
// apply a "/N" call-area change). The area digit is the first digit that comes
|
||||||
func replaceFirstDigit(call string, d byte) string {
|
// AFTER a prefix letter — NOT a leading digit that is part of a digit-first
|
||||||
|
// prefix (7X, 3A, 4X, 9A, 2E…). So 7X2ARA/4 → 7X4ARA (still Algeria, area 4),
|
||||||
|
// NOT 4X2ARA (which is Israel — the old first-digit bug). Returns the call
|
||||||
|
// unchanged if it has no such digit.
|
||||||
|
func replaceAreaDigit(call string, d byte) string {
|
||||||
b := []byte(call)
|
b := []byte(call)
|
||||||
|
seenLetter := false
|
||||||
for i := range b {
|
for i := range b {
|
||||||
if b[i] >= '0' && b[i] <= '9' {
|
switch {
|
||||||
|
case b[i] >= 'A' && b[i] <= 'Z':
|
||||||
|
seenLetter = true
|
||||||
|
case b[i] >= '0' && b[i] <= '9' && seenLetter:
|
||||||
b[i] = d
|
b[i] = d
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ func TestNormalize(t *testing.T) {
|
|||||||
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||||
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||||
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||||
|
"7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel)
|
||||||
|
"3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3
|
||||||
|
"4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4
|
||||||
"DL/F4BPO": "DL",
|
"DL/F4BPO": "DL",
|
||||||
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
||||||
"MM/LY3X/P": "MM",
|
"MM/LY3X/P": "MM",
|
||||||
|
|||||||
@@ -53,6 +53,155 @@ 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 link out of the DownloadInBox reply.
|
||||||
|
// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes,
|
||||||
|
// e.g. `<A HREF=/downloadedFiles/xxxx.adi>` or `<a href="…/xxxx.adi">`, and the
|
||||||
|
// path may be root-relative or a bare filename. Match case-insensitively with
|
||||||
|
// optional quoting; resolve to an absolute URL afterwards.
|
||||||
|
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.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: …", "You have no … confirmations").
|
||||||
|
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so
|
||||||
|
// the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\
|
||||||
|
// xxx.adi`); normalise those. It may also be absolute or a bare filename.
|
||||||
|
link := strings.TrimSpace(m[1])
|
||||||
|
link = strings.ReplaceAll(link, `\`, "/")
|
||||||
|
link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…"
|
||||||
|
var fileURL string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(strings.ToLower(link), "http"):
|
||||||
|
fileURL = link
|
||||||
|
case strings.HasPrefix(link, "/"):
|
||||||
|
fileURL = eqslBaseURL + link
|
||||||
|
default:
|
||||||
|
fileURL = eqslBaseURL + "/" + link
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: fetch the generated .adi file.
|
||||||
|
adif, err := eqslGet(ctx, client, fileURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
|
||||||
|
}
|
||||||
|
return adif, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's
|
||||||
|
// own error/notice text if present, else a short tag-stripped snippet of the page
|
||||||
|
// so the real wording (e.g. "You have no Inbox items") is visible in the log.
|
||||||
|
func eqslNoLinkReason(page string) string {
|
||||||
|
low := strings.ToLower(page)
|
||||||
|
for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} {
|
||||||
|
if i := strings.Index(low, marker); i >= 0 {
|
||||||
|
seg := page[i:]
|
||||||
|
if len(seg) > 160 {
|
||||||
|
seg = seg[:160]
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(stripTags(seg)), " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txt := strings.Join(strings.Fields(stripTags(page)), " ")
|
||||||
|
if len(txt) > 200 {
|
||||||
|
txt = txt[:200]
|
||||||
|
}
|
||||||
|
if txt == "" {
|
||||||
|
txt = "empty response"
|
||||||
|
}
|
||||||
|
return txt
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripTags removes HTML tags for a readable one-line diagnostic.
|
||||||
|
func stripTags(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '<':
|
||||||
|
depth++
|
||||||
|
case '>':
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if depth == 0 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
|||||||
@@ -43,9 +43,15 @@ type Event struct {
|
|||||||
|
|
||||||
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
||||||
DXGrid string // ServiceWSJT (Status)
|
DXGrid string // ServiceWSJT (Status)
|
||||||
Mode string // ServiceWSJT (Status)
|
Mode string // ServiceWSJT (Status/Decode)
|
||||||
FreqHz int64 // ServiceWSJT (Status)
|
FreqHz int64 // ServiceWSJT (Status)
|
||||||
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
||||||
|
|
||||||
|
// A WSJT-X Decode (heard station) to render on the panadapter.
|
||||||
|
DecodeCall string // transmitting (DE) callsign
|
||||||
|
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||||
|
DecodeSNR int // reported SNR (dB)
|
||||||
|
DecodeCQ bool // the decode was a CQ
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is a single inbound UDP listener.
|
// Server is a single inbound UDP listener.
|
||||||
@@ -57,6 +63,8 @@ type Server struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
|
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer(cfg Config, out chan<- Event) *Server {
|
func newServer(cfg Config, out chan<- Event) *Server {
|
||||||
@@ -175,9 +183,29 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Status carries the current dial frequency; remember it so Decode audio
|
||||||
|
// offsets can be turned into RF frequencies for the panadapter.
|
||||||
|
if w.FreqHz > 0 && !w.IsDecode {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastDialHz = w.FreqHz
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
if w.IsDecode {
|
||||||
|
s.mu.Lock()
|
||||||
|
dial := s.lastDialHz
|
||||||
|
s.mu.Unlock()
|
||||||
|
if dial <= 0 {
|
||||||
|
return // no dial freq yet (Status not seen) → can't place the spot
|
||||||
|
}
|
||||||
|
ev.DecodeCall = w.DecodeCall
|
||||||
|
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||||
|
ev.DecodeSNR = w.SNR
|
||||||
|
ev.DecodeCQ = w.IsCQ
|
||||||
|
ev.Mode = w.Mode
|
||||||
|
break
|
||||||
|
}
|
||||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||||
ev.DXCall = w.DXCall
|
ev.DXCall = w.DXCall
|
||||||
@@ -241,7 +269,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Empty events are useless; skip.
|
// Empty events are useless; skip.
|
||||||
if ev.DXCall == "" && ev.LoggedADIF == "" {
|
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -37,18 +37,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// WSJTEvent is the parsed, typed result of decoding a single packet.
|
// WSJTEvent is the parsed, typed result of decoding a single packet.
|
||||||
// One of (DXCall, LoggedADIF) is non-empty depending on the message.
|
// One of (DXCall, LoggedADIF, DecodeCall) is non-empty depending on the message.
|
||||||
type WSJTEvent struct {
|
type WSJTEvent struct {
|
||||||
DXCall string // current "DX Call" field in the WSJT app
|
DXCall string // current "DX Call" field in the WSJT app (Status)
|
||||||
DXGrid string // optional grid for that call
|
DXGrid string // optional grid for that call (Status)
|
||||||
Mode string // FT8 / FT4 / …
|
Mode string // FT8 / FT4 / …
|
||||||
FreqHz int64 // current dial freq when available
|
FreqHz int64 // current dial freq when available (Status)
|
||||||
LoggedADIF string // full ADIF text when message is LoggedADIF
|
LoggedADIF string // full ADIF text when message is LoggedADIF
|
||||||
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
|
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
|
||||||
|
|
||||||
|
// Decode (type 2): the transmitting station heard on the band. FreqHz is NOT
|
||||||
|
// set here (Decode carries only the audio offset); the caller adds the last
|
||||||
|
// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
|
||||||
|
IsDecode bool
|
||||||
|
DecodeCall string // the sender (DE) callsign extracted from the message text
|
||||||
|
DeltaFreqHz int64 // audio offset within the passband (Hz)
|
||||||
|
SNR int // reported signal-to-noise (dB)
|
||||||
|
IsCQ bool // the decode was a CQ call
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
|
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
|
||||||
// don't care about (heartbeat, decode lines, clears, etc.).
|
// don't care about (heartbeat, clears, etc.).
|
||||||
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||||
if len(pkt) < 12 {
|
if len(pkt) < 12 {
|
||||||
return WSJTEvent{}, false, fmt.Errorf("packet too short")
|
return WSJTEvent{}, false, fmt.Errorf("packet too short")
|
||||||
@@ -143,6 +152,56 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
|||||||
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
|
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
|
||||||
return ev, true, nil
|
return ev, true, nil
|
||||||
|
|
||||||
|
case wsjtMsgDecode:
|
||||||
|
// Decode payload (v2):
|
||||||
|
// bool is_new
|
||||||
|
// quint32 time (ms since midnight)
|
||||||
|
// qint32 snr
|
||||||
|
// double delta_time (seconds)
|
||||||
|
// quint32 delta_frequency (Hz, audio offset in the passband)
|
||||||
|
// QUtf8 mode
|
||||||
|
// QUtf8 message (the decoded text, e.g. "CQ K1ABC FN42")
|
||||||
|
// bool low_confidence
|
||||||
|
// bool off_air
|
||||||
|
var b uint8
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
var t32, df uint32
|
||||||
|
var snr int32
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &snr); err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
var dt float64
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
mode, err := readQString(r)
|
||||||
|
if err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
msg, err := readQString(r)
|
||||||
|
if err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
call, isCQ := wsjtSender(msg)
|
||||||
|
if call == "" {
|
||||||
|
return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
|
||||||
|
}
|
||||||
|
ev.IsDecode = true
|
||||||
|
ev.DecodeCall = call
|
||||||
|
ev.IsCQ = isCQ
|
||||||
|
ev.DeltaFreqHz = int64(df)
|
||||||
|
ev.SNR = int(snr)
|
||||||
|
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
return ev, true, nil
|
||||||
|
|
||||||
case wsjtMsgLoggedADIF:
|
case wsjtMsgLoggedADIF:
|
||||||
// Payload: a single QString containing the ADIF record.
|
// Payload: a single QString containing the ADIF record.
|
||||||
adif, err := readQString(r)
|
adif, err := readQString(r)
|
||||||
@@ -155,6 +214,64 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
|||||||
return WSJTEvent{}, false, nil
|
return WSJTEvent{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and
|
||||||
|
// whether it was a CQ. Grammar:
|
||||||
|
//
|
||||||
|
// CQ [modifier] <de_call> [grid] → de_call, isCQ=true
|
||||||
|
// <to_call> <de_call> [report|…] → de_call, isCQ=false
|
||||||
|
//
|
||||||
|
// Returns "" for free-text / telemetry / hashed-call messages we can't resolve.
|
||||||
|
func wsjtSender(message string) (call string, isCQ bool) {
|
||||||
|
f := strings.Fields(strings.ToUpper(strings.TrimSpace(message)))
|
||||||
|
if len(f) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if f[0] == "CQ" {
|
||||||
|
// Skip an optional modifier after CQ (DX / a region like NA / a zone like
|
||||||
|
// 020) — it never looks like a callsign (no letter+digit mix).
|
||||||
|
idx := 1
|
||||||
|
if len(f) > 2 && !looksLikeCall(f[1]) {
|
||||||
|
idx = 2
|
||||||
|
}
|
||||||
|
if idx < len(f) && looksLikeCall(f[idx]) {
|
||||||
|
return f[idx], true
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
// Standard exchange: the DE (sender) call is the second token.
|
||||||
|
if len(f) >= 2 && looksLikeCall(f[1]) {
|
||||||
|
return f[1], false
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeCall is a loose callsign test: 3–12 chars of A–Z/0–9//, with at least
|
||||||
|
// one letter AND one digit. Rejects the fixed exchange tokens (RR73/RRR/73) that
|
||||||
|
// would otherwise pass.
|
||||||
|
func looksLikeCall(s string) bool {
|
||||||
|
switch s {
|
||||||
|
case "RR73", "RRR", "73":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(s) < 3 || len(s) > 12 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasL, hasD := false, false
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case c >= 'A' && c <= 'Z':
|
||||||
|
hasL = true
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
hasD = true
|
||||||
|
case c == '/':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasL && hasD
|
||||||
|
}
|
||||||
|
|
||||||
// readQString reads a Qt QString as written by QDataStream: an int32 byte
|
// readQString reads a Qt QString as written by QDataStream: an int32 byte
|
||||||
// length (or -1 for null) followed by the UTF-8 bytes.
|
// length (or -1 for null) followed by the UTF-8 bytes.
|
||||||
func readQString(r *bytes.Reader) (string, error) {
|
func readQString(r *bytes.Reader) (string, error) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestWSJTSender(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
msg string
|
||||||
|
wantCall string
|
||||||
|
wantCQ bool
|
||||||
|
}{
|
||||||
|
{"CQ K1ABC FN42", "K1ABC", true},
|
||||||
|
{"CQ DX W2XYZ EM12", "W2XYZ", true}, // modifier "DX" skipped
|
||||||
|
{"CQ NA VE3ABC FN03", "VE3ABC", true}, // region modifier skipped
|
||||||
|
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true},// zone modifier skipped
|
||||||
|
{"W2XYZ K1ABC -10", "K1ABC", false}, // exchange → sender is 2nd call
|
||||||
|
{"W2XYZ K1ABC R-10", "K1ABC", false},
|
||||||
|
{"W2XYZ K1ABC RR73", "K1ABC", false},
|
||||||
|
{"F4BPO K1ABC/P 73", "K1ABC/P", false},// portable call kept
|
||||||
|
{"CQ F/DL1ABC JO31", "F/DL1ABC", true},// compound prefix
|
||||||
|
// Non-callsign / free text → no sender.
|
||||||
|
{"TNX 73 GL", "", false},
|
||||||
|
{"K1ABC RR73", "", false}, // only one call + a token → 2nd token not a call
|
||||||
|
{"", "", false},
|
||||||
|
{"CQ CQ CQ", "", true}, // CQ but no resolvable call
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
call, cq := wsjtSender(c.msg)
|
||||||
|
if call != c.wantCall || cq != c.wantCQ {
|
||||||
|
t.Errorf("wsjtSender(%q) = (%q,%v), want (%q,%v)", c.msg, call, cq, c.wantCall, c.wantCQ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Package lotwusers tracks which callsigns are LoTW users and when they last
|
||||||
|
// uploaded, from ARRL's public "lotw-user-activity.csv" feed. OpsLog shows a
|
||||||
|
// colour-coded badge next to the entered callsign (recent upload = likely to
|
||||||
|
// confirm on LoTW). The list is cached on disk so it survives restarts.
|
||||||
|
package lotwusers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// feedURL is ARRL's public LoTW last-upload activity list (CSV:
|
||||||
|
// "CALLSIGN,YYYY-MM-DD,HH:MM:SS", one line per registered LoTW callsign).
|
||||||
|
const feedURL = "https://lotw.arrl.org/lotw-user-activity.csv"
|
||||||
|
|
||||||
|
const cacheFile = "lotw-user-activity.csv"
|
||||||
|
|
||||||
|
// Info is a lookup result for one callsign.
|
||||||
|
type Info struct {
|
||||||
|
IsUser bool `json:"is_user"`
|
||||||
|
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
||||||
|
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager holds the parsed list + cache location.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
||||||
|
updated time.Time // when the cache was last refreshed
|
||||||
|
dir string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager loads any on-disk cache and returns a ready manager.
|
||||||
|
func NewManager(dataDir string) *Manager {
|
||||||
|
m := &Manager{
|
||||||
|
users: map[string]time.Time{},
|
||||||
|
dir: dataDir,
|
||||||
|
client: &http.Client{Timeout: 90 * time.Second},
|
||||||
|
}
|
||||||
|
m.loadCache()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
|
||||||
|
|
||||||
|
func (m *Manager) loadCache() {
|
||||||
|
data, err := os.ReadFile(m.path())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.parse(data)
|
||||||
|
if fi, e := os.Stat(m.path()); e == nil {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.updated = fi.ModTime()
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download fetches the latest list, caches it to disk and replaces the in-memory
|
||||||
|
// map. Returns the number of callsigns loaded.
|
||||||
|
func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("lotwusers: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return 0, fmt.Errorf("lotwusers: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("lotwusers: read: %w", err)
|
||||||
|
}
|
||||||
|
n := m.parse(body)
|
||||||
|
if n == 0 {
|
||||||
|
return 0, fmt.Errorf("lotwusers: feed parsed to 0 callsigns")
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(m.path(), body, 0o644) // best-effort cache
|
||||||
|
m.mu.Lock()
|
||||||
|
m.updated = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse loads the CSV bytes into the map and returns the count.
|
||||||
|
func (m *Manager) parse(data []byte) int {
|
||||||
|
users := make(map[string]time.Time, 1<<20)
|
||||||
|
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||||
|
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, ",")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||||
|
if call == "" || strings.EqualFold(call, "callsign") {
|
||||||
|
continue // header row
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", strings.TrimSpace(parts[1]))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
users[call] = t
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.users = users
|
||||||
|
m.mu.Unlock()
|
||||||
|
return len(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
||||||
|
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
||||||
|
// the longest slash-separated segment — the base call LoTW is keyed on.
|
||||||
|
func (m *Manager) Lookup(callsign string) Info {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
|
if c == "" {
|
||||||
|
return Info{DaysAgo: -1}
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if len(m.users) == 0 {
|
||||||
|
return Info{DaysAgo: -1}
|
||||||
|
}
|
||||||
|
t, ok := m.users[c]
|
||||||
|
if !ok && strings.Contains(c, "/") {
|
||||||
|
base := ""
|
||||||
|
for _, seg := range strings.Split(c, "/") {
|
||||||
|
if len(seg) > len(base) {
|
||||||
|
base = seg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t, ok = m.users[base]
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return Info{DaysAgo: -1}
|
||||||
|
}
|
||||||
|
days := int(time.Since(t).Hours() / 24)
|
||||||
|
if days < 0 {
|
||||||
|
days = 0
|
||||||
|
}
|
||||||
|
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns how many callsigns are loaded.
|
||||||
|
func (m *Manager) Count() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return len(m.users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updated returns when the list was last refreshed (zero if never).
|
||||||
|
func (m *Manager) Updated() time.Time {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.updated
|
||||||
|
}
|
||||||
+20
-10
@@ -22,16 +22,26 @@ import (
|
|||||||
|
|
||||||
// Station is one roster entry: a station registered in a net.
|
// Station is one roster entry: a station registered in a net.
|
||||||
type Station struct {
|
type Station struct {
|
||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
QTH string `json:"qth,omitempty"`
|
QTH string `json:"qth,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
DXCC int `json:"dxcc,omitempty"`
|
DXCC int `json:"dxcc,omitempty"`
|
||||||
ITU int `json:"itu,omitempty"`
|
ITU int `json:"itu,omitempty"`
|
||||||
CQ int `json:"cq,omitempty"`
|
CQ int `json:"cq,omitempty"`
|
||||||
Groups string `json:"groups,omitempty"`
|
// Full lookup detail so a roster contact carries EVERYTHING into the QSO when
|
||||||
SIG string `json:"sig,omitempty"`
|
// put on air (was previously only name/qth — grid/address/etc. were lost).
|
||||||
SIGInfo string `json:"sig_info,omitempty"`
|
Grid string `json:"grid,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Cnty string `json:"cnty,omitempty"`
|
||||||
|
Cont string `json:"cont,omitempty"`
|
||||||
|
Lat float64 `json:"lat,omitempty"`
|
||||||
|
Lon float64 `json:"lon,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Groups string `json:"groups,omitempty"`
|
||||||
|
SIG string `json:"sig,omitempty"`
|
||||||
|
SIGInfo string `json:"sig_info,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Net is a named net with default report values and a station roster.
|
// Net is a named net with default report values and a station roster.
|
||||||
|
|||||||
@@ -46,6 +46,28 @@ func (r *Repo) List(ctx context.Context) ([]Record, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListFor returns the templates VISIBLE to a profile: its own plus any shared
|
||||||
|
// (NULL-profile) ones — so each profile sees and defaults independently instead
|
||||||
|
// of one global list. Defaults first, then newest first.
|
||||||
|
func (r *Repo) ListFor(ctx context.Context, profileID int64) ([]Record, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT `+repoCols+`
|
||||||
|
FROM qsl_templates WHERE profile_id = ? OR profile_id IS NULL
|
||||||
|
ORDER BY is_default DESC, id DESC`, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Record
|
||||||
|
for rows.Next() {
|
||||||
|
rec, err := scanRecord(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Get returns one template by ID, or sql.ErrNoRows if missing.
|
// Get returns one template by ID, or sql.ErrNoRows if missing.
|
||||||
func (r *Repo) Get(ctx context.Context, id int64) (Record, error) {
|
func (r *Repo) Get(ctx context.Context, id int64) (Record, error) {
|
||||||
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+`
|
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+`
|
||||||
@@ -124,10 +146,15 @@ func (r *Repo) SetDefault(ctx context.Context, id int64) error {
|
|||||||
// DefaultFor returns the best template for a profile: its own default, then
|
// DefaultFor returns the best template for a profile: its own default, then
|
||||||
// any profile-less default, then the newest template. sql.ErrNoRows if none.
|
// any profile-less default, then the newest template. sql.ErrNoRows if none.
|
||||||
func (r *Repo) DefaultFor(ctx context.Context, profileID int64) (Record, error) {
|
func (r *Repo) DefaultFor(ctx context.Context, profileID int64) (Record, error) {
|
||||||
|
// Only the profile's OWN templates or shared (NULL) ones — never another
|
||||||
|
// profile's, so a profile with no default doesn't inherit some other
|
||||||
|
// profile's newest template (the "default looked global" bug).
|
||||||
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+` FROM qsl_templates
|
row := r.db.QueryRowContext(ctx, `SELECT `+repoCols+` FROM qsl_templates
|
||||||
|
WHERE profile_id = ? OR profile_id IS NULL
|
||||||
ORDER BY (is_default = 1 AND profile_id = ?) DESC,
|
ORDER BY (is_default = 1 AND profile_id = ?) DESC,
|
||||||
(is_default = 1 AND profile_id IS NULL) DESC,
|
(is_default = 1 AND profile_id IS NULL) DESC,
|
||||||
id DESC LIMIT 1`, profileID)
|
(profile_id = ?) DESC,
|
||||||
|
id DESC LIMIT 1`, profileID, profileID, profileID)
|
||||||
return scanRecord(row)
|
return scanRecord(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ type QSOBox struct {
|
|||||||
H float64 `json:"h"`
|
H float64 `json:"h"`
|
||||||
BG string `json:"bg"`
|
BG string `json:"bg"`
|
||||||
BGOpacity float64 `json:"bg_opacity"`
|
BGOpacity float64 `json:"bg_opacity"`
|
||||||
|
FG string `json:"fg,omitempty"` // text colour (default dark)
|
||||||
Radius float64 `json:"radius"`
|
Radius float64 `json:"radius"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Fields []string `json:"fields"`
|
Fields []string `json:"fields"`
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tm(s string) time.Time {
|
||||||
|
t, _ := time.Parse("2006-01-02T15:04", s)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchIndexWindowAndMode(t *testing.T) {
|
||||||
|
idx := &MatchIndex{byMode: map[string][]matchRef{}}
|
||||||
|
// Local log: a CW QSO, an SSB (logged as USB) QSO, and an FT8 QSO.
|
||||||
|
idx.Add("4Z4DX", "15m", "CW", tm("2026-06-20T10:13"), 1)
|
||||||
|
idx.Add("V85NPV", "15m", "USB", tm("2025-04-18T17:01"), 2)
|
||||||
|
idx.Add("DK0SWL", "20m", "FT8", tm("2026-03-01T09:00"), 3)
|
||||||
|
|
||||||
|
win := 10 * time.Minute
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
call, band, mode string
|
||||||
|
when string
|
||||||
|
wantID int64
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"exact", "4Z4DX", "15m", "CW", "2026-06-20T10:13", 1, true},
|
||||||
|
{"1-min drift", "4Z4DX", "15m", "CW", "2026-06-20T10:12", 1, true},
|
||||||
|
{"9-min drift ok", "4Z4DX", "15m", "CW", "2026-06-20T10:04", 1, true},
|
||||||
|
{"30-min drift misses", "4Z4DX", "15m", "CW", "2026-06-20T10:43", 0, false},
|
||||||
|
// Phone sidebands are interchangeable: an SSB confirmation matches a USB log.
|
||||||
|
{"SSB conf vs USB log", "V85NPV", "15m", "SSB", "2025-04-18T17:00", 2, true},
|
||||||
|
{"LSB conf vs USB log", "V85NPV", "15m", "LSB", "2025-04-18T17:00", 2, true},
|
||||||
|
// But a digital/phone mismatch does NOT match (exact for non-phone).
|
||||||
|
{"FT8 conf vs SSB log misses", "V85NPV", "15m", "FT8", "2025-04-18T17:00", 0, false},
|
||||||
|
// Digital is exact: FT8 matches FT8, but FT4 would not.
|
||||||
|
{"FT8 exact", "DK0SWL", "20m", "FT8", "2026-03-01T09:01", 3, true},
|
||||||
|
{"FT4 vs FT8 misses", "DK0SWL", "20m", "FT4", "2026-03-01T09:01", 0, false},
|
||||||
|
{"wrong band misses", "DK0SWL", "40m", "FT8", "2026-03-01T09:01", 0, false},
|
||||||
|
{"unknown call misses", "N0CALL", "15m", "CW", "2026-06-20T10:13", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
id, ok := idx.Match(c.call, c.band, c.mode, tm(c.when), win)
|
||||||
|
if ok != c.wantOK || (ok && id != c.wantID) {
|
||||||
|
t.Errorf("%s: got (id=%d ok=%v), want (id=%d ok=%v)", c.name, id, ok, c.wantID, c.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -490,6 +490,7 @@ type UploadRow struct {
|
|||||||
// Manager may filter on (guards the dynamic column name in the query).
|
// Manager may filter on (guards the dynamic column name in the query).
|
||||||
var uploadStatusCols = map[string]bool{
|
var uploadStatusCols = map[string]bool{
|
||||||
"lotw_sent": true,
|
"lotw_sent": true,
|
||||||
|
"eqsl_sent": true,
|
||||||
"qrzcom_qso_upload_status": true,
|
"qrzcom_qso_upload_status": true,
|
||||||
"clublog_qso_upload_status": true,
|
"clublog_qso_upload_status": true,
|
||||||
"hrdlog_qso_upload_status": true,
|
"hrdlog_qso_upload_status": true,
|
||||||
@@ -723,6 +724,26 @@ var bulkEditableCols = map[string]bool{
|
|||||||
"my_antenna": true,
|
"my_antenna": true,
|
||||||
"my_sig": true,
|
"my_sig": true,
|
||||||
"my_sig_info": true,
|
"my_sig_info": true,
|
||||||
|
// Contest — the exchange/label fields that are constant across a run.
|
||||||
|
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||||
|
"contest_id": true,
|
||||||
|
"srx_string": true,
|
||||||
|
"stx_string": true,
|
||||||
|
"arrl_sect": true,
|
||||||
|
"precedence": true,
|
||||||
|
"class": true,
|
||||||
|
// Propagation / satellite — usually identical across a session.
|
||||||
|
"prop_mode": true,
|
||||||
|
"sat_name": true,
|
||||||
|
"sat_mode": true,
|
||||||
|
// Contacted station activation refs / SIG — meaningful to set across a
|
||||||
|
// selected batch (e.g. a park/summit worked repeatedly).
|
||||||
|
"pota_ref": true,
|
||||||
|
"sota_ref": true,
|
||||||
|
"wwff_ref": true,
|
||||||
|
"iota": true,
|
||||||
|
"sig": true,
|
||||||
|
"sig_info": true,
|
||||||
// Misc text
|
// Misc text
|
||||||
"comment": true,
|
"comment": true,
|
||||||
"notes": true,
|
"notes": true,
|
||||||
@@ -1793,6 +1814,117 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchRef is one local QSO's time + id, for time-window confirmation matching.
|
||||||
|
type matchRef struct {
|
||||||
|
when time.Time
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchIndex matches a downloaded confirmation to a local QSO within a TIME
|
||||||
|
// WINDOW (not an exact minute): QSL confirmations carry the OTHER station's
|
||||||
|
// logged time, which drifts a minute or two from ours. The MODE must match, with
|
||||||
|
// ONE equivalence: the phone modes SSB/USB/LSB are interchangeable (a station may
|
||||||
|
// confirm USB where we logged SSB). Every other mode is exact (FT8 only matches
|
||||||
|
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
|
||||||
|
type MatchIndex struct {
|
||||||
|
byMode map[string][]matchRef // call|band|canonMode → refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonMode folds the phone sidebands into a single "SSB" bucket; every other
|
||||||
|
// mode is returned uppercased and unchanged (exact-match).
|
||||||
|
func canonMode(mode string) string {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
switch m {
|
||||||
|
case "SSB", "USB", "LSB":
|
||||||
|
return "SSB"
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchKeyMode(call, band, mode string) string {
|
||||||
|
return strings.ToUpper(call) + "|" + strings.ToLower(band) + "|" + canonMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseQSODate reads the stored qso_date (ISO "2006-01-02T15:04[:05…]") to minute
|
||||||
|
// precision as UTC. Zero time on failure (never matched).
|
||||||
|
func parseQSODate(s string) time.Time {
|
||||||
|
if len(s) >= 16 {
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04", s[:16]); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildMatchIndex scans the log once and indexes every QSO by call+band(+mode)
|
||||||
|
// with its time, for windowed confirmation matching. When ownerCall is non-empty
|
||||||
|
// the index is SCOPED to QSOs whose station_callsign is that call (or blank —
|
||||||
|
// legacy QSOs that predate station-callsign stamping), so a confirmation download
|
||||||
|
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
|
||||||
|
// another (e.g. TM2Q).
|
||||||
|
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
|
||||||
|
idx := &MatchIndex{byMode: map[string][]matchRef{}}
|
||||||
|
query := `SELECT id, callsign, qso_date, band, mode FROM qso`
|
||||||
|
var args []any
|
||||||
|
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
|
||||||
|
query += ` WHERE UPPER(TRIM(COALESCE(station_callsign,''))) IN ('', ?)`
|
||||||
|
args = append(args, oc)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return idx, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var call, when, band, mode string
|
||||||
|
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
|
||||||
|
return idx, err
|
||||||
|
}
|
||||||
|
idx.add(call, band, mode, parseQSODate(when), id)
|
||||||
|
}
|
||||||
|
return idx, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// add inserts a QSO into the index (also used to register a freshly-inserted
|
||||||
|
// "add not-found" QSO so later confirmations in the same batch can match it).
|
||||||
|
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
|
||||||
|
mk := matchKeyMode(call, band, mode)
|
||||||
|
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add registers a QSO in the index (exported wrapper for callers that inserted a
|
||||||
|
// not-found confirmation).
|
||||||
|
func (idx *MatchIndex) Add(call, band, mode string, when time.Time, id int64) {
|
||||||
|
idx.add(call, band, mode, when, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match returns the id of the local QSO closest in time to `when` (within
|
||||||
|
// `window`) for the given call/band/mode. The mode must match (phone sidebands
|
||||||
|
// folded together by canonMode). Ok is false when nothing is within the window.
|
||||||
|
func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window time.Duration) (int64, bool) {
|
||||||
|
return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
|
||||||
|
var best int64
|
||||||
|
bestD := window + time.Second
|
||||||
|
found := false
|
||||||
|
for _, rf := range refs {
|
||||||
|
if rf.when.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := when.Sub(rf.when)
|
||||||
|
if d < 0 {
|
||||||
|
d = -d
|
||||||
|
}
|
||||||
|
if d <= window && d < bestD {
|
||||||
|
bestD, best, found = d, rf.id, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, found
|
||||||
|
}
|
||||||
|
|
||||||
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
||||||
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
||||||
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
||||||
@@ -1870,6 +2002,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 {
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Package solar fetches live space-weather / propagation numbers (solar flux,
|
||||||
|
// sunspot number, A/K indices, X-ray, geomagnetic field) from N0NBH's public
|
||||||
|
// hamqsl.com XML feed, caches them, and refreshes periodically. OpsLog shows the
|
||||||
|
// numbers in a header strip and stamps SFI / A_INDEX / K_INDEX onto each logged
|
||||||
|
// QSO (SSN has no standard ADIF field — the app stores it as an extra).
|
||||||
|
package solar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// feedURL is N0NBH's solar data as XML. Free, no key.
|
||||||
|
const feedURL = "https://www.hamqsl.com/solarxml.php"
|
||||||
|
|
||||||
|
// Data is the parsed snapshot exposed to the UI (strings — some fields read
|
||||||
|
// "No Report" or carry units, so we keep them verbatim and parse to numbers only
|
||||||
|
// when stamping a QSO).
|
||||||
|
type Data struct {
|
||||||
|
SFI string `json:"sfi"` // solar flux index
|
||||||
|
SSN string `json:"ssn"` // sunspot number
|
||||||
|
AIndex string `json:"a_index"` // geomagnetic A index
|
||||||
|
KIndex string `json:"k_index"` // geomagnetic K index
|
||||||
|
XRay string `json:"xray"` // X-ray flux class (e.g. C1.5)
|
||||||
|
GeomagField string `json:"geomag_field"` // QUIET / UNSETTLED / STORM…
|
||||||
|
Aurora string `json:"aurora"`
|
||||||
|
MUF string `json:"muf"`
|
||||||
|
Updated string `json:"updated"` // source timestamp text
|
||||||
|
Source string `json:"source"` // e.g. N0NBH
|
||||||
|
OK bool `json:"ok"` // last fetch succeeded
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// xmlSolar mirrors the hamqsl solarxml.php document.
|
||||||
|
type xmlSolar struct {
|
||||||
|
XMLName xml.Name `xml:"solar"`
|
||||||
|
SolarData struct {
|
||||||
|
Source string `xml:"source"`
|
||||||
|
Updated string `xml:"updated"`
|
||||||
|
SolarFlux string `xml:"solarflux"`
|
||||||
|
AIndex string `xml:"aindex"`
|
||||||
|
KIndex string `xml:"kindex"`
|
||||||
|
XRay string `xml:"xray"`
|
||||||
|
Sunspots string `xml:"sunspots"`
|
||||||
|
Aurora string `xml:"aurora"`
|
||||||
|
GeomagField string `xml:"geomagfield"`
|
||||||
|
MUF string `xml:"muf"`
|
||||||
|
} `xml:"solardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch performs one GET + parse of the feed.
|
||||||
|
func Fetch(ctx context.Context, client *http.Client) (Data, error) {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, fmt.Errorf("solar: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, fmt.Errorf("solar: read: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return Data{}, fmt.Errorf("solar: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return parseSolar(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSolar turns the feed's XML bytes into a Data (split out so it can be
|
||||||
|
// unit-tested without a network call).
|
||||||
|
func parseSolar(body []byte) (Data, error) {
|
||||||
|
var x xmlSolar
|
||||||
|
if err := xml.Unmarshal(body, &x); err != nil {
|
||||||
|
return Data{}, fmt.Errorf("solar: parse: %w", err)
|
||||||
|
}
|
||||||
|
s := x.SolarData
|
||||||
|
return Data{
|
||||||
|
SFI: strings.TrimSpace(s.SolarFlux),
|
||||||
|
SSN: strings.TrimSpace(s.Sunspots),
|
||||||
|
AIndex: strings.TrimSpace(s.AIndex),
|
||||||
|
KIndex: strings.TrimSpace(s.KIndex),
|
||||||
|
XRay: strings.TrimSpace(s.XRay),
|
||||||
|
GeomagField: strings.TrimSpace(s.GeomagField),
|
||||||
|
Aurora: strings.TrimSpace(s.Aurora),
|
||||||
|
MUF: strings.TrimSpace(s.MUF),
|
||||||
|
Updated: strings.TrimSpace(s.Updated),
|
||||||
|
Source: strings.TrimSpace(s.Source),
|
||||||
|
OK: true,
|
||||||
|
FetchedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager caches the latest Data and refreshes it on a timer.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
data Data
|
||||||
|
client *http.Client
|
||||||
|
onChange func()
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager builds a manager. onChange (optional) fires after each refresh so
|
||||||
|
// the host can push a UI event.
|
||||||
|
func NewManager(onChange func()) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
client: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
onChange: onChange,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start fetches now and every hour until Stop (the feed updates roughly hourly).
|
||||||
|
func (m *Manager) Start() {
|
||||||
|
go func() {
|
||||||
|
m.refresh()
|
||||||
|
t := time.NewTicker(1 * time.Hour)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
m.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop ends the refresh loop.
|
||||||
|
func (m *Manager) Stop() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.once.Do(func() { close(m.stop) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the latest snapshot.
|
||||||
|
func (m *Manager) Get() Data {
|
||||||
|
if m == nil {
|
||||||
|
return Data{}
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh forces an immediate fetch (used by a manual "refresh" binding).
|
||||||
|
func (m *Manager) Refresh() { m.refresh() }
|
||||||
|
|
||||||
|
func (m *Manager) refresh() {
|
||||||
|
d, err := Fetch(context.Background(), m.client)
|
||||||
|
m.mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
m.data.OK = false
|
||||||
|
m.data.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
m.data = d
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if m.onChange != nil {
|
||||||
|
m.onChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package solar
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A trimmed sample of the real N0NBH hamqsl.com solarxml.php document.
|
||||||
|
const sampleXML = `<?xml version="1.0"?>
|
||||||
|
<solar>
|
||||||
|
<solardata>
|
||||||
|
<source url="http://www.hamqsl.com/solar.html">N0NBH</source>
|
||||||
|
<updated>08 Jul 2025 2100 GMT</updated>
|
||||||
|
<solarflux>170</solarflux>
|
||||||
|
<aindex>5</aindex>
|
||||||
|
<kindex>2</kindex>
|
||||||
|
<xray>C1.5</xray>
|
||||||
|
<sunspots>150</sunspots>
|
||||||
|
<geomagfield>QUIET</geomagfield>
|
||||||
|
<muf>18.5</muf>
|
||||||
|
</solardata>
|
||||||
|
</solar>`
|
||||||
|
|
||||||
|
func TestParseSolar(t *testing.T) {
|
||||||
|
d, err := parseSolar([]byte(sampleXML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if !d.OK {
|
||||||
|
t.Fatalf("expected OK")
|
||||||
|
}
|
||||||
|
checks := map[string]string{
|
||||||
|
"SFI": d.SFI, "SSN": d.SSN, "A": d.AIndex, "K": d.KIndex,
|
||||||
|
"XRay": d.XRay, "Geomag": d.GeomagField, "Source": d.Source,
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"SFI": "170", "SSN": "150", "A": "5", "K": "2",
|
||||||
|
"XRay": "C1.5", "Geomag": "QUIET", "Source": "N0NBH",
|
||||||
|
}
|
||||||
|
for k, got := range checks {
|
||||||
|
if got != want[k] {
|
||||||
|
t.Errorf("%s = %q, want %q", k, got, want[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSolarBadXML(t *testing.T) {
|
||||||
|
if _, err := parseSolar([]byte("not xml")); err == nil {
|
||||||
|
t.Fatalf("expected an error for non-XML input")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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.18"
|
appVersion = "0.19.4"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 **F1–F6** 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]].)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 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** (state, QTH, address, notes, …) for a reference,
|
||||||
|
using one **Match by** mode:
|
||||||
|
|
||||||
|
- **code** — the field value *is* the reference (e.g. state = `NY`).
|
||||||
|
- **description** — the reference's **name** appears in the field (substring,
|
||||||
|
space-sensitive — "Hongkong" won't match "Hong Kong").
|
||||||
|
- **pattern** — the field is tested against a regex.
|
||||||
|
|
||||||
|
Plus a **leading/trailing** strip, a **prefix** prepended to found references, a
|
||||||
|
**dynamic** mode (any value counts, like POTA), and the **fallback searches**
|
||||||
|
described below.
|
||||||
|
|
||||||
|
### The five rules to remember
|
||||||
|
|
||||||
|
1. **Every regex is case-insensitive.** Award and per-reference patterns match
|
||||||
|
regardless of case (log fields are typed however the operator felt) — so
|
||||||
|
`\bTok[iy]o\b` catches `TOKYO`, `Tokyo` and even `TOKIO`. Set your own flag
|
||||||
|
group (e.g. `(?s)`) to override.
|
||||||
|
|
||||||
|
2. **A reference's own `Pattern` is opt-in extra recognition.** On the
|
||||||
|
*References* tab each reference has a **Pattern (regex)** field. Fill it to
|
||||||
|
recognise a reference by more than its name — spelling variants, or the
|
||||||
|
**cities/terms** that imply it. Leave it empty and the reference is matched by
|
||||||
|
name only. Example — Jiangsu (China) carries the cities in its own regex:
|
||||||
|
`\b(Jiangyin|Wuxi|Suzhou|Nanjing|Changzhou)\b`.
|
||||||
|
|
||||||
|
3. **`description` also runs the per-reference regexes** (on the same field).
|
||||||
|
A description search looks for the reference *name* **OR** fires each
|
||||||
|
reference's own Pattern — so a description award over the QTH/address will
|
||||||
|
auto-match your city regexes with no extra rule. (A reference with an empty
|
||||||
|
Pattern stays name-only.)
|
||||||
|
|
||||||
|
4. **OR rules are ordered fallbacks (first hit wins).** The **Fallback searches**
|
||||||
|
section adds extra searches tried **in order, only while nothing has matched
|
||||||
|
yet** — the first that finds a reference wins and the rest are skipped
|
||||||
|
(short-circuit, like a chain of *else-if*). So a province already resolved by
|
||||||
|
name isn't also re-tagged, possibly differently, by a later city regex.
|
||||||
|
|
||||||
|
5. **Scope is checked first — a manual reference does not bypass it.** DXCC
|
||||||
|
filter, valid bands / modes / emission and the valid-from/to dates gate every
|
||||||
|
QSO before any reference is looked at. A 17 m QSO can't count for an award
|
||||||
|
whose valid bands are 80/40/20/15/10 m, even if you assign the reference by
|
||||||
|
hand. Widen the award's bands if it *should* count.
|
||||||
|
|
||||||
|
> **Tip — Hong Kong / Macau style mismatches:** if the log says "Hong Kong" but
|
||||||
|
> the reference name is "Hongkong", either fix the name, or give that reference a
|
||||||
|
> **Pattern** like `\bHong ?Kong\b`. Thanks to rule 3 the pattern now works even
|
||||||
|
> for a *description* award — no separate rule needed.
|
||||||
|
|
||||||
|
### Worked example — Worked All Provinces of China (WAPC)
|
||||||
|
|
||||||
|
A custom province award where the log rarely spells the province out (it names a
|
||||||
|
*city*). The clean setup:
|
||||||
|
|
||||||
|
- **Primary:** *Search in* **QTH**, *Match by* **description**.
|
||||||
|
- **Fallback search:** *Search in* **address**, *Match by* **description**.
|
||||||
|
- On the *References* tab, give each province a Pattern listing its cities.
|
||||||
|
|
||||||
|
Now a QSO is resolved by the province **name** if present, else by a **city**
|
||||||
|
regex — first in the QTH, then in the address. First hit wins.
|
||||||
|
|
||||||
|
## Live detection & manual refs
|
||||||
|
|
||||||
|
- References are detected **live** as you enter a callsign.
|
||||||
|
- You can **manually assign** a reference to a QSO (the *Award Refs* tab of the
|
||||||
|
QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it
|
||||||
|
survives export/import — see [[Import and Export ADIF]] — and is honoured
|
||||||
|
everywhere (award panel, grid columns, totals). For a list-backed award the
|
||||||
|
assigned reference must still be a **valid, listed** reference, and the QSO
|
||||||
|
must be **in scope** (rule 5) for it to count.
|
||||||
|
|
||||||
|
## 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]]).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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]]
|
||||||
@@ -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]].
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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]]**.
|
||||||
@@ -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]]
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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]].
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# QSL Card Designer
|
||||||
|
|
||||||
|
**Tools → QSL Card Designer…** turns a few photos into a polished eQSL card.
|
||||||
|
|
||||||
|
## Design flow
|
||||||
|
|
||||||
|
1. **Pick 1–6 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]].
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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]].
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
**OpsLog** — a modern ham-radio logger by **F4BPO** · [Home](Home) · [Troubleshooting](Troubleshooting) · A French README is in `README.fr.md`.
|
||||||
@@ -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]]
|
||||||
Reference in New Issue
Block a user