42 Commits
Author SHA1 Message Date
rouggy 64f2d38d87 chore: release v0.14.1 2026-06-28 20:39:15 +02:00
rouggy 299184712a fix: beam heading full color 2026-06-28 20:38:59 +02:00
rouggy 76c1e2df60 fix: forcing 3 sec for each notification instead of one replacing the other 2026-06-28 19:12:55 +02:00
rouggy 165f33caa5 fix: recording not being sent if mail enable was not checked 2026-06-28 19:08:32 +02:00
rouggy 464a1c702c feat: added mute and AGC-T to Flex control 2026-06-27 21:03:13 +02:00
rouggy 19c5045dc6 fix: autocall in remote not working properly 2026-06-27 20:28:29 +02:00
rouggy fa09251039 chore: release v0.14 2026-06-25 01:09:37 +02:00
rouggy d6626d96d0 fix: added functionality to net control 2026-06-24 19:09:34 +02:00
rouggy 8b831145ad feat: Added Net control 2026-06-22 23:40:25 +02:00
rouggy 81c60628c6 feat: While recording a QSO the count is clickable to start the recording again 2026-06-22 21:55:21 +02:00
rouggy 678787ec62 fix: app crashed sometimes while converting qso in MP3 2026-06-22 21:51:29 +02:00
rouggy 79dc20a859 fix: Showing beam heading on map even if no call is entered 2026-06-22 21:46:41 +02:00
rouggy 824971d0a1 fix: bug on the antenna genius 2026-06-22 19:48:29 +02:00
rouggy 60bcd2422d fix: small bug for PGXL fan and ultrabeam direction 2026-06-22 17:29:29 +02:00
rouggy 9b0d7ce1dc feat: While importing ADIF, can import default QSL confirmation
as well as My working conditions, locator, owner callsign
2026-06-22 13:03:57 +02:00
rouggy 572e8ca538 chore: release v0.13 2026-06-21 21:57:50 +02:00
rouggy 6ac9783f7c feat: qrz clickable in cw decoder 2026-06-21 21:57:11 +02:00
rouggy 725600c341 fix: bug where contextual menu was disappearing quickly 2026-06-21 20:31:54 +02:00
rouggy 5d9765be09 feat: Support for Power Genius XL 2026-06-21 20:27:53 +02:00
rouggy b302d4d87b feat: Support for Antenna Genius 2026-06-21 20:15:30 +02:00
rouggy 8b7c42ec9b fix: alignement of fields in QSO entry 2026-06-21 15:14:56 +02:00
rouggy cde0add5e0 fix: adjusted fields width in qso entry 2026-06-21 03:01:05 +02:00
rouggy 0e2ef317c3 feat: Added chat when MySQL is in use 2026-06-21 02:30:01 +02:00
rouggy a9f2e515e1 chore: release v0.12 2026-06-20 20:18:28 +02:00
rouggy 260172cd6d feat: Added --profile argument to start OpsLog on specific profile 2026-06-20 19:47:22 +02:00
rouggy 4b5e2e0b72 fix: Binding to Gui Client to get proper values for cw delay and pitch 2026-06-20 19:34:10 +02:00
rouggy 4a6ea45665 fix: bandmap opened or closed is now persistent over restart 2026-06-20 18:59:12 +02:00
rouggy 95d37da3bb feat: While importing ADIF, update MY fields 2026-06-20 15:48:21 +02:00
rouggy e1b3f0faf3 fix: CW decoder was loosing first caracters 2026-06-20 02:38:02 +02:00
rouggy 6379e2cd1f fix: persistence on columns awards 2026-06-20 02:32:32 +02:00
rouggy 2228816057 fix: improve cw decoding with qrm 2026-06-20 02:25:53 +02:00
rouggy 32878c17be fix: bug when autocall for cw keyer is on which was
autocalling no matter which macro now only on CQ
fix: ESC stop transmission but also autocall
2026-06-20 02:05:12 +02:00
rouggy 079d0c32df feat: cw decoder 2026-06-19 17:31:10 +02:00
rouggy 45d081ac0c feat: added colonns for awards in recent qso 2026-06-18 23:20:24 +02:00
rouggy 183db7ac2b fix: Upload to HRDLog 2026-06-18 22:58:00 +02:00
rouggy 4d074de27e fix: batch upload to HRDLog instead of one by one 2026-06-18 19:28:36 +02:00
rouggy 679e8f8d39 fix: added additional selection in recent qso filters 2026-06-18 19:08:38 +02:00
rouggy dd2deee939 feat: added support for eQSL 2026-06-18 14:56:13 +02:00
rouggy cdd71b17c8 feat: implemented HRDLog upload 2026-06-18 14:27:33 +02:00
rouggy e8eedcc1dc fix: updated gitignore 2026-06-18 13:14:26 +02:00
rouggy 3c47366f56 chore: stop tracking personal log.adi 2026-06-18 13:14:16 +02:00
rouggy bd11bb4763 fix: bug where worked filter did not work in cluster 2026-06-18 13:03:13 +02:00
52 changed files with 7927 additions and 51021 deletions
+1
View File
@@ -39,6 +39,7 @@ desktop.ini
hamlog.db* hamlog.db*
cty.dat cty.dat
cat.log cat.log
*.adi
# --- Secrets --- # --- Secrets ---
.env .env
+1120 -66
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
package main
import (
"fmt"
"time"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/cwdecode"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
// only by the frontend, and only while the entry mode is CW.
//
// Pitch targeting: the single-channel decoder is far more reliable when it locks
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
// instead of auto-searching for the loudest tone. So we follow the radio's CW
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
// back to auto-search otherwise.
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
func (a *App) cwTargetPitch() int {
if a.cwPitchHz > 0 {
return a.cwPitchHz
}
if a.cat != nil {
if st, ok := a.cat.FlexState(); ok && st.Available {
// Only trust the radio's pitch when it's actually in CW.
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
if st.CWPitch > 0 {
return st.CWPitch
}
}
}
}
return 0
}
// StartCWDecoder begins decoding CW from the configured RX audio device. The
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
// call repeatedly; a second call is a no-op while already running.
func (a *App) StartCWDecoder() error {
a.cwMu.Lock()
defer a.cwMu.Unlock()
if a.cwStop != nil {
return nil // already running
}
dev := ""
if a.settings != nil {
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
}
if dev == "" {
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
}
dec := cwdecode.New(audio.SampleRate,
func(text string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:text", text)
}
},
func(st cwdecode.Status) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:status", st)
}
},
)
dec.SetTarget(a.cwTargetPitch())
a.cwDecoder = dec
stop := make(chan struct{})
a.cwStop = stop
go func() {
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
applog.Printf("cw: capture failed: %v", err)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
}
}
a.cwMu.Lock()
if a.cwStop == stop {
a.cwStop = nil
a.cwDecoder = nil
}
a.cwMu.Unlock()
}()
// Follow the radio's CW pitch live (every second) while this run is active.
go a.cwFollowPitch(stop, dec)
return nil
}
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
dec.SetTarget(a.cwTargetPitch())
}
}
}
// StopCWDecoder halts the CW decoder if running.
func (a *App) StopCWDecoder() {
a.cwMu.Lock()
stop := a.cwStop
a.cwStop = nil
a.cwDecoder = nil
a.cwMu.Unlock()
if stop != nil {
close(stop)
}
}
// CWDecoderRunning reports whether the decoder is currently capturing.
func (a *App) CWDecoderRunning() bool {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwStop != nil
}
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
// the Flex CW pitch, or search). Applies live to a running decoder.
func (a *App) SetCWDecoderPitch(hz int) {
if hz < 0 {
hz = 0
}
a.cwMu.Lock()
a.cwPitchHz = hz
dec := a.cwDecoder
a.cwMu.Unlock()
if dec != nil {
dec.SetTarget(a.cwTargetPitch())
}
}
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
func (a *App) GetCWDecoderPitch() int {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwPitchHz
}
+2
View File
@@ -24,6 +24,8 @@ var sensitiveSettingKeys = map[string]bool{
keyExtClublogPassword: true, keyExtClublogPassword: true,
keyExtLoTWKeyPassword: true, keyExtLoTWKeyPassword: true,
keyExtLoTWWebPassword: true, keyExtLoTWWebPassword: true,
keyExtHRDLogCode: true,
keyExtEQSLPassword: true,
} }
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] } func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"database/sql"
"fmt"
"strings"
"time"
"hamlog/internal/applog"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// Multi-operator chat over the SHARED MySQL logbook. The database is the message
// bus: each OpsLog INSERTs into chat_messages and polls for new rows (~3 s), so
// operators on the same shared log (e.g. a special-event call) can talk. No
// extra server. Presence is a lightweight heartbeat into chat_presence. Chat is
// only available on a MySQL logbook (SQLite/solo has no one else to talk to).
const (
chatPollInterval = 3 * time.Second
chatPresenceEvery = 20 * time.Second
chatRetentionDays = 7
chatHistoryDefault = 80
chatPresenceStaleSecs = 120 // a presence row older than this = offline
)
// ChatMessage is one chat line.
type ChatMessage struct {
ID int64 `json:"id"`
Operator string `json:"operator"`
Station string `json:"station"`
Message string `json:"message"`
CreatedAt string `json:"created_at"` // ISO UTC
}
// ChatPresence is one operator currently online (recent heartbeat).
type ChatPresence struct {
Operator string `json:"operator"`
Station string `json:"station"`
AgoSecs int `json:"ago_secs"`
}
// chatActive reports whether chat can run (shared MySQL logbook).
func (a *App) chatActive() bool {
return a.logDb != nil && a.dbBackend == "mysql"
}
// ChatAvailable lets the UI show/hide the chat icon (only on a shared log).
func (a *App) ChatAvailable() bool { return a.chatActive() }
func (a *App) ensureChatTables() error {
if _, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS chat_messages ("+
"id BIGINT AUTO_INCREMENT PRIMARY KEY, "+
"operator VARCHAR(32), station VARCHAR(32), "+
"message TEXT, created_at DATETIME)"); err != nil {
return err
}
_, err := a.logDb.ExecContext(a.ctx,
"CREATE TABLE IF NOT EXISTS chat_presence ("+
"operator VARCHAR(32) PRIMARY KEY, station VARCHAR(32), updated_at DATETIME)")
return err
}
// SendChatMessage posts a message to the shared chat and returns the stored row
// (with its id) so the UI can show it immediately; the poll loop dedupes by id.
func (a *App) SendChatMessage(text string) (ChatMessage, error) {
text = strings.TrimSpace(text)
if text == "" {
return ChatMessage{}, nil
}
if len(text) > 1000 {
text = text[:1000]
}
if !a.chatActive() {
return ChatMessage{}, fmt.Errorf("chat is only available on a shared MySQL logbook")
}
op, station := a.liveStatusOperator()
if op == "" {
return ChatMessage{}, fmt.Errorf("set your callsign/operator in Settings → Station first")
}
if err := a.ensureChatTables(); err != nil {
return ChatMessage{}, err
}
res, err := a.logDb.ExecContext(a.ctx,
"INSERT INTO chat_messages (operator, station, message, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP())",
op, station, text)
if err != nil {
return ChatMessage{}, err
}
id, _ := res.LastInsertId()
return ChatMessage{ID: id, Operator: op, Station: station, Message: text,
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
}
// GetChatHistory returns the most recent messages (oldest first) for the panel.
func (a *App) GetChatHistory(limit int) ([]ChatMessage, error) {
if !a.chatActive() {
return nil, nil
}
if limit <= 0 || limit > 500 {
limit = chatHistoryDefault
}
if err := a.ensureChatTables(); err != nil {
return nil, err
}
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT id, operator, station, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?", limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatMessage
for rows.Next() {
var m ChatMessage
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
// Reverse to chronological order (we queried newest-first to honour LIMIT).
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, rows.Err()
}
// GetOnlineOperators lists operators with a recent presence heartbeat.
func (a *App) GetOnlineOperators() ([]ChatPresence, error) {
if !a.chatActive() {
return nil, nil
}
if err := a.ensureChatTables(); err != nil {
return nil, err
}
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT operator, station, TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) AS ago "+
"FROM chat_presence WHERE updated_at > UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY operator",
chatPresenceStaleSecs)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChatPresence
for rows.Next() {
var p ChatPresence
if err := rows.Scan(&p.Operator, &p.Station, &p.AgoSecs); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// chatLoop polls for new messages and heartbeats presence while on a shared
// MySQL logbook. Started once at startup; a cheap no-op otherwise.
func (a *App) chatLoop() {
defer func() { _ = recover() }()
var lastID int64 = -1 // -1 = not yet baselined
var lastDB *sql.DB // logbook the baseline belongs to
lastPresence := time.Time{}
lastPurge := time.Time{}
t := time.NewTicker(chatPollInterval)
defer t.Stop()
for range t.C {
if !a.chatActive() {
lastID = -1 // re-baseline if the backend changes
lastDB = nil
continue
}
// Profile switch swaps the logbook under us: re-baseline against the new
// DB so we don't query it with the previous log's id cursor.
if a.logDb != lastDB {
lastID = -1
lastDB = a.logDb
}
if err := a.ensureChatTables(); err != nil {
continue
}
now := time.Now()
// Presence heartbeat.
if now.Sub(lastPresence) >= chatPresenceEvery {
if op, station := a.liveStatusOperator(); op != "" {
_, _ = a.logDb.ExecContext(a.ctx,
"INSERT INTO chat_presence (operator, station, updated_at) VALUES (?, ?, UTC_TIMESTAMP()) "+
"ON DUPLICATE KEY UPDATE station=VALUES(station), updated_at=UTC_TIMESTAMP()",
op, station)
}
lastPresence = now
}
// Baseline on first run so existing history isn't replayed as "new"
// (the panel loads it via GetChatHistory).
if lastID < 0 {
row := a.logDb.QueryRowContext(a.ctx, "SELECT COALESCE(MAX(id),0) FROM chat_messages")
_ = row.Scan(&lastID)
continue
}
// Emit new messages.
rows, err := a.logDb.QueryContext(a.ctx,
"SELECT id, operator, station, message, created_at FROM chat_messages WHERE id > ? ORDER BY id", lastID)
if err != nil {
continue
}
for rows.Next() {
var m ChatMessage
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
continue
}
if m.ID > lastID {
lastID = m.ID
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "chat:message", m)
}
}
rows.Close()
// Purge old messages occasionally (hourly).
if now.Sub(lastPurge) >= time.Hour {
_, err := a.logDb.ExecContext(a.ctx,
"DELETE FROM chat_messages WHERE created_at < UTC_TIMESTAMP() - INTERVAL ? DAY", chatRetentionDays)
if err != nil {
applog.Printf("chat: purge failed: %v", err)
}
lastPurge = now
}
}
}
+526 -50
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Eraser, Hash, Loader2, Lock, AlertCircle, Antenna, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, 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';
import { import {
@@ -19,6 +19,7 @@ import {
RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading,
GetDBConnectionInfo, GetLogbookRevision, GetDBConnectionInfo, GetLogbookRevision,
GetUltrabeamStatus, SetUltrabeamDirection, GetUltrabeamStatus, SetUltrabeamDirection,
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
OpenExternalURL, OpenExternalURL,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
ListClusterServers, ClusterSpotStatuses, SendClusterSpot, ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
@@ -29,10 +30,13 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs, GetAwardDefs,
GetUIPref, GetUIPref,
ReportLiveActivity, ReportLiveActivity,
AwardRefsForQSOs,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs } from '@/lib/awardRefs';
@@ -53,6 +57,8 @@ import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap'; import { BandMap } from '@/components/BandMap';
import { WorldMap, LocatorMap } from '@/components/MainMap'; import { WorldMap, LocatorMap } from '@/components/MainMap';
import { FlexPanel } from '@/components/FlexPanel'; import { FlexPanel } from '@/components/FlexPanel';
import { IcomPanel } from '@/components/IcomPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel'; import { AwardsPanel } from '@/components/AwardsPanel';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
@@ -60,6 +66,9 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid'; import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
import { BulkEditModal } from '@/components/BulkEditModal';
import { ChatPanel, type ChatMsg, type ChatPresence } from '@/components/ChatPopover';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel'; import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
@@ -108,7 +117,7 @@ const emptyDetails: DetailsState = {
prop_mode: '', my_rig: '', my_antenna: '', prop_mode: '', my_rig: '', my_antenna: '',
tx_pwr: undefined, tx_pwr: undefined,
sat_name: '', sat_mode: '', sat_name: '', sat_mode: '',
contest_id: '', srx: undefined, stx: undefined, contest_id: '', srx_string: '', stx_string: '',
email: '', email: '',
award_refs: '', award_refs: '',
}; };
@@ -197,6 +206,24 @@ function rstCategory(mode: string): keyof RSTLists {
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw'; if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone'; return 'phone';
} }
// estimateCwMs roughly estimates how long the resolved text takes to send in CW
// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm
// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can
// lag by tens of seconds over a remote/serial-over-IP link.
function estimateCwMs(resolved: string, wpm: number): number {
const w = Math.max(5, wpm || 25);
const chars = resolved.replace(/\s/g, '').length;
const spaces = (resolved.match(/\s/g) || []).length;
return ((chars * 10 + spaces * 4) * 1200) / w;
}
// exchangeFields splits a contest exchange the operator typed as free text into
// the ADIF numeric field (SRX/STX, when it's all digits) or the string field
// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone).
function exchangeFields(raw?: string): { num?: number; str: string } {
const t = (raw || '').trim();
if (t === '') return { str: '' };
return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t };
}
// rstOptions returns the valid report choices for a mode from the user's // rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load. // editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] { function rstOptions(mode: string, lists: RSTLists): string[] {
@@ -327,6 +354,12 @@ export default function App() {
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any); const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
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 [agEnabled, setAgEnabled] = useState(false);
// Per-port optimistic selection that the status poll must not revert until the
// device confirms it (or it expires) — otherwise a stale poll right after a
// click reverts the UI and the click looks like it did nothing.
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null); const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
@@ -388,10 +421,12 @@ export default function App() {
// User changed band/mode in the entry strip → push to the rig if CAT is up. // User changed band/mode in the entry strip → push to the rig if CAT is up.
// Both calls are fire-and-forget; CAT will reflect back via cat:state. // Both calls are fire-and-forget; CAT will reflect back via cat:state.
// When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do
// NOT drive the radio — the lock means "this value is decoupled from the rig".
function onBandUserChange(v: string) { function onBandUserChange(v: string) {
setBand(v); setBand(v);
noteManualEdit(); noteManualEdit();
if (catState.enabled && catState.connected) { if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
const hz = qsyFreqHz(v, mode); const hz = qsyFreqHz(v, mode);
if (hz > 0) SetCATFrequency(hz).catch(() => {}); if (hz > 0) SetCATFrequency(hz).catch(() => {});
} }
@@ -400,7 +435,7 @@ export default function App() {
setMode(v); setMode(v);
applyModePreset(v); applyModePreset(v);
noteManualEdit(); noteManualEdit();
if (catState.enabled && catState.connected) { if (catState.enabled && catState.connected && !locks.mode) {
SetCATMode(v).catch(() => {}); SetCATMode(v).catch(() => {});
} }
} }
@@ -455,11 +490,25 @@ export default function App() {
}; };
// Transient success toast (bottom-right, auto-dismiss). Used for things // Transient success toast (bottom-right, auto-dismiss). Used for things
// like "spot sent" where a blocking error banner would be overkill. // like "spot sent" where a blocking error banner would be overkill.
// Toasts are QUEUED so two firing at once don't clobber each other: each shows
// for 3s, then the next takes over (no gap). `toast` is the one on screen now.
const [toast, setToast] = useState(''); const [toast, setToast] = useState('');
const showToast = useCallback((msg: string) => { const toastQueue = useRef<string[]>([]);
setToast(msg); const toastTimer = useRef<number | undefined>(undefined);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500); const advanceToast = useCallback(() => {
const next = toastQueue.current.shift();
if (next === undefined) { toastTimer.current = undefined; setToast(''); return; }
setToast(next);
toastTimer.current = window.setTimeout(advanceToast, 3000);
}, []); }, []);
const showToast = useCallback((msg: string) => {
toastQueue.current.push(msg);
if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now
}, [advanceToast]);
const dismissToast = useCallback(() => {
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
advanceToast(); // skip to the next queued toast (or clear if none)
}, [advanceToast]);
// Error banners auto-dismiss after a few seconds (longer than toasts since // Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately. // they may be multi-line). The X button still closes them immediately.
useEffect(() => { useEffect(() => {
@@ -493,6 +542,12 @@ export default function App() {
if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase(); if (forCall !== undefined) recordingCallRef.current = forCall.trim().toUpperCase();
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {}); QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
}; };
// Reset the recording to zero (drop everything so far, pre-roll included) —
// bound to clicking the REC timer. Use when the station was already in a long
// QSO and you only want your own exchange in the file.
const resetRecordingClock = () => {
QSOAudioResetClock().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
};
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState(''); const [filterCallsign, setFilterCallsign] = useState('');
// Advanced filter builder (replaces the old band/mode dropdowns). // Advanced filter builder (replaces the old band/mode dropdowns).
@@ -588,6 +643,97 @@ export default function App() {
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]); useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
// === Digital Voice Keyer (DVK) === // === Digital Voice Keyer (DVK) ===
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
// mode is CW. The decoded text appears in a strip above the tabs.
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
const [cwText, setCwText] = useState('');
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
const cwOn = cwEnabled && mode === 'CW';
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
const cwScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
useEffect(() => {
const hz = parseInt(cwPitch, 10);
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
localStorage.setItem('opslog.cwPitch', cwPitch);
}, [cwPitch, cwOn]);
useEffect(() => {
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
return () => { offT?.(); offS?.(); offE?.(); };
}, []);
// Start/stop the backend decoder as the (enabled, mode) combination changes.
useEffect(() => {
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
else { StopCWDecoder().catch(() => {}); }
}, [cwOn]);
function toggleCwDecoder() {
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
}
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
const [chatAvailable, setChatAvailable] = useState(false);
const [chatOpen, setChatOpen] = useState(false);
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>([]);
const [chatOnline, setChatOnline] = useState<ChatPresence[]>([]);
const [chatUnread, setChatUnread] = useState(0);
const [chatEpoch, setChatEpoch] = useState(0); // bumped on profile switch to reload the chat for the new logbook
const chatOpenRef = useRef(chatOpen); chatOpenRef.current = chatOpen;
const chatSeen = useRef<Set<number>>(new Set());
// Availability (only on a shared MySQL logbook; re-checked as profiles switch).
useEffect(() => {
let alive = true;
const chk = () => ChatAvailable().then((v) => alive && setChatAvailable(!!v)).catch(() => {});
chk();
const id = window.setInterval(chk, 10000);
return () => { alive = false; window.clearInterval(id); };
}, []);
// Incoming messages — append + bump unread when the panel is closed.
useEffect(() => {
const off = EventsOn('chat:message', (m: ChatMsg) => {
if (!m || chatSeen.current.has(m.id)) return;
chatSeen.current.add(m.id);
setChatMsgs((p) => [...p, m].slice(-300));
if (!chatOpenRef.current) setChatUnread((u) => u + 1);
});
return () => { off?.(); };
}, []);
// On open: clear unread, load history + the online list (refreshed).
useEffect(() => {
if (!chatOpen) return;
setChatUnread(0);
GetChatHistory(80).then((h: any) => {
const list = (h ?? []) as ChatMsg[];
list.forEach((m) => chatSeen.current.add(m.id));
setChatMsgs((prev) => {
const byId = new Map<number, ChatMsg>();
[...list, ...prev].forEach((m) => byId.set(m.id, m));
return Array.from(byId.values()).sort((a, b) => a.id - b.id).slice(-300);
});
}).catch(() => {});
const lo = () => GetOnlineOperators().then((o: any) => setChatOnline((o ?? []) as ChatPresence[])).catch(() => {});
lo();
const id = window.setInterval(lo, 15000);
return () => window.clearInterval(id);
}, [chatOpen, chatEpoch]);
async function chatSend(t: string) {
try {
const m = (await SendChatMessage(t)) as any as ChatMsg;
if (m && m.id && !chatSeen.current.has(m.id)) {
chatSeen.current.add(m.id);
setChatMsgs((p) => [...p, m].slice(-300));
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
const chatShown = chatOpen && chatAvailable;
// NET Control tab — enabled from Tools (persisted; once on it's a tab like Cluster).
const [netEnabled, setNetEnabled] = useState(() => localStorage.getItem('opslog.netEnabled') === '1');
useEffect(() => { localStorage.setItem('opslog.netEnabled', netEnabled ? '1' : '0'); }, [netEnabled]);
const [dvkEnabled, setDvkEnabled] = useState(false); const [dvkEnabled, setDvkEnabled] = useState(false);
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 });
@@ -628,7 +774,7 @@ export default function App() {
const [clusterLockMode, setClusterLockMode] = useState(false); const [clusterLockMode, setClusterLockMode] = useState(false);
// Status filter chips. Empty set = show every status (including // Status filter chips. Empty set = show every status (including
// already-worked). Otherwise only matching spots pass. // already-worked). Otherwise only matching spots pass.
type SpotStatusKey = 'new' | 'new-band' | 'new-slot' | 'worked'; type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set()); const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
// Mode filter chips. Empty set = show every mode. Categories map the // Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital). // inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
@@ -651,7 +797,12 @@ export default function App() {
}, []); }, []);
// Single band map docked beside the table (toggled by the toolbar button, // Single band map docked beside the table (toggled by the toolbar button,
// visible across tabs). Independent of the multi-band "Band Map" tab. // visible across tabs). Independent of the multi-band "Band Map" tab.
const [showBandMap, setShowBandMap] = useState(false); const [showBandMap, setShowBandMap] = useState(() => localStorage.getItem('bandmap.show') === '1');
// Persist the Band Map open/closed state (portable) so it survives a restart.
const setBandMapShown = useCallback((v: boolean) => {
setShowBandMap(v);
writeUiPref('bandmap.show', v ? '1' : '0');
}, []);
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>( const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'), () => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
); );
@@ -705,6 +856,8 @@ export default function App() {
const [deletingIds, setDeletingIds] = useState<number[]>([]); const [deletingIds, setDeletingIds] = useState<number[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
const [bulkEditOpen, setBulkEditOpen] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
// Re-read the "beam on map" toggle when Preferences closes (it's edited there). // Re-read the "beam on map" toggle when Preferences closes (it's edited there).
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]); useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
@@ -740,6 +893,7 @@ export default function App() {
const [pendingImportPath, setPendingImportPath] = useState<string | null>(null); const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip'); const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
const [importApplyCty, setImportApplyCty] = useState(true); const [importApplyCty, setImportApplyCty] = useState(true);
const [importApplyStation, setImportApplyStation] = useState(false);
// QRZ profile photo lightbox (full-size, in-app — not the browser). // QRZ profile photo lightbox (full-size, in-app — not the browser).
const [photoModal, setPhotoModal] = useState<string | null>(null); const [photoModal, setPhotoModal] = useState<string | null>(null);
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the // Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
@@ -761,6 +915,40 @@ export default function App() {
const wbTimerRef = useRef<number | null>(null); const wbTimerRef = useRef<number | null>(null);
const [wb, setWb] = useState<WB | null>(null); const [wb, setWb] = useState<WB | null>(null);
const [wbBusy, setWbBusy] = useState(false); const [wbBusy, setWbBusy] = useState(false);
// Per-award columns for the Recent QSOs / Worked-before grids: load the award
// 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).
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
useEffect(() => {
GetAwardDefs().then((defs: any[]) =>
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
).catch(() => {});
}, []);
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; }
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [qsos, awardCols.length]);
const qsosWithAwards = useMemo(
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
[qsos, qsoAwardRefs],
);
const [wbAwardRefs, setWbAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const ids = ((wb?.entries ?? []) as any[]).map((e) => e.id).filter(Boolean);
if (ids.length === 0 || awardCols.length === 0) { setWbAwardRefs({}); return; }
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [wb, awardCols.length]);
const wbWithAwards = useMemo(
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
[wb, wbAwardRefs],
);
// Always-current copy of the entry callsign, so the UDP event handlers // Always-current copy of the entry callsign, so the UDP event handlers
// (which live in a []-deps effect with a stale `callsign` closure) can // (which live in a []-deps effect with a stale `callsign` closure) can
// tell whether an incoming DX call actually changed anything. // tell whether an incoming DX call actually changed anything.
@@ -832,6 +1020,7 @@ export default function App() {
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs). // Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0'); const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
@@ -904,6 +1093,12 @@ export default function App() {
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); }; return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
}, [refresh]); }, [refresh]);
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
useEffect(() => {
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
return () => { off(); };
}, [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
// rotator is disabled (the backend just reads settings and returns). // rotator is disabled (the backend just reads settings and returns).
useEffect(() => { useEffect(() => {
@@ -927,6 +1122,38 @@ export default function App() {
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
// Poll the Antenna Genius switch for active antenna per port + the list.
// Re-read the enabled flag each tick so toggling it in Settings makes the
// top-bar icon appear/disappear without an app restart.
useEffect(() => {
let alive = true;
const tick = async () => {
try { const en: any = await GetAntGeniusSettings(); if (alive) setAgEnabled(!!en?.enabled); } catch {}
try {
const s = (await GetAntGeniusStatus()) as AGStatus;
if (!alive || !s) return;
const now = Date.now();
const pend = agPending.current;
// Keep an optimistic selection until the device confirms it or it ages out.
if (pend.a) { if (now > pend.a.t || s.port_a === pend.a.v) delete pend.a; else s.port_a = pend.a.v; }
if (pend.b) { if (now > pend.b.t || s.port_b === pend.b.v) delete pend.b; else s.port_b = pend.b.v; }
// Only update when something actually changed — avoids re-rendering the
// widget every 1.5s (which made buttons flicker on hover).
setAgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
} catch {}
};
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, []);
const agActivate = (port: number, antenna: number) => {
// Optimistic: reflect the change immediately and pin it for ~3s so the next
// poll (which may still carry the old cached value) can't revert it.
agPending.current[port === 1 ? 'a' : 'b'] = { v: antenna, t: Date.now() + 3000 };
setAgStatus((s) => ({ ...s, ...(port === 1 ? { port_a: antenna } : { port_b: antenna }) }));
AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
};
// RX band auto-follows the TX band (only differs for cross-band work). // RX band auto-follows the TX band (only differs for cross-band work).
useEffect(() => { setBandRx(band); }, [band]); useEffect(() => { setBandRx(band); }, [band]);
@@ -1059,12 +1286,20 @@ export default function App() {
// source behaves identically. // source behaves identically.
function handleSpotClick(s: any) { function handleSpotClick(s: any) {
const m = inferSpotMode(s.comment ?? '', s.freq_hz); const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) { // Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
tuneRigCAT(s.freq_hz, m); // then tune the rig if CAT is connected. We must not wait for the CAT status
} else { // echo to update the display: on a remote Flex link that echo lags by seconds,
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5)); // so the frequency used to only catch up once you nudged the VFO. noteManualEdit()
if (s.band) setBand(s.band); // freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't
// revert the optimistic value before the new freq is reported back.
if (s.freq_hz && s.freq_hz > 0) {
const mhz = (s.freq_hz / 1_000_000).toFixed(5);
setFreqMhz(mhz);
setRxFreqMhz(mhz);
noteManualEdit();
} }
if (s.band) setBand(s.band);
if (catState.connected) tuneRigCAT(s.freq_hz, m);
if (m) applyModeFromSpot(m); if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call, { force: true }); onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref); applySpotPOTA((s as any).pota_ref);
@@ -1254,7 +1489,11 @@ export default function App() {
// typing: only update when the field is empty, already shows this call, or // typing: only update when the field is empty, already shows this call, or
// still shows the previous broadcast (i.e. the field content is ours, not // still shows the previous broadcast (i.e. the field content is ours, not
// a different call the user typed). Returns true if it actually changed. // a different call the user typed). Returns true if it actually changed.
const applyUdpCall = (raw: string): boolean => { // force = true means an EXPLICIT action (a panadapter/spot click, a remote
// "set call" command) that should always win, even over a call the operator
// already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber
// guard (it re-broadcasts constantly and must not fight what you typed).
const applyUdpCall = (raw: string, force = false): boolean => {
const call = String(raw ?? '').trim(); const call = String(raw ?? '').trim();
if (!call) return false; if (!call) return false;
const upper = call.toUpperCase(); const upper = call.toUpperCase();
@@ -1262,22 +1501,26 @@ export default function App() {
const prev = lastUdpCallRef.current; const prev = lastUdpCallRef.current;
lastUdpCallRef.current = upper; // remember this broadcast either way lastUdpCallRef.current = upper; // remember this broadcast either way
if (current === upper) return false; // already shown → no-op if (current === upper) return false; // already shown → no-op
if (current !== '' && current !== prev) return false; // user typed a different call → leave it if (!force && current !== '' && current !== prev) return false; // user typed a different call → leave it
onCallsignInput(call, { force: true }); // programmatic → always look up onCallsignInput(call, { force: true }); // programmatic → always look up
return true; return true;
}; };
const unsubDX = EventsOn('udp:dx_call', (p: any) => { const unsubDX = EventsOn('udp:dx_call', (p: any) => {
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
// over UDP…) is an explicit pick → force it over an existing call.
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
// External app moved to a new station → fresh recording for the new target. // External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? '')); if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
}); });
const unsubRC = EventsOn('udp:remote_call', (raw: string) => { const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? '')); if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
}); });
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call // Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq). // (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
// An explicit click always wins over whatever call is currently in the field.
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => { const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
const call = String(p?.call ?? ''); const call = String(p?.call ?? '');
if (applyUdpCall(call)) restartRecordingForNewTarget(call); if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
}); });
const unsubProg = EventsOn('import:progress', (p: any) => { const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) }); setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
@@ -1322,6 +1565,11 @@ export default function App() {
useEffect(() => { useEffect(() => {
const off = EventsOn('profile:changed', () => { const off = EventsOn('profile:changed', () => {
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
// The chat is per shared logbook — clear the previous profile's messages
// and reload for the new logbook (or hide if it isn't a MySQL log).
setChatMsgs([]); chatSeen.current.clear(); setChatOnline([]); setChatUnread(0);
ChatAvailable().then((v: any) => setChatAvailable(!!v)).catch(() => setChatAvailable(false));
setChatEpoch((e) => e + 1);
}); });
return () => { off(); }; return () => { off(); };
}, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]); }, [loadStation, loadLists, loadCATCfg, reloadWk, loadMainPanes]);
@@ -1369,7 +1617,7 @@ export default function App() {
GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '', GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '', MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '', MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
CONT_RX: details.srx != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '', CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '',
}; };
let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? ''); let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
out = out.replace(/\*/g, myCall).replace(/!/g, his); out = out.replace(/\*/g, myCall).replace(/!/g, his);
@@ -1384,15 +1632,22 @@ export default function App() {
} }
async function wkSend(rawText: string) { async function wkSend(rawText: string) {
setWkSent(''); setWkSent('');
const resolved = resolveCW(rawText);
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "") const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
await WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has // <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — wait for the busy flag to rise then fall, so the QSO // finished sending — so the QSO isn't logged (and the form cleared) while CW
// isn't logged (and the form cleared) while the CW is still going out. // is still going out. We'd like to wait for the busy flag to rise then fall,
// but over a remote/serial-over-IP link that status echo can lag by tens of
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
// send duration (text length × WPM) plus slack: log when busy clears OR the
// estimate elapses, whichever comes first.
if (!doLog) return; if (!doLog) return;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish const deadline = Date.now() + capMs;
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
void save(); void save();
} }
// stopAutoCall cancels any running auto-call loop. // stopAutoCall cancels any running auto-call loop.
@@ -1407,8 +1662,14 @@ export default function App() {
const m = wkMacros[i]; const m = wkMacros[i];
if (!m) break; if (!m) break;
await wkSend(m.text); await wkSend(m.text);
// Wait for the message to finish before the gap+resend. Cap the wait at the
// ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP
// link the keyer's "busy" status lags badly and stays stuck true for tens of
// seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead.
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish const deadline = Date.now() + capMs;
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
if (gen !== autoCallGenRef.current) break; if (gen !== autoCallGenRef.current) break;
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
} }
@@ -1416,8 +1677,16 @@ export default function App() {
function wkSendMacro(i: number) { function wkSendMacro(i: number) {
const m = wkMacros[i]; const m = wkMacros[i];
if (!m) return; if (!m) return;
if (wkAutoCallRef.current) runAutoCall(i); // loop this macro until a reply / stop // Auto-call only loops CQ-type macros. Sending any other macro (e.g. a
else wkSend(m.text); // report once someone answers) sends ONCE and cancels a running loop —
// otherwise a report would keep repeating.
const isCQ = (m.text || '').toUpperCase().includes('CQ');
if (wkAutoCallRef.current && isCQ) {
runAutoCall(i); // loop this CQ until a reply is sent / Stop / ESC
} else {
stopAutoCall();
wkSend(m.text);
}
} }
wkSendMacroRef.current = wkSendMacro; wkSendMacroRef.current = wkSendMacro;
function wkToggleAutoCall(on: boolean) { function wkToggleAutoCall(on: boolean) {
@@ -1491,6 +1760,10 @@ export default function App() {
// when you first entered the call (minutes early) and won't match LoTW. // when you first entered the call (minutes early) and won't match LoTW.
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1'; const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
const start = (startEqualsEnd && !locks.start) ? end : baseStart; const start = (startEqualsEnd && !locks.start) ? end : baseStart;
// Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an
// alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING.
const srxE = exchangeFields(details.srx_string);
const stxE = exchangeFields(details.stx_string);
const payload: any = { const payload: any = {
callsign: callsign.trim().toUpperCase(), callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(), qso_date: start.toISOString(),
@@ -1509,7 +1782,7 @@ export default function App() {
tx_pwr: details.tx_pwr, tx_pwr: details.tx_pwr,
sat_name: details.sat_name, sat_mode: details.sat_mode, sat_name: details.sat_name, sat_mode: details.sat_mode,
contest_id: details.contest_id, contest_id: details.contest_id,
srx: details.srx, stx: details.stx, srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
email: details.email, email: details.email,
}; };
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current); applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
@@ -1598,6 +1871,11 @@ export default function App() {
if (wb && wbCall.length >= 3) runWorkedBefore(wbCall); if (wb && wbCall.length >= 3) runWorkedBefore(wbCall);
showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`); showToast(n > 0 ? `${n} QSO${n > 1 ? 's' : ''} updated ${label}` : `No change ${label}`);
} }
function openBulkEdit(ids: number[]) {
if (ids.length === 0) return;
setBulkEditIds(ids);
setBulkEditOpen(true);
}
async function bulkUpdateFromCty(ids: number[]) { async function bulkUpdateFromCty(ids: number[]) {
if (ids.length === 0) return; if (ids.length === 0) return;
try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); } try { await afterBulkUpdate(await UpdateQSOsFromCty(ids as any), 'from cty.dat'); }
@@ -1863,7 +2141,7 @@ export default function App() {
setImportErrorsOpen(false); setImportErrorsOpen(false);
setImportDupsOpen(false); setImportDupsOpen(false);
try { try {
const res = await ImportADIF(path, importDupMode, importApplyCty); const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation);
setImportResult(res); setImportResult(res);
await refresh(); await refresh();
} catch (e: any) { } catch (e: any) {
@@ -1886,6 +2164,7 @@ export default function App() {
{ name: 'edit', label: 'Edit', items: [ { name: 'edit', label: 'Edit', items: [
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null }, { type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
{ type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null }, { type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
{ type: 'item', label: selectedIds.length > 1 ? `Bulk edit field (${selectedIds.length})…` : 'Bulk edit field…', action: 'edit.bulkedit', disabled: selectedIds.length === 0 },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' }, { type: 'item', label: 'Preferences…', action: 'edit.prefs' },
]}, ]},
@@ -1899,6 +2178,9 @@ export default function App() {
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' }, { type: 'item', label: wkEnabled ? '✓ WinKeyer CW keyer' : 'WinKeyer CW keyer', action: 'tools.winkeyer' },
{ type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' }, { type: 'item', label: dvkEnabled ? '✓ Digital Voice Keyer' : 'Digital Voice Keyer', action: 'tools.dvk' },
{ type: 'item', label: cwEnabled ? '✓ CW decoder (RX audio)' : 'CW decoder (RX audio)', action: 'tools.cwdecoder' },
{ type: 'separator' },
{ type: 'item', label: netEnabled ? '✓ NET Control' : 'NET Control', action: 'tools.net' },
{ type: 'separator' }, { type: 'separator' },
// Maintenance — bumped here while we only have one entry. Will move // Maintenance — bumped here while we only have one entry. Will move
// to a Tools → Maintenance submenu once Clublog + LoTW refresh land. // to a Tools → Maintenance submenu once Clublog + LoTW refresh land.
@@ -1908,7 +2190,7 @@ export default function App() {
{ name: 'help', label: 'Help', items: [ { name: 'help', label: 'Help', items: [
{ type: 'item', label: 'About OpsLog', action: 'help.about' }, { type: 'item', label: 'About OpsLog', action: 'help.about' },
]}, ]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]); ], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled]);
function handleMenu(action: string) { function handleMenu(action: string) {
switch (action) { switch (action) {
@@ -1919,11 +2201,14 @@ export default function App() {
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break; case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
case 'edit.delete': askDeleteSelected(); break; case 'edit.delete': askDeleteSelected(); break;
case 'edit.bulkedit': openBulkEdit(selectedIds); break;
case 'edit.prefs': setShowSettings(true); break; case 'edit.prefs': setShowSettings(true); break;
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break; case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
case 'tools.qsldesigner': setQslDesignerOpen(true); break; case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break; case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break; case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.cwdecoder': toggleCwDecoder(); break;
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
case 'tools.refreshCty': refreshCtyDat(); break; case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break; case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); break; case 'help.about': setShowAbout(true); break;
@@ -1977,7 +2262,9 @@ export default function App() {
return; return;
} }
const keyerLive = wkActiveRef.current; const keyerLive = wkActiveRef.current;
if (keyerLive) WinkeyerStop().catch(() => {}); // ESC aborts the current CW transmission AND the auto-call loop, so it
// won't resend after the gap — you must click a CQ macro to restart it.
if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); }
if (!keyerLive || wkEscClearsRef.current) { if (!keyerLive || wkEscClearsRef.current) {
resetEntry(); resetEntry();
callsignRef.current?.focus(); callsignRef.current?.focus();
@@ -2045,10 +2332,16 @@ export default function App() {
</Label> </Label>
<div className="relative"> <div className="relative">
{recording && RECORDABLE_MODES.has(mode.toUpperCase()) && ( {recording && RECORDABLE_MODES.has(mode.toUpperCase()) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap pointer-events-none"> <button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={resetRecordingClock}
title="Click to restart the recording from 0 — drops everything captured so far (incl. pre-roll) so the file holds only your exchange"
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 inline-flex items-center gap-1 text-[10px] font-semibold tabular-nums text-red-600 whitespace-nowrap cursor-pointer rounded px-1 hover:bg-red-50"
>
<span className="size-2 rounded-full bg-red-600 animate-pulse" /> <span className="size-2 rounded-full bg-red-600 animate-pulse" />
{String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')} {String(Math.floor(recSeconds / 60)).padStart(2, '0')}:{String(recSeconds % 60).padStart(2, '0')}
</span> </button>
)} )}
<Input <Input
ref={callsignRef} ref={callsignRef}
@@ -2316,8 +2609,14 @@ export default function App() {
} }
if (clusterStatusFilter.size > 0) { if (clusterStatusFilter.size > 0) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
const st = spotStatus[k]?.status || ''; const e = spotStatus[k];
if (!clusterStatusFilter.has(st as SpotStatusKey)) return false; const st = (e?.status || '') as SpotStatusKey;
// A previously-worked call counts as WORKED for filtering even when its
// entity status is still new-band/new-slot (the grid flags it WKD CALL),
// matching the "Hide worked" toggle. Additive: it still matches its own
// entity status too, so it stays visible under NEW BAND / NEW SLOT.
const matches = clusterStatusFilter.has(st) || (!!e?.worked_call && clusterStatusFilter.has('worked'));
if (!matches) return false;
} }
if (clusterHideWorked) { if (clusterHideWorked) {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
@@ -2430,8 +2729,9 @@ export default function App() {
{([ {([
{ k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' }, { k: 'new' as SpotStatusKey, label: 'NEW', cls: 'bg-rose-100 text-rose-800 border-rose-300' },
{ k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' }, { k: 'new-band' as SpotStatusKey, label: 'NEW BAND', cls: 'bg-amber-100 text-amber-800 border-amber-300' },
{ k: 'new-mode' as SpotStatusKey, label: 'NEW MODE', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' }, { k: 'new-slot' as SpotStatusKey, label: 'NEW SLOT', cls: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ k: 'worked' as SpotStatusKey, label: 'WORKED', cls: 'bg-muted text-muted-foreground border-border' }, // (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
]).map((s) => { ]).map((s) => {
const on = clusterStatusFilter.has(s.k); const on = clusterStatusFilter.has(s.k);
return ( return (
@@ -2526,9 +2826,9 @@ export default function App() {
case 'worked': case 'worked':
return ( return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden"> <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)} <WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} /> onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
</div> </div>
); );
case 'flex': case 'flex':
@@ -2592,7 +2892,7 @@ export default function App() {
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in"> <div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" /> <Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span> <span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3" /></button> <button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={dismissToast}><X className="size-3" /></button>
</div> </div>
)} )}
</div> </div>
@@ -2725,6 +3025,24 @@ export default function App() {
<Zap className="size-4" /> <Zap className="size-4" />
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-amber-500 animate-pulse" />} {wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-amber-500 animate-pulse" />}
</button> </button>
<button
type="button"
onClick={toggleCwDecoder}
title={
cwEnabled
? (mode === 'CW' ? 'CW decoder — on (decoding) · click to disable' : 'CW decoder — on, idle until CW mode · click to disable')
: 'CW decoder · click to enable (decodes RX audio in CW mode)'
}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
cwOn && cwStatus.active ? 'border-emerald-400 bg-emerald-100 text-emerald-800'
: cwEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Ear className="size-4" />
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500 animate-pulse" />}
</button>
<button <button
type="button" type="button"
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }} onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
@@ -2737,6 +3055,38 @@ export default function App() {
> >
<Compass className="size-4" /> <Compass className="size-4" />
</button> </button>
{agEnabled && (
<button
type="button"
onClick={() => { const v = !showAntGenius; setShowAntGenius(v); writeUiPref('opslog.showAntGenius', v ? '1' : '0'); }}
title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showAntGenius ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Antenna className="size-4" />
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500" />}
</button>
)}
{chatAvailable && (
<button
type="button"
onClick={() => setChatOpen((o) => !o)}
title={`Multi-op chat${chatUnread > 0 ? `${chatUnread} new` : ''}`}
className={cn('relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
chatShown ? 'border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100'
: 'border-border text-muted-foreground hover:bg-muted')}
>
<MessageSquare className="size-4" />
{chatUnread > 0 && (
<span className="absolute -top-1 -right-1 min-w-3.5 h-3.5 px-0.5 rounded-full bg-rose-500 text-white text-[9px] font-bold leading-[14px] text-center">
{chatUnread > 9 ? '9+' : chatUnread}
</span>
)}
</button>
)}
</div> </div>
<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">
@@ -2765,7 +3115,7 @@ export default function App() {
<Button <Button
variant={showBandMap ? 'default' : 'outline'} variant={showBandMap ? 'default' : 'outline'}
size="sm" size="sm"
onClick={() => setShowBandMap((v) => !v)} onClick={() => setBandMapShown(!showBandMap)}
title="Toggle a single band map docked on the side (use the Band Map tab for several at once)" title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
className="h-8" className="h-8"
> >
@@ -2951,10 +3301,15 @@ export default function App() {
</div> </div>
</div> </div>
{/* Row 2: wide Name + QTH + Grid across the full width. */} {/* 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. */}
<div className="flex gap-2 items-end"> <div className="flex gap-2 items-end">
{nameBlock} <div className="flex flex-col w-[300px] shrink-0"><Label className="mb-1 h-3.5">Name</Label>
{qthBlock} <Input value={name} onChange={(e) => { setName(e.target.value); markEdited('name'); }} />
</div>
<div className="flex flex-col flex-1 min-w-[70px]"><Label className="mb-1 h-3.5">QTH</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div>
{gridBlock} {gridBlock}
</div> </div>
@@ -3013,8 +3368,19 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */} otherwise it shows the QRZ profile photo. */}
{!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && ( {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch"> <div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
{chatShown && (
// relative + absolute inner: the chat takes the row height (set by the
// entry strip) WITHOUT its message list growing the row, like the
// Stats panel. The list scrolls inside this fixed height.
<div className="w-[280px] shrink-0 min-h-0 relative">
<div className="absolute inset-0 flex flex-col min-h-0">
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
onSend={chatSend} onClose={() => setChatOpen(false)} />
</div>
</div>
)}
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a {/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. */} rotator is configured or a DX bearing exists. */}
{showRotor && (rotatorHeading.enabled || dxPath) && ( {showRotor && (rotatorHeading.enabled || dxPath) && (
@@ -3032,6 +3398,15 @@ export default function App() {
/> />
</div> </div>
)} )}
{showAntGenius && agEnabled && (
<div className="w-[230px] shrink-0 min-h-0">
<AntGeniusPanel
status={agStatus}
onActivate={agActivate}
onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
/>
</div>
)}
{dvkEnabled && ( {dvkEnabled && (
<div className="w-[264px] shrink-0 min-h-0"> <div className="w-[264px] shrink-0 min-h-0">
<DvkPanel <DvkPanel
@@ -3097,6 +3472,54 @@ export default function App() {
)} )}
</div>{/* /entry + aside row */} </div>{/* /entry + aside row */}
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
{cwOn && (
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1.5 text-xs">
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
{/* Input-level meter — if this stays flat with a strong signal, the RX
audio device is wrong/silent rather than a decode problem. */}
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
<div className="h-full bg-emerald-500 transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
</div>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
</span>
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
<input
type="number"
value={cwPitch}
onChange={(e) => setCwPitch(e.target.value)}
placeholder="auto"
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
className="shrink-0 w-14 h-5 rounded border border-emerald-300/70 bg-white/60 px-1 text-[10px] font-mono text-center outline-none"
/>
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
text (see cwScrollRef effect) so the latest stays in view. */}
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
{cwText.trim() === '' ? (
<span className="text-muted-foreground italic">listening</span>
) : (
<div className="inline-flex items-center whitespace-nowrap">
{cwText.trim().split(/\s+/).map((tok, i) => (
<button
key={i}
type="button"
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-emerald-200/70"
title="Use as callsign"
onClick={() => onCallsignInput(tok, { force: true })}
>
{tok}
</button>
))}
</div>
)}
</div>
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title="Clear" onClick={() => setCwText('')}>
<Eraser className="size-3.5" />
</button>
</div>
)}
{/* ===== LOWER: tabbed table / cluster / band map ===== */} {/* ===== LOWER: tabbed table / cluster / band map ===== */}
{compact ? null : <> {compact ? null : <>
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]', <div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
@@ -3117,7 +3540,23 @@ export default function App() {
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="awards">Awards</TabsTrigger> <TabsTrigger value="awards">Awards</TabsTrigger>
<TabsTrigger value="bandmap">Band Map</TabsTrigger> <TabsTrigger value="bandmap">Band Map</TabsTrigger>
{netEnabled && (
<TabsTrigger value="net" className="gap-1.5">
Net
<span
role="button"
aria-label="Close Net"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); setNetEnabled(false); setActiveTab((t) => (t === 'net' ? 'recent' : t)); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>} {catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
{/* Not a tab — QRZ blocks embedding, so this opens the call's {/* Not a tab — QRZ blocks embedding, so this opens the call's
QRZ.com page in the system browser. Styled like a trigger. */} QRZ.com page in the system browser. Styled like a trigger. */}
<button <button
@@ -3209,8 +3648,9 @@ export default function App() {
)} )}
<RecentQSOsGrid <RecentQSOsGrid
rows={qsos as any} rows={qsosWithAwards as any}
total={total} total={total}
awardCols={awardCols}
onRowDoubleClicked={(q) => openEdit(q.id as number)} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromQRZ={bulkUpdateFromQRZ}
@@ -3218,8 +3658,10 @@ export default function App() {
onSendTo={bulkSendTo} onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording} onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF} onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF} onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }} onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/> />
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30"> <div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
@@ -3393,9 +3835,9 @@ export default function App() {
</TabsContent> </TabsContent>
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
<WorkedBeforeGrid wb={wb} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)} <WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} /> onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} onDelete={(ids) => setDeletingIds(ids)} />
</TabsContent> </TabsContent>
{/* Opened on demand from Tools → QSL Manager; closable via the {/* Opened on demand from Tools → QSL Manager; closable via the
@@ -3429,9 +3871,23 @@ export default function App() {
</TabsContent> </TabsContent>
)} )}
{/* Icom CI-V receive-DSP control panel — only when the CAT backend
is an Icom. */}
{catState.backend === 'icom' && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel />
</TabsContent>
)}
{/* Band Map: several bands shown side-by-side (panadapter-style {/* Band Map: several bands shown side-by-side (panadapter-style
strips). Pick bands with the chips; each strip is clickable to strips). Pick bands with the chips; each strip is clickable to
tune the rig. */} tune the rig. */}
{netEnabled && (
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
</TabsContent>
)}
<TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1"> <TabsContent value="bandmap" className="mt-0 flex flex-col min-h-0 flex-1">
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap"> <div className="flex items-center gap-1 px-3 py-1.5 border-b border-border/60 shrink-0 flex-wrap">
<span className="text-xs text-muted-foreground mr-1">Bands:</span> <span className="text-xs text-muted-foreground mr-1">Bands:</span>
@@ -3478,7 +3934,7 @@ export default function App() {
spotStatus={spotStatus} spotStatus={spotStatus}
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0} currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick} onSpotClick={handleSpotClick}
onClose={() => setShowBandMap(false)} onClose={() => setBandMapShown(false)}
/> />
</div> </div>
)} )}
@@ -3545,6 +4001,13 @@ export default function App() {
<QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} /> <QSOEditModal qso={editingQSO} onSave={onModalSave} onDelete={onModalDelete} onClose={() => setEditingQSO(null)} countries={countries} bands={bands} modes={modes} />
)} )}
<BulkEditModal
open={bulkEditOpen}
ids={bulkEditIds}
onClose={() => setBulkEditOpen(false)}
onApplied={(n) => { afterBulkUpdate(n, 'in bulk'); }}
/>
<SendSpotModal <SendSpotModal
open={showSpotModal} open={showSpotModal}
onClose={() => setShowSpotModal(false)} onClose={() => setShowSpotModal(false)}
@@ -3720,6 +4183,19 @@ export default function App() {
</span> </span>
</span> </span>
</label> </label>
<label className="flex items-start gap-2 text-sm cursor-pointer pt-1">
<Checkbox
checked={importApplyStation}
onCheckedChange={(c) => setImportApplyStation(!!c)}
className="mt-0.5"
/>
<span>
Fill my station fields from my profile
<span className="block text-xs text-muted-foreground mt-0.5">
Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log.
</span>
</span>
</label>
</div> </div>
<DialogFooter className="px-2 bg-transparent border-t-0"> <DialogFooter className="px-2 bg-transparent border-t-0">
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button> <Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
+100
View File
@@ -0,0 +1,100 @@
import { Antenna, X } from 'lucide-react';
import { cn } from '@/lib/utils';
export type AGAntenna = { index: number; name: string };
export type AGStatus = {
connected: boolean; host?: string; last_error?: string;
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
antennas: AGAntenna[];
};
// Format an antenna name: first letter uppercase, the rest lowercase
// (e.g. "DX COMMANDER" → "Dx commander").
function pretty(name: string): string {
const t = name.trim();
if (!t) return t;
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
}
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
// match the app's light theme with soft gradients + glows. Each antenna row has
// 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.
// Clicking an already-selected port deselects it (port → None).
export function AntGeniusPanel({ status, onActivate, onClose }: {
status: AGStatus;
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
onClose: () => void;
}) {
const list = status.antennas ?? [];
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
: active
? (port === 1
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? `Port ${letter} — click to deselect` : `Select on port ${letter}`}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
};
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Antenna className={cn('size-4', status.connected ? 'text-emerald-600 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="flex-1" />
<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-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? 'online' : 'offline'}</span>
</span>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title="Close">
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
{!status.connected ? (
<div className="text-center py-6 text-xs space-y-2">
<div className="text-muted-foreground italic animate-pulse">Connecting</div>
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>}
</div>
) : list.length === 0 ? (
<div className="text-muted-foreground italic text-center py-6 text-xs">No antennas configured.</div>
) : list.map((a) => {
const aActive = status.port_a === a.index;
const bActive = status.port_b === a.index;
const aTx = aActive && !!status.tx_a;
const bTx = bActive && !!status.tx_b;
const nameCls = (aTx || bTx)
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40 shadow-[0_0_11px_rgba(244,63,94,0.35)]'
: aActive
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40 shadow-[0_0_11px_rgba(16,185,129,0.3)]'
: bActive
? 'bg-gradient-to-r from-sky-500 to-sky-600 text-white border-sky-400/40 shadow-[0_0_11px_rgba(14,165,233,0.3)]'
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
return (
<div key={a.index} className="flex items-center gap-1.5">
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
{pretty(a.name)}
</div>
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
</div>
);
})}
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { useMemo, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { BulkUpdateField } from '../../wailsjs/go/main/App';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
type FieldKind = 'status' | 'text';
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
// the backend BulkUpdateField whitelist.
const FIELDS: FieldDef[] = [
// QSL / upload status
{ id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' },
{ id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' },
{ id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' },
{ id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' },
{ id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' },
{ id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' },
{ id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' },
// My station / operator
{ id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true },
{ id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true },
{ id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true },
{ id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' },
{ id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' },
{ id: 'my_street', label: 'My street', group: 'My station', kind: 'text' },
{ id: 'my_city', label: 'My city', group: 'My station', kind: 'text' },
{ id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' },
{ id: 'my_country', label: 'My country', group: 'My station', kind: 'text' },
{ id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true },
{ id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' },
{ id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sota_ref', label: 'My SOTA ref', group: 'My station', kind: 'text', upper: true },
{ id: 'my_pota_ref', label: 'My POTA ref', group: 'My station', kind: 'text', upper: true },
{ id: 'my_wwff_ref', label: 'My WWFF ref', group: 'My station', kind: 'text', upper: true },
{ id: 'my_sig', label: 'My SIG', group: 'My station', kind: 'text' },
{ id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' },
// Misc
{ id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' },
{ id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' },
{ id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' },
{ id: 'ant', label: 'Antenna (contacted)', group: 'Misc', kind: 'text' },
];
const STATUS_VALUES: { v: string; label: string }[] = [
{ v: 'Y', label: 'Y — Yes / uploaded' },
{ v: 'N', label: 'N — No' },
{ v: 'R', label: 'R — Requested' },
{ v: 'I', label: 'I — Ignore' },
{ v: '_', label: '(blank — clear)' },
];
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
type Props = {
open: boolean;
ids: number[];
onClose: () => void;
onApplied: (n: number) => void;
};
// BulkEditModal sets one QSL/upload status field to a chosen value across the
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
// so they become eligible for upload.
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
const [field, setField] = useState('hrdlog_upload');
const [statusValue, setStatusValue] = useState('R');
const [textValue, setTextValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]);
const isStatus = def.kind === 'status';
const effectiveValue = isStatus
? (statusValue === '_' ? '' : statusValue)
: textValue.trim();
async function apply() {
setBusy(true);
setError('');
try {
const n = await BulkUpdateField(ids as any, field, effectiveValue);
onApplied(Number(n) || 0);
onClose();
} catch (e: any) {
setError(String(e?.message ?? e));
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Bulk edit field</DialogTitle>
<DialogDescription>
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
This overwrites the current value there is no undo.
</DialogDescription>
</DialogHeader>
<div className="px-5 py-2 space-y-3">
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
<Label className="text-sm">Field</Label>
<Select value={field} onValueChange={setField}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{GROUPS.map((g) => (
<div key={g}>
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
{FIELDS.filter((f) => f.group === g).map((f) => (
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
<Label className="text-sm">Value</Label>
{isStatus ? (
<Select value={statusValue} onValueChange={setStatusValue}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{v.label}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input
className="h-8 text-xs"
value={textValue}
placeholder="leave empty to clear the field"
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
/>
)}
</div>
<div className="text-[11px] text-muted-foreground">
Will set <span className="font-semibold">{def.label}</span> ={' '}
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
</div>
{error && <div className="text-xs text-rose-700">{error}</div>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={apply} disabled={busy || ids.length === 0}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
Apply to {ids.length}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useRef, useState } from 'react';
import { MessageSquare, Send, Users, X } from 'lucide-react';
import { cn } from '@/lib/utils';
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
export type ChatPresence = { operator: string; station: string; ago_secs: number };
function hhmm(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
// ChatPanel — presentational multi-op chat panel, docked in the aside row next
// to the rotor / WinKeyer / DVK panels. All data + state lives in App.tsx.
export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
onSend: (text: string) => void; onClose: () => void;
}) {
const [text, setText] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const me = (myCall || '').toUpperCase();
useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs]);
function send() {
const t = text.trim();
if (!t) return;
setText('');
onSend(t);
}
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
<MessageSquare className="size-4 text-sky-600" />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
<span className="flex-1" />
{/* Online count — hover to see who's connected. */}
<div className="relative group">
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
<Users className="size-3.5" />{online.length}
</span>
{online.length > 0 && (
<div className="hidden group-hover:block absolute right-0 top-5 z-20 min-w-[130px] rounded-md border border-border bg-popover shadow-lg p-1.5">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">Online</div>
{online.map((o) => (
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
</div>
))}
</div>
)}
</div>
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
<X className="size-3.5" />
</button>
</div>
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
{msgs.length === 0 ? (
<div className="text-muted-foreground italic text-center py-6">No messages yet.</div>
) : msgs.map((m) => {
const mine = m.operator.toUpperCase() === me;
return (
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-sky-100 text-sky-900' : 'bg-muted')}>
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
<span className="whitespace-pre-wrap break-words">{m.message}</span>
</div>
<span className="text-[9px] text-muted-foreground/70 px-1">{hhmm(m.created_at)}</span>
</div>
);
})}
</div>
<div className="flex items-center gap-1.5 p-2 border-t border-border/60 shrink-0">
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
placeholder="Message…"
maxLength={1000}
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
/>
<button type="button" onClick={send} disabled={!text.trim()}
className="inline-flex items-center justify-center size-8 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
<Send className="size-3.5" />
</button>
</div>
</div>
);
}
+26 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
@@ -121,6 +121,7 @@ function statusBadge(s: SpotStatusEntry | undefined): { text: string; fg: string
switch (s?.status) { switch (s?.status) {
case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' }; case 'new': return { text: 'NEW DXCC', fg: '#be123c', bg: '#ffe4e6' };
case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' }; case 'new-band': return { text: 'NEW BAND', fg: '#92400e', bg: '#fde68a' };
case 'new-mode': return { text: 'NEW MODE', fg: '#854d0e', bg: '#fef08a' };
case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' }; case 'new-slot': return { text: 'NEW SLOT', fg: '#854d0e', bg: '#fef08a' };
default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null; default: return s?.worked_call ? { text: 'WKD CALL', fg: '#0369a1', bg: '#e0f2fe' } : null;
} }
@@ -159,6 +160,7 @@ const COL_CATALOG: ColEntry[] = [
const s = statusFor(p); const s = statusFor(p);
if (s?.status === 'new') return 'NEW DXCC'; if (s?.status === 'new') return 'NEW DXCC';
if (s?.status === 'new-band') return 'NEW BAND'; if (s?.status === 'new-band') return 'NEW BAND';
if (s?.status === 'new-mode') return 'NEW MODE';
if (s?.status === 'new-slot') return 'NEW SLOT'; if (s?.status === 'new-slot') return 'NEW SLOT';
return s?.worked_call ? 'WKD CALL' : ''; return s?.worked_call ? 'WKD CALL' : '';
}, },
@@ -212,12 +214,22 @@ const COL_CATALOG: ColEntry[] = [
defaultVisible: true, defaultVisible: true,
cellClass: 'font-mono', cellClass: 'font-mono',
valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '', valueGetter: (p: any) => p.data ? inferSpotMode(p.data.comment ?? '', p.data.freq_hz) : '',
// NEW SLOT (mode not yet worked on this band) → fill the cell. // Fill the mode cell: teal = NEW MODE (mode never worked on this entity),
cellStyle: (p: any) => (statusFor(p)?.status === 'new-slot' // yellow = NEW SLOT (this band+mode combo new, but the mode was worked elsewhere).
? { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 } cellStyle: (p: any) => {
: undefined), const st = statusFor(p)?.status;
// Both NEW MODE and NEW SLOT highlight the mode cell (same yellow); the
// Status badge text tells them apart.
if (st === 'new-mode' || st === 'new-slot') return { backgroundColor: '#fef08a', color: '#854d0e', fontWeight: 700 };
return undefined;
},
cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}></span>, cellRenderer: (p: any) => p.value ? p.value : <span style={{ color: '#a8a29e', fontSize: 10 }}></span>,
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-slot' ? 'NEW SLOT (mode not yet worked on this band)' : undefined), tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
if (st === 'new-mode') return 'NEW MODE (this mode never worked on this entity)';
if (st === 'new-slot') return 'NEW SLOT (this band+mode not yet worked)';
return undefined;
},
}, },
{ {
group: 'Spot', label: 'Pfx', colId: 'pfx', group: 'Spot', label: 'Pfx', colId: 'pfx',
@@ -327,6 +339,14 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
// change below. // change below.
const context = useMemo(() => ({ spotStatus }), [spotStatus]); const context = useMemo(() => ({ spotStatus }), [spotStatus]);
// Spot statuses arrive asynchronously (~after the rows render). The Call/Band/
// Mode cellStyles depend on them but their cell VALUE doesn't change, so ag-grid
// won't re-render those cells on its own — force a refresh so e.g. a worked call
// turns blue once its status loads.
useEffect(() => {
gridRef.current?.api?.refreshCells({ force: true });
}, [spotStatus]);
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(COL_STATE_KEY);
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
+8 -5
View File
@@ -35,8 +35,11 @@ export interface DetailsState {
sat_name: string; sat_name: string;
sat_mode: string; sat_mode: string;
contest_id: string; contest_id: string;
srx?: number; // Contest exchanges as free text — most are serials (001) but some are
stx?: number; // alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
srx_string?: string;
stx_string?: string;
email: string; email: string;
// Award references for the contacted station (set via the Awards tab picker). // Award references for the contacted station (set via the Awards tab picker).
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064". // Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
@@ -214,7 +217,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
))} ))}
</nav> </nav>
<div className="flex-1 overflow-y-auto min-h-0"> <div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
{open === 'stats' && ( {open === 'stats' && (
<div className="px-3 py-2.5"> <div className="px-3 py-2.5">
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} /> <BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
@@ -371,10 +374,10 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} /> <Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
</Field> </Field>
<Field label="SRX"> <Field label="SRX">
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} /> <Input value={details.srx_string ?? ''} placeholder="rcvd exchange" onChange={(e) => onChange({ srx_string: e.target.value })} />
</Field> </Field>
<Field label="STX"> <Field label="STX">
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} /> <Input value={details.stx_string ?? ''} placeholder="sent exchange" onChange={(e) => onChange({ stx_string: e.target.value })} />
</Field> </Field>
<Field label="Contacted email" span={3}> <Field label="Contacted email" span={3}>
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} /> <Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
+16 -1
View File
@@ -53,6 +53,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'iota', label: 'IOTA', type: 'text' }, { value: 'iota', label: 'IOTA', type: 'text' },
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' }, { value: 'sota_ref', label: 'SOTA ref', type: 'text' },
{ value: 'pota_ref', label: 'POTA ref', type: 'text' }, { value: 'pota_ref', label: 'POTA ref', type: 'text' },
{ value: 'wwff_ref', label: 'WWFF ref', type: 'text' },
{ value: 'rig', label: 'Rig', type: 'text' }, { value: 'rig', label: 'Rig', type: 'text' },
{ value: 'ant', label: 'Antenna', type: 'text' }, { value: 'ant', label: 'Antenna', type: 'text' },
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' }, { value: 'qsl_sent', label: 'QSL sent', type: 'text' },
@@ -64,6 +65,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' }, { value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' }, { value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' }, { value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
{ value: 'hrdlog_qso_upload_status', label: 'HRDLog upload status', type: 'text' },
{ value: 'contest_id', label: 'Contest ID', type: 'text' }, { value: 'contest_id', label: 'Contest ID', type: 'text' },
{ value: 'srx', label: 'Serial rcvd', type: 'number' }, { value: 'srx', label: 'Serial rcvd', type: 'number' },
{ value: 'stx', label: 'Serial sent', type: 'number' }, { value: 'stx', label: 'Serial sent', type: 'number' },
@@ -74,6 +76,17 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' }, { value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
{ value: 'my_grid', label: 'My grid', type: 'text' }, { value: 'my_grid', label: 'My grid', type: 'text' },
{ value: 'my_country', label: 'My country', type: 'text' }, { value: 'my_country', label: 'My country', type: 'text' },
{ value: 'my_state', label: 'My state', type: 'text' },
{ value: 'my_cnty', label: 'My county', type: 'text' },
{ value: 'my_iota', label: 'My IOTA', type: 'text' },
{ value: 'my_sota_ref', label: 'My SOTA ref', type: 'text' },
{ value: 'my_pota_ref', label: 'My POTA ref', type: 'text' },
{ value: 'my_wwff_ref', label: 'My WWFF ref', type: 'text' },
{ value: 'my_street', label: 'My street', type: 'text' },
{ value: 'my_city', label: 'My city', type: 'text' },
{ value: 'my_postal_code', label: 'My postal code', type: 'text' },
{ value: 'my_rig', label: 'My rig', type: 'text' },
{ value: 'my_antenna', label: 'My antenna', type: 'text' },
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' }, { value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
{ value: 'comment', label: 'Comment', type: 'text' }, { value: 'comment', label: 'Comment', type: 'text' },
{ value: 'notes', label: 'Notes', type: 'text' }, { value: 'notes', label: 'Notes', type: 'text' },
@@ -211,6 +224,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
)} )}
{conditions.map((c, i) => { {conditions.map((c, i) => {
const needsValue = c.op !== 'empty' && c.op !== 'notempty'; const needsValue = c.op !== 'empty' && c.op !== 'notempty';
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
return ( return (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span> <span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
@@ -231,9 +245,10 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
<Input <Input
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
className="h-8 flex-1 text-xs" className="h-8 flex-1 text-xs"
disabled={!needsValue} disabled={!needsValue}
placeholder={needsValue ? 'value' : '—'} placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
value={c.value} value={c.value}
onChange={(e) => setCond(i, { value: e.target.value })} onChange={(e) => setCond(i, { value: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }} onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
+164 -49
View File
@@ -1,11 +1,14 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Mic2, Settings2, Power, AudioLines, Antenna, Flame, Gauge } from 'lucide-react'; import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
import { import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay, GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexAmpOperate, FlexMox, FlexAmpOperate,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -16,8 +19,11 @@ type FlexState = {
proc_enable: boolean; proc_level: number; proc_enable: boolean; proc_level: number;
mon: boolean; mon_level: number; mic_level: number; mon: boolean; mon_level: number; mic_level: number;
atu_status?: string; atu_memories: boolean; atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
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;
mode?: string;
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;
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[];
}; };
@@ -28,8 +34,10 @@ const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0, vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
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, rx_avail: false, agc_threshold: 0, audio_level: 0, mute: 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,
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,
amp_available: false, amp_operate: false, amp_available: false, amp_operate: false,
}; };
@@ -143,14 +151,17 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
)} )}
</span> </span>
</div> </div>
<div className="flex gap-[2px] h-2.5 items-stretch"> {/* 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]">
{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;
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent); const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
return ( return (
<div key={i} className="flex-1 rounded-[1.5px] transition-colors duration-100" <div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
style={on ? { background: col, boxShadow: `0 0 3px ${col}66` } : { background: '#cfc6ad', opacity: 0.45 }} /> style={on
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
: { background: '#cfc6ad', opacity: 0.35 }} />
); );
})} })}
</div> </div>
@@ -174,6 +185,15 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
export function FlexPanel() { export function FlexPanel() {
const [st, setSt] = useState<FlexState>(ZERO); const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({}); const hold = useRef<Record<string, number>>({});
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
// read steadily instead of jumping every poll.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => {
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
@@ -196,6 +216,16 @@ export function FlexPanel() {
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, []);
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
useEffect(() => {
let alive = true;
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
tick();
const id = window.setInterval(tick, 2000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => { const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
hold.current[key] = Date.now() + 900; hold.current[key] = Date.now() + 900;
setSt((p) => ({ ...p, [key]: val })); setSt((p) => ({ ...p, [key]: val }));
@@ -204,8 +234,19 @@ export function FlexPanel() {
const off = !st.available; const off = !st.available;
const rxOff = off || !st.rx_avail; const rxOff = off || !st.rx_avail;
const isCW = (st.mode || '').toUpperCase().includes('CW');
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }]; const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }]; const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
const CW_BW = [100, 200, 300, 400, 500];
const SSB_BW = [1800, 2100, 2400, 2700, 3000, 4000, 6000];
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
// Highlight the preset CLOSEST to the radio's actual filter width. The rig
// rarely reports a width that lands exactly on a preset (e.g. 2.7k presets as
// 1002790), so an exact/±50 match would leave nothing lit — "doesn't pick up
// the current filter". Snapping to the nearest preset always reflects the rig.
const ssbActiveBW = curBW > 0
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
: -1;
return ( return (
<div className="h-full min-h-0 overflow-auto bg-background"> <div className="h-full min-h-0 overflow-auto bg-background">
@@ -262,6 +303,7 @@ export function FlexPanel() {
</button> </button>
</div> </div>
{!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet" <Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
@@ -288,6 +330,29 @@ export function FlexPanel() {
<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>
</div> </div>
) : (
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
</div>
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Pitch</span>
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
</div>
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Delay</span>
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
</div>
<LevelRow label="STONE" on={st.cw_sidetone} disabled={off} value={st.cw_mon_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))}
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
</div>
)}
</Card> </Card>
{/* RECEIVE */} {/* RECEIVE */}
@@ -298,14 +363,21 @@ export function FlexPanel() {
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} /> onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} /> <button type="button" disabled={rxOff}
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span> title={st.mute ? 'Muted — click to unmute' : 'Mute RX audio'}
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
</button>
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} /> <Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div> </div>
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<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"
@@ -314,32 +386,61 @@ export function FlexPanel() {
<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))} />
{/* ANF (auto notch) is for carriers in voice — meaningless on a CW tone, so hide it in CW. */}
{!isCW && (
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed" <LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))} onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} /> onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
</div>
</Card>
</div>
{/* ATU */}
<Card icon={Settings2} title="Antenna Tuner">
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={off} onClick={() => FlexATUStart().catch(() => {})}
className="px-3 py-1.5 rounded-md text-xs font-bold border border-emerald-400 text-emerald-700 hover:bg-emerald-50 disabled:opacity-30">
<Antenna className="size-3.5 inline mr-1 -mt-0.5" /> Tune ATU
</button>
<button type="button" disabled={off} onClick={() => FlexATUBypass().catch(() => {})}
className="px-3 py-1.5 rounded-md text-xs font-bold border border-border text-muted-foreground hover:bg-muted disabled:opacity-30">
Bypass
</button>
<Chip on={st.atu_memories} disabled={off} label="MEM"
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
<div className="flex-1" />
{st.atu_status && (
<span className="text-xs font-mono text-muted-foreground">{st.atu_status.replace(/_/g, ' ')}</span>
)} )}
</div> </div>
{isCW && (
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{CW_BW.map((bw) => (
<button key={bw} type="button" disabled={rxOff}
onClick={() => { setSt((p) => { const c = ((p.filter_lo || 0) + (p.filter_hi || 0)) ? Math.round(((p.filter_lo || 0) + (p.filter_hi || 0)) / 2) : (p.cw_pitch || 600); return { ...p, filter_lo: c - bw / 2, filter_hi: c + bw / 2 }; }); FlexSetCWFilter(bw).catch(() => {}); }}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
Math.abs(curBW - bw) <= 1 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{bw}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
</div>
</div>
)}
{!isCW && (
<div className="border-t border-border/60 pt-3">
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{SSB_BW.map((bw) => (
<button key={bw} type="button" disabled={rxOff}
onClick={() => {
const lsb = (st.mode || '').toUpperCase().includes('LSB');
let lo: number, hi: number;
if (lsb) { const near = (st.filter_hi && st.filter_hi < 0) ? st.filter_hi : -100; hi = near; lo = near - bw; }
else { const near = (st.filter_lo && st.filter_lo > 0) ? st.filter_lo : 100; lo = near; hi = near + bw; }
setSt((p) => ({ ...p, filter_lo: lo, filter_hi: hi }));
FlexSetFilter(lo, hi).catch(() => {});
}}
className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
bw === ssbActiveBW ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{(bw / 1000).toFixed(1)}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground/70 font-mono">kHz</span>
</div>
</div>
)}
</Card> </Card>
</div>
{/* External amplifier (PowerGenius XL) — only when detected. */} {/* External amplifier (PowerGenius XL) — only when detected. */}
{st.amp_available && ( {st.amp_available && (
@@ -354,12 +455,30 @@ export function FlexPanel() {
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'} {st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
</span> </span>
{/* Fan mode — shown when the PowerGenius is configured (Settings →
PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */}
{(pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? 'PowerGenius connected' : (pg.last_error || 'PowerGenius offline')}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-rose-500')} />
<span className="text-muted-foreground">Fan</span>
<select
disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500 disabled:opacity-40"
>
<option value="STANDARD">Standard</option>
<option value="CONTEST">Contest</option>
<option value="BROADCAST">Broadcast</option>
</select>
</label>
)}
<div className="flex-1" /> <div className="flex-1" />
{st.amp_fault && st.amp_fault !== 'NONE' && ( {st.amp_fault && st.amp_fault !== 'NONE' && (
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span> <span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
)} )}
</div> </div>
<p className="text-[11px] text-muted-foreground">Amplifier power / SWR / temperature appear in the Meters panel below (src AMP).</p>
</Card> </Card>
)} )}
@@ -378,9 +497,6 @@ export function FlexPanel() {
const sig = radio('LEVEL') || radio('SIGNAL'); const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR'); const fwd = radio('FWDPWR');
const swr = radio('SWR'); const swr = radio('SWR');
const alc = radio('ALC');
const temp = radio('PATEMP');
const volts = radio('13.8B') || meters.find((m) => /volts/i.test(m.unit || '') && !(m.src || '').toUpperCase().includes('AMP'));
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim())); && !/^(RL|DRV)$/i.test((m.name || '').trim()));
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626' const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
@@ -396,16 +512,15 @@ export function FlexPanel() {
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) }; return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
}; };
const cur = [ const cur = [
sig && (() => { const s = sUnit(sig.value); return ( 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={`${sig.value.toFixed(1)} dBm`} <MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} /> segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(), ); })(),
fwd && <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
value={isDbm(fwd) ? dbmToW(fwd.value) : fwd.value} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />, <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
swr && <MeterBar key="w" label="SWR" value={swr.value} unit="" lo={1} hi={3} accent="#d97706" />, value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
alc && <MeterBar key="a" label="ALC" value={alc.value} unit={alc.unit} lo={alc.lo} hi={alc.hi || 100} accent="#7c3aed" />, ); })(),
temp && <MeterBar key="t" label="PA TEMP" value={temp.value} unit={temp.unit} lo={temp.lo || 0} hi={temp.hi || 80} accent="#ea580c" />, swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
volts && <MeterBar key="v" label="VOLTS" value={volts.value} unit={volts.unit} lo={volts.lo || 0} hi={volts.hi || 15} accent="#2563eb" />,
].filter(Boolean); ].filter(Boolean);
return ( return (
<> <>
@@ -416,9 +531,9 @@ export function FlexPanel() {
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{amp.map((m) => { {amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) { if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={dbmToW(m.value)} unit="W" lo={0} hi={1500} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />; 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={m.value} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />; 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>
</div> </div>
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, RefreshCw } from 'lucide-react';
import {
GetIcomState, IcomRefresh,
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
type IcomState = {
available: boolean; model?: string; mode?: string;
af_gain: number; rf_gain: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
agc?: string; preamp: number; att: number; filter: number;
};
const ZERO: IcomState = {
available: false, af_gain: 0, rf_gain: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
preamp: 0, att: 0, filter: 1,
};
function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string;
}) {
const v = Math.max(0, Math.min(100, value));
return (
<input
type="range" min={0} max={100} value={v} disabled={disabled}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
/>
);
}
function Segmented({ value, options, onChange }: {
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
}) {
return (
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
{options.map((o) => (
<button key={o.v} type="button" onClick={() => onChange(o.v)}
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{o.l}
</button>
))}
</div>
);
}
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
return (
<button type="button" onClick={onClick}
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{label}
</button>
);
}
function LevelRow({ label, on, onToggle, value, onLevel }: {
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
}) {
return (
<div className="flex items-center gap-2">
<Chip on={on} onClick={onToggle} label={label} />
<Slider value={value} disabled={!on} onChange={onLevel} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
</div>
);
}
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
</div>
<div className="p-3 space-y-3">{children}</div>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
{children}
</div>
);
}
// IcomPanel — receive-DSP control surface for an Icom on the CI-V backend.
// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects
// the last refresh plus optimistic updates. Front-panel knob changes show after
// the next ↻ Refresh.
export function IcomPanel() {
const [st, setSt] = useState<IcomState>(ZERO);
const [busy, setBusy] = useState(false);
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
const refresh = async () => {
setBusy(true);
try { await IcomRefresh(); } catch {}
await load();
setBusy(false);
};
useEffect(() => {
refresh();
const id = window.setInterval(load, 1500); // cheap cache poll (mode + optimistic state)
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Optimistic local update + fire the command; the cache poll reconciles.
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
setSt((s) => ({ ...s, ...patch }));
fn().catch(() => {});
};
if (!st.available) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
Icom not connected. Enable the Icom CI-V backend in Settings CAT and connect the radio's USB port.
</div>
);
}
return (
<div className="h-full overflow-y-auto p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-bold">{st.model || 'Icom'}{st.mode ? <span className="ml-2 text-xs font-mono text-muted-foreground">{st.mode}</span> : null}</div>
<button type="button" onClick={refresh} disabled={busy}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> Refresh
</button>
</div>
<Card icon={Radio} title="Receive" accent="#2563eb">
<Row label="AF">
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
</Row>
<Row label="RF">
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
</Row>
<Row label="AGC">
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
</Row>
<Row label="Preamp">
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
</Row>
<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' }]}
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
</Row>
<Row label="Filter">
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
</Row>
</Card>
<Card icon={AudioLines} title="Noise / Notch" accent="#16a34a">
<LevelRow label="NB" on={st.nb} value={st.nb_level}
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
<LevelRow label="NR" on={st.nr} value={st.nr_level}
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
<div className="flex items-center gap-2">
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
<span className="text-xs text-muted-foreground">Auto notch filter</span>
</div>
</Card>
</div>
);
}
+33 -26
View File
@@ -139,18 +139,13 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
const from = gridToLatLon(fromGrid); const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid); const to = gridToLatLon(toGrid);
if (from && to) { // Station marker + antenna beam/boom are drawn whenever the station grid is
// known — independent of any DX. The antenna is always pointed somewhere, so
// the beam heading should show even before a callsign is entered.
if (from) {
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' }) L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' }) .bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo); .addTo(wo);
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
// line, which makes a smooth arc look angular/bumpy).
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ── // ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (beamAzimuths && beamAzimuths.length) { if (beamAzimuths && beamAzimuths.length) {
@@ -169,21 +164,19 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
return out; return out;
}; };
for (const az of beamAzimuths) { for (const az of beamAzimuths) {
// Draw the lobe as a FAN of translucent great-circle radials, not a // Draw the lobe as a single FILLED shape that HUGS the great-circle
// filled polygon: a polygon breaks badly near the poles on Mercator // curve: bound it by the two edge radials (az ± half-beamwidth), each a
// (its edges run off toward ±90° and the fill smears across the map), // great-circle line, joined by a short far cap. Filling between the two
// while each radial LINE stays clean. The overlapping lines read as a // curved edges follows the arc instead of making a straight triangle.
// lobe — solid near the antenna, fanning out toward the front. Works const edgeL = radial(az - half);
// for any azimuth, north/south included. const edgeR = radial(az + half);
for (let b = az - half; b <= az + half + 0.001; b += 1.5) { const lobe = [[from.lat, from.lon], ...edgeL, ...edgeR.slice().reverse()] as [number, number][];
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]); L.polygon(unwrapLon(lobe) as L.LatLngExpression[],
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo); { stroke: false, fillColor: '#ff2d2d', fillOpacity: 0.18, smoothFactor: 0 }).addTo(wo);
}
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]); const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
// Dark casing under the boresight so the bright dashed line stays // Dark casing under the boresight so the bright dashed line stays
// readable on any basemap (esp. dark satellite imagery). Same dashArray // readable on any basemap. Same dashArray so the casing tracks each dash.
// as the red line so the casing tracks each dash — otherwise the wide
// casing peeks through the gaps and the line looks bumpy.
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo); L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 }) L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo); .bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
@@ -204,17 +197,31 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 }) L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo); .bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
} }
}
// DX marker + great-circle arc — only when a DX grid is known (callsign entered).
let arcPts: [number, number][] | null = null;
if (from && to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(wo);
arcPts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the line).
L.polyline(unwrapLon(arcPts) as L.LatLngExpression[],
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
}
if (autoZoom) { if (autoZoom) {
if (from && to && arcPts) {
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]); const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 }); wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} } else if (to) {
} else if (autoZoom && to) {
wm.setView([to.lat, to.lon], 3); wm.setView([to.lat, to.lon], 3);
} else if (autoZoom && from) { } else if (from) {
wm.setView([from.lat, from.lon], 3); wm.setView([from.lat, from.lon], 3);
} }
}
setTimeout(() => { wm.invalidateSize(); }, 0); setTimeout(() => { wm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]); }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
+397
View File
@@ -0,0 +1,397 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef,
} from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Plus, Trash2, Radio, PlusCircle, MinusCircle, Search, UserPlus } from 'lucide-react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { QSOEditModal } from '@/components/QSOEditModal';
import type { QSOForm } from '@/types';
import {
NetList, NetCreate, NetRename, NetDelete, NetOpen, NetClose, NetOpenID,
NetRoster, NetRosterUpsert, NetRosterRemove, NetLookup,
NetActiveList, NetActivate, NetDeactivate, NetUpdateActive, NetDiscardActive,
} from '@/../wailsjs/go/main/App';
import { netctl } from '@/../wailsjs/go/models';
ModuleRegistry.registerModules([AllCommunityModule]);
const hamlogTheme = themeQuartz.withParams({
fontFamily: 'inherit',
fontSize: 12.5,
backgroundColor: '#faf6ea',
foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9',
headerTextColor: '#5a4f3a',
headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0',
rowHoverColor: '#ecdcb4',
selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994',
rowBorder: { color: '#d8c9a8', width: 1 },
columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10,
rowHeight: 30,
headerHeight: 32,
spacing: 4,
accentColor: '#b8410c',
iconSize: 12,
});
type Net = netctl.Net;
type Station = netctl.Station;
function fmtTimeOn(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}Z`;
}
const emptyStation = (): Station => netctl.Station.createFrom({ callsign: '' });
type Props = {
onLogged?: () => void;
countries?: string[];
bands?: string[];
modes?: string[];
};
export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
const [nets, setNets] = useState<Net[]>([]);
const [selId, setSelId] = useState<string>('');
const [openId, setOpenId] = useState<string>('');
const [roster, setRoster] = useState<Station[]>([]);
const [active, setActive] = useState<QSOForm[]>([]);
const [error, setError] = useState('');
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
// Add/edit-contact dialog.
const [contactOpen, setContactOpen] = useState(false);
const [contact, setContact] = useState<Station>(emptyStation());
const [looking, setLooking] = useState(false);
const activeGrid = useRef<any>(null);
const rosterGrid = useRef<any>(null);
const isOpen = openId !== '' && openId === selId;
const refreshNets = useCallback(async () => {
try {
const [list, oid] = await Promise.all([NetList(), NetOpenID()]);
const arr = (list ?? []) as Net[];
setNets(arr);
setOpenId(oid ?? '');
setSelId((cur) => cur || (oid ?? '') || (arr[0]?.id ?? ''));
} catch (e: any) { setError(String(e?.message ?? e)); }
}, []);
const refreshRoster = useCallback(async (id: string) => {
if (!id) { setRoster([]); return; }
try { setRoster(((await NetRoster(id)) ?? []) as Station[]); }
catch (e: any) { setError(String(e?.message ?? e)); }
}, []);
const refreshActive = useCallback(async () => {
try { setActive(((await NetActiveList()) ?? []) as unknown as QSOForm[]); }
catch { /* ignore */ }
}, []);
useEffect(() => { refreshNets(); }, [refreshNets]);
useEffect(() => { refreshRoster(selId); }, [selId, refreshRoster]);
useEffect(() => { if (isOpen) refreshActive(); else setActive([]); }, [isOpen, refreshActive]);
// The roster side hides callsigns currently on the air (they live in the left
// grid until logged), mirroring Log4OM's two-list behaviour.
const activeCalls = useMemo(
() => new Set(active.map((a) => (a.callsign ?? '').toUpperCase())),
[active],
);
const rosterShown = useMemo(
() => roster.filter((s) => !activeCalls.has((s.callsign ?? '').toUpperCase())),
[roster, activeCalls],
);
async function newNet() {
const name = window.prompt('New NET name:');
if (!name?.trim()) return;
try { const n = await NetCreate(name.trim()); await refreshNets(); setSelId(n.id); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function renameNet() {
if (!selId) return;
const cur = nets.find((n) => n.id === selId);
const name = window.prompt('Rename NET:', cur?.name ?? '');
if (!name?.trim()) return;
try { await NetRename(selId, name.trim()); await refreshNets(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function deleteNet() {
if (!selId) return;
const cur = nets.find((n) => n.id === selId);
if (!window.confirm(`Delete NET "${cur?.name}" and its roster? This cannot be undone.`)) return;
try { await NetDelete(selId); setSelId(''); await refreshNets(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function toggleOpen() {
try {
if (isOpen) {
if (active.length > 0 &&
!window.confirm(`${active.length} station(s) still on the air will be dropped WITHOUT logging. Close anyway?`)) return;
await NetClose(); setOpenId('');
} else {
await NetOpen(selId); setOpenId(selId); await refreshActive();
}
} catch (e: any) { setError(String(e?.message ?? e)); }
}
// Roster → active (start QSO).
async function activate(call: string) {
if (!isOpen || !call) return;
try { await NetActivate(call); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Active → logged (end QSO, removed from session, written to the logbook).
async function deactivate(id?: number) {
if (id == null) return;
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Edit-modal handlers (operate on the in-memory draft, not the DB).
async function saveDraft(q: QSOForm) {
try { await NetUpdateActive(q as any); setEditingDraft(null); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
async function discardDraft(id: number) {
try { await NetDiscardActive(id); setEditingDraft(null); await refreshActive(); }
catch (e: any) { setError(String(e?.message ?? e)); }
}
// Add-contact dialog.
function openAddContact() { setContact(emptyStation()); setContactOpen(true); }
async function lookupContact() {
const call = (contact.callsign ?? '').trim();
if (!call) return;
setLooking(true);
try {
const r = await NetLookup(call);
setContact((c) => netctl.Station.createFrom({
...c, callsign: (r.callsign || call).toUpperCase(),
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,
}));
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setLooking(false); }
}
async function saveContact() {
const call = (contact.callsign ?? '').trim().toUpperCase();
if (!call || !selId) return;
try {
await NetRosterUpsert(selId, netctl.Station.createFrom({ ...contact, callsign: call }));
setContactOpen(false);
await refreshRoster(selId);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
async function removeSelectedRoster() {
const sel = (rosterGrid.current?.api?.getSelectedRows() ?? []) as Station[];
if (sel.length === 0) return;
if (!window.confirm(`Remove ${sel.length} station(s) from this NET's roster?`)) return;
try {
for (const s of sel) await NetRosterRemove(selId, s.callsign);
await refreshRoster(selId);
} catch (e: any) { setError(String(e?.message ?? e)); }
}
const activeCols = useMemo<ColDef<QSOForm>[]>(() => [
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: 'Name', field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: 'Time on', valueGetter: (p) => fmtTimeOn((p.data as any)?.qso_date), width: 90, cellClass: 'font-mono text-[11px]' },
{ headerName: 'Band', field: 'band', width: 70, cellClass: 'font-mono' },
{ headerName: 'Mode', field: 'mode', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST S', field: 'rst_sent', width: 70, cellClass: 'font-mono' },
{ headerName: 'RST R', field: 'rst_rcvd', width: 70, cellClass: 'font-mono' },
{ headerName: 'Comment', field: 'comment', flex: 1.5 },
], []);
const rosterCols = useMemo<ColDef<Station>[]>(() => [
{ headerName: 'Callsign', field: 'callsign', width: 110, cellClass: 'font-mono font-semibold' },
{ headerName: 'Name', field: 'name', flex: 1 },
{ headerName: 'QTH', field: 'qth', flex: 1 },
{ headerName: 'Country', field: 'country', width: 130 },
], []);
const defaultColDef = useMemo<ColDef>(() => ({ sortable: true, resizable: true, suppressMovable: false }), []);
return (
<div className="flex flex-col min-h-0 flex-1">
{/* Toolbar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Button variant="outline" size="sm" className="h-8" onClick={newNet}>
<Plus className="size-3.5" /> New NET
</Button>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm min-w-[180px]"
value={selId}
disabled={isOpen}
onChange={(e) => setSelId(e.target.value)}
title={isOpen ? 'Close the NET to switch' : 'Select a NET'}
>
<option value=""> select a NET </option>
{nets.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
</select>
<Button variant={isOpen ? 'destructive' : 'default'} size="sm" className="h-8" disabled={!selId} onClick={toggleOpen}>
<Radio className="size-3.5" /> {isOpen ? 'Close NET' : 'Open NET'}
</Button>
<Button variant="ghost" size="sm" className="h-8" disabled={!selId || isOpen} onClick={renameNet}>Rename</Button>
<Button variant="ghost" size="sm" className="h-8 text-rose-700" disabled={!selId || isOpen} onClick={deleteNet}>
<Trash2 className="size-3.5" /> Delete
</Button>
<div className="flex-1" />
{isOpen && <Badge className="bg-emerald-600 text-white tracking-wider">NET OPEN</Badge>}
<span className="text-[11px] text-muted-foreground">
On air: <strong className="text-foreground">{active.length}</strong> ·
Roster: <strong className="text-foreground">{roster.length}</strong>
</span>
{error && <Badge variant="destructive" className="max-w-[280px] truncate" title={error}>{error}</Badge>}
</div>
<div className="flex min-h-0 flex-1">
{/* ACTIVE USERS (left) */}
<div className="flex flex-col min-h-0 flex-1 border-r border-border/60">
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-600 text-white text-[11px] font-semibold uppercase tracking-wider">
On air active QSOs
<span className="ml-auto font-normal normal-case opacity-90">double-click edit all fields · "Log &amp; end" to save</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<QSOForm>
ref={activeGrid}
theme={hamlogTheme}
rowData={active}
columnDefs={activeCols}
defaultColDef={defaultColDef}
onRowDoubleClicked={(e) => e.data && setEditingDraft(e.data)}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }}
animateRows={false}
getRowId={(p) => String((p.data as any).id)}
/>
</div>
</div>
{isOpen && active.length > 0 && (
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]"
onClick={() => { const r = activeGrid.current?.api?.getSelectedRows?.()?.[0] as QSOForm; if (r) deactivate(r.id as number); }}>
<MinusCircle className="size-3.5" /> Log &amp; end selected
</Button>
</div>
)}
</div>
{/* NET USERS / roster (right) */}
<div className="flex flex-col min-h-0 w-[40%] max-w-[560px]">
<div className="flex items-center gap-2 px-3 py-1.5 bg-emerald-700 text-white text-[11px] font-semibold uppercase tracking-wider">
NET users roster
<span className="ml-auto font-normal normal-case opacity-90">double-click put on air</span>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<AgGridReact<Station>
ref={rosterGrid}
theme={hamlogTheme}
rowData={rosterShown}
columnDefs={rosterCols}
defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onRowDoubleClicked={(e) => e.data && activate(e.data.callsign)}
animateRows={false}
getRowId={(p) => String((p.data as any).callsign)}
/>
</div>
</div>
<div className="px-3 py-1.5 border-t border-border/60 bg-muted/30 flex items-center gap-2">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" disabled={!selId} onClick={openAddContact}>
<UserPlus className="size-3.5" /> Add contact
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px] text-rose-700" disabled={!selId} onClick={removeSelectedRoster}>
<MinusCircle className="size-3.5" /> Remove
</Button>
{isOpen && (
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto"
onClick={() => { const r = rosterGrid.current?.api?.getSelectedRows?.()?.[0] as Station; if (r) activate(r.callsign); }}>
<PlusCircle className="size-3.5" /> Put selected on air
</Button>
)}
</div>
</div>
</div>
{/* Full-QSO edit modal for the selected on-air draft. Save writes back to
the in-memory draft (NetUpdateActive); Delete cancels it (no log). */}
{editingDraft && (
<QSOEditModal
qso={editingDraft}
onSave={saveDraft}
onDelete={discardDraft}
onClose={() => setEditingDraft(null)}
countries={countries}
bands={bands}
modes={modes}
/>
)}
{/* Add / edit contact dialog */}
<Dialog open={contactOpen} onOpenChange={setContactOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add contact to NET</DialogTitle>
<DialogDescription>Saved in this NET's roster (reused next time you open it).</DialogDescription>
</DialogHeader>
<div className="grid gap-3 px-5 py-2">
<div className="flex items-end gap-2">
<div className="flex-1">
<Label className="text-[11px]">Callsign</Label>
<Input className="font-mono uppercase" value={contact.callsign ?? ''}
onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, callsign: e.target.value.toUpperCase() }))}
onKeyDown={(e) => { if (e.key === 'Enter') lookupContact(); }} />
</div>
<Button variant="outline" size="sm" className="h-9" onClick={lookupContact} disabled={looking || !(contact.callsign ?? '').trim()}>
<Search className="size-3.5" /> {looking ? '' : 'Search'}
</Button>
</div>
<div>
<Label className="text-[11px]">Name</Label>
<Input value={contact.name ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, name: e.target.value }))} />
</div>
<div>
<Label className="text-[11px]">QTH</Label>
<Input value={contact.qth ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, qth: e.target.value }))} />
</div>
<div>
<Label className="text-[11px]">Country</Label>
<Input value={contact.country ?? ''} onChange={(e) => setContact((c) => netctl.Station.createFrom({ ...c, country: e.target.value }))} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setContactOpen(false)}>Cancel</Button>
<Button size="sm" onClick={saveContact} disabled={!(contact.callsign ?? '').trim()}>
<PlusCircle className="size-3.5" /> Save in NET
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+92 -82
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react'; import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import {
@@ -8,13 +10,34 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
ModuleRegistry.registerModules([AllCommunityModule]);
// Warm theme matching the other grids (Recent QSOs / Cluster).
const qslTheme = themeQuartz.withParams({
fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419',
headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600,
oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8',
borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 },
cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12,
});
type UploadRow = { type UploadRow = {
id: number; qso_date: string; callsign: string; id: number; qso_date: string; callsign: string;
band: string; mode: string; country: string; status: string; band: string; mode: string; country: string; status: string;
}; };
const UPLOAD_COLS: ColDef<UploadRow>[] = [
{ field: 'qso_date', headerName: 'Date UTC', valueFormatter: (p) => fmtDate(p.value), minWidth: 150 },
{ field: 'callsign', headerName: 'Callsign', cellClass: 'font-mono font-bold', width: 130 },
{ field: 'band', headerName: 'Band', width: 90 },
{ field: 'mode', headerName: 'Mode', width: 100 },
{ field: 'country', headerName: 'Country', flex: 1, minWidth: 140 },
{ field: 'status', headerName: 'Sent', width: 90, valueFormatter: (p) => p.value || '—' },
];
type Confirmation = { type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string; callsign: string; qso_date: string; band: string; mode: string; country: string;
new_dxcc: boolean; new_band: boolean; new_slot: boolean; new_dxcc: boolean; new_band: boolean; new_slot: boolean;
@@ -23,6 +46,8 @@ type Confirmation = {
const SERVICES = [ const SERVICES = [
{ v: 'qrz', label: 'QRZ.com' }, { v: 'qrz', label: 'QRZ.com' },
{ v: 'clublog', label: 'Club Log' }, { v: 'clublog', label: 'Club Log' },
{ v: 'hrdlog', label: 'HRDLog.net' },
{ v: 'eqsl', label: 'eQSL.cc' },
{ v: 'lotw', label: 'LoTW' }, { v: 'lotw', label: 'LoTW' },
{ v: 'pota', label: 'POTA hunter log' }, { v: 'pota', label: 'POTA hunter log' },
{ v: 'paper', label: 'Paper QSL' }, { v: 'paper', label: 'Paper QSL' },
@@ -36,6 +61,14 @@ const QSL_STATUSES = [
{ v: 'I', label: 'Ignore' }, { v: 'I', label: 'Ignore' },
]; ];
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
const QSL_VIA_OPTIONS = [
{ v: '_', label: '— leave —' },
{ v: 'Bureau', label: 'Bureau' },
{ v: 'Direct', label: 'Direct' },
{ v: 'Electronic', label: 'Electronic' },
];
type LogQSO = { type LogQSO = {
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string; id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string; qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
@@ -103,13 +136,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [paperCall, setPaperCall] = useState(''); const [paperCall, setPaperCall] = useState('');
const [paperRows, setPaperRows] = useState<LogQSO[]>([]); const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
const [paperSel, setPaperSel] = useState<Set<number>>(new Set()); const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
const [paperBusy, setPaperBusy] = useState(false); const [paperBusy, setPaperBusy] = useState(false);
const [paperMsg, setPaperMsg] = useState(''); const [paperMsg, setPaperMsg] = useState('');
const [qslRcvd, setQslRcvd] = useState('Y'); const [qslRcvd, setQslRcvd] = useState('N');
const [qslSent, setQslSent] = useState('_'); const [qslSent, setQslSent] = useState('_');
const [qslRcvdDate, setQslRcvdDate] = useState(''); const [qslRcvdDate, setQslRcvdDate] = useState('');
const [qslSentDate, setQslSentDate] = useState(''); const [qslSentDate, setQslSentDate] = useState('');
const [qslVia, setQslVia] = useState(''); const [qslVia, setQslVia] = useState('_');
const [qslNotes, setQslNotes] = useState('');
const [qslComment, setQslComment] = useState('');
const searchPaper = useCallback(async () => { const searchPaper = useCallback(async () => {
const c = paperCall.trim().toUpperCase(); const c = paperCall.trim().toUpperCase();
@@ -120,14 +156,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const list = (r ?? []) as LogQSO[]; const list = (r ?? []) as LogQSO[];
setPaperRows(list); setPaperRows(list);
setPaperSel(new Set(list.map((x) => x.id))); setPaperSel(new Set(list.map((x) => x.id)));
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); } } catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
finally { setPaperBusy(false); } finally { setPaperBusy(false); }
}, [paperCall]); }, [paperCall]);
function togglePaper(id: number) {
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
async function applyPaper() { async function applyPaper() {
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id); const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
@@ -140,7 +174,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
rcvd_status: qslRcvd === '_' ? '' : qslRcvd, rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
sent_date: ymd(qslSentDate), sent_date: ymd(qslSentDate),
rcvd_date: ymd(qslRcvdDate), rcvd_date: ymd(qslRcvdDate),
via: qslVia, via: qslVia === '_' ? '' : qslVia,
notes: qslNotes,
comment: qslComment,
} as any); } as any);
setPaperMsg(`${n} QSO updated.`); setPaperMsg(`${n} QSO updated.`);
await searchPaper(); await searchPaper();
@@ -148,8 +184,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
finally { setPaperBusy(false); } finally { setPaperBusy(false); }
} }
const [sent, setSent] = useState('R'); const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]); // Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
const [selected, setSelected] = useState<Set<number>>(new Set()); const [rows, setRows] = useState<any[]>([]);
const [selectedCount, setSelectedCount] = useState(0);
const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false); const [addNotFound, setAddNotFound] = useState(false);
@@ -180,10 +219,14 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
return () => { offLog(); offDone(); offConf(); }; return () => { offLog(); offDone(); offConf(); };
}, []); }, []);
const selectedCount = selected.size;
const allSelected = rows.length > 0 && selected.size === rows.length;
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]); const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
// Grid selection → just track the count; ids are read from the grid at upload.
function onUploadRowSelected(ids: number[]) {
setUploadSelIds(ids);
setSelectedCount(ids.length);
}
const shownConfs = useMemo(() => confirmations.filter((c) => { const shownConfs = useMemo(() => confirmations.filter((c) => {
switch (confFilter) { switch (confFilter) {
case 'new': return c.new_dxcc || c.new_band || c.new_slot; case 'new': return c.new_dxcc || c.new_band || c.new_slot;
@@ -199,33 +242,24 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
setError(''); setError('');
try { try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent); const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[]; const list = (r ?? []) as any[];
setRows(list); setRows(list);
setSelected(new Set(list.map((x) => x.id))); setUploadSelIds(list.map((x) => x.id));
setSelectedCount(list.length);
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
setViewMode('upload'); setViewMode('upload');
setShowLog(false); setShowLog(false);
} catch (e: any) { } catch (e: any) {
setError(String(e?.message ?? e)); setError(String(e?.message ?? e));
setRows([]); setRows([]);
setSelected(new Set()); setSelectedCount(0);
} finally { } finally {
setSearching(false); setSearching(false);
} }
}, [service, sent]); }, [service, sent]);
function toggle(id: number) {
setSelected((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id); else n.add(id);
return n;
});
}
function toggleAll() {
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
}
async function upload() { async function upload() {
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id); const ids = uploadSelIds;
if (ids.length === 0) return; if (ids.length === 0) return;
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true); setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadQSOsManual(service, ids); } try { await UploadQSOsManual(service, ids); }
@@ -256,7 +290,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Service</label>
<Select value={service} onValueChange={setService}> <Select value={service} onValueChange={setService}>
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent> <SelectContent>{[...SERVICES].sort((a, b) => a.label.localeCompare(b.label)).map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select> </Select>
</div> </div>
{uploadCall && ( {uploadCall && (
@@ -360,32 +394,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
paperRows.length === 0 ? ( paperRows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div> <div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
) : ( ) : (
<table className="w-full text-xs border-collapse"> // Same grid as Recent QSOs (full columns + column picker). Selection
<thead className="sticky top-0 bg-card"> // drives which QSOs the apply-form below updates; a search selects all.
<tr className="text-left text-muted-foreground border-b border-border"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th> <RecentQSOsGrid
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th> rows={paperRows as any}
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th> total={paperRows.length}
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th> selectAllSignal={paperSelAllSig}
</tr> onRowSelected={(ids) => setPaperSel(new Set(ids))}
</thead> />
<tbody> </div>
{paperRows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
onClick={() => togglePaper(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
</tr>
))}
</tbody>
</table>
) )
) : service === 'pota' ? ( ) : service === 'pota' ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -482,33 +500,14 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div> <div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div>
) : ( ) : (
<table className="w-full text-xs border-collapse"> <div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<thead className="sticky top-0 bg-card"> <RecentQSOsGrid
<tr className="text-left text-muted-foreground border-b border-border"> rows={rows as any}
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th> total={rows.length}
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th> selectAllSignal={uploadSelAllSig}
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th> onRowSelected={onUploadRowSelected}
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th> />
</tr> </div>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
onClick={() => toggle(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
</td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
</tr>
))}
</tbody>
</table>
)} )}
</div> </div>
@@ -537,7 +536,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" /> <Select value={qslVia} onValueChange={setQslVia}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Notes</label>
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder="e.g. paid 3€" />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Comment</label>
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder="comment" />
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>} {paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
+36 -7
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-react'; import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
export type QSOMenuState = { x: number; y: number; ids: number[] } | null; export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
@@ -12,32 +12,35 @@ type Props = {
onSendTo?: (service: string, ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void; onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onDelete?: (ids: number[]) => void;
}; };
const UPLOAD_TARGETS: { service: string; label: string }[] = [ const UPLOAD_TARGETS: { service: string; label: string }[] = [
{ service: 'qrz', label: 'Send to QRZ.com' }, { service: 'qrz', label: 'Send to QRZ.com' },
{ service: 'clublog', label: 'Send to Club Log' }, { service: 'clublog', label: 'Send to Club Log' },
{ service: 'hrdlog', label: 'Send to HRDLog.net' },
{ service: 'eqsl', label: 'Send to eQSL.cc' },
{ service: 'lotw', label: 'Send to LoTW' }, { service: 'lotw', label: 'Send to LoTW' },
]; ];
// Lightweight right-click menu for the QSO grids. AG Grid's native context // Lightweight right-click menu for the QSO grids. AG Grid's native context
// menu is an Enterprise feature, so this is a plain floating menu driven by // menu is an Enterprise feature, so this is a plain floating menu driven by
// onCellContextMenu. Closes on any outside click, scroll or Escape. // onCellContextMenu. Stays open until the user clicks outside, presses Escape,
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) { // or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
// which used to dismiss the menu the instant it appeared.)
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete }: Props) {
useEffect(() => { useEffect(() => {
if (!menu) return; if (!menu) return;
const close = () => onClose(); const close = () => onClose();
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('mousedown', close); window.addEventListener('mousedown', close);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => {
window.removeEventListener('mousedown', close); window.removeEventListener('mousedown', close);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
window.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey);
}; };
}, [menu, onClose]); }, [menu, onClose]);
@@ -105,6 +108,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
</> </>
)} )}
{onBulkEdit && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
>
<PencilLine className="size-4 text-indigo-600" />
<span>Bulk edit field ({n})</span>
</button>
</>
)}
{(onExportSelected || onExportFiltered) && ( {(onExportSelected || onExportFiltered) && (
<> <>
<div className="my-1 border-t border-border" /> <div className="my-1 border-t border-border" />
@@ -144,6 +160,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
))} ))}
</> </>
)} )}
{onDelete && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-rose-700 hover:bg-rose-50"
onClick={() => { onDelete(menu.ids); onClose(); }}
>
<Trash2 className="size-4" />
<span>Delete {n} QSO{n > 1 ? 's' : ''}</span>
</button>
</>
)}
</div> </div>
); );
} }
+11 -5
View File
@@ -215,6 +215,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]); }, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
// accepts letters (sections/zones), not just numbers.
function setExchange(which: 'srx' | 'stx', raw: string) {
const t = raw.trim();
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
}
function set<K extends keyof QSO>(key: K, value: QSO[K]) { function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value })); setDraft((d) => ({ ...d, [key]: value }));
} }
@@ -561,7 +569,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</thead> </thead>
<tbody> <tbody>
{CONFIRMATIONS.map((c) => ( {CONFIRMATIONS.map((c) => (
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}> <tr key={c.key} className="text-xs">
<td className="font-medium pr-2 py-0.5">{c.label}</td> <td className="font-medium pr-2 py-0.5">{c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td> <td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td> <td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
@@ -578,10 +586,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<TabsContent value="contest" className="mt-0"> <TabsContent value="contest" className="mt-0">
<div className="grid grid-cols-6 gap-3"> <div className="grid grid-cols-6 gap-3">
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F> <F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F> <F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder="rcvd exchange" onChange={(e) => setExchange('srx', e.target.value)} /></F>
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F> <F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder="sent exchange" onChange={(e) => setExchange('stx', e.target.value)} /></F>
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F> <F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F> <F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F> <F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
+64 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
@@ -45,6 +45,9 @@ const hamlogTheme = themeQuartz.withParams({
type Props = { type Props = {
rows: QSOForm[]; rows: QSOForm[];
total: number; total: number;
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
onRowDoubleClicked?: (q: QSOForm) => void; onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void; onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void; onUpdateFromCty?: (ids: number[]) => void;
@@ -53,8 +56,13 @@ type Props = {
onSendTo?: (service: string, ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void; onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void;
onBulkEdit?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void; onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void; onExportFiltered?: () => void;
onDelete?: (ids: number[]) => void;
// One column per defined award; the cell shows the reference this QSO counts
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[];
}; };
const COL_STATE_KEY = 'hamlog.qsoColState.v2'; const COL_STATE_KEY = 'hamlog.qsoColState.v2';
@@ -152,6 +160,8 @@ export const COL_CATALOG: ColEntry[] = [
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc. // App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true }, { group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: 'Recording sent', colId: 'opslog_recording_sent', headerName: 'Rec sent', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
// ── Uploads (online logbooks) ── // ── Uploads (online logbooks) ──
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and, // ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
@@ -218,7 +228,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc', 'Contest', 'Propagation', 'My station', 'Misc',
]; ];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) {
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -244,10 +254,21 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
// Compute initial column defs: all columns defined, but those not marked // Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady) // defaultVisible start hidden. The user's saved state (loaded onGridReady)
// overrides this so a previously toggled column wins. // overrides this so a previously toggled column wins.
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => { const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c; const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible }; return { ...rest, hide: !defaultVisible };
}), []); });
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
headerName: a.code,
headerTooltip: `${a.name} — reference this QSO counts for`,
width: 110,
cellClass: 'text-[11px]',
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols]);
const defaultColDef = useMemo<ColDef>(() => ({ const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, sortable: true,
@@ -273,6 +294,18 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
if (state) saveState(COL_STATE_KEY, state); if (state) saveState(COL_STATE_KEY, state);
}, []); }, []);
// The award columns load asynchronously; when they arrive (or change) the
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
// default — wiping the user's saved visibility (award columns reappear,
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
// every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => {
const api = gridRef.current?.api;
if (!api) return;
const local = loadLocal(COL_STATE_KEY);
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
}, [awardCols]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) { function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
} }
@@ -280,6 +313,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? []; const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null)); onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
} }
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => {
if (selectAllSignal === undefined) return;
gridRef.current?.api?.selectAll();
}, [selectAllSignal]);
// ── Column picker (visibility) ── // ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React // Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
@@ -365,8 +403,10 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
onSendTo={onSendTo} onSendTo={onSendTo}
onSendRecording={onSendRecording} onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL} onSendEQSL={onSendEQSL}
onBulkEdit={onBulkEdit}
onExportSelected={onExportSelected} onExportSelected={onExportSelected}
onExportFiltered={onExportFiltered} onExportFiltered={onExportFiltered}
onDelete={onDelete}
/> />
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}> <Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
@@ -405,6 +445,26 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
</div> </div>
); );
})} })}
{awardCols && awardCols.length > 0 && (
<div className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
</div>
</div>
<div className="flex flex-col gap-1">
{awardCols.map((a) => (
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
<span className="font-mono font-semibold">{a.code}</span>
<span className="text-muted-foreground truncate">{a.name}</span>
</label>
))}
</div>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button> <Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
+298 -19
View File
@@ -12,6 +12,8 @@ import {
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetPGXLSettings, SavePGXLSettings,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
@@ -31,7 +33,7 @@ import {
GetTelemetryEnabled, SetTelemetryEnabled, GetTelemetryEnabled, SetTelemetryEnabled,
GetLiveStatusEnabled, SetLiveStatusEnabled, GetLiveStatusEnabled, SetLiveStatusEnabled,
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo, ComputeStationInfo,
@@ -170,6 +172,8 @@ type SectionId =
| 'rotator' | 'rotator'
| 'winkeyer' | 'winkeyer'
| 'antenna' | 'antenna'
| 'antgenius'
| 'pgxl'
| 'audio'; | 'audio';
type TreeNode = type TreeNode =
@@ -204,9 +208,11 @@ const TREE: TreeNode[] = [
{ {
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [ kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
{ kind: 'item', label: 'CAT interface', id: 'cat' }, { kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'Rotator', id: 'rotator' }, { kind: 'item', label: 'PstRotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' }, { kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna', id: 'antenna' }, { kind: 'item', label: 'UltraBeam', id: 'antenna' },
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
{ kind: 'item', label: 'Audio devices', id: 'audio' }, { kind: 'item', label: 'Audio devices', id: 'audio' },
], ],
}, },
@@ -229,9 +235,11 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
udp: 'UDP integrations', udp: 'UDP integrations',
awards: 'Awards', awards: 'Awards',
cat: 'CAT interface', cat: 'CAT interface',
rotator: 'Rotator', rotator: 'PstRotator',
winkeyer: 'CW Keyer', winkeyer: 'CW Keyer',
antenna: 'Antenna', antenna: 'UltraBeam',
antgenius: 'Antenna Genius',
pgxl: 'Power Genius',
audio: 'Audio devices', audio: 'Audio devices',
}; };
@@ -610,7 +618,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, poll_ms: 250, delay_ms: 0, enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', digital_default: 'FT8',
}); });
const [rotator, setRotator] = useState<RotatorSettings>({ const [rotator, setRotator] = useState<RotatorSettings>({
@@ -626,6 +635,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [ubTesting, setUbTesting] = useState(false); const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string }>({ enabled: false, host: '' });
// PowerGenius XL (4O3A) amp fan-control settings.
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
// WinKeyer CW keyer settings + macro editor. // WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string }; type WKMac = { label: string; text: string };
type WKSettings = { type WKSettings = {
@@ -747,20 +762,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// wired today. upload_mode is 'immediate' | 'delayed' (per-service). // wired today. upload_mode is 'immediate' | 'delayed' (per-service).
type ExtServiceCfg = { type ExtServiceCfg = {
api_key: string; email: string; username: string; password: string; callsign: string; api_key: string; email: string; username: string; password: string; callsign: string;
code: string; qth_nickname: string;
force_station_callsign: string; force_station_callsign: string;
tqsl_path: string; station_location: string; key_password: string; tqsl_path: string; station_location: string; key_password: string;
upload_flags: string[]; write_log: boolean; upload_flags: string[]; write_log: boolean;
auto_upload: boolean; upload_mode: string; auto_upload: boolean; upload_mode: string;
}; };
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg }; type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
const emptyExtCfg = (): ExtServiceCfg => ({ const emptyExtCfg = (): ExtServiceCfg => ({
api_key: '', email: '', username: '', password: '', callsign: '', api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '', force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
upload_flags: ['N', 'R'], write_log: false, upload_flags: ['N', 'R'], write_log: false,
auto_upload: false, upload_mode: 'immediate', auto_upload: false, upload_mode: 'immediate',
}); });
const [extSvc, setExtSvc] = useState<ExternalServices>({ const [extSvc, setExtSvc] = useState<ExternalServices>({
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
}); });
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null); const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [qrzTesting, setQrzTesting] = useState(false); const [qrzTesting, setQrzTesting] = useState(false);
@@ -768,10 +784,14 @@ 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);
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hrdlogTesting, setHrdlogTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [eqslTesting, setEqslTesting] = useState(false);
const [stationLocations, setStationLocations] = useState<string[]>([]); const [stationLocations, setStationLocations] = useState<string[]>([]);
// Active tab in the External Services panel — lifted here because // Active tab in the External Services panel — lifted here because
// PANELS[selected]() is called as a function, so panels can't hold hooks. // PANELS[selected]() is called as a function, so panels can't hold hooks.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw' | 'pota'>('qrz'); const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log). // POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
const [potaToken, setPotaToken] = useState(''); const [potaToken, setPotaToken] = useState('');
const [potaBusy, setPotaBusy] = useState(false); const [potaBusy, setPotaBusy] = useState(false);
@@ -878,6 +898,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setCatCfg(c); setCatCfg(c);
setRotator(r); setRotator(r);
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setPgxl(await GetPGXLSettings() as any); } catch {}
setBackupCfg(b as any); setBackupCfg(b as any);
setQslDefaults(qd as any); setQslDefaults(qd as any);
setExtSvc(es as any); setExtSvc(es as any);
@@ -917,6 +939,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setCatCfg(await GetCATSettings() as any); } catch {} try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotator(await GetRotatorSettings() as any); } catch {} try { setRotator(await GetRotatorSettings() as any); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setPgxl(await GetPGXLSettings() as any); } catch {}
try { setBackupCfg(await GetBackupSettings() as any); } catch {} try { setBackupCfg(await GetBackupSettings() as any); } catch {}
try { setQslDefaults(await GetQSLDefaults() as any); } catch {} try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
try { setExtSvc(await GetExternalServices() as any); } catch {} try { setExtSvc(await GetExternalServices() as any); } catch {}
@@ -1084,6 +1108,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveCATSettings(catCfg as any); await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any); await SaveRotatorSettings(rotator as any);
await SaveUltrabeamSettings(ultrabeam as any); await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await SavePGXLSettings(pgxl as any);
await SaveWinkeyerSettings(wk as any); await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any); await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any); await SaveEmailSettings(emailCfg as any);
@@ -1769,6 +1795,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent> <SelectContent>
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem> <SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem> <SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -1805,7 +1832,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label> </label>
</> </>
)} )}
{catCfg.backend === 'omnirig' && ( {catCfg.backend === 'icom' && (
<>
<div className="space-y-1">
<Label>Icom CI-V port</Label>
<div className="flex gap-2">
<Select value={catCfg.icom_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_port: v }))}>
<SelectTrigger><SelectValue placeholder="Select COM port" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud rate</Label>
<Select value={String(catCfg.icom_baud || 115200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_baud: parseInt(v) || 115200 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1 col-span-2">
<Label>CI-V address (hex)</Label>
<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 })); }} />
<p className="text-xs text-muted-foreground">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.</p>
</div>
</>
)}
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
<> <>
<div className="space-y-1"> <div className="space-y-1">
<Label>Poll interval (ms)</Label> <Label>Poll interval (ms)</Label>
@@ -1902,7 +1962,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<> <>
<SectionHeader <SectionHeader
title="Antenna (Ultrabeam)" title="Antenna (Ultrabeam)"
hint="OpsLog talks to the Ultrabeam controller over TCP — typically via an RS232↔Ethernet adapter. Enter its IP address and port; the pattern direction (Normal / 180° / Bidirectional) is then controlled from the status bar."
/> />
<div className="space-y-4 max-w-xl"> <div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -1964,9 +2023,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{ubTest.msg} {ubTest.msg}
</div> </div>
)} )}
<p className="text-xs text-muted-foreground"> </div>
Once enabled, the antenna's direction control (Normal / 180° / Bidirectional) appears in the bottom status bar, next to CAT and Rotator. Changing the direction re-tunes the elements at the current frequency. </>
</p> );
}
function AntGeniusPanelSettings() {
return (
<>
<SectionHeader
title="Antenna Genius (4O3A)"
hint="OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B)."
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={antgenius.enabled} onCheckedChange={(c) => setAntgenius((s) => ({ ...s, enabled: !!c }))} />
Enable Antenna Genius control
</label>
<div className="space-y-1">
<Label>Host / IP</Label>
<Input
value={antgenius.host ?? ''}
onChange={(e) => setAntgenius((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.60"
className="font-mono"
/>
</div>
</div>
</>
);
}
function PGXLPanelSettings() {
return (
<>
<SectionHeader
title="Power Genius XL"
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
Enable PowerGenius fan control
</label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={pgxl.host ?? ''}
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.70"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input
type="number" min={1} max={65535}
value={pgxl.port}
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
className="font-mono"
/>
</div>
</div>
</div> </div>
</> </>
); );
@@ -2050,7 +2168,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<> <>
<SectionHeader <SectionHeader
title="CW Keyer" title="CW Keyer"
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools WinKeyer CW keyer)."
/> />
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -2603,9 +2720,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [ const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
{ k: 'qrz', label: 'QRZ.COM', ready: true }, { k: 'qrz', label: 'QRZ.COM', ready: true },
{ k: 'clublog', label: 'CLUBLOG', ready: true }, { k: 'clublog', label: 'CLUBLOG', ready: true },
{ k: 'hrdlog', label: 'HRDLOG.NET' }, { k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
{ k: 'eqsl', label: 'EQSL' }, { k: 'eqsl', label: 'EQSL', ready: true },
{ k: 'hamqth', label: 'HAMQTH' },
{ k: 'lotw', label: 'LOTW', ready: true }, { k: 'lotw', label: 'LOTW', ready: true },
{ k: 'pota', label: 'POTA', ready: true }, { k: 'pota', label: 'POTA', ready: true },
]; ];
@@ -2658,6 +2774,42 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
} }
} }
const hrdlog = extSvc.hrdlog;
const setHrdlog = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, hrdlog: { ...s.hrdlog, ...patch } }));
async function testHrdlog() {
setHrdlogTesting(true);
setHrdlogTest(null);
try {
await SaveExternalServices(extSvc as any);
const msg = await TestHRDLogUpload();
setHrdlogTest({ ok: true, msg });
} catch (e: any) {
setHrdlogTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setHrdlogTesting(false);
}
}
const eqsl = extSvc.eqsl;
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
async function testEqsl() {
setEqslTesting(true);
setEqslTest(null);
try {
await SaveExternalServices(extSvc as any);
const msg = await TestEQSLUpload();
setEqslTest({ ok: true, msg });
} catch (e: any) {
setEqslTest({ ok: false, msg: String(e?.message ?? e) });
} finally {
setEqslTesting(false);
}
}
const lotw = extSvc.lotw; const lotw = extSvc.lotw;
const setLotw = (patch: Partial<ExtServiceCfg>) => const setLotw = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } })); setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
@@ -2833,6 +2985,131 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
</div> </div>
</div> </div>
) : extSvcTab === 'hrdlog' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Station callsign</Label>
<Input
value={hrdlog.callsign}
onChange={(e) => setHrdlog({ callsign: e.target.value.toUpperCase() })}
placeholder="defaults to the active profile's callsign"
className="font-mono text-xs"
/>
<Label className="text-sm">Upload code</Label>
<Input
type="password"
value={hrdlog.code}
onChange={(e) => setHrdlog({ code: e.target.value })}
placeholder="HRDLog account My Account Upload code"
className="text-xs"
/>
</div>
<div className="text-[10px] text-muted-foreground -mt-1">
Find the upload code on HRDLog.net under <em>My Account</em>. It authorises uploads for this callsign.
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={hrdlog.auto_upload}
onCheckedChange={(c) => setHrdlog({ auto_upload: !!c })}
/>
Automatic upload on new QSO
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Upload timing</Label>
<Select
value={hrdlog.upload_mode || 'immediate'}
onValueChange={(v) => setHrdlog({ upload_mode: v })}
>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate">Immediate</SelectItem>
<SelectItem value="delayed">Delayed (12 min, lets you fix mistakes)</SelectItem>
<SelectItem value="on_close">On app close (batch)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={testHrdlog} disabled={hrdlogTesting}>
<UploadCloud className="size-3.5" /> {hrdlogTesting ? 'Testing…' : 'Test connection'}
</Button>
{hrdlogTest && (
<span className={cn('text-xs', hrdlogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
{hrdlogTest.msg}
</span>
)}
</div>
</div>
</div>
) : extSvcTab === 'eqsl' ? (
<div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Username (callsign)</Label>
<Input
value={eqsl.username}
onChange={(e) => setEqsl({ username: e.target.value.toUpperCase() })}
placeholder="your eQSL.cc login (callsign)"
className="font-mono text-xs"
/>
<Label className="text-sm">Password</Label>
<Input
type="password"
value={eqsl.password}
onChange={(e) => setEqsl({ password: e.target.value })}
placeholder="eQSL.cc account password"
className="text-xs"
/>
<Label className="text-sm">QTH nickname</Label>
<Input
value={eqsl.qth_nickname}
onChange={(e) => setEqsl({ qth_nickname: e.target.value })}
placeholder="optional required only if your eQSL account has several QTHs"
className="text-xs"
/>
</div>
<div className="text-[10px] text-muted-foreground -mt-1">
The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={eqsl.auto_upload}
onCheckedChange={(c) => setEqsl({ auto_upload: !!c })}
/>
Automatic upload on new QSO
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Upload timing</Label>
<Select
value={eqsl.upload_mode || 'immediate'}
onValueChange={(v) => setEqsl({ upload_mode: v })}
>
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate">Immediate</SelectItem>
<SelectItem value="delayed">Delayed (12 min, lets you fix mistakes)</SelectItem>
<SelectItem value="on_close">On app close (batch)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={testEqsl} disabled={eqslTesting}>
<UploadCloud className="size-3.5" /> {eqslTesting ? 'Testing…' : 'Test connection'}
</Button>
{eqslTest && (
<span className={cn('text-xs', eqslTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
{eqslTest.msg}
</span>
)}
</div>
</div>
</div>
) : extSvcTab === 'lotw' ? ( ) : extSvcTab === 'lotw' ? (
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center"> <div className="grid grid-cols-[170px_1fr] gap-3 items-center">
@@ -3567,6 +3844,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
rotator: RotatorPanel, rotator: RotatorPanel,
winkeyer: WinkeyerPanel, winkeyer: WinkeyerPanel,
antenna: UltrabeamPanel, antenna: UltrabeamPanel,
antgenius: AntGeniusPanelSettings,
pgxl: PGXLPanelSettings,
audio: AudioPanel, audio: AudioPanel,
}; };
+2 -2
View File
@@ -181,7 +181,7 @@ export function WinkeyerPanel({
someone answers. The seconds box is the gap AFTER the message. */} someone answers. The seconds box is the gap AFTER the message. */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none" <label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
title="After you click a macro (e.g. F1 CQ), resend it on a loop — message, then the gap, then repeat — until a callsign is entered or you press Stop"> title="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.">
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected} <input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
onChange={(e) => onToggleAutoCall(e.target.checked)} /> onChange={(e) => onToggleAutoCall(e.target.checked)} />
Auto-call Auto-call
@@ -193,7 +193,7 @@ export function WinkeyerPanel({
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} /> value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
<span className="text-[9px] text-muted-foreground">sec</span> <span className="text-[9px] text-muted-foreground">sec</span>
</div> </div>
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>} {autoCall && <span className="text-[10px] text-amber-600/80">click a CQ macro to loop it</span>}
</div> </div>
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */} {/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
+38 -3
View File
@@ -53,6 +53,9 @@ type Props = {
onSendTo?: (service: string, ids: number[]) => void; onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void; onSendRecording?: (ids: number[]) => void;
onSendEQSL?: (ids: number[]) => void; onSendEQSL?: (ids: number[]) => void;
onDelete?: (ids: number[]) => void;
// One column per defined award (cell = the reference this QSO counts for).
awardCols?: { code: string; name: string }[];
}; };
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2'; const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
@@ -65,7 +68,7 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
} }
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL }: Props) { export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onDelete, awardCols }: Props) {
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null); const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -93,10 +96,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
const count = wb?.count ?? 0; const count = wb?.count ?? 0;
const entries = wb?.entries ?? []; const entries = wb?.entries ?? [];
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => { const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c; const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible }; return { ...rest, hide: !defaultVisible };
}), []); });
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
colId: `award_${a.code}`,
headerName: a.code,
headerTooltip: `${a.name} — reference this QSO counts for`,
width: 110,
cellClass: 'text-[11px]',
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
}));
return [...base, ...awards];
}, [awardCols]);
const defaultColDef = useMemo<ColDef>(() => ({ const defaultColDef = useMemo<ColDef>(() => ({
sortable: true, resizable: true, filter: true, suppressMovable: false, sortable: true, resizable: true, filter: true, suppressMovable: false,
@@ -240,6 +254,7 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
onSendTo={onSendTo} onSendTo={onSendTo}
onSendRecording={onSendRecording} onSendRecording={onSendRecording}
onSendEQSL={onSendEQSL} onSendEQSL={onSendEQSL}
onDelete={onDelete}
/> />
{count > entries.length && ( {count > entries.length && (
@@ -283,6 +298,26 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
</div> </div>
); );
})} })}
{awardCols && awardCols.length > 0 && (
<div className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
<div className="flex gap-0.5">
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
</div>
</div>
<div className="flex flex-col gap-1">
{awardCols.map((a) => (
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
<span className="font-mono font-semibold">{a.code}</span>
<span className="text-muted-foreground truncate">{a.name}</span>
</label>
))}
</div>
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button> <Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
+1
View File
@@ -12,6 +12,7 @@ import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
const PORTABLE_KEYS = [ const PORTABLE_KEYS = [
'hamlog.qsoLimit', // QSO list page size 'hamlog.qsoLimit', // QSO list page size
'bandmap.side', // band map docked left / right 'bandmap.side', // band map docked left / right
'bandmap.show', // band map open / closed
'opslog.autofocusWB', // auto-focus Worked-before 'opslog.autofocusWB', // auto-focus Worked-before
'hamlog.filterPresets', // Filter Builder saved presets 'hamlog.filterPresets', // Filter Builder saved presets
'opslog.showRotor', // rotor compass shown next to the keyers 'opslog.showRotor', // rotor compass shown next to the keyers
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.11.3'; export const APP_VERSION = '0.14.1';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+131 -2
View File
@@ -5,15 +5,18 @@ import {qso} from '../models';
import {main} from '../models'; import {main} from '../models';
import {cat} from '../models'; import {cat} from '../models';
import {profile} from '../models'; import {profile} from '../models';
import {antgenius} from '../models';
import {award} from '../models'; import {award} from '../models';
import {awardref} from '../models'; 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 {winkeyer} from '../models'; import {winkeyer} from '../models';
import {audio} from '../models'; import {audio} from '../models';
import {operating} from '../models'; import {operating} from '../models';
import {udp} from '../models'; import {udp} from '../models';
import {lookup} from '../models'; import {lookup} from '../models';
import {netctl} from '../models';
export function ADIFFields():Promise<Array<adif.FieldDef>>; export function ADIFFields():Promise<Array<adif.FieldDef>>;
@@ -23,6 +26,10 @@ export function ActivateProfile(arg1:number):Promise<void>;
export function AddQSO(arg1:qso.QSO):Promise<number>; export function AddQSO(arg1:qso.QSO):Promise<number>;
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
export function AntGeniusDeselect(arg1:number):Promise<void>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>; 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>;
@@ -33,10 +40,18 @@ export function AwardFields():Promise<Array<string>>;
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>; export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
export function BrowseExecutable():Promise<string>; export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>; export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CWDecoderRunning():Promise<boolean>;
export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>; export function CheckForUpdate():Promise<main.UpdateInfo>;
export function ClearLookupCache():Promise<void>; export function ClearLookupCache():Promise<void>;
@@ -115,7 +130,7 @@ export function ExportAwards():Promise<string>;
export function FilterFields():Promise<Array<string>>; export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>; export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
export function FlexATUBypass():Promise<void>; export function FlexATUBypass():Promise<void>;
@@ -133,16 +148,34 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
export function FlexSetANFLevel(arg1:number):Promise<void>; export function FlexSetANFLevel(arg1:number):Promise<void>;
export function FlexSetAPF(arg1:boolean):Promise<void>;
export function FlexSetAPFLevel(arg1:number):Promise<void>;
export function FlexSetATUMemories(arg1:boolean):Promise<void>; export function FlexSetATUMemories(arg1:boolean):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 FlexSetCWFilter(arg1:number):Promise<void>;
export function FlexSetCWPitch(arg1:number):Promise<void>;
export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
export function FlexSetCWSpeed(arg1:number):Promise<void>;
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 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>;
export function FlexSetMute(arg1:boolean):Promise<void>;
export function FlexSetNB(arg1:boolean):Promise<void>; export function FlexSetNB(arg1:boolean):Promise<void>;
export function FlexSetNBLevel(arg1:number):Promise<void>; export function FlexSetNBLevel(arg1:number):Promise<void>;
@@ -157,6 +190,8 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>; export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetSidetoneLevel(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>;
@@ -169,6 +204,10 @@ export function FlexTune(arg1:boolean):Promise<void>;
export function GetActiveProfile():Promise<profile.Profile>; export function GetActiveProfile():Promise<profile.Profile>;
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
export function GetAntGeniusStatus():Promise<antgenius.Status>;
export function GetAudioSettings():Promise<main.AudioSettings>; export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>; export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
@@ -191,6 +230,10 @@ export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>; export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>; export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
export function GetClusterAutoConnect():Promise<boolean>; export function GetClusterAutoConnect():Promise<boolean>;
@@ -217,6 +260,8 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexState():Promise<cat.FlexTXState>; export function GetFlexState():Promise<cat.FlexTXState>;
export function GetIcomState():Promise<cat.IcomTXState>;
export function GetListsSettings():Promise<main.ListsSettings>; export function GetListsSettings():Promise<main.ListsSettings>;
export function GetLiveStatusEnabled():Promise<boolean>; export function GetLiveStatusEnabled():Promise<boolean>;
@@ -229,6 +274,12 @@ export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMySQLSettings():Promise<main.MySQLSettings>; export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
export function GetPGXLSettings():Promise<main.PGXLSettings>;
export function GetPGXLStatus():Promise<powergenius.Status>;
export function GetPOTAToken():Promise<string>; export function GetPOTAToken():Promise<string>;
export function GetQSLDefaults():Promise<main.QSLDefaults>; export function GetQSLDefaults():Promise<main.QSLDefaults>;
@@ -259,7 +310,31 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>; export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>; export function IcomRefresh():Promise<void>;
export function IcomSetAFGain(arg1:number):Promise<void>;
export function IcomSetAGC(arg1:string):Promise<void>;
export function IcomSetANF(arg1:boolean):Promise<void>;
export function IcomSetAtt(arg1:number):Promise<void>;
export function IcomSetFilter(arg1:number):Promise<void>;
export function IcomSetNB(arg1:boolean):Promise<void>;
export function IcomSetNBLevel(arg1:number):Promise<void>;
export function IcomSetNR(arg1:boolean):Promise<void>;
export function IcomSetNRLevel(arg1:number):Promise<void>;
export function IcomSetPreamp(arg1:number):Promise<void>;
export function IcomSetRFGain(arg1:number):Promise<void>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>; export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
@@ -299,6 +374,40 @@ export function LookupCallsign(arg1:string):Promise<lookup.Result>;
export function MoveDatabase(arg1:string):Promise<void>; export function MoveDatabase(arg1:string):Promise<void>;
export function NetActivate(arg1:string):Promise<qso.QSO>;
export function NetActiveList():Promise<Array<qso.QSO>>;
export function NetClose():Promise<void>;
export function NetCreate(arg1:string):Promise<netctl.Net>;
export function NetDeactivate(arg1:number):Promise<number>;
export function NetDelete(arg1:string):Promise<void>;
export function NetDiscardActive(arg1:number):Promise<void>;
export function NetList():Promise<Array<netctl.Net>>;
export function NetLookup(arg1:string):Promise<netctl.Station>;
export function NetOpen(arg1:string):Promise<void>;
export function NetOpenID():Promise<string>;
export function NetRename(arg1:string,arg2:string):Promise<void>;
export function NetRoster(arg1:string):Promise<Array<netctl.Station>>;
export function NetRosterRemove(arg1:string,arg2:string):Promise<void>;
export function NetRosterUpsert(arg1:string,arg2:netctl.Station):Promise<void>;
export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function OpenADIFFile():Promise<string>; export function OpenADIFFile():Promise<string>;
export function OpenDatabase(arg1:string):Promise<void>; export function OpenDatabase(arg1:string):Promise<void>;
@@ -307,6 +416,8 @@ export function OpenExternalURL(arg1:string):Promise<void>;
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>; export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
export function PGXLSetFanMode(arg1:string):Promise<void>;
export function PickAudioFolder():Promise<string>; export function PickAudioFolder():Promise<string>;
export function PickBackupFolder():Promise<string>; export function PickBackupFolder():Promise<string>;
@@ -355,6 +466,8 @@ export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>; export function QSOAudioCancel():Promise<void>;
export function QSOAudioResetClock():Promise<boolean>;
export function QSOAudioRestart():Promise<boolean>; export function QSOAudioRestart():Promise<boolean>;
export function QuitApp():Promise<void>; export function QuitApp():Promise<void>;
@@ -389,6 +502,8 @@ export function RunBackupNow():Promise<string>;
export function SaveADIFFile():Promise<string>; export function SaveADIFFile():Promise<string>;
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>; export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>; export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
@@ -417,6 +532,8 @@ export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.A
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>; export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
export function SavePOTAToken(arg1:string):Promise<void>; export function SavePOTAToken(arg1:string):Promise<void>;
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>; export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
@@ -435,6 +552,8 @@ export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>; export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
export function SendClusterCommand(arg1:string):Promise<void>; export function SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>; export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
@@ -447,6 +566,8 @@ export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>; export function SetCATMode(arg1:string):Promise<void>;
export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>; export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>; export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
@@ -465,14 +586,22 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>; export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StopCWDecoder():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>; export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>; export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
export function TestClublogUpload():Promise<string>; export function TestClublogUpload():Promise<string>;
export function TestEQSLUpload():Promise<string>;
export function TestEmail(arg1:string):Promise<void>; export function TestEmail(arg1:string):Promise<void>;
export function TestHRDLogUpload():Promise<string>;
export function TestLoTWUpload():Promise<string>; export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
+254 -2
View File
@@ -18,6 +18,14 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1); return window['go']['main']['App']['AddQSO'](arg1);
} }
export function AntGeniusActivate(arg1, arg2) {
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
}
export function AntGeniusDeselect(arg1) {
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
}
export function ApplyAwardPreset(arg1, arg2) { export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2); return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
} }
@@ -38,14 +46,30 @@ export function AwardMissingQSOs(arg1) {
return window['go']['main']['App']['AwardMissingQSOs'](arg1); return window['go']['main']['App']['AwardMissingQSOs'](arg1);
} }
export function AwardRefsForQSOs(arg1) {
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
}
export function BrowseExecutable() { export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable'](); return window['go']['main']['App']['BrowseExecutable']();
} }
export function BulkUpdateField(arg1, arg2, arg3) {
return window['go']['main']['App']['BulkUpdateField'](arg1, arg2, arg3);
}
export function BulkUpdateQSL(arg1, arg2) { export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2); return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
} }
export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable']();
}
export function CheckForUpdate() { export function CheckForUpdate() {
return window['go']['main']['App']['CheckForUpdate'](); return window['go']['main']['App']['CheckForUpdate']();
} }
@@ -238,6 +262,14 @@ export function FlexSetANFLevel(arg1) {
return window['go']['main']['App']['FlexSetANFLevel'](arg1); return window['go']['main']['App']['FlexSetANFLevel'](arg1);
} }
export function FlexSetAPF(arg1) {
return window['go']['main']['App']['FlexSetAPF'](arg1);
}
export function FlexSetAPFLevel(arg1) {
return window['go']['main']['App']['FlexSetAPFLevel'](arg1);
}
export function FlexSetATUMemories(arg1) { export function FlexSetATUMemories(arg1) {
return window['go']['main']['App']['FlexSetATUMemories'](arg1); return window['go']['main']['App']['FlexSetATUMemories'](arg1);
} }
@@ -246,6 +278,30 @@ export function FlexSetAudioLevel(arg1) {
return window['go']['main']['App']['FlexSetAudioLevel'](arg1); return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
} }
export function FlexSetCWBreakInDelay(arg1) {
return window['go']['main']['App']['FlexSetCWBreakInDelay'](arg1);
}
export function FlexSetCWFilter(arg1) {
return window['go']['main']['App']['FlexSetCWFilter'](arg1);
}
export function FlexSetCWPitch(arg1) {
return window['go']['main']['App']['FlexSetCWPitch'](arg1);
}
export function FlexSetCWSidetone(arg1) {
return window['go']['main']['App']['FlexSetCWSidetone'](arg1);
}
export function FlexSetCWSpeed(arg1) {
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
}
export function FlexSetFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
}
export function FlexSetMic(arg1) { export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1); return window['go']['main']['App']['FlexSetMic'](arg1);
} }
@@ -258,6 +314,10 @@ export function FlexSetMonLevel(arg1) {
return window['go']['main']['App']['FlexSetMonLevel'](arg1); return window['go']['main']['App']['FlexSetMonLevel'](arg1);
} }
export function FlexSetMute(arg1) {
return window['go']['main']['App']['FlexSetMute'](arg1);
}
export function FlexSetNB(arg1) { export function FlexSetNB(arg1) {
return window['go']['main']['App']['FlexSetNB'](arg1); return window['go']['main']['App']['FlexSetNB'](arg1);
} }
@@ -286,6 +346,10 @@ export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1); return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
} }
export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
}
export function FlexSetTunePower(arg1) { export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1); return window['go']['main']['App']['FlexSetTunePower'](arg1);
} }
@@ -310,6 +374,14 @@ export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile'](); return window['go']['main']['App']['GetActiveProfile']();
} }
export function GetAntGeniusSettings() {
return window['go']['main']['App']['GetAntGeniusSettings']();
}
export function GetAntGeniusStatus() {
return window['go']['main']['App']['GetAntGeniusStatus']();
}
export function GetAudioSettings() { export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings'](); return window['go']['main']['App']['GetAudioSettings']();
} }
@@ -354,6 +426,14 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState'](); return window['go']['main']['App']['GetCATState']();
} }
export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch']();
}
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
export function GetClublogCtyInfo() { export function GetClublogCtyInfo() {
return window['go']['main']['App']['GetClublogCtyInfo'](); return window['go']['main']['App']['GetClublogCtyInfo']();
} }
@@ -406,6 +486,10 @@ export function GetFlexState() {
return window['go']['main']['App']['GetFlexState'](); return window['go']['main']['App']['GetFlexState']();
} }
export function GetIcomState() {
return window['go']['main']['App']['GetIcomState']();
}
export function GetListsSettings() { export function GetListsSettings() {
return window['go']['main']['App']['GetListsSettings'](); return window['go']['main']['App']['GetListsSettings']();
} }
@@ -430,6 +514,18 @@ export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings'](); return window['go']['main']['App']['GetMySQLSettings']();
} }
export function GetOnlineOperators() {
return window['go']['main']['App']['GetOnlineOperators']();
}
export function GetPGXLSettings() {
return window['go']['main']['App']['GetPGXLSettings']();
}
export function GetPGXLStatus() {
return window['go']['main']['App']['GetPGXLStatus']();
}
export function GetPOTAToken() { export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken'](); return window['go']['main']['App']['GetPOTAToken']();
} }
@@ -490,8 +586,56 @@ export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1); return window['go']['main']['App']['HasBuiltinReferences'](arg1);
} }
export function ImportADIF(arg1, arg2, arg3) { export function IcomRefresh() {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3); return window['go']['main']['App']['IcomRefresh']();
}
export function IcomSetAFGain(arg1) {
return window['go']['main']['App']['IcomSetAFGain'](arg1);
}
export function IcomSetAGC(arg1) {
return window['go']['main']['App']['IcomSetAGC'](arg1);
}
export function IcomSetANF(arg1) {
return window['go']['main']['App']['IcomSetANF'](arg1);
}
export function IcomSetAtt(arg1) {
return window['go']['main']['App']['IcomSetAtt'](arg1);
}
export function IcomSetFilter(arg1) {
return window['go']['main']['App']['IcomSetFilter'](arg1);
}
export function IcomSetNB(arg1) {
return window['go']['main']['App']['IcomSetNB'](arg1);
}
export function IcomSetNBLevel(arg1) {
return window['go']['main']['App']['IcomSetNBLevel'](arg1);
}
export function IcomSetNR(arg1) {
return window['go']['main']['App']['IcomSetNR'](arg1);
}
export function IcomSetNRLevel(arg1) {
return window['go']['main']['App']['IcomSetNRLevel'](arg1);
}
export function IcomSetPreamp(arg1) {
return window['go']['main']['App']['IcomSetPreamp'](arg1);
}
export function IcomSetRFGain(arg1) {
return window['go']['main']['App']['IcomSetRFGain'](arg1);
}
export function ImportADIF(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
} }
export function ImportAwardReferencesText(arg1, arg2) { export function ImportAwardReferencesText(arg1, arg2) {
@@ -570,6 +714,74 @@ export function MoveDatabase(arg1) {
return window['go']['main']['App']['MoveDatabase'](arg1); return window['go']['main']['App']['MoveDatabase'](arg1);
} }
export function NetActivate(arg1) {
return window['go']['main']['App']['NetActivate'](arg1);
}
export function NetActiveList() {
return window['go']['main']['App']['NetActiveList']();
}
export function NetClose() {
return window['go']['main']['App']['NetClose']();
}
export function NetCreate(arg1) {
return window['go']['main']['App']['NetCreate'](arg1);
}
export function NetDeactivate(arg1) {
return window['go']['main']['App']['NetDeactivate'](arg1);
}
export function NetDelete(arg1) {
return window['go']['main']['App']['NetDelete'](arg1);
}
export function NetDiscardActive(arg1) {
return window['go']['main']['App']['NetDiscardActive'](arg1);
}
export function NetList() {
return window['go']['main']['App']['NetList']();
}
export function NetLookup(arg1) {
return window['go']['main']['App']['NetLookup'](arg1);
}
export function NetOpen(arg1) {
return window['go']['main']['App']['NetOpen'](arg1);
}
export function NetOpenID() {
return window['go']['main']['App']['NetOpenID']();
}
export function NetRename(arg1, arg2) {
return window['go']['main']['App']['NetRename'](arg1, arg2);
}
export function NetRoster(arg1) {
return window['go']['main']['App']['NetRoster'](arg1);
}
export function NetRosterRemove(arg1, arg2) {
return window['go']['main']['App']['NetRosterRemove'](arg1, arg2);
}
export function NetRosterUpsert(arg1, arg2) {
return window['go']['main']['App']['NetRosterUpsert'](arg1, arg2);
}
export function NetSetDefaults(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['NetSetDefaults'](arg1, arg2, arg3, arg4);
}
export function NetUpdateActive(arg1) {
return window['go']['main']['App']['NetUpdateActive'](arg1);
}
export function OpenADIFFile() { export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile'](); return window['go']['main']['App']['OpenADIFFile']();
} }
@@ -586,6 +798,10 @@ export function OperatingDefaultForBand(arg1) {
return window['go']['main']['App']['OperatingDefaultForBand'](arg1); return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
} }
export function PGXLSetFanMode(arg1) {
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
}
export function PickAudioFolder() { export function PickAudioFolder() {
return window['go']['main']['App']['PickAudioFolder'](); return window['go']['main']['App']['PickAudioFolder']();
} }
@@ -682,6 +898,10 @@ export function QSOAudioCancel() {
return window['go']['main']['App']['QSOAudioCancel'](); return window['go']['main']['App']['QSOAudioCancel']();
} }
export function QSOAudioResetClock() {
return window['go']['main']['App']['QSOAudioResetClock']();
}
export function QSOAudioRestart() { export function QSOAudioRestart() {
return window['go']['main']['App']['QSOAudioRestart'](); return window['go']['main']['App']['QSOAudioRestart']();
} }
@@ -750,6 +970,10 @@ export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile'](); return window['go']['main']['App']['SaveADIFFile']();
} }
export function SaveAntGeniusSettings(arg1) {
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
}
export function SaveAudioSettings(arg1) { export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1); return window['go']['main']['App']['SaveAudioSettings'](arg1);
} }
@@ -806,6 +1030,10 @@ export function SaveOperatingStation(arg1) {
return window['go']['main']['App']['SaveOperatingStation'](arg1); return window['go']['main']['App']['SaveOperatingStation'](arg1);
} }
export function SavePGXLSettings(arg1) {
return window['go']['main']['App']['SavePGXLSettings'](arg1);
}
export function SavePOTAToken(arg1) { export function SavePOTAToken(arg1) {
return window['go']['main']['App']['SavePOTAToken'](arg1); return window['go']['main']['App']['SavePOTAToken'](arg1);
} }
@@ -842,6 +1070,10 @@ export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4); return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
} }
export function SendChatMessage(arg1) {
return window['go']['main']['App']['SendChatMessage'](arg1);
}
export function SendClusterCommand(arg1) { export function SendClusterCommand(arg1) {
return window['go']['main']['App']['SendClusterCommand'](arg1); return window['go']['main']['App']['SendClusterCommand'](arg1);
} }
@@ -866,6 +1098,10 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1); return window['go']['main']['App']['SetCATMode'](arg1);
} }
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
export function SetClublogCtyEnabled(arg1) { export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1); return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
} }
@@ -902,6 +1138,14 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1); return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
} }
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
export function SwitchCATRig(arg1) { export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1); return window['go']['main']['App']['SwitchCATRig'](arg1);
} }
@@ -914,10 +1158,18 @@ export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload'](); return window['go']['main']['App']['TestClublogUpload']();
} }
export function TestEQSLUpload() {
return window['go']['main']['App']['TestEQSLUpload']();
}
export function TestEmail(arg1) { export function TestEmail(arg1) {
return window['go']['main']['App']['TestEmail'](arg1); return window['go']['main']['App']['TestEmail'](arg1);
} }
export function TestHRDLogUpload() {
return window['go']['main']['App']['TestHRDLogUpload']();
}
export function TestLoTWUpload() { export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload'](); return window['go']['main']['App']['TestLoTWUpload']();
} }
+309 -24
View File
@@ -65,6 +65,69 @@ export namespace adif {
} }
export namespace antgenius {
export class Antenna {
index: number;
name: string;
static createFrom(source: any = {}) {
return new Antenna(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.index = source["index"];
this.name = source["name"];
}
}
export class Status {
connected: boolean;
host?: string;
last_error?: string;
port_a: number;
port_b: number;
tx_a: boolean;
tx_b: boolean;
antennas: Antenna[];
static createFrom(source: any = {}) {
return new Status(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.host = source["host"];
this.last_error = source["last_error"];
this.port_a = source["port_a"];
this.port_b = source["port_b"];
this.tx_a = source["tx_a"];
this.tx_b = source["tx_b"];
this.antennas = this.convertValues(source["antennas"], Antenna);
}
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 audio { export namespace audio {
export class Device { export class Device {
@@ -452,12 +515,23 @@ export namespace cat {
agc_mode?: string; agc_mode?: string;
agc_threshold: number; agc_threshold: number;
audio_level: number; audio_level: number;
mute: boolean;
nb: boolean; nb: boolean;
nb_level: number; nb_level: number;
nr: boolean; nr: boolean;
nr_level: number; nr_level: number;
anf: boolean; anf: boolean;
anf_level: number; anf_level: number;
mode?: string;
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;
amp_available: boolean; amp_available: boolean;
amp_model?: string; amp_model?: string;
amp_operate: boolean; amp_operate: boolean;
@@ -490,12 +564,23 @@ export namespace cat {
this.agc_mode = source["agc_mode"]; this.agc_mode = source["agc_mode"];
this.agc_threshold = source["agc_threshold"]; this.agc_threshold = source["agc_threshold"];
this.audio_level = source["audio_level"]; this.audio_level = source["audio_level"];
this.mute = source["mute"];
this.nb = source["nb"]; this.nb = source["nb"];
this.nb_level = source["nb_level"]; this.nb_level = source["nb_level"];
this.nr = source["nr"]; this.nr = source["nr"];
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.mode = source["mode"];
this.cw_speed = source["cw_speed"];
this.cw_pitch = source["cw_pitch"];
this.cw_break_in_delay = source["cw_break_in_delay"];
this.cw_sidetone = source["cw_sidetone"];
this.cw_mon_level = source["cw_mon_level"];
this.apf = source["apf"];
this.apf_level = source["apf_level"];
this.filter_lo = source["filter_lo"];
this.filter_hi = source["filter_hi"];
this.amp_available = source["amp_available"]; this.amp_available = source["amp_available"];
this.amp_model = source["amp_model"]; this.amp_model = source["amp_model"];
this.amp_operate = source["amp_operate"]; this.amp_operate = source["amp_operate"];
@@ -521,6 +606,44 @@ export namespace cat {
return a; return a;
} }
} }
export class IcomTXState {
available: boolean;
model?: string;
mode?: string;
af_gain: number;
rf_gain: number;
nb: boolean;
nb_level: number;
nr: boolean;
nr_level: number;
anf: boolean;
agc?: string;
preamp: number;
att: number;
filter: number;
static createFrom(source: any = {}) {
return new IcomTXState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"];
this.model = source["model"];
this.mode = source["mode"];
this.af_gain = source["af_gain"];
this.rf_gain = source["rf_gain"];
this.nb = source["nb"];
this.nb_level = source["nb_level"];
this.nr = source["nr"];
this.nr_level = source["nr_level"];
this.anf = source["anf"];
this.agc = source["agc"];
this.preamp = source["preamp"];
this.att = source["att"];
this.filter = source["filter"];
}
}
export class RigState { export class RigState {
enabled: boolean; enabled: boolean;
connected: boolean; connected: boolean;
@@ -669,6 +792,8 @@ export namespace extsvc {
username: string; username: string;
password: string; password: string;
callsign: string; callsign: string;
code: string;
qth_nickname: string;
force_station_callsign: string; force_station_callsign: string;
tqsl_path: string; tqsl_path: string;
station_location: string; station_location: string;
@@ -689,6 +814,8 @@ export namespace extsvc {
this.username = source["username"]; this.username = source["username"];
this.password = source["password"]; this.password = source["password"];
this.callsign = source["callsign"]; this.callsign = source["callsign"];
this.code = source["code"];
this.qth_nickname = source["qth_nickname"];
this.force_station_callsign = source["force_station_callsign"]; this.force_station_callsign = source["force_station_callsign"];
this.tqsl_path = source["tqsl_path"]; this.tqsl_path = source["tqsl_path"];
this.station_location = source["station_location"]; this.station_location = source["station_location"];
@@ -703,6 +830,8 @@ export namespace extsvc {
qrz: ServiceConfig; qrz: ServiceConfig;
clublog: ServiceConfig; clublog: ServiceConfig;
lotw: ServiceConfig; lotw: ServiceConfig;
hrdlog: ServiceConfig;
eqsl: ServiceConfig;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new ExternalServices(source); return new ExternalServices(source);
@@ -713,6 +842,8 @@ export namespace extsvc {
this.qrz = this.convertValues(source["qrz"], ServiceConfig); this.qrz = this.convertValues(source["qrz"], ServiceConfig);
this.clublog = this.convertValues(source["clublog"], ServiceConfig); this.clublog = this.convertValues(source["clublog"], ServiceConfig);
this.lotw = this.convertValues(source["lotw"], ServiceConfig); this.lotw = this.convertValues(source["lotw"], ServiceConfig);
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -829,6 +960,20 @@ export namespace lookup {
export namespace main { export namespace main {
export class AntGeniusSettings {
enabled: boolean;
host: string;
static createFrom(source: any = {}) {
return new AntGeniusSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.host = source["host"];
}
}
export class AudioSettings { export class AudioSettings {
from_radio: string; from_radio: string;
to_radio: string; to_radio: string;
@@ -1014,6 +1159,9 @@ export namespace main {
flex_host: string; flex_host: string;
flex_port: number; flex_port: number;
flex_spots: boolean; flex_spots: boolean;
icom_port: string;
icom_baud: number;
icom_addr: number;
poll_ms: number; poll_ms: number;
delay_ms: number; delay_ms: number;
digital_default: string; digital_default: string;
@@ -1030,11 +1178,50 @@ 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.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"];
this.poll_ms = source["poll_ms"]; this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"]; this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"]; this.digital_default = source["digital_default"];
} }
} }
export class ChatMessage {
id: number;
operator: string;
station: string;
message: string;
created_at: string;
static createFrom(source: any = {}) {
return new ChatMessage(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.operator = source["operator"];
this.station = source["station"];
this.message = source["message"];
this.created_at = source["created_at"];
}
}
export class ChatPresence {
operator: string;
station: string;
ago_secs: number;
static createFrom(source: any = {}) {
return new ChatPresence(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.operator = source["operator"];
this.station = source["station"];
this.ago_secs = source["ago_secs"];
}
}
export class ClublogCtyInfo { export class ClublogCtyInfo {
enabled: boolean; enabled: boolean;
loaded: boolean; loaded: boolean;
@@ -1288,6 +1475,22 @@ export namespace main {
this.database = source["database"]; this.database = source["database"];
} }
} }
export class PGXLSettings {
enabled: boolean;
host: string;
port: number;
static createFrom(source: any = {}) {
return new PGXLSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.host = source["host"];
this.port = source["port"];
}
}
export class POTAUnmatched { export class POTAUnmatched {
activator: string; activator: string;
date: string; date: string;
@@ -1361,6 +1564,8 @@ export namespace main {
sent_date: string; sent_date: string;
rcvd_date: string; rcvd_date: string;
via: string; via: string;
notes: string;
comment: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSLBulkUpdate(source); return new QSLBulkUpdate(source);
@@ -1373,6 +1578,8 @@ export namespace main {
this.sent_date = source["sent_date"]; this.sent_date = source["sent_date"];
this.rcvd_date = source["rcvd_date"]; this.rcvd_date = source["rcvd_date"];
this.via = source["via"]; this.via = source["via"];
this.notes = source["notes"];
this.comment = source["comment"];
} }
} }
export class QSLDefaults { export class QSLDefaults {
@@ -1806,6 +2013,81 @@ export namespace main {
} }
export namespace netctl {
export class Station {
callsign: string;
name?: string;
qth?: string;
country?: string;
dxcc?: number;
itu?: number;
cq?: number;
groups?: string;
sig?: string;
sig_info?: string;
static createFrom(source: any = {}) {
return new Station(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.callsign = source["callsign"];
this.name = source["name"];
this.qth = source["qth"];
this.country = source["country"];
this.dxcc = source["dxcc"];
this.itu = source["itu"];
this.cq = source["cq"];
this.groups = source["groups"];
this.sig = source["sig"];
this.sig_info = source["sig_info"];
}
}
export class Net {
id: string;
name: string;
default_rst_sent?: string;
default_rst_rcvd?: string;
default_comment?: string;
stations?: Station[];
static createFrom(source: any = {}) {
return new Net(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.default_rst_sent = source["default_rst_sent"];
this.default_rst_rcvd = source["default_rst_rcvd"];
this.default_comment = source["default_comment"];
this.stations = this.convertValues(source["stations"], Station);
}
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 operating { export namespace operating {
export class AntennaBand { export class AntennaBand {
@@ -1924,6 +2206,33 @@ export namespace operating {
} }
export namespace powergenius {
export class Status {
connected: boolean;
host?: string;
last_error?: string;
state?: string;
fan_mode?: string;
temperature: number;
static createFrom(source: any = {}) {
return new Status(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.host = source["host"];
this.last_error = source["last_error"];
this.state = source["state"];
this.fan_mode = source["fan_mode"];
this.temperature = source["temperature"];
}
}
}
export namespace profile { export namespace profile {
export class ProfileDB { export class ProfileDB {
@@ -2592,30 +2901,6 @@ export namespace qso {
return a; return a;
} }
} }
export class UploadRow {
id: number;
qso_date: string;
callsign: string;
band: string;
mode: string;
country: string;
status: string;
static createFrom(source: any = {}) {
return new UploadRow(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.qso_date = source["qso_date"];
this.callsign = source["callsign"];
this.band = source["band"];
this.mode = source["mode"];
this.country = source["country"];
this.status = source["status"];
}
}
export class WorkedBefore { export class WorkedBefore {
callsign: string; callsign: string;
count: number; count: number;
+369
View File
@@ -0,0 +1,369 @@
// Package antgenius drives a 4O3A Antenna Genius switch over its v4 TCP/IP
// text API (default port 9007). On connect the device sends a banner line
// (e.g. "V4.1.16 AG"); commands are "C<seq>|<command>\r" and the device replies
// with "R<seq>|<hex>|<message>" (hex "0" = success) plus asynchronous
// "S<0>|<message>" status pushes once you subscribe with "sub port/antenna".
//
// (The older "GSCP" binary-ish framing documented at gscp.arula.rs is only used
// by pre-v4 firmware and is NOT what v4 speaks.)
package antgenius
import (
"bufio"
"fmt"
"net"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
defaultPort = 9007
dialTimeout = 5 * time.Second
writeTimeout = 3 * time.Second
readIdleTimeout = 12 * time.Second // no data for this long → assume the link is dead
keepaliveEvery = 3 * time.Second // periodic "port get" refreshes state + keeps the link alive
reconnectDelay = 2 * time.Second
)
// Antenna is one configured antenna (index + name as stored on the device).
type Antenna struct {
Index int `json:"index"`
Name string `json:"name"`
}
// Status is the snapshot the UI renders.
type Status struct {
Connected bool `json:"connected"`
Host string `json:"host,omitempty"`
LastError string `json:"last_error,omitempty"`
PortA int `json:"port_a"` // active antenna index on port A (0 = none)
PortB int `json:"port_b"` // active antenna index on port B
TxA bool `json:"tx_a"` // port A is transmitting
TxB bool `json:"tx_b"` // port B is transmitting
Antennas []Antenna `json:"antennas"`
}
type Client struct {
host string
port int
mu sync.Mutex // guards conn + writes
conn net.Conn
statusMu sync.RWMutex
status Status
antennas map[int]string // index → name (rebuilt into status.Antennas)
stop chan struct{}
running bool
}
func New(host string, port int) *Client {
if port <= 0 || port > 65535 {
port = defaultPort
}
return &Client{
host: host,
port: port,
stop: make(chan struct{}),
antennas: map[int]string{},
status: Status{Host: host},
}
}
func (c *Client) Start() error {
c.running = true
go c.runLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stop)
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setStatus(fn func(*Status)) {
c.statusMu.Lock()
fn(&c.status)
c.statusMu.Unlock()
}
// Activate selects antenna on a port (1 = A, 2 = B). antenna 0 deselects (sets
// the port to "None"). We set both RX and TX antennas and force manual mode so
// the choice sticks regardless of the device's auto band-following.
func (c *Client) Activate(port, antenna int) error {
if port != 1 && port != 2 {
return fmt.Errorf("antgenius: invalid port %d (1=A, 2=B)", port)
}
if antenna < 0 {
return fmt.Errorf("antgenius: invalid antenna %d", antenna)
}
// Set only rxant (like the reference ShackMaster client): the AG mirrors it
// to the TX antenna automatically. Forcing txant too can be rejected on the
// 8x2 (an antenna can't be TX on both ports at once), which broke port-B
// selection and deselection.
if err := c.send(fmt.Sprintf("port set %d rxant=%d", port, antenna)); err != nil {
return err
}
// Ask for the new port state so the snapshot reflects it promptly (the
// subscription also pushes it, but this makes the change deterministic).
_ = c.send(fmt.Sprintf("port get %d", port))
return nil
}
func (c *Client) runLoop() {
for {
if !c.running {
return
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
if err != nil {
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
if c.sleep(reconnectDelay) {
return
}
continue
}
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
// Subscribe to live updates and pull the initial state. Command set and
// order mirror a known-working Node-RED v4 client (WA9WUD).
_ = c.send("antenna list")
_ = c.send("sub port all")
_ = c.send("port get 1")
_ = c.send("port get 2")
done := make(chan struct{})
go c.keepalive(conn, done)
err = c.readLoop(conn) // blocks until the link errors
close(done)
c.mu.Lock()
if c.conn == conn {
c.conn = nil
}
c.mu.Unlock()
conn.Close()
c.setStatus(func(s *Status) {
s.Connected = false
if err != nil {
s.LastError = "read: " + err.Error()
}
})
if c.sleep(reconnectDelay) {
return
}
}
}
// keepalive periodically re-reads a port so an idle-but-dead link is detected
// (the read loop's idle timeout fires if these stop producing replies).
func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
t := time.NewTicker(keepaliveEvery)
defer t.Stop()
for {
select {
case <-done:
return
case <-c.stop:
return
case <-t.C:
_ = c.send("port get 1")
_ = c.send("port get 2")
}
}
}
func (c *Client) readLoop(conn net.Conn) error {
r := bufio.NewReader(conn)
var sb strings.Builder
for {
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
b, err := r.ReadByte()
if err != nil {
return err
}
if b == '\r' || b == '\n' {
if sb.Len() > 0 {
c.handleLine(sb.String())
sb.Reset()
}
continue
}
sb.WriteByte(b)
}
}
// send writes a "C<seq>|<command>\r" line to the device.
func (c *Client) send(command string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("antgenius: not connected")
}
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
// The device only accepts the constant "C1|" sequence prefix for every
// command (using incrementing sequence numbers makes it drop the link);
// commands are LF-terminated.
_, err := fmt.Fprintf(c.conn, "C1|%s\n", command)
return err
}
// handleLine parses one response/status/banner line and updates the snapshot.
func (c *Client) handleLine(line string) {
line = strings.TrimSpace(line)
if line == "" {
return
}
// Banner like "V4.1.16 AG" — just confirms the link is up.
if line[0] == 'V' && strings.Contains(line, "AG") {
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
return
}
// R<seq>|<hex>|<message> or S<seq>|<message>
var msg string
switch {
case strings.HasPrefix(line, "R"):
p := strings.SplitN(line, "|", 3)
if len(p) == 3 {
msg = p[2]
}
case strings.HasPrefix(line, "S"):
p := strings.SplitN(line, "|", 2)
if len(p) == 2 {
msg = p[1]
}
}
msg = strings.TrimSpace(msg)
switch {
case strings.HasPrefix(msg, "antenna "):
c.parseAntenna(msg)
case strings.HasPrefix(msg, "port "):
c.parsePort(msg)
}
}
// parseAntenna handles "antenna <id> name=<name> tx=.. rx=.. inband=..".
// The name may contain spaces, so it's extracted up to the " tx=" field.
func (c *Client) parseAntenna(msg string) {
fields := strings.Fields(msg)
if len(fields) < 2 {
return
}
id, err := strconv.Atoi(fields[1])
if err != nil {
return
}
name := ""
if i := strings.Index(msg, "name="); i >= 0 {
name = msg[i+len("name="):]
if j := strings.Index(name, " tx="); j >= 0 {
name = name[:j]
}
// The device stores spaces as underscores in names.
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
}
c.statusMu.Lock()
if name != "" && !isPlaceholderName(name) {
c.antennas[id] = name
} else {
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
}
c.status.Antennas = sortedAntennas(c.antennas)
c.status.Connected = true
c.statusMu.Unlock()
}
// parsePort handles "port <id> ... rxant=<n> txant=<n> ...". The active antenna
// shown is the TX antenna, falling back to the RX antenna when TX is none.
func (c *Client) parsePort(msg string) {
fields := strings.Fields(msg)
if len(fields) < 2 {
return
}
id, err := strconv.Atoi(fields[1])
if err != nil || (id != 1 && id != 2) {
return
}
tx := kvInt(msg, "txant")
rx := kvInt(msg, "rxant")
active := tx
if active == 0 {
active = rx
}
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
c.setStatus(func(s *Status) {
s.Connected = true
if id == 1 {
s.PortA, s.TxA = active, txOn
} else {
s.PortB, s.TxB = active, txOn
}
})
}
func (c *Client) sleep(d time.Duration) (stopped bool) {
select {
case <-c.stop:
return true
case <-time.After(d):
return false
}
}
// kvInt extracts the integer value of a "key=<int>" token from a space-
// separated string (returns 0 if absent).
func kvInt(s, key string) int {
i := strings.Index(s, key+"=")
if i < 0 {
return 0
}
v := s[i+len(key)+1:]
if sp := strings.IndexByte(v, ' '); sp >= 0 {
v = v[:sp]
}
n, _ := strconv.Atoi(strings.TrimSpace(v))
return n
}
// isPlaceholderName reports whether name is an unconfigured-slot default like
// "Antenna 4" / "antenna_5" (after underscores become spaces): the word
// "antenna" followed by a number, which the UI shouldn't list.
func isPlaceholderName(name string) bool {
f := strings.Fields(strings.ToLower(name))
if len(f) != 2 || f[0] != "antenna" {
return false
}
_, err := strconv.Atoi(f[1])
return err == nil
}
func sortedAntennas(m map[int]string) []Antenna {
out := make([]Antenna, 0, len(m))
for idx, name := range m {
out = append(out, Antenna{Index: idx, Name: name})
}
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
return out
}
+71
View File
@@ -0,0 +1,71 @@
package antgenius
import "testing"
func TestHandleAntennaList(t *testing.T) {
c := New("x", 9007)
// Names may contain spaces — must be captured up to " tx=".
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
st := c.GetStatus()
if len(st.Antennas) != 2 {
t.Fatalf("got %d antennas, want 2: %+v", len(st.Antennas), st.Antennas)
}
if st.Antennas[0].Index != 1 || st.Antennas[0].Name != "UB VL2.3" {
t.Errorf("antenna 1 = %+v", st.Antennas[0])
}
if st.Antennas[1].Name != "DX Commander" {
t.Errorf("antenna 2 name = %q", st.Antennas[1].Name)
}
}
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
c := New("x", 9007)
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
st := c.GetStatus()
if len(st.Antennas) != 1 || st.Antennas[0].Name != "Hex Beam" {
t.Fatalf("want only [Hex Beam], got %+v", st.Antennas)
}
}
func TestHandlePortStatus(t *testing.T) {
c := New("x", 9007)
// Async push after "sub port all": active antenna is txant (fallback rxant).
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
st := c.GetStatus()
if st.PortA != 2 {
t.Errorf("PortA = %d, want 2", st.PortA)
}
if st.PortB != 0 {
t.Errorf("PortB = %d, want 0 (none)", st.PortB)
}
// A "port get" reply (R-line) must parse the same way.
c.handleLine("R15|0|port 2 source=MANUAL band=3 rxant=5 txant=5 inband=0 inhibit=0")
if st = c.GetStatus(); st.PortB != 5 {
t.Errorf("PortB after port get = %d, want 5", st.PortB)
}
}
func TestPortTxFallbackToRx(t *testing.T) {
c := New("x", 9007)
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
if st := c.GetStatus(); st.PortA != 3 {
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
}
}
func TestKvInt(t *testing.T) {
s := "port 1 source=MANUAL band=6 rxant=2 txant=7 inhibit=0"
if v := kvInt(s, "txant"); v != 7 {
t.Errorf("txant = %d, want 7", v)
}
if v := kvInt(s, "rxant"); v != 2 {
t.Errorf("rxant = %d, want 2", v)
}
if v := kvInt(s, "missing"); v != 0 {
t.Errorf("missing = %d, want 0", v)
}
}
+13
View File
@@ -11,6 +11,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug"
"sync" "sync"
"time" "time"
) )
@@ -19,6 +20,7 @@ var (
mu sync.Mutex mu sync.Mutex
file *os.File file *os.File
path string path string
crashFile *os.File // kept open so the runtime can write a crash traceback to it
) )
// Init opens (creates) the log file in dataDir. On rotation we truncate // Init opens (creates) the log file in dataDir. On rotation we truncate
@@ -57,6 +59,17 @@ func Init(dataDir string) (string, error) {
file = f file = f
path = logPath path = logPath
// Capture a full traceback on a FATAL crash (a Go panic that escapes our
// recover()s, or a runtime-fatal error like a concurrent map write, or a
// Windows access violation routed through the Go signal handler) into a
// dedicated file the runtime writes directly — so otherwise-silent process
// deaths leave a stack we can read.
if cf, cerr := os.OpenFile(filepath.Join(dataDir, "opslog-crash.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); cerr == nil {
crashFile = cf
_ = debug.SetCrashOutput(cf, debug.CrashOptions{})
}
// Redirect log.Print* and the standard logger to the file too, so // Redirect log.Print* and the standard logger to the file too, so
// any third-party output stays consistent. // any third-party output stays consistent.
log.SetOutput(io.MultiWriter(file, os.Stderr)) log.SetOutput(io.MultiWriter(file, os.Stderr))
+17
View File
@@ -0,0 +1,17 @@
package audio
// SampleRate is the fixed capture rate (mono 16-bit PCM). Exported so consumers
// like the CW decoder can configure their DSP to match.
const SampleRate = sampleRate
// StreamCapture captures from deviceID and calls onSamples with mono 16-bit PCM
// frames (as int16) until stop closes. A thin wrapper over the internal capture
// loop for live consumers (the CW decoder) that want samples, not raw bytes.
func StreamCapture(deviceID string, stop <-chan struct{}, onSamples func([]int16)) error {
return captureStream(deviceID, stop, func(chunk []byte) {
if len(chunk) == 0 {
return
}
onSamples(bytesToInt16(chunk))
})
}
+14
View File
@@ -32,6 +32,20 @@ func writeMP3(path string, pcm []byte) error {
for i, v := range mono32 { for i, v := range mono32 {
stereo[2*i], stereo[2*i+1] = v, v stereo[2*i], stereo[2*i+1] = v, v
} }
// Shine's Write() reads a WHOLE frame (samplesPerPass × channels = 1152 × 2 =
// 2304 interleaved samples for MPEG-1) per pass via unsafe pointer arithmetic,
// regardless of how short the trailing chunk is. If the buffer isn't an exact
// multiple of a frame, the final pass reads past the slice and the process
// dies with an access violation (0xc0000005) inside windowFilterSubband.
// Pad with trailing silence to a whole number of frames so no partial pass
// exists. (~36 ms of silence at most — inaudible.)
const frameInterleaved = 1152 * 2 // samplesPerPass(MPEG-1) × 2 channels
if rem := len(stereo) % frameInterleaved; rem != 0 {
stereo = append(stereo, make([]int16, frameInterleaved-rem)...)
}
if len(stereo) == 0 {
return nil // nothing to encode (empty recording)
}
enc := mp3.NewEncoder(mp3Rate, 2) enc := mp3.NewEncoder(mp3Rate, 2)
return enc.Write(f, stereo) return enc.Write(f, stereo)
} }
+16
View File
@@ -239,6 +239,22 @@ func (r *Recorder) RestartQSO() {
r.active = true r.active = true
} }
// ResetQSOClock restarts the active accumulation from ZERO — discarding
// everything captured so far INCLUDING the pre-roll. Unlike RestartQSO (which
// re-seeds from the pre-roll ring), this keeps nothing: the saved file will
// contain only audio from this moment onward. Used when the contact you entered
// was already in a long QSO and you want to record just your own exchange.
// No-op if not running; if no take is active it begins one (empty).
func (r *Recorder) ResetQSOClock() {
r.mu.Lock()
defer r.mu.Unlock()
if !r.running {
return
}
r.acc = nil
r.active = true
}
// TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and // TakeQSO snapshots the accumulated recording as raw 16 kHz mono PCM bytes and
// stops accumulating — fast, no encoding. The next BeginQSO can safely start a // stops accumulating — fast, no encoding. The next BeginQSO can safely start a
// new take immediately. Pair with WritePCM to encode/write off the hot path so // new take immediately. Pair with WritePCM to encode/write off the hot path so
+87
View File
@@ -251,12 +251,24 @@ type FlexTXState struct {
AGCMode string `json:"agc_mode,omitempty"` AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"` AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"` AudioLevel int `json:"audio_level"`
Mute bool `json:"mute"`
NB bool `json:"nb"` NB bool `json:"nb"`
NBLevel int `json:"nb_level"` NBLevel int `json:"nb_level"`
NR bool `json:"nr"` NR bool `json:"nr"`
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"`
// CW / mode-specific controls.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"`
CWPitch int `json:"cw_pitch"`
CWBreakInDelay int `json:"cw_break_in_delay"`
CWSidetone bool `json:"cw_sidetone"`
CWMonLevel int `json:"cw_mon_level"` // sidetone level
APF bool `json:"apf"`
APFLevel int `json:"apf_level"`
FilterLo int `json:"filter_lo"`
FilterHi int `json:"filter_hi"`
// External amplifier (PowerGenius XL). // External amplifier (PowerGenius XL).
AmpAvailable bool `json:"amp_available"` AmpAvailable bool `json:"amp_available"`
AmpModel string `json:"amp_model,omitempty"` AmpModel string `json:"amp_model,omitempty"`
@@ -301,12 +313,23 @@ type FlexController interface {
SetAGCMode(string) error SetAGCMode(string) error
SetAGCThreshold(int) error SetAGCThreshold(int) error
SetAudioLevel(int) error SetAudioLevel(int) error
SetMute(bool) error
SetNB(bool) error SetNB(bool) error
SetNBLevel(int) error SetNBLevel(int) error
SetNR(bool) error SetNR(bool) error
SetNRLevel(int) error SetNRLevel(int) error
SetANF(bool) error SetANF(bool) error
SetANFLevel(int) error SetANFLevel(int) error
SetAPF(bool) error
SetAPFLevel(int) error
// CW keyer + mode-specific controls.
SetCWSpeed(int) error
SetCWPitch(int) error
SetCWBreakInDelay(int) error
SetCWSidetone(bool) error
SetSidetoneLevel(int) error
SetCWFilter(int) error
SetFilter(lo, hi int) error
// External amplifier (PowerGenius XL) operate/standby. // External amplifier (PowerGenius XL) operate/standby.
SetAmpOperate(bool) error SetAmpOperate(bool) error
} }
@@ -335,6 +358,70 @@ func (m *Manager) FlexDo(fn func(FlexController) error) error {
}) })
} }
// IcomTXState is the Icom receive-DSP state surfaced to the dedicated Icom
// control tab. Levels are 0-100 (scaled from the rig's 0-255). Unlike Flex,
// the Icom doesn't push changes, so these reflect the last RefreshIcom() read
// plus the optimistic updates each setter applies.
type IcomTXState struct {
Available bool `json:"available"`
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
AFGain int `json:"af_gain"`
RFGain int `json:"rf_gain"`
NB bool `json:"nb"`
NBLevel int `json:"nb_level"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"`
ANF bool `json:"anf"`
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
Att int `json:"att"` // dB attenuation, 0=off
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
}
// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the
// receive-DSP controls shown on the Icom tab. IcomState() is mutex-guarded in
// the backend so it's safe off the CAT goroutine; setters dispatch via IcomDo.
type IcomController interface {
IcomState() IcomTXState
RefreshIcom() error // re-read all DSP state from the rig
SetAFGain(int) error
SetRFGain(int) error
SetNB(bool) error
SetNBLevel(int) error
SetNR(bool) error
SetNRLevel(int) error
SetANF(bool) error
SetAGC(string) error
SetPreamp(int) error
SetAtt(int) error
SetIcomFilter(int) error
}
// IcomState returns the current Icom DSP state, or (zero, false) when the active
// backend isn't an Icom. Safe to call from any goroutine.
func (m *Manager) IcomState() (IcomTXState, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if ic, ok := b.(IcomController); ok {
return ic.IcomState(), true
}
return IcomTXState{}, false
}
// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the
// active backend isn't an Icom.
func (m *Manager) IcomDo(fn func(IcomController) error) error {
return m.exec(func(b Backend) error {
ic, ok := b.(IcomController)
if !ok {
return fmt.Errorf("active CAT backend is not an Icom")
}
return fn(ic)
})
}
// exec marshals a backend operation onto the CAT goroutine. Returns the // exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed. // operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error { func (m *Manager) exec(fn func(Backend) error) error {
+242
View File
@@ -0,0 +1,242 @@
// Package civ implements the Icom CI-V protocol independently of the transport
// carrying it. The exact same frames travel over a USB/serial port (local
// control) and, wrapped in Icom's UDP "serial" stream, over the network
// (remote control). Keeping the wire format in one place means the USB backend
// (icomserial) and a future network backend (icomnet) share all of it — only
// the transport differs.
//
// Frame layout: FE FE <to> <from> <cmd> [sub] [data…] FD
package civ
import (
"bytes"
"fmt"
)
// Protocol bytes.
const (
Pre = 0xFE // preamble (sent twice at the start of every frame)
End = 0xFD // end-of-message
OK = 0xFB // rig acknowledged a set command
NG = 0xFA // rig rejected a set command
// AddrController is the conventional address software uses for itself.
AddrController = 0xE0
)
// Commands (the few Phase-1 control needs; more get added with the panel).
const (
CmdTransceiveFreq = 0x00 // unsolicited freq update (dial turned)
CmdTransceiveMode = 0x01 // unsolicited mode update
CmdReadFreq = 0x03
CmdReadMode = 0x04
CmdSetFreq = 0x05
CmdSetMode = 0x06
CmdPTT = 0x1C // sub 0x00 = PTT
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
SubDataMode = 0x06
SubPTT = 0x00
// CmdLevel sub-commands.
SubLevelAF = 0x01 // AF (volume)
SubLevelRF = 0x02 // RF gain
SubLevelNR = 0x06 // noise-reduction depth
SubLevelNB = 0x12 // noise-blanker depth
// CmdSwitch sub-commands.
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
SubSwNB = 0x22 // noise blanker on/off
SubSwNR = 0x40 // noise reduction on/off
SubSwANF = 0x41 // auto-notch on/off
)
// Icom mode codes (used by CmdReadMode / CmdSetMode).
const (
ModeLSB = 0x00
ModeUSB = 0x01
ModeAM = 0x02
ModeCW = 0x03
ModeRTTY = 0x04
ModeFM = 0x05
ModeCWR = 0x07
ModeRTTYR = 0x08
)
// Frame builds a complete CI-V frame (preamble … end) for payload, which is the
// command byte followed by any sub-command/data bytes.
func Frame(to, from byte, payload ...byte) []byte {
f := make([]byte, 0, len(payload)+5)
f = append(f, Pre, Pre, to, from)
f = append(f, payload...)
f = append(f, End)
return f
}
// FreqToBCD encodes a frequency in Hz as the 5 little-endian BCD bytes Icom
// expects (10 digits, 2 per byte, least-significant byte first).
func FreqToBCD(hz int64) []byte {
if hz < 0 {
hz = 0
}
b := make([]byte, 5)
for i := 0; i < 5; i++ {
lo := hz % 10
hz /= 10
hi := hz % 10
hz /= 10
b[i] = byte(lo) | byte(hi)<<4
}
return b
}
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
func BCDToFreq(b []byte) int64 {
var hz int64
mult := int64(1)
for i := 0; i < len(b) && i < 5; i++ {
hz += int64(b[i]&0x0F) * mult
mult *= 10
hz += int64(b[i]>>4) * mult
mult *= 10
}
return hz
}
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
// CmdLevel commands use (e.g. 128 → 0x01 0x28, 255 → 0x02 0x55).
func LevelToBCD(v int) []byte {
if v < 0 {
v = 0
}
if v > 255 {
v = 255
}
return []byte{byte(v / 100), byte(((v/10)%10)<<4 | v%10)}
}
// BCDToLevel decodes the 2 BCD bytes of a CmdLevel response back to 0-255.
func BCDToLevel(b []byte) int {
if len(b) < 2 {
return 0
}
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
}
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
func ByteToBCD(v int) byte {
if v < 0 {
v = 0
}
if v > 99 {
v = 99
}
return byte((v/10)<<4 | v%10)
}
func BCDToByte(b byte) int { return int(b>>4)*10 + int(b&0x0F) }
// ModeToADIF maps an Icom mode byte (plus the data-mode flag) to an ADIF mode
// string. Data mode on USB/LSB is surfaced as "DATA" so the app can substitute
// the user's preferred digital mode (FT8/RTTY/…), matching the OmniRig backend.
func ModeToADIF(m byte, data bool) string {
switch m {
case ModeCW, ModeCWR:
return "CW"
case ModeRTTY, ModeRTTYR:
return "RTTY"
case ModeAM:
return "AM"
case ModeFM:
return "FM"
case ModeLSB, ModeUSB:
if data {
return "DATA"
}
return "SSB"
}
return ""
}
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
// model. Unknown addresses fall back to a hex label.
func ModelName(addr byte) string {
switch addr {
case 0x94:
return "IC-7300"
case 0x98:
return "IC-7610"
case 0xA2:
return "IC-9700"
case 0xA4:
return "IC-705"
case 0x88:
return "IC-7700"
case 0x80:
return "IC-7800"
}
return fmt.Sprintf("Icom (0x%02X)", addr)
}
// Decoded is one parsed CI-V frame. Data is everything after the command byte
// (so it still carries the sub-command for multi-byte commands like 1A 06).
type Decoded struct {
To byte
From byte
Cmd byte
Data []byte
}
// Scan extracts every complete frame from buf and reports how many leading
// bytes the caller may now discard. A trailing partial frame (or a lone
// preamble byte) is left unconsumed so it can be completed by the next read.
func Scan(buf []byte) (frames []Decoded, consumed int) {
pos := 0
for {
p := indexPreamble(buf, pos)
if p < 0 {
// No further preamble. Keep a trailing FE (possible start of the
// next preamble); otherwise everything seen is consumable.
if len(buf) > 0 && buf[len(buf)-1] == Pre {
return frames, len(buf) - 1
}
return frames, len(buf)
}
start := p + 2
for start < len(buf) && buf[start] == Pre { // tolerate padding FEs
start++
}
end := bytes.IndexByte(buf[start:], End)
if end < 0 {
return frames, p // incomplete frame — keep from its preamble
}
end += start
if body := buf[start:end]; len(body) >= 3 {
frames = append(frames, Decoded{
To: body[0],
From: body[1],
Cmd: body[2],
Data: append([]byte(nil), body[3:]...),
})
}
pos = end + 1
consumed = pos
}
}
// indexPreamble returns the index of the next FE FE pair at or after from.
func indexPreamble(buf []byte, from int) int {
for i := from; i+1 < len(buf); i++ {
if buf[i] == Pre && buf[i+1] == Pre {
return i
}
}
return -1
}
+132
View File
@@ -0,0 +1,132 @@
package civ
import (
"bytes"
"testing"
)
func TestFreqBCDRoundTrip(t *testing.T) {
cases := []int64{0, 1, 7074000, 14250000, 28074000, 50313000, 144174000, 1296000000}
for _, hz := range cases {
b := FreqToBCD(hz)
if len(b) != 5 {
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
}
if got := BCDToFreq(b); got != hz {
t.Errorf("round trip %d → % X → %d", hz, b, got)
}
}
}
func TestFreqBCDKnownEncoding(t *testing.T) {
// 14.250.000 Hz → little-endian BCD 00 00 25 14 00.
want := []byte{0x00, 0x00, 0x25, 0x14, 0x00}
if got := FreqToBCD(14250000); !bytes.Equal(got, want) {
t.Errorf("FreqToBCD(14250000) = % X, want % X", got, want)
}
}
func TestFrame(t *testing.T) {
// Read-frequency request to a 7610 (0x98) from the controller (0xE0).
got := Frame(0x98, AddrController, CmdReadFreq)
want := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD}
if !bytes.Equal(got, want) {
t.Errorf("Frame = % X, want % X", got, want)
}
}
func TestScanSingleFreqResponse(t *testing.T) {
// Rig (0x98) → controller (0xE0): freq read response for 14.250 MHz.
in := Frame(AddrController, 0x98, CmdReadFreq, 0x00, 0x00, 0x25, 0x14, 0x00)
frames, consumed := Scan(in)
if consumed != len(in) {
t.Fatalf("consumed=%d, want %d", consumed, len(in))
}
if len(frames) != 1 {
t.Fatalf("got %d frames, want 1", len(frames))
}
f := frames[0]
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
t.Errorf("addrs/cmd wrong: %+v", f)
}
if hz := BCDToFreq(f.Data); hz != 14250000 {
t.Errorf("decoded freq %d, want 14250000", hz)
}
}
func TestScanSkipsEchoAndKeepsPartial(t *testing.T) {
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
buf := append(append([]byte{}, echo...), resp...)
buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet)
frames, consumed := Scan(buf)
if len(frames) != 2 {
t.Fatalf("got %d frames, want 2", len(frames))
}
// The partial frame must be left unconsumed so the next read can finish it.
if consumed != len(echo)+len(resp) {
t.Errorf("consumed=%d, want %d (partial frame retained)", consumed, len(echo)+len(resp))
}
if frames[1].Cmd != CmdReadMode || len(frames[1].Data) < 1 || frames[1].Data[0] != ModeCW {
t.Errorf("second frame wrong: %+v", frames[1])
}
}
func TestModeToADIF(t *testing.T) {
cases := []struct {
m byte
data bool
want string
}{
{ModeUSB, false, "SSB"},
{ModeLSB, false, "SSB"},
{ModeUSB, true, "DATA"},
{ModeCW, false, "CW"},
{ModeCWR, false, "CW"},
{ModeRTTY, false, "RTTY"},
{ModeAM, false, "AM"},
{ModeFM, false, "FM"},
}
for _, c := range cases {
if got := ModeToADIF(c.m, c.data); got != c.want {
t.Errorf("ModeToADIF(0x%02X, %v) = %q, want %q", c.m, c.data, got, c.want)
}
}
}
func TestLevelBCDRoundTrip(t *testing.T) {
for _, v := range []int{0, 1, 50, 99, 100, 128, 200, 255} {
b := LevelToBCD(v)
if len(b) != 2 {
t.Fatalf("LevelToBCD(%d) len=%d", v, len(b))
}
if got := BCDToLevel(b); got != v {
t.Errorf("level round trip %d → % X → %d", v, b, got)
}
}
// Known encodings from the Icom CI-V reference.
if got := LevelToBCD(128); !bytes.Equal(got, []byte{0x01, 0x28}) {
t.Errorf("LevelToBCD(128) = % X, want 01 28", got)
}
if got := LevelToBCD(255); !bytes.Equal(got, []byte{0x02, 0x55}) {
t.Errorf("LevelToBCD(255) = % X, want 02 55", got)
}
}
func TestByteBCDRoundTrip(t *testing.T) {
for _, v := range []int{0, 6, 12, 18, 21} {
if got := BCDToByte(ByteToBCD(v)); got != v {
t.Errorf("byte BCD round trip %d → %d", v, got)
}
}
}
func TestModelName(t *testing.T) {
if got := ModelName(0x98); got != "IC-7610" {
t.Errorf("ModelName(0x98) = %q, want IC-7610", got)
}
if got := ModelName(0x12); got != "Icom (0x12)" {
t.Errorf("ModelName(0x12) = %q, want fallback", got)
}
}
+259 -1
View File
@@ -38,6 +38,7 @@ type Flex struct {
amp flexAmp // external amplifier (PowerGenius XL) state amp flexAmp // external amplifier (PowerGenius XL) state
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).
// Live meters streamed over UDP (VITA-49). meterMeta is the definitions // Live meters streamed over UDP (VITA-49). meterMeta is the definitions
// pushed over TCP; meterVal the latest scaled values keyed by meter id. // pushed over TCP; meterVal the latest scaled values keyed by meter id.
@@ -72,12 +73,17 @@ type flexSlice struct {
agcMode string // off | slow | med | fast agcMode string // off | slow | med | fast
agcThreshold int // 0-100 agcThreshold int // 0-100
audioLevel int // 0-100 (RX volume) audioLevel int // 0-100 (RX volume)
mute bool // RX audio muted
nb bool // noise blanker nb bool // noise blanker
nbLevel int nbLevel int
nr bool // noise reduction nr bool // noise reduction
nrLevel int nrLevel int
anf bool // auto notch filter anf bool // auto notch filter
anfLevel int anfLevel int
apf bool // CW audio peaking filter
apfLevel int
filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz)
} }
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style // flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
@@ -97,6 +103,12 @@ type flexTX struct {
micLevel int micLevel int
atuStatus string atuStatus string
atuMemories bool atuMemories bool
// CW keyer params (set via the top-level "cw" commands).
cwSpeed int // WPM
cwPitch int // Hz
cwBreakInDelay int // ms (QSK delay)
cwSidetone bool // sidetone (audible monitor) enable
cwMonLevel int // sidetone level (mon_gain_cw)
} }
// flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the // flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the
@@ -164,6 +176,7 @@ func (f *Flex) Connect() error {
f.slices = map[int]*flexSlice{} f.slices = map[int]*flexSlice{}
f.meterVal = map[int]float64{} f.meterVal = map[int]float64{}
f.meterSub = map[int]bool{} f.meterSub = map[int]bool{}
f.boundClientID = "" // re-bind to the GUI client on each (re)connect
f.mu.Unlock() f.mu.Unlock()
debugLog.Printf("Flex: connected to %s:%d", host, port) debugLog.Printf("Flex: connected to %s:%d", host, port)
@@ -176,6 +189,8 @@ func (f *Flex) Connect() error {
f.send("sub atu all") // antenna-tuner status + memories f.send("sub atu all") // antenna-tuner status + memories
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 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 {
// Subscribe so the radio pushes existing spots (we learn their indices), // Subscribe so the radio pushes existing spots (we learn their indices),
@@ -325,7 +340,10 @@ func (f *Flex) handleStatus(payload string) {
// Transmit object — RF/tune power, VOX, speech processor, monitor, mic, // Transmit object — RF/tune power, VOX, speech processor, monitor, mic,
// tune carrier. Field names per the SmartSDR API (logged so the exact set // tune carrier. Field names per the SmartSDR API (logged so the exact set
// is auditable against a real radio). // is auditable against a real radio).
if len(fields) >= 1 && fields[0] == "transmit" { // "transmit band <N> band_name=… rfpower=…" lines are PER-BAND power
// presets, not the current TX state — ignore them, otherwise the last
// band's rfpower (e.g. 630m=100) clobbers the real current value.
if len(fields) >= 1 && fields[0] == "transmit" && !(len(fields) >= 2 && fields[1] == "band") {
if !f.txRawLogged { if !f.txRawLogged {
f.txRawLogged = true f.txRawLogged = true
debugLog.Printf("Flex: FIRST transmit status: %s", payload) debugLog.Printf("Flex: FIRST transmit status: %s", payload)
@@ -364,12 +382,85 @@ func (f *Flex) handleStatus(payload string) {
f.tx.mon = val == "1" f.tx.mon = val == "1"
case "mon_gain_sb": case "mon_gain_sb":
f.tx.monLevel = atoiDefault(val, f.tx.monLevel) f.tx.monLevel = atoiDefault(val, f.tx.monLevel)
case "mon_gain_cw":
f.tx.cwMonLevel = atoiDefault(val, f.tx.cwMonLevel)
case "sidetone", "cw_sidetone":
f.tx.cwSidetone = val == "1"
// Once bound to the GUI client (see the client branch) the transmit
// object carries the GUI client's LIVE CW values, so read them here
// (and from cwx). Before binding these are the radio's static
// defaults — that was the "always 600 / 5" bug.
case "speed", "cwl_speed", "cw_speed", "wpm", "cw_wpm":
f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed)
case "pitch", "cwl_pitch", "cw_pitch":
f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch)
case "break_in_delay", "cwl_delay", "cw_break_in_delay", "delay":
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)
} }
} }
f.mu.Unlock() f.mu.Unlock()
} }
// Client object — list of connected clients. GUI clients (SmartSDR /
// Maestro) carry a client_id; non-GUI clients don't. We bind to the GUI
// client so the radio routes GUI-tied data (CW pitch/speed, break-in
// delay, RF power) to us. Logged so the exact field names are confirmable.
if len(fields) >= 1 && fields[0] == "client" {
debugLog.Printf("Flex: status %s", payload)
var clientID, program string
disconnected := false
for _, kv := range fields[1:] {
if kv == "disconnected" {
disconnected = true
continue
}
key, val, ok := splitKV(kv)
if !ok {
continue
}
switch key {
case "client_id":
clientID = val
case "program":
program = val
}
}
f.mu.Lock()
alreadyBound := f.boundClientID != ""
f.mu.Unlock()
lp := strings.ToLower(program)
isGUI := program == "" || strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")
if !disconnected && clientID != "" && !alreadyBound && isGUI {
f.mu.Lock()
f.boundClientID = clientID
f.mu.Unlock()
f.send("client bind client_id=" + clientID)
debugLog.Printf("Flex: bound to GUI client %s (program=%q)", clientID, program)
}
}
// CWX object — the LIVE CW keyer values (speed/pitch/break-in delay).
// SmartSDR reads these here; the transmit object only carries a static
// default. Logged in full so we can confirm the exact field names.
if len(fields) >= 1 && fields[0] == "cwx" {
debugLog.Printf("Flex: status %s", payload)
f.mu.Lock()
for _, kv := range fields[1:] {
key, val, ok := splitKV(kv)
if !ok {
continue
}
switch key {
case "wpm", "speed", "cw_speed":
f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed)
case "pitch", "cw_pitch":
f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch)
case "delay", "break_in_delay", "cw_break_in_delay":
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
}
}
f.mu.Unlock()
}
// ATU object — auto-tuner status + memories. // ATU object — auto-tuner status + memories.
if len(fields) >= 1 && fields[0] == "atu" { if len(fields) >= 1 && fields[0] == "atu" {
debugLog.Printf("Flex: status %s", payload) debugLog.Printf("Flex: status %s", payload)
@@ -425,6 +516,16 @@ func (f *Flex) handleStatus(payload string) {
f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE") f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE")
case "mode": case "mode":
f.amp.operate = strings.EqualFold(val, "OPERATE") f.amp.operate = strings.EqualFold(val, "OPERATE")
case "state":
// The PowerGenius XL reports its live state here (the status
// push has no operate= field). Anything but STANDBY/OFF means
// the amp is IN LINE (OPERATE) — IDLE = operate, not keyed.
switch strings.ToUpper(val) {
case "STANDBY", "OFF", "POWERED_OFF", "DISCONNECTED":
f.amp.operate = false
case "OPERATE", "IDLE", "TRANSMIT", "TX", "RECEIVE", "RX", "KEYED", "OPERATING":
f.amp.operate = true
}
case "fault": case "fault":
f.amp.fault = val f.amp.fault = val
} }
@@ -570,6 +671,8 @@ func (f *Flex) handleStatus(payload string) {
s.agcThreshold = atoiDefault(val, s.agcThreshold) s.agcThreshold = atoiDefault(val, s.agcThreshold)
case "audio_level": case "audio_level":
s.audioLevel = atoiDefault(val, s.audioLevel) s.audioLevel = atoiDefault(val, s.audioLevel)
case "audio_mute", "mute":
s.mute = val == "1"
case "nb": case "nb":
s.nb = val == "1" s.nb = val == "1"
case "nb_level": case "nb_level":
@@ -582,11 +685,28 @@ func (f *Flex) handleStatus(payload string) {
s.anf = val == "1" s.anf = val == "1"
case "anf_level": case "anf_level":
s.anfLevel = atoiDefault(val, s.anfLevel) s.anfLevel = atoiDefault(val, s.anfLevel)
case "apf":
s.apf = val == "1"
case "apf_level":
s.apfLevel = atoiDefault(val, s.apfLevel)
case "filter_lo":
s.filterLo = atoiDefault(val, s.filterLo)
case "filter_hi":
s.filterHi = atoiDefault(val, s.filterHi)
} }
} }
f.mu.Unlock() f.mu.Unlock()
} }
// defInt returns v, or def when v is zero (so sliders show sane defaults before
// the radio has pushed the real value).
func defInt(v, def int) int {
if v == 0 {
return def
}
return v
}
// ReadState returns the cached state derived from the radio's push messages — // ReadState returns the cached state derived from the radio's push messages —
// no round-trip, so it's always current. // no round-trip, so it's always current.
func (f *Flex) ReadState() (RigState, error) { func (f *Flex) ReadState() (RigState, error) {
@@ -899,18 +1019,30 @@ func (f *Flex) FlexState() FlexTXState {
MicLevel: f.tx.micLevel, MicLevel: f.tx.micLevel,
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).
CWSpeed: defInt(f.tx.cwSpeed, 25),
CWPitch: defInt(f.tx.cwPitch, 600),
CWBreakInDelay: defInt(f.tx.cwBreakInDelay, 30),
CWSidetone: f.tx.cwSidetone,
CWMonLevel: f.tx.cwMonLevel,
} }
if _, rx := f.rxSliceLocked(); rx != nil { if _, rx := f.rxSliceLocked(); rx != nil {
st.RXAvail = true st.RXAvail = true
st.Mode = strings.ToUpper(rx.mode)
st.AGCMode = rx.agcMode st.AGCMode = rx.agcMode
st.AGCThreshold = rx.agcThreshold st.AGCThreshold = rx.agcThreshold
st.AudioLevel = rx.audioLevel st.AudioLevel = rx.audioLevel
st.Mute = rx.mute
st.NB = rx.nb st.NB = rx.nb
st.NBLevel = rx.nbLevel st.NBLevel = rx.nbLevel
st.NR = rx.nr st.NR = rx.nr
st.NRLevel = rx.nrLevel st.NRLevel = rx.nrLevel
st.ANF = rx.anf st.ANF = rx.anf
st.ANFLevel = rx.anfLevel st.ANFLevel = rx.anfLevel
st.APF = rx.apf
st.APFLevel = rx.apfLevel
st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi
} }
if f.amp.handle != "" { if f.amp.handle != "" {
st.AmpAvailable = true st.AmpAvailable = true
@@ -948,6 +1080,8 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.agcThreshold = toInt(val) rx.agcThreshold = toInt(val)
case "audio_level": case "audio_level":
rx.audioLevel = toInt(val) rx.audioLevel = toInt(val)
case "audio_mute", "mute":
rx.mute = fmt.Sprint(val) == "1"
case "nb": case "nb":
rx.nb = val == "1" rx.nb = val == "1"
case "nb_level": case "nb_level":
@@ -960,6 +1094,10 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.anf = val == "1" rx.anf = val == "1"
case "anf_level": case "anf_level":
rx.anfLevel = toInt(val) rx.anfLevel = toInt(val)
case "apf":
rx.apf = val == "1"
case "apf_level":
rx.apfLevel = toInt(val)
} }
} }
f.mu.Unlock() f.mu.Unlock()
@@ -994,12 +1132,132 @@ func (f *Flex) SetAGCMode(m string) error {
} }
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) } func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) } func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) } func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) } func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) } func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) } func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) } 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) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
// ── CW keyer controls (top-level "cw" commands) ──
func (f *Flex) SetCWSpeed(wpm int) error {
if wpm < 5 {
wpm = 5
} else if wpm > 100 {
wpm = 100
}
f.mu.Lock()
f.tx.cwSpeed = wpm
f.mu.Unlock()
f.send(fmt.Sprintf("cw wpm %d", wpm))
return nil
}
func (f *Flex) SetCWPitch(hz int) error {
if hz < 100 {
hz = 100
} else if hz > 6000 {
hz = 6000
}
f.mu.Lock()
f.tx.cwPitch = hz
f.mu.Unlock()
f.send(fmt.Sprintf("cw pitch %d", hz))
return nil
}
func (f *Flex) SetCWBreakInDelay(ms int) error {
if ms < 0 {
ms = 0
} else if ms > 2000 {
ms = 2000
}
f.mu.Lock()
f.tx.cwBreakInDelay = ms
f.mu.Unlock()
f.send(fmt.Sprintf("cw break_in_delay %d", ms))
return nil
}
func (f *Flex) SetCWSidetone(on bool) error {
f.mu.Lock()
f.tx.cwSidetone = on
f.mu.Unlock()
f.send("cw sidetone " + boolWord(on))
return nil
}
// SetSidetoneLevel sets the CW sidetone (audible monitor) gain via mon_gain_cw.
func (f *Flex) SetSidetoneLevel(l int) error {
l = clampLevel(l)
return f.txSet(fmt.Sprintf("transmit set mon_gain_cw=%d", l), "mon_gain_cw", func(t *flexTX) { t.cwMonLevel = l })
}
// SetCWFilter changes the CW passband WIDTH to bw Hz while keeping the current
// filter CENTER fixed — so the frequency never shifts. The new low/high cuts are
// center ± bw/2, where center is the midpoint of the slice's current filter
// (falling back to the CW pitch only if the filter isn't known yet).
func (f *Flex) SetCWFilter(bw int) error {
if bw < 50 {
bw = 50
}
f.mu.Lock()
idx, rx := f.rxSliceLocked()
connected := f.conn != nil
center := 0
if rx != nil && (rx.filterLo != 0 || rx.filterHi != 0) {
center = (rx.filterLo + rx.filterHi) / 2
} else {
center = defInt(f.tx.cwPitch, 600)
}
lo := center - bw/2
hi := center + bw/2
if rx != nil {
rx.filterLo, rx.filterHi = lo, hi
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if rx == nil || idx < 0 {
return fmt.Errorf("flex: no receive slice")
}
f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi))
return nil
}
// SetFilter sets the active RX slice's passband to an explicit low/high cut (Hz,
// audio offsets; negative for LSB). Used by the SSB width presets, where the
// frontend keeps the carrier-side edge and extends the far edge.
func (f *Flex) SetFilter(lo, hi int) error {
f.mu.Lock()
idx, rx := f.rxSliceLocked()
connected := f.conn != nil
if rx != nil {
rx.filterLo, rx.filterHi = lo, hi
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if rx == nil || idx < 0 {
return fmt.Errorf("flex: no receive slice")
}
f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi))
return nil
}
// boolWord renders a Flex on/off boolean as the word form some commands want.
func boolWord(on bool) string {
if on {
return "on"
}
return "off"
}
// connected reports whether the TCP link is up (commands are no-ops otherwise). // connected reports whether the TCP link is up (commands are no-ops otherwise).
func (f *Flex) connected() bool { func (f *Flex) connected() bool {
+557
View File
@@ -0,0 +1,557 @@
package cat
import (
"fmt"
"strings"
"sync"
"time"
"hamlog/internal/cat/civ"
"go.bug.st/serial"
)
// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local
// control). It speaks the shared civ protocol, so when the network backend
// (icomnet) is added it will reuse the same encode/decode — only the transport
// changes. Implements Backend; all methods run on the Manager's CAT goroutine,
// so the port is accessed single-threaded (no locking needed).
type IcomSerial struct {
portName string
baud int
rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
digital string // mode to command for DATA (FT8/RTTY/…)
port serial.Port
rx []byte // accumulated bytes awaiting a complete frame
model string
curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time
// dsp caches the receive-DSP state for the Icom control tab. Read off the
// CAT goroutine via IcomState(), written on the CAT goroutine (RefreshIcom
// / setters) — hence the mutex.
dspMu sync.Mutex
dsp IcomTXState
}
const (
icomReadTimeout = 350 * time.Millisecond // wait for a poll response
icomCmdTimeout = 400 * time.Millisecond // wait for a set ack (FB/FA)
)
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
// 115200, rig address to the IC-7610's 0x98 when out of range.
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
if baud <= 0 {
baud = 115200
}
if civAddr <= 0 || civAddr > 0xFF {
civAddr = 0x98 // IC-7610
}
if digitalDefault == "" {
digitalDefault = "FT8"
}
return &IcomSerial{
portName: portName,
baud: baud,
rigAddr: byte(civAddr),
digital: strings.ToUpper(digitalDefault),
model: "Icom",
}
}
func (b *IcomSerial) Name() string { return "icom" }
func (b *IcomSerial) Connect() error {
if b.portName == "" {
return fmt.Errorf("no serial port configured")
}
port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
if err != nil {
return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
}
// Short read timeout so recv() polls in a tight loop without blocking the
// CAT goroutine when the rig is silent.
_ = port.SetReadTimeout(60 * time.Millisecond)
b.port = port
b.rx = b.rx[:0]
b.model = civ.ModelName(b.rigAddr)
// Best-effort model identification: ask the rig for its own CI-V address.
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
}); err == nil {
b.model = civ.ModelName(f.Data[1])
}
}
b.readDSP() // best-effort initial snapshot for the control tab
return nil
}
func (b *IcomSerial) Disconnect() {
if b.port != nil {
_ = b.port.Close()
b.port = nil
}
}
// ReadState polls the rig for frequency and mode. A failed frequency read is
// treated as "lost the rig" so the Manager reconnects.
func (b *IcomSerial) ReadState() (RigState, error) {
if b.port == nil {
return RigState{}, fmt.Errorf("not connected")
}
s := RigState{Backend: b.Name(), Connected: true, Rig: b.model}
hz, err := b.readFreq()
if err != nil {
return RigState{}, err
}
s.FreqHz = hz
b.curFreq = hz
if m, ok := b.readMode(); ok {
b.curModeByte = m
data := b.readDataMode() // best-effort; ignored on failure
s.Mode = civ.ModeToADIF(m, data)
if s.Mode == "DATA" {
s.Mode = b.digital
}
b.dspMu.Lock()
b.dsp.Mode = s.Mode
b.dspMu.Unlock()
}
return s, nil
}
func (b *IcomSerial) SetFrequency(hz int64) error {
if hz <= 0 {
return fmt.Errorf("invalid frequency")
}
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
}
func (b *IcomSerial) SetMode(mode string) error {
code, data, err := b.modeCode(mode)
if err != nil {
return err
}
// Set the base mode (keeping the rig's current filter by sending only the
// mode byte), then set the data-mode flag for digital modes.
if err := b.exec(civ.CmdSetMode, code); err != nil {
return err
}
dataByte := byte(0)
if data {
dataByte = 1
}
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
return nil
}
func (b *IcomSerial) SetPTT(on bool) error {
state := byte(0)
if on {
state = 1
}
return b.exec(civ.CmdPTT, civ.SubPTT, state)
}
// ── helpers ───────────────────────────────────────────────────────────────
func (b *IcomSerial) write(payload ...byte) error {
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
return err
}
// recv reads from the port until a frame from the rig satisfies match or the
// timeout elapses. Frames that are our own echo (from == controller) or don't
// match are discarded.
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
deadline := time.Now().Add(timeout)
tmp := make([]byte, 256)
for time.Now().Before(deadline) {
n, err := b.port.Read(tmp)
if err != nil {
return civ.Decoded{}, err
}
if n == 0 {
continue
}
b.rx = append(b.rx, tmp[:n]...)
frames, consumed := civ.Scan(b.rx)
if consumed > 0 {
b.rx = append(b.rx[:0], b.rx[consumed:]...)
}
for _, f := range frames {
if f.From != b.rigAddr {
continue // skip echo of our own commands
}
if match(f) {
return f, nil
}
}
}
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
}
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
func (b *IcomSerial) exec(payload ...byte) error {
if err := b.write(payload...); err != nil {
return err
}
f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.OK || d.Cmd == civ.NG
})
if err != nil {
return err
}
if f.Cmd == civ.NG {
return fmt.Errorf("icom: rig rejected command 0x%02X", payload[0])
}
return nil
}
func (b *IcomSerial) readFreq() (int64, error) {
if err := b.write(civ.CmdReadFreq); err != nil {
return 0, err
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdReadFreq || d.Cmd == civ.CmdTransceiveFreq
})
if err != nil {
return 0, err
}
return civ.BCDToFreq(f.Data), nil
}
func (b *IcomSerial) readMode() (byte, bool) {
if err := b.write(civ.CmdReadMode); err != nil {
return 0, false
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return (d.Cmd == civ.CmdReadMode || d.Cmd == civ.CmdTransceiveMode) && len(d.Data) >= 1
})
if err != nil {
return 0, false
}
return f.Data[0], true
}
func (b *IcomSerial) readDataMode() bool {
if err := b.write(civ.CmdExtra, civ.SubDataMode); err != nil {
return false
}
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubDataMode
})
if err != nil {
return false
}
return f.Data[1] != 0
}
// modeCode maps an ADIF mode to an Icom mode byte plus whether the data-mode
// flag should be set. SSB sideband follows the usual convention (LSB below
// 10 MHz, USB above); the frequency just commanded is preferred over the last
// poll so a clicked spot (freq then mode) picks the right sideband immediately.
func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
freq := b.curFreq
if b.lastSetFreq > 0 && time.Since(b.lastSetFreqAt) < 5*time.Second {
freq = b.lastSetFreq
}
usb := byte(civ.ModeUSB)
if freq > 0 && freq < 10_000_000 {
usb = civ.ModeLSB
}
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
return civ.ModeCW, false, nil
case "SSB":
return usb, false, nil
case "AM":
return civ.ModeAM, false, nil
case "FM":
return civ.ModeFM, false, nil
case "RTTY", "FSK":
return civ.ModeRTTY, false, nil
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
// Digital data modes ride on USB with the data flag set (FT8 etc.).
return civ.ModeUSB, true, nil
}
return 0, false, fmt.Errorf("icom: unsupported mode %q", mode)
}
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
func (b *IcomSerial) IcomState() IcomTXState {
b.dspMu.Lock()
defer b.dspMu.Unlock()
return b.dsp
}
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
// goroutine (dispatched via IcomDo).
func (b *IcomSerial) RefreshIcom() error {
if b.port == nil {
return fmt.Errorf("not connected")
}
b.readDSP()
return nil
}
// readDSP polls every DSP value once and replaces the cache. Best-effort: a
// value the rig doesn't answer keeps its previous cached value rather than
// stalling (each read has a short timeout).
func (b *IcomSerial) readDSP() {
st := IcomTXState{Available: true, Model: b.model}
b.dspMu.Lock()
st.Mode = b.dsp.Mode // preserve mode (set by ReadState)
b.dspMu.Unlock()
if v, ok := b.readLevel(civ.SubLevelAF); ok {
st.AFGain = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelRF); ok {
st.RFGain = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelNR); ok {
st.NRLevel = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelNB); ok {
st.NBLevel = from255(v)
}
if v, ok := b.readSwitch(civ.SubSwNB); ok {
st.NB = v != 0
}
if v, ok := b.readSwitch(civ.SubSwNR); ok {
st.NR = v != 0
}
if v, ok := b.readSwitch(civ.SubSwANF); ok {
st.ANF = v != 0
}
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
st.AGC = agcName(v)
}
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
st.Preamp = int(v)
}
if v, ok := b.readAtt(); ok {
st.Att = v
}
if _, f, ok := b.readModeFilter(); ok {
st.Filter = int(f)
}
b.dspMu.Lock()
b.dsp = st
b.dspMu.Unlock()
}
const icomDSPTimeout = 150 * time.Millisecond // shorter: unsupported reads mustn't stall the poll
func (b *IcomSerial) readLevel(sub byte) (int, bool) {
if err := b.write(civ.CmdLevel, sub); err != nil {
return 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdLevel && len(d.Data) >= 3 && d.Data[0] == sub
})
if err != nil {
return 0, false
}
return civ.BCDToLevel(f.Data[1:3]), true
}
func (b *IcomSerial) readSwitch(sub byte) (byte, bool) {
if err := b.write(civ.CmdSwitch, sub); err != nil {
return 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdSwitch && len(d.Data) >= 2 && d.Data[0] == sub
})
if err != nil {
return 0, false
}
return f.Data[1], true
}
func (b *IcomSerial) readAtt() (int, bool) {
if err := b.write(civ.CmdAtt); err != nil {
return 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdAtt && len(d.Data) >= 1
})
if err != nil {
return 0, false
}
return civ.BCDToByte(f.Data[0]), true
}
func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) {
if err := b.write(civ.CmdReadMode); err != nil {
return 0, 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdReadMode && len(d.Data) >= 2
})
if err != nil {
return 0, 0, false
}
return f.Data[0], f.Data[1], true
}
func (b *IcomSerial) SetAFGain(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAF}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.AFGain = clampPct(p) })
return nil
}
func (b *IcomSerial) SetRFGain(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRF}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.RFGain = clampPct(p) })
return nil
}
func (b *IcomSerial) SetNB(on bool) error {
if err := b.exec(civ.CmdSwitch, civ.SubSwNB, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.NB = on })
return nil
}
func (b *IcomSerial) SetNBLevel(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNB}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.NBLevel = clampPct(p) })
return nil
}
func (b *IcomSerial) SetNR(on bool) error {
if err := b.exec(civ.CmdSwitch, civ.SubSwNR, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.NR = on })
return nil
}
func (b *IcomSerial) SetNRLevel(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNR}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.NRLevel = clampPct(p) })
return nil
}
func (b *IcomSerial) SetANF(on bool) error {
if err := b.exec(civ.CmdSwitch, civ.SubSwANF, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.ANF = on })
return nil
}
func (b *IcomSerial) SetAGC(name string) error {
v := agcValue(name)
if v == 0 {
return fmt.Errorf("icom: invalid AGC %q", name)
}
if err := b.exec(civ.CmdSwitch, civ.SubSwAGC, v); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.AGC = strings.ToUpper(name) })
return nil
}
func (b *IcomSerial) SetPreamp(n int) error {
if n < 0 || n > 2 {
return fmt.Errorf("icom: invalid preamp %d", n)
}
if err := b.exec(civ.CmdSwitch, civ.SubSwPreamp, byte(n)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.Preamp = n })
return nil
}
func (b *IcomSerial) SetAtt(db int) error {
if err := b.exec(civ.CmdAtt, civ.ByteToBCD(db)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.Att = db })
return nil
}
func (b *IcomSerial) SetIcomFilter(n int) error {
if n < 1 || n > 3 {
return fmt.Errorf("icom: invalid filter %d", n)
}
if b.curModeByte == 0 {
// Need the current mode to re-send with the chosen filter.
if m, _, ok := b.readModeFilter(); ok {
b.curModeByte = m
}
}
if err := b.exec(civ.CmdSetMode, b.curModeByte, byte(n)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.Filter = n })
return nil
}
func (b *IcomSerial) setCache(fn func(*IcomTXState)) {
b.dspMu.Lock()
fn(&b.dsp)
b.dspMu.Unlock()
}
// ── small helpers ──────────────────────────────────────────────────────────
func to255(p int) int { return clampPct(p) * 255 / 100 }
func from255(v int) int { return (v*100 + 127) / 255 }
func clampPct(p int) int { return min(100, max(0, p)) }
func boolByte(on bool) byte {
if on {
return 1
}
return 0
}
func agcName(v byte) string {
switch v {
case 1:
return "FAST"
case 2:
return "MID"
case 3:
return "SLOW"
}
return ""
}
func agcValue(name string) byte {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "FAST":
return 1
case "MID":
return 2
case "SLOW":
return 3
}
return 0
}
+370
View File
@@ -0,0 +1,370 @@
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
// tone (level-independent, so weak or strong signals both key cleanly), an
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
// marks/spaces to Morse and then to characters.
//
// It is deliberately self-contained and dependency-free so it can be unit
// tested with synthetic signals. As with every audio CW decoder, weak signals
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
// out of the decode.
package cwdecode
import (
"math"
"sort"
"sync/atomic"
)
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
type Status struct {
WPM int `json:"wpm"`
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
Active bool `json:"active"` // a tone is currently keyed down
}
// Decoder consumes PCM and emits decoded characters via onChar (one or more
// characters at a time, including " " for word gaps) and periodic onStatus.
type Decoder struct {
fs int
hop int // samples between updates
win int // Goertzel window length
freqs []float64
coeffs []float64 // precomputed 2*cos(w) per freq
ring []float64 // last win samples
acc int // samples since last hop
mags []float64 // per-bin magnitude this hop
nbuf []float64 // scratch for the noise percentile
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
// another goroutine, so it's atomic.
targetHz atomic.Int32
// Pitch lock.
lockIdx int // index of the locked tone bin, -1 = unlocked
candIdx int // current argmax candidate while unlocked
candHops int // consecutive hops the candidate has been dominant
quietHops int // consecutive key-up hops while locked
noise float64 // broadband noise estimate (percentile of bins)
relockHops int // quiet hops before the lock is released
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
// Adaptive keying envelope, on the LOCKED bin's magnitude.
peak, floor float64
state bool // true = mark (key down)
stateHops int
dotHops float64 // adaptive dot length, in hops
elem []byte // current "." / "-" run for the in-progress character
charEmitted bool
wordEmitted bool
lastPitch float64
lastRMS float64
statusEvery int
sinceStatus int
onChar func(string)
onStatus func(Status)
}
var morse = map[string]byte{
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
"-.--": 'Y', "--..": 'Z',
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
}
// New builds a decoder for the given sample rate. onChar receives decoded text
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
if sampleRate <= 0 {
sampleRate = 16000
}
d := &Decoder{
fs: sampleRate,
hop: sampleRate / 250, // ~4 ms resolution
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
dotHops: 15, // ~20 WPM seed
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
lockIdx: -1,
candIdx: -1,
statusEvery: 25, // ~10 Hz
onChar: onChar,
onStatus: onStatus,
}
if d.hop < 1 {
d.hop = 1
}
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
// Candidate CW tones: 4001000 Hz every 25 Hz. Deliberately NOT lower: strong
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
for f := 400.0; f <= 1000.0; f += 25 {
d.freqs = append(d.freqs, f)
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
}
d.mags = make([]float64, len(d.freqs))
d.nbuf = make([]float64, len(d.freqs))
return d
}
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
// nearestBin returns the bin index closest to hz.
func (d *Decoder) nearestBin(hz float64) int {
best, bestD := 0, math.Inf(1)
for i, f := range d.freqs {
if dd := math.Abs(f - hz); dd < bestD {
bestD, best = dd, i
}
}
return best
}
// Reset clears decode state (e.g. when the user re-arms the decoder).
func (d *Decoder) Reset() {
d.ring = d.ring[:0]
d.acc = 0
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
d.peak, d.floor = 0, 0
d.state = false
d.stateHops = 0
d.dotHops = 15
d.elem = d.elem[:0]
d.charEmitted, d.wordEmitted = false, false
}
// Process feeds a block of mono samples through the decoder.
func (d *Decoder) Process(samples []int16) {
for _, s := range samples {
d.ring = append(d.ring, float64(s))
if len(d.ring) > d.win {
d.ring = d.ring[len(d.ring)-d.win:]
}
d.acc++
if d.acc >= d.hop && len(d.ring) >= d.win {
d.acc = 0
d.analyze()
d.step()
}
}
}
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
// pitch lock (which tone the envelope detector then follows).
func (d *Decoder) analyze() {
n := float64(len(d.ring))
var sumSq float64
maxIdx, maxMag := 0, -1.0
for i, coeff := range d.coeffs {
var s1, s2 float64
for _, x := range d.ring {
s0 := x + coeff*s1 - s2
s2 = s1
s1 = s0
}
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
d.mags[i] = m
if m > maxMag {
maxMag = m
maxIdx = i
}
}
for _, x := range d.ring {
sumSq += x * x
}
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
if th := int(d.targetHz.Load()); th > 0 {
d.lockIdx = d.nearestBin(float64(th))
d.lastPitch = d.freqs[d.lockIdx]
return
}
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
if d.lockIdx < 0 {
if maxIdx == d.candIdx {
d.candHops++
} else {
d.candIdx, d.candHops = maxIdx, 1
}
snr := maxMag / (d.noise + 1e-9)
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
// don't eat the first element of a strong signal), a marginal/weak tone
// locks after a couple of stable hops (so we don't lock onto pure noise).
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
d.lockIdx = maxIdx
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
d.quietHops = 0
}
}
if d.lockIdx >= 0 {
d.lastPitch = d.freqs[d.lockIdx]
} else {
d.lastPitch = 0
}
}
// step runs the adaptive envelope on the locked bin and the timing state
// machine, one hop. The envelope adapts to the signal level (not an absolute
// threshold), so weak and strong signals both key correctly.
func (d *Decoder) step() {
on := false
if d.lockIdx >= 0 {
m := d.mags[d.lockIdx]
// Peak: fast attack, slow release.
if m > d.peak {
d.peak += (m - d.peak) * 0.4
} else {
d.peak += (m - d.peak) * 0.02
}
// Floor: drops fast toward the signal, but only RISES between marks (when
// keyed up). Letting the floor rise during a long dash would shrink the
// span until the dash drops below the threshold and fragments into dots —
// the cause of the "all dots" garbage on a strong clean signal.
if m < d.floor {
d.floor += (m - d.floor) * 0.4
} else if !d.state {
d.floor += (m - d.floor) * 0.02
}
span := d.peak - d.floor
// The frozen floor already stops dashes fragmenting, so keep balanced
// thresholds: low enough that short inter-element GAPS are still seen
// (otherwise elements merge into >7-symbol runs that decode to nothing).
if span > d.floor*0.3+1e-9 {
onTh := d.floor + 0.55*span
offTh := d.floor + 0.35*span
if d.state {
on = m > offTh
} else {
on = m > onTh
}
}
// Release the lock after a long quiet so we can retune to a new signal.
if on {
d.quietHops = 0
} else {
d.quietHops++
if d.quietHops > d.relockHops {
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
}
}
}
if on == d.state {
d.stateHops++
if !d.state {
d.spaceProgress()
}
} else {
if d.state {
d.endMark(d.stateHops)
}
d.state = on
d.stateHops = 1
if on {
d.charEmitted, d.wordEmitted = false, false
}
}
d.emitStatus(on)
}
// endMark classifies a finished key-down run as a dot or dash and adapts the
// dot-length estimate. Runs shorter than a third of a dot are rejected as
// clicks/noise.
func (d *Decoder) endMark(hops int) {
h := float64(hops)
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
if h < d.dotHops*0.35 || h < 4 {
return
}
if h > d.dotHops*2 {
d.elem = append(d.elem, '-')
d.adaptDot(h / 3)
} else {
d.elem = append(d.elem, '.')
d.adaptDot(h)
}
}
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
// to ~5100 WPM).
func (d *Decoder) adaptDot(obs float64) {
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
d.dotHops = 5
}
if d.dotHops > 55 {
d.dotHops = 55
}
}
// spaceProgress flushes the current character once the gap exceeds a character
// gap, and a word space once it exceeds a word gap.
func (d *Decoder) spaceProgress() {
g := float64(d.stateHops)
if !d.charEmitted && g > d.dotHops*2 {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && g > d.dotHops*5 {
if d.onChar != nil {
d.onChar(" ")
}
d.wordEmitted = true
}
}
// flushChar looks up the accumulated element string and emits the character.
func (d *Decoder) flushChar() {
if len(d.elem) == 0 {
return
}
if c, ok := morse[string(d.elem)]; ok {
if d.onChar != nil {
d.onChar(string(c))
}
} else if d.onChar != nil && len(d.elem) <= 7 {
// Only flag a genuinely Morse-shaped but unknown char with "?". An
// over-long element run is noise — drop it silently rather than spam "?".
d.onChar("?")
}
d.elem = d.elem[:0]
}
func (d *Decoder) emitStatus(on bool) {
d.sinceStatus++
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
return
}
d.sinceStatus = 0
hopMs := float64(d.hop) / float64(d.fs) * 1000
wpm := 0
if d.dotHops > 0 {
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
}
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
}
+201
View File
@@ -0,0 +1,201 @@
package cwdecode
import (
"math"
"strings"
"testing"
)
// reverse Morse map for the synthesizer.
func charToMorse() map[byte]string {
m := map[byte]string{}
for code, ch := range morse {
m[ch] = code
}
return m
}
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
}
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
dot := fs * 1200 / (wpm * 1000) // samples per dot
c2m := charToMorse()
var out []int16
phase := 0.0
dphi := 2 * math.Pi * pitch / float64(fs)
tone := func(n int) {
for i := 0; i < n; i++ {
out = append(out, int16(amp*math.Sin(phase)))
phase += dphi
}
}
silence := func(n int) {
for i := 0; i < n; i++ {
out = append(out, 0)
}
}
silence(fs / 4) // 250 ms lead-in for AGC warmup
for i := 0; i < len(msg); i++ {
ch := msg[i]
if ch == ' ' {
silence(7 * dot)
continue
}
code := c2m[ch]
for j := 0; j < len(code); j++ {
if code[j] == '.' {
tone(dot)
} else {
tone(3 * dot)
}
silence(dot) // inter-element gap
}
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
}
silence(fs / 4)
return out
}
func TestDecodeCleanSignal(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
// Repeat so AGC warm-up only costs the first word.
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
// Feed in small chunks like the live capture would.
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "PARIS") {
t.Fatalf("decoded %q, want it to contain PARIS", got)
}
}
func TestDecodeWithQRM(t *testing.T) {
const fs = 16000
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
// quieter, sending different text. The pitch lock should hold on the target.
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
mix := make([]int16, len(target))
for i := range target {
v := int(target[i])
if i < len(qrm) {
v += int(qrm[i])
}
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
mix[i] = int16(v)
}
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
for i := 0; i < len(mix); i += 256 {
end := i + 256
if end > len(mix) {
end = len(mix)
}
d.Process(mix[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "PARIS") {
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
}
}
func TestDecodeFirstCharStrong(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
// Strong signal: the very first element (T = a dash) must not be eaten by
// lock acquisition. Output should begin with the first character.
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(strings.TrimSpace(sb.String()))
if !strings.HasPrefix(got, "TEST") {
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
}
}
func TestDecodeWithAmplitudeRipple(t *testing.T) {
const fs = 16000
// A real signal's tone amplitude wobbles within a mark; if the floor chases
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
rp := 0.0
for i := range samples {
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
}
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "TEST DE OM") {
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
}
}
func TestDecodeCQFixedPitch(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
d.SetTarget(700) // fixed pitch like the user's manual override
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if n := strings.Count(got, "CQ"); n < 2 {
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
}
}
func TestDecodeNumbersAndProsign(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "599") {
t.Fatalf("decoded %q, want it to contain 599", got)
}
}
+161
View File
@@ -0,0 +1,161 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// eqslImportURL is eQSL.cc's ADIF import endpoint. It accepts a form-encoded
// POST (or URL params) with the account credentials and the ADIF content.
const eqslImportURL = "https://www.eQSL.cc/qslcard/ImportADIF.cfm"
// eqslResultRe extracts "Result: X out of Y records added" from the reply.
var eqslResultRe = regexp.MustCompile(`(?i)result:\s*(\d+)\s+out of\s+(\d+)\s+records added`)
// eqslPost performs the import POST and returns the raw response body. eQSL
// replies HTTP 200 with a plain-text/HTML body for both success and errors;
// callers classify it via the markers below.
func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) (string, error) {
form := url.Values{}
form.Set("EQSL_USER", user)
form.Set("EQSL_PSWD", pswd)
form.Set("ADIFData", adif)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, eqslImportURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("eqsl: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("eqsl: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
msg := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK {
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return msg, fmt.Errorf("eqsl: http %d: %s", resp.StatusCode, msg)
}
return msg, nil
}
// authErrEQSL returns a reason when the response signals bad credentials, else
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
func authErrEQSL(body string) string {
if strings.Contains(strings.ToLower(body), "no match on eqsl") {
return "invalid username or password"
}
return ""
}
// eqslRecordWithNickname prepends the APP_EQSL_QTH_NICKNAME tag to an ADIF
// record when nick is set, so eQSL files the QSO under the right QTH profile
// (required when the account has more than one). ADIF field order is free, so
// prepending before the rest of the record is valid.
func eqslRecordWithNickname(record, nick string) string {
nick = strings.TrimSpace(nick)
if nick == "" {
return record
}
return fmt.Sprintf("<APP_EQSL_QTH_NICKNAME:%d>%s%s", len(nick), nick, record)
}
// UploadEQSL pushes one ADIF record to eQSL.cc for the given account. qthNick
// is the optional eQSL QTH nickname.
//
// eQSL replies with text: "Result: 1 out of 1 records added" on success,
// "Bad record: Duplicate" for an already-present QSO (treated as success so
// retries are idempotent), or "Error: No match on eQSL_User/eQSL_Pswd" for bad
// credentials.
func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, adifRecord string) (UploadResult, error) {
user = strings.ToUpper(strings.TrimSpace(user))
if user == "" {
return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
}
if strings.TrimSpace(pswd) == "" {
return UploadResult{}, fmt.Errorf("eqsl: password not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("eqsl: empty adif record")
}
body, err := eqslPost(ctx, client, user, pswd, eqslRecordWithNickname(adifRecord, qthNick))
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
b := strings.ToLower(body)
if reason := authErrEQSL(body); reason != "" {
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
}
if strings.Contains(b, "duplicate") {
return UploadResult{OK: true, Message: "already in logbook"}, nil
}
if m := eqslResultRe.FindStringSubmatch(body); m != nil {
added, _ := strconv.Atoi(m[1])
if added >= 1 {
return UploadResult{OK: true, Message: strings.TrimSpace(m[0])}, nil
}
// "0 out of N" — eQSL accepted nothing; surface why if it said so.
reason := eqslReason(body)
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
}
reason := eqslReason(body)
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
}
// eqslReason trims an eQSL reply to a short human-readable reason: the first
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
// (capped), else a generic phrase.
func eqslReason(body string) string {
for _, line := range strings.Split(body, "\n") {
l := strings.TrimSpace(line)
ll := strings.ToLower(l)
if strings.HasPrefix(ll, "error:") || strings.HasPrefix(ll, "warning:") || strings.Contains(ll, "bad record") {
return l
}
}
b := strings.TrimSpace(body)
if b == "" {
return "upload rejected"
}
if len(b) > 200 {
b = b[:200]
}
return b
}
// TestEQSL validates the configured eQSL credentials with a REAL request: it
// posts an empty ADIF so nothing is inserted, then checks for the bad-login
// marker. Anything else means the credentials were accepted.
func TestEQSL(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
if user == "" {
return "", fmt.Errorf("eqsl: username (callsign) not set")
}
if strings.TrimSpace(cfg.Password) == "" {
return "", fmt.Errorf("eqsl: password not set")
}
body, err := eqslPost(ctx, client, user, cfg.Password, "")
if err != nil {
return "", err
}
if reason := authErrEQSL(body); reason != "" {
return "", fmt.Errorf("eqsl: %s", reason)
}
return fmt.Sprintf("Credentials accepted — %s", user), nil
}
+10 -1
View File
@@ -31,6 +31,8 @@ const (
ServiceQRZ Service = "qrz" // QRZ.com Logbook ServiceQRZ Service = "qrz" // QRZ.com Logbook
ServiceClublog Service = "clublog" // Club Log real-time upload ServiceClublog Service = "clublog" // Club Log real-time upload
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL) ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
) )
// UploadMode selects when an auto-upload fires after a QSO is saved. // UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -64,7 +66,9 @@ type ServiceConfig struct {
Email string `json:"email"` // Club Log account email Email string `json:"email"` // Club Log account email
Username string `json:"username"` // LoTW website login (for confirmation download) Username string `json:"username"` // LoTW website login (for confirmation download)
Password string `json:"password"` // Club Log account / LoTW website password Password string `json:"password"` // Club Log account / LoTW website password
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
Code string `json:"code"` // HRDLog: account upload code
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
@@ -81,6 +85,9 @@ func (c ServiceConfig) normalised() ServiceConfig {
c.APIKey = strings.TrimSpace(c.APIKey) c.APIKey = strings.TrimSpace(c.APIKey)
c.Email = strings.TrimSpace(c.Email) c.Email = strings.TrimSpace(c.Email)
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign)) c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
c.Code = strings.TrimSpace(c.Code)
c.Username = strings.TrimSpace(c.Username)
c.QTHNickname = strings.TrimSpace(c.QTHNickname)
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign)) c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
c.TQSLPath = strings.TrimSpace(c.TQSLPath) c.TQSLPath = strings.TrimSpace(c.TQSLPath)
c.StationLocation = strings.TrimSpace(c.StationLocation) c.StationLocation = strings.TrimSpace(c.StationLocation)
@@ -111,6 +118,8 @@ type ExternalServices struct {
QRZ ServiceConfig `json:"qrz"` QRZ ServiceConfig `json:"qrz"`
Clublog ServiceConfig `json:"clublog"` Clublog ServiceConfig `json:"clublog"`
LoTW ServiceConfig `json:"lotw"` LoTW ServiceConfig `json:"lotw"`
HRDLog ServiceConfig `json:"hrdlog"`
EQSL ServiceConfig `json:"eqsl"`
} }
// UploadResult is the outcome of a single upload attempt. // UploadResult is the outcome of a single upload attempt.
+145
View File
@@ -0,0 +1,145 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// hrdlogUploadURL is HRDLog.net's real-time upload endpoint. It accepts a
// form-encoded POST with the uploader's callsign, the account's secret upload
// Code (HRDLog account → "My Account" → upload code), an App identifier, and
// one ADIF record.
const hrdlogUploadURL = "https://robot.hrdlog.net/NewEntry.aspx"
// hrdlogApp is the App identifier sent to HRDLog so uploads are attributed to
// OpsLog in the user's HRDLog activity.
const hrdlogApp = "OpsLog"
// hrdlogPost performs the form POST to NewEntry.aspx and returns the raw
// response body. The endpoint replies HTTP 200 with a small XML document even
// for errors; callers classify it via the markers in classifyHRDLog.
func hrdlogPost(ctx context.Context, client *http.Client, callsign, code, adif string) (string, error) {
form := url.Values{}
form.Set("Callsign", callsign)
form.Set("Code", code)
form.Set("App", hrdlogApp)
form.Set("ADIFData", adif)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hrdlogUploadURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("hrdlog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("hrdlog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
if resp.StatusCode != http.StatusOK {
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return msg, fmt.Errorf("hrdlog: http %d: %s", resp.StatusCode, msg)
}
return msg, nil
}
// authErrHRDLog returns a non-empty, human-readable reason when the response
// signals a credential problem (wrong upload code or unregistered callsign),
// or "" otherwise. Markers mirror HRDLog's documented XML replies
// ("Invalid token</error>" / "Unknown user</error>").
func authErrHRDLog(body string) string {
b := strings.ToLower(body)
switch {
case strings.Contains(b, "invalid token"):
return "invalid upload code"
case strings.Contains(b, "unknown user"):
return "callsign not registered at HRDLog"
default:
return ""
}
}
// UploadHRDLog pushes one ADIF record to HRDLog.net. callsign is the station
// callsign the log belongs to, code is the account's upload code.
//
// Form fields (application/x-www-form-urlencoded POST):
//
// Callsign=<station call>&Code=<upload code>&App=OpsLog&ADIFData=<one record>
//
// HRDLog replies with XML: "<insert>1" on success, "<insert>0" for a duplicate
// (already logged — treated as success so retries are idempotent), or an
// "<error>…</error>" payload otherwise.
func UploadHRDLog(ctx context.Context, client *http.Client, callsign, code, adifRecord string) (UploadResult, error) {
callsign = strings.ToUpper(strings.TrimSpace(callsign))
code = strings.TrimSpace(code)
if callsign == "" {
return UploadResult{}, fmt.Errorf("hrdlog: station callsign not set")
}
if code == "" {
return UploadResult{}, fmt.Errorf("hrdlog: upload code not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("hrdlog: empty adif record")
}
body, err := hrdlogPost(ctx, client, callsign, code, adifRecord)
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
b := strings.ToLower(body)
switch {
case strings.Contains(b, "<insert>1"):
return UploadResult{OK: true, Message: "uploaded"}, nil
case strings.Contains(b, "<insert>0"):
return UploadResult{OK: true, Message: "already in logbook"}, nil
}
reason := authErrHRDLog(body)
if reason == "" {
reason = body
if reason == "" {
reason = "upload rejected"
}
}
return UploadResult{OK: false, Message: reason}, fmt.Errorf("hrdlog: upload failed: %s", reason)
}
// NOTE: HRDLog's NewEntry.aspx inserts ONLY the first record of a multi-record
// ADIFData, so there is no batch upload — callers must POST one record per
// request (see UploadHRDLog). The bulk uploader in app.go does exactly that.
// TestHRDLog validates the configured HRDLog credentials with a REAL request:
// it posts an empty ADIF so nothing is inserted, then checks for HRDLog's auth
// errors. A wrong upload code comes back as "Invalid token", a wrong callsign
// as "Unknown user"; anything else means the credentials were accepted (HRDLog
// simply had no QSO to add).
func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
callsign := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
code := strings.TrimSpace(cfg.Code)
if callsign == "" {
return "", fmt.Errorf("hrdlog: station callsign not set")
}
if code == "" {
return "", fmt.Errorf("hrdlog: upload code not set")
}
body, err := hrdlogPost(ctx, client, callsign, code, "")
if err != nil {
return "", err
}
if reason := authErrHRDLog(body); reason != "" {
return "", fmt.Errorf("hrdlog: %s", reason)
}
return fmt.Sprintf("Credentials accepted — %s", callsign), nil
}
+49 -1
View File
@@ -115,6 +115,8 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.QRZ = cfg.QRZ.normalised() cfg.QRZ = cfg.QRZ.normalised()
cfg.Clublog = cfg.Clublog.normalised() cfg.Clublog = cfg.Clublog.normalised()
cfg.LoTW = cfg.LoTW.normalised() cfg.LoTW = cfg.LoTW.normalised()
cfg.HRDLog = cfg.HRDLog.normalised()
cfg.EQSL = cfg.EQSL.normalised()
m.cfg = cfg m.cfg = cfg
} }
@@ -151,6 +153,14 @@ func (m *Manager) OnQSOLogged(id int64) {
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" { if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
m.route(ServiceLoTW, id, lt) m.route(ServiceLoTW, id, lt)
} }
// HRDLog — needs the station callsign + the account upload code.
if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" {
m.route(ServiceHRDLog, id, h)
}
// eQSL — needs the account username (callsign) + password.
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
m.route(ServiceEQSL, id, e)
}
} }
// route sends a logged QSO down the configured timing path: queue it for the // route sends a logged QSO down the configured timing path: queue it for the
@@ -190,6 +200,12 @@ func (m *Manager) onCloseServices() []Service {
if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" { if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" {
out = append(out, ServiceLoTW) out = append(out, ServiceLoTW)
} }
if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" {
out = append(out, ServiceHRDLog)
}
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
out = append(out, ServiceEQSL)
}
return out return out
} }
@@ -237,6 +253,18 @@ func (m *Manager) FlushOnClose() int {
uploaded++ uploaded++
} }
} }
case ServiceHRDLog:
for _, id := range ids {
if m.upload(svc, id, cfg.HRDLog) {
uploaded++
}
}
case ServiceEQSL:
for _, id := range ids {
if m.upload(svc, id, cfg.EQSL) {
uploaded++
}
}
} }
} }
return uploaded return uploaded
@@ -303,8 +331,10 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
switch svc { switch svc {
case ServiceQRZ, ServiceLoTW: case ServiceQRZ, ServiceLoTW:
owner = cfg.ForceStationCallsign owner = cfg.ForceStationCallsign
case ServiceClublog: case ServiceClublog, ServiceHRDLog:
owner = cfg.Callsign owner = cfg.Callsign
case ServiceEQSL:
owner = cfg.Username
} }
if owner != "" && m.deps.StationCallOf != nil { if owner != "" && m.deps.StationCallOf != nil {
qcall := m.deps.StationCallOf(id) qcall := m.deps.StationCallOf(id)
@@ -351,6 +381,24 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
return false return false
} }
res, err = UploadLoTW(ctx, cfg, "", record) res, err = UploadLoTW(ctx, cfg, "", record)
case ServiceHRDLog:
// HRDLog takes the station callsign as a separate param, so the ADIF
// keeps the QSO's own station call (no override), like Club Log.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
}
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
case ServiceEQSL:
// eQSL keeps the QSO's own station call; the account is identified by
// the Username + Password, with an optional QTH nickname.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false
}
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
default: default:
return false return false
} }
+234
View File
@@ -0,0 +1,234 @@
// Package netctl persists "NET" definitions and their station rosters for the
// NET Control feature (managing a directed net / round-table on a frequency).
//
// A NET is a named net (e.g. "French QSO", "QSO des Brasses") with a roster of
// stations that habitually check in. The roster grows over time as you add new
// callsigns. Storage is a single JSON file in the data dir — global/shared
// across all logbooks (a net like "French QSO" is reused whatever logbook is
// open). The QSOs themselves are logged into the active logbook by the caller;
// this package only owns the net definitions + rosters, not the live session.
package netctl
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Station is one roster entry: a station registered in a net.
type Station struct {
Callsign string `json:"callsign"`
Name string `json:"name,omitempty"`
QTH string `json:"qth,omitempty"`
Country string `json:"country,omitempty"`
DXCC int `json:"dxcc,omitempty"`
ITU int `json:"itu,omitempty"`
CQ int `json:"cq,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.
type Net struct {
ID string `json:"id"`
Name string `json:"name"`
DefaultRSTSent string `json:"default_rst_sent,omitempty"`
DefaultRSTRcvd string `json:"default_rst_rcvd,omitempty"`
DefaultComment string `json:"default_comment,omitempty"`
Stations []Station `json:"stations,omitempty"`
}
// Store is the persistent collection of nets, backed by a JSON file.
type Store struct {
mu sync.Mutex
path string
nets []Net
}
// Open loads the store from path (creating an empty one if the file is absent).
func Open(path string) (*Store, error) {
s := &Store{path: path}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, err
}
if len(b) > 0 {
if err := json.Unmarshal(b, &s.nets); err != nil {
// Corrupt file: start empty rather than failing the whole app.
s.nets = nil
}
}
return s, nil
}
// save writes the current state to disk. Caller must hold s.mu.
func (s *Store) save() error {
b, err := json.MarshalIndent(s.nets, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) }
// List returns a copy of all nets (with rosters), ordered by name.
func (s *Store) List() []Net {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Net, len(s.nets))
copy(out, s.nets)
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out
}
// find returns the index of the net with id, or -1. Caller must hold s.mu.
func (s *Store) find(id string) int {
for i := range s.nets {
if s.nets[i].ID == id {
return i
}
}
return -1
}
// Create adds a new net with default reports of 59/59 and returns it.
func (s *Store) Create(name string) (Net, error) {
name = strings.TrimSpace(name)
if name == "" {
return Net{}, fmt.Errorf("net name required")
}
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.nets {
if strings.EqualFold(s.nets[i].Name, name) {
return Net{}, fmt.Errorf("a net named %q already exists", name)
}
}
n := Net{ID: newID(), Name: name, DefaultRSTSent: "59", DefaultRSTRcvd: "59"}
s.nets = append(s.nets, n)
return n, s.save()
}
// Rename changes a net's name.
func (s *Store) Rename(id, name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("net name required")
}
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
s.nets[i].Name = name
return s.save()
}
// SetDefaults updates the per-net default report/comment values.
func (s *Store) SetDefaults(id, rstSent, rstRcvd, comment string) error {
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
s.nets[i].DefaultRSTSent = rstSent
s.nets[i].DefaultRSTRcvd = rstRcvd
s.nets[i].DefaultComment = comment
return s.save()
}
// Delete removes a net and its roster.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
s.nets = append(s.nets[:i], s.nets[i+1:]...)
return s.save()
}
// Get returns a copy of one net by id.
func (s *Store) Get(id string) (Net, bool) {
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return Net{}, false
}
return s.nets[i], true
}
// Roster returns a net's stations, sorted by callsign.
func (s *Store) Roster(id string) ([]Station, error) {
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return nil, fmt.Errorf("net not found")
}
out := make([]Station, len(s.nets[i].Stations))
copy(out, s.nets[i].Stations)
sort.Slice(out, func(a, b int) bool { return out[a].Callsign < out[b].Callsign })
return out, nil
}
// RosterUpsert adds st to the net's roster, or updates it if the callsign is
// already present (matched case-insensitively; the callsign is stored upper).
func (s *Store) RosterUpsert(id string, st Station) error {
st.Callsign = strings.ToUpper(strings.TrimSpace(st.Callsign))
if st.Callsign == "" {
return fmt.Errorf("callsign required")
}
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
for j := range s.nets[i].Stations {
if strings.EqualFold(s.nets[i].Stations[j].Callsign, st.Callsign) {
s.nets[i].Stations[j] = st
return s.save()
}
}
s.nets[i].Stations = append(s.nets[i].Stations, st)
return s.save()
}
// RosterRemove deletes a callsign from a net's roster.
func (s *Store) RosterRemove(id, callsign string) error {
callsign = strings.ToUpper(strings.TrimSpace(callsign))
s.mu.Lock()
defer s.mu.Unlock()
i := s.find(id)
if i < 0 {
return fmt.Errorf("net not found")
}
for j := range s.nets[i].Stations {
if strings.EqualFold(s.nets[i].Stations[j].Callsign, callsign) {
s.nets[i].Stations = append(s.nets[i].Stations[:j], s.nets[i].Stations[j+1:]...)
return s.save()
}
}
return nil // not present → nothing to do
}
+239
View File
@@ -0,0 +1,239 @@
// Package powergenius drives a 4O3A PowerGenius XL amplifier over its TCP text
// API (same "Genius Series" line protocol as the Antenna Genius). OpsLog reads
// the amp's operate state via the FlexRadio amplifier object, but the fan mode
// is a PGXL-only setting only reachable on the amp's own control port — hence
// this small direct client. Commands are "C<id>|<cmd>\n"; replies are
// "R<id>|0|<k=v …>" and asynchronous "S0|<k=v …>".
package powergenius
import (
"bufio"
"fmt"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
defaultPort = 9008
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
pollEvery = 1500 * time.Millisecond
reconnectDelay = 2 * time.Second
)
// Status is the snapshot the UI renders (only the bits OpsLog needs).
type Status struct {
Connected bool `json:"connected"`
Host string `json:"host,omitempty"`
LastError string `json:"last_error,omitempty"`
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
Temperature float64 `json:"temperature"`
}
type Client struct {
host string
port int
mu sync.Mutex // serialises command send/recv on the connection
conn net.Conn
reader *bufio.Reader
statusMu sync.RWMutex
status Status
// Optimistic fan mode kept until the amp's status poll confirms it (or it
// ages out) — otherwise a stale poll right after a change reverts the UI.
fanPending string
fanPendingAt time.Time
cmdID atomic.Int64
stop chan struct{}
running bool
}
func New(host string, port int) *Client {
if port <= 0 || port > 65535 {
port = defaultPort
}
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
}
func (c *Client) Start() error {
c.running = true
go c.pollLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stop)
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setStatus(fn func(*Status)) {
c.statusMu.Lock()
fn(&c.status)
c.statusMu.Unlock()
}
// SetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
func (c *Client) SetFanMode(mode string) error {
m := strings.ToUpper(strings.TrimSpace(mode))
switch m {
case "STANDARD", "CONTEST", "BROADCAST":
default:
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
}
if _, err := c.command("setup fanmode=" + m); err != nil {
return err
}
c.statusMu.Lock()
c.status.FanMode = m // optimistic
c.fanPending, c.fanPendingAt = m, time.Now()
c.statusMu.Unlock()
return nil
}
// SetOperate puts the amp in OPERATE (1) or STANDBY (0).
func (c *Client) SetOperate(on bool) error {
v := "0"
if on {
v = "1"
}
_, err := c.command("operate=" + v)
return err
}
func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery)
defer t.Stop()
for {
select {
case <-c.stop:
return
case <-t.C:
if err := c.ensureConnected(); err != nil {
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
continue
}
if _, err := c.command("status"); err != nil {
c.dropConn()
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
}
}
}
}
func (c *Client) ensureConnected() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return nil
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
if err != nil {
return err
}
c.conn = conn
c.reader = bufio.NewReader(conn)
// Discard the version banner the device sends on connect.
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
_, _ = c.reader.ReadString('\n')
return nil
}
func (c *Client) dropConn() {
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.reader = nil
}
c.mu.Unlock()
}
// command sends "C<id>|<cmd>\n" and parses the single-line reply into status.
func (c *Client) command(cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil || c.reader == nil {
return "", fmt.Errorf("powergenius: not connected")
}
id := c.cmdID.Add(1)
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil {
return "", err
}
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
line, err := c.reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
c.parse(line)
return line, nil
}
// parse handles "R<id>|0|<k=v …>" and "S0|<k=v …>" status lines.
func (c *Client) parse(resp string) {
var data string
switch {
case strings.HasPrefix(resp, "R"):
p := strings.SplitN(resp, "|", 3)
if len(p) < 3 {
return
}
data = p[2]
case strings.HasPrefix(resp, "S"):
p := strings.SplitN(resp, "|", 2)
if len(p) < 2 {
return
}
data = p[1]
default:
return
}
c.statusMu.Lock()
c.status.Connected = true
c.status.LastError = ""
for _, pair := range strings.Fields(data) {
kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "state":
c.status.State = kv[1]
case "fanmode":
dev := strings.ToUpper(kv[1])
// Honour a recent optimistic change until the amp confirms it.
if c.fanPending != "" && time.Since(c.fanPendingAt) < 3*time.Second && dev != c.fanPending {
break
}
c.fanPending = ""
c.status.FanMode = dev
case "temp":
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
}
}
c.statusMu.Unlock()
}
+156 -2
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"reflect" "reflect"
"regexp"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -491,10 +492,36 @@ var uploadStatusCols = map[string]bool{
"lotw_sent": true, "lotw_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,
} }
// ListForUpload returns QSOs whose per-service sent-status column equals // ListForUpload returns QSOs whose per-service sent-status column equals
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required". // value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
// ListForUploadFull is like ListForUpload but returns FULL QSO rows so the UI
// can show the same rich, column-pickable table as Recent QSOs. column is an
// upload-status column (validated); value is the status to match ("" = not yet
// uploaded).
func (r *Repo) ListForUploadFull(ctx context.Context, column, value string) ([]QSO, error) {
if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column)
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE COALESCE(`+column+`,'') = ? ORDER BY qso_date DESC, id DESC`, value)
if err != nil {
return nil, fmt.Errorf("list for upload: %w", err)
}
defer rows.Close()
out := make([]QSO, 0, 64)
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) { func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
if !uploadStatusCols[column] { if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column) return nil, fmt.Errorf("invalid upload column %q", column)
@@ -597,6 +624,19 @@ func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) e
return nil return nil
} }
// MarkHRDLogUploaded stamps HRDLOG_QSO_UPLOAD_STATUS=Y and the upload date
// after a successful HRDLog.net push. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkHRDLogUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET hrdlog_qso_upload_status = 'Y', hrdlog_qso_upload_date = ?,
updated_at = ? WHERE id = ?`,
date, db.NowISO(), id)
if err != nil {
return fmt.Errorf("mark hrdlog uploaded %d: %w", id, err)
}
return nil
}
// MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a // MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a
// successful TQSL upload. date is an ADIF YYYYMMDD string. // successful TQSL upload. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error { func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
@@ -610,6 +650,29 @@ func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) erro
return nil return nil
} }
// MarkUploadedBatch sets <statusCol>='Y' and <dateCol>=date on EVERY id in one
// UPDATE — used by bulk upload (Club Log / HRDLog) so a 25k-QSO run isn't one
// round-trip per QSO on a remote MySQL. statusCol/dateCol come from a fixed
// whitelist (not user input), so the column interpolation is safe.
func (r *Repo) MarkUploadedBatch(ctx context.Context, statusCol, dateCol, date string, ids []int64) error {
if len(ids) == 0 {
return nil
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
args := make([]any, 0, len(ids)+2)
args = append(args, date, db.NowISO())
for _, id := range ids {
args = append(args, id)
}
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
args...)
if err != nil {
return fmt.Errorf("mark uploaded batch (%d): %w", len(ids), err)
}
return nil
}
// MarkEQSLSent stamps EQSL_QSL_SENT=Y and the sent date after a successful // MarkEQSLSent stamps EQSL_QSL_SENT=Y and the sent date after a successful
// eQSL e-mail. date is an ADIF YYYYMMDD string. // eQSL e-mail. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error { func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
@@ -623,6 +686,76 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
return nil return nil
} }
// bulkEditableCols whitelists the columns BulkSetField may write. Limited to
// TEXT fields where setting one value across many QSOs is meaningful: the
// per-service QSL/upload status fields, plus "my station"/operator fields that
// are naturally constant across a run (grid, antenna, rig, address, …). It
// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the
// contacted station's details) and numeric columns (power, zones, lat/lon),
// which would be corrupted or meaningless if bulk-set to a single value.
var bulkEditableCols = map[string]bool{
// QSL / upload status
"lotw_sent": true,
"lotw_rcvd": true,
"eqsl_sent": true,
"eqsl_rcvd": true,
"qsl_sent": true,
"qsl_rcvd": true,
"qsl_via": true,
"qrzcom_qso_upload_status": true,
"clublog_qso_upload_status": true,
"hrdlog_qso_upload_status": true,
// My station / operator
"station_callsign": true,
"operator": true,
"my_grid": true,
"my_country": true,
"my_state": true,
"my_cnty": true,
"my_iota": true,
"my_sota_ref": true,
"my_pota_ref": true,
"my_wwff_ref": true,
"my_street": true,
"my_city": true,
"my_postal_code": true,
"my_rig": true,
"my_antenna": true,
"my_sig": true,
"my_sig_info": true,
// Misc text
"comment": true,
"notes": true,
"rig": true,
"ant": true,
}
// BulkSetField sets one whitelisted column to value on every listed QSO in a
// single statement. value "" clears the field. Returns rows affected.
func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value string) (int64, error) {
if !bulkEditableCols[column] {
return 0, fmt.Errorf("field %q is not bulk-editable", column)
}
if len(ids) == 0 {
return 0, nil
}
ph := make([]string, len(ids))
args := make([]any, 0, len(ids)+2)
args = append(args, value, db.NowISO())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
res, err := r.db.ExecContext(ctx,
`UPDATE qso SET `+column+` = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
args...)
if err != nil {
return 0, fmt.Errorf("bulk set %s: %w", column, err)
}
n, _ := res.RowsAffected()
return n, nil
}
// Update overwrites all editable fields of an existing QSO. updated_at is bumped. // Update overwrites all editable fields of an existing QSO. updated_at is bumped.
func (r *Repo) Update(ctx context.Context, q QSO) error { func (r *Repo) Update(ctx context.Context, q QSO) error {
if q.ID == 0 { if q.ID == 0 {
@@ -815,16 +948,26 @@ var filterableColumns = map[string]bool{
"name": true, "qth": true, "address": true, "email": true, "name": true, "qth": true, "address": true, "email": true,
"grid": true, "country": true, "state": true, "cnty": true, "grid": true, "country": true, "state": true, "cnty": true,
"dxcc": true, "cont": true, "cqz": true, "ituz": true, "dxcc": true, "cont": true, "cqz": true, "ituz": true,
"iota": true, "sota_ref": true, "pota_ref": true, "rig": true, "ant": true, "iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true, "qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true, "lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
"contest_id": true, "srx": true, "stx": true, "contest_id": true, "srx": true, "stx": true,
"prop_mode": true, "sat_name": true, "prop_mode": true, "sat_name": true,
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true, "station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
"my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": true,
"my_wwff_ref": true, "my_street": true, "my_city": true, "my_postal_code": true,
"my_rig": true, "my_antenna": true, "my_sig": true, "my_sig_info": true,
"tx_pwr": true, "comment": true, "notes": true, "tx_pwr": true, "comment": true, "notes": true,
} }
// dateColumns are stored as full ISO timestamps; a filter on a bare YYYY-MM-DD
// value compares on the date part (see conditionSQL) so day filters are exact.
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
// bareDateRe matches a plain calendar date with no time component.
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
// filterableExtras whitelists virtual filter fields stored inside extras_json // filterableExtras whitelists virtual filter fields stored inside extras_json
// (valid ADIF fields we don't promote to columns). The value is the uppercase // (valid ADIF fields we don't promote to columns). The value is the uppercase
// ADIF/Extras key; the SQL expression uses json_extract. // ADIF/Extras key; the SQL expression uses json_extract.
@@ -871,6 +1014,14 @@ func conditionSQL(c Condition) (string, []any, error) {
return "", nil, fmt.Errorf("unknown filter field %q", c.Field) return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
} }
v := c.Value v := c.Value
// Date columns hold full ISO timestamps ("2020-01-01T12:34:56.000Z"). When
// the user filters on a bare calendar date, compare only the date part so
// "= 2020-01-01" matches that whole day and "<= 2020-12-31" includes it
// (a raw string compare would otherwise drop times on the boundary day).
if dateColumns[strings.ToLower(strings.TrimSpace(c.Field))] && bareDateRe.MatchString(strings.TrimSpace(v)) {
col = "substr(" + col + ",1,10)"
v = strings.TrimSpace(v)
}
switch c.Op { switch c.Op {
case "eq": case "eq":
return col + " = ?", []any{v}, nil return col + " = ?", []any{v}, nil
@@ -1496,6 +1647,7 @@ func scanAwardQSO(s scanner) (QSO, error) {
type EntitySlot struct { type EntitySlot struct {
Country string Country string
Bands map[string]struct{} // bands worked, any mode Bands map[string]struct{} // bands worked, any mode
Modes map[string]struct{} // modes worked, any band
Slots map[string]map[string]struct{} // band → modes worked Slots map[string]map[string]struct{} // band → modes worked
} }
@@ -1541,11 +1693,13 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
e = &EntitySlot{ e = &EntitySlot{
Country: country, Country: country,
Bands: make(map[string]struct{}), Bands: make(map[string]struct{}),
Modes: make(map[string]struct{}),
Slots: make(map[string]map[string]struct{}), Slots: make(map[string]map[string]struct{}),
} }
out[key] = e out[key] = e
} }
e.Bands[band] = struct{}{} e.Bands[band] = struct{}{}
e.Modes[mode] = struct{}{}
bandSlots, ok := e.Slots[band] bandSlots, ok := e.Slots[band]
if !ok { if !ok {
bandSlots = make(map[string]struct{}) bandSlots = make(map[string]struct{})
+23
View File
@@ -68,6 +68,13 @@ type Client struct {
running bool running bool
seqNum byte seqNum byte
seqMu sync.Mutex seqMu sync.Mutex
// Optimistic pattern direction kept until the antenna's status poll reports
// it (or it ages out) — the motors take a second or two, and a stale poll in
// between would otherwise snap the UI back to the old direction.
pendingDir int
pendingDirAt time.Time
pendingDirSet bool
} }
type Status struct { type Status struct {
@@ -199,6 +206,14 @@ func (c *Client) pollLoop() {
} }
c.statusMu.Lock() c.statusMu.Lock()
// Keep a just-commanded direction until the antenna reports it.
if c.pendingDirSet {
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
c.pendingDirSet = false
} else {
status.Direction = c.pendingDir
}
}
c.lastStatus = status c.lastStatus = status
c.statusMu.Unlock() c.statusMu.Unlock()
@@ -449,6 +464,14 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
} }
_, err := c.sendCommand(CMD_FREQ, data) _, err := c.sendCommand(CMD_FREQ, data)
if err == nil {
c.statusMu.Lock()
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
if c.lastStatus != nil {
c.lastStatus.Direction = direction // reflect immediately
}
c.statusMu.Unlock()
}
return err return err
} }
-50578
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -2,6 +2,8 @@ package main
import ( import (
"embed" "embed"
"os"
"strings"
"github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options"
@@ -11,9 +13,32 @@ import (
//go:embed all:frontend/dist //go:embed all:frontend/dist
var assets embed.FS var assets embed.FS
// profileArg extracts a profile name from the command line. Accepts
// "--profile NAME", "--profile=NAME", "-profile NAME", "-p NAME" so a desktop
// shortcut can launch OpsLog straight into a given profile (e.g. F4BPO / TM2Q).
func profileArg(args []string) string {
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--profile" || a == "-profile" || a == "-p":
if i+1 < len(args) {
return strings.TrimSpace(args[i+1])
}
case strings.HasPrefix(a, "--profile="):
return strings.TrimSpace(strings.TrimPrefix(a, "--profile="))
case strings.HasPrefix(a, "-profile="):
return strings.TrimSpace(strings.TrimPrefix(a, "-profile="))
case strings.HasPrefix(a, "-p="):
return strings.TrimSpace(strings.TrimPrefix(a, "-p="))
}
}
return ""
}
func main() { func main() {
// Create an instance of the app structure // Create an instance of the app structure
app := NewApp() app := NewApp()
app.startupProfile = profileArg(os.Args[1:])
// Create application with options // Create application with options
err := wails.Run(&options.App{ err := wails.Run(&options.App{
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.11.3" appVersion = "0.14.1"
// 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.