diff --git a/app.go b/app.go index 3dd3665..b306f5f 100644 --- a/app.go +++ b/app.go @@ -114,6 +114,7 @@ const ( keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend) keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992) keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter + keyCATFlexDVKDax = "cat.flex.dvk_dax" // raise the transmit-bar DAX button while a voice message plays keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal keyCATPollMs = "cat.poll_ms" @@ -245,6 +246,9 @@ const ( keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150" keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts keyRowColors = "appearance.row_colors" + keyRotorPresets = "rotator.presets" // quick-turn buttons on the rotor widget + keyCATOffsetHz = "cat.offset_hz" // transverter offset: real frequency − rig frequency + keyCATOffsetOn = "cat.offset_on" // "1" → apply the transverter offset keyMatrixColors = "appearance.matrix_colors" // band/mode matrix palette overrides keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam) keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp) @@ -427,6 +431,7 @@ type CATSettings struct { FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter + FlexDVKDax bool `json:"flex_dvk_dax"` // raise transmit DAX while a voice message plays, lower it after FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120) XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…) @@ -448,21 +453,26 @@ type CATSettings struct { // stops some USB-serial interfaces transmitting at all. YaesuLowLines bool `json:"yaesu_low_lines"` KenwoodLowLines bool `json:"kenwood_low_lines"` - IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) - IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) - IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) - IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server) - IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID - IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password - IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification - TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) - TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) - TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama - PollMs int `json:"poll_ms"` // poll interval in ms (default 250) - DelayMs int `json:"delay_ms"` // pause between commands (default 0) - DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) - ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs - SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532) + IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) + IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) + IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) + IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server) + IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID + IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password + IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification + TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) + TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) + TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama + PollMs int `json:"poll_ms"` // poll interval in ms (default 250) + DelayMs int `json:"delay_ms"` // pause between commands (default 0) + // Transverter offset: real frequency − rig frequency, in Hz. A 28 MHz IF + // driving a 144 MHz transverter is +116000000. Kept as a separate on/off so + // the number survives switching the transverter out of line for an evening. + OffsetOn bool `json:"offset_on"` + OffsetHz int64 `json:"offset_hz"` + DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) + ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs + SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532) // ShareProto picks WHICH server runs — "rigctl" or "tci". One or the other, // not both: they are two ways of asking the same radio the same questions, // and a second listener is only a second thing to go wrong. @@ -740,6 +750,7 @@ type App struct { startupProfile string // --profile from the command line (activate at startup) dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends + flexDaxRaised bool // we pressed the Flex transmit DAX button for it; restore when it ends pttMu sync.Mutex // confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ) // so closing the QSL Manager — or starting another download — stops the previous @@ -774,6 +785,7 @@ type App struct { offlineMode bool // last write failed because the DB was unreachable catFlexSpots bool // push cluster spots to the FlexRadio panadapter + catFlexDVKDax bool // raise the Flex transmit DAX button around a voice message catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter catFlexDecodeSecs int // decode spot display duration (seconds) liveActMu sync.Mutex // guards the entry-strip activity reported for live status @@ -1400,6 +1412,10 @@ func (a *App) startup(ctx context.Context) { if keyed { go a.dvkUnkeyPTT(gen) } + // And give the microphone back, if we took it (see raiseFlexDVKDax). + // Off the callback's goroutine: this talks to the radio, and the + // audio manager is reporting a state change, not waiting on us. + go a.lowerFlexDVKDax() } if a.ctx != nil { wruntime.EventsEmit(a.ctx, "audio:status", st) @@ -7508,7 +7524,7 @@ func (a *App) GetCATSettings() (CATSettings, error) { if a.settings == nil { return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") } - m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort) + m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort) if err != nil { return CATSettings{}, err } @@ -7519,6 +7535,7 @@ func (a *App) GetCATSettings() (CATSettings, error) { FlexHost: m[keyCATFlexHost], FlexPort: 4992, FlexSpots: m[keyCATFlexSpots] == "1", + FlexDVKDax: m[keyCATFlexDVKDax] == "1", FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1", FlexDecodeSecs: 120, XieguPort: m[keyCATXieguPort], @@ -7547,6 +7564,7 @@ func (a *App) GetCATSettings() (CATSettings, error) { PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1", PollMs: 250, DelayMs: 0, + OffsetOn: m[keyCATOffsetOn] == "1", DigitalDefault: m[keyCATDigitalDefault], ShareEnabled: m[keyCATShareEnabled] == "1", SharePort: 4532, @@ -7556,6 +7574,11 @@ func (a *App) GetCATSettings() (CATSettings, error) { if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 { out.FlexPort = n } + // Signed: a transverter can sit either side of its IF (a 144 MHz IF feeding a + // 28 MHz receive converter is a negative offset). + if n, err := strconv.ParseInt(strings.TrimSpace(m[keyCATOffsetHz]), 10, 64); err == nil { + out.OffsetHz = n + } if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 { out.FlexDecodeSecs = n } @@ -7666,6 +7689,14 @@ func (a *App) SaveCATSettings(s CATSettings) error { if s.FlexSpots { flexSpots = "1" } + flexDVKDax := "0" + if s.FlexDVKDax { + flexDVKDax = "1" + } + offsetOn := "0" + if s.OffsetOn { + offsetOn = "1" + } flexDecodeSpots := "0" if s.FlexDecodeSpots { flexDecodeSpots = "1" @@ -7702,6 +7733,7 @@ func (a *App) SaveCATSettings(s CATSettings) error { keyCATFlexHost: strings.TrimSpace(s.FlexHost), keyCATFlexPort: strconv.Itoa(s.FlexPort), keyCATFlexSpots: flexSpots, + keyCATFlexDVKDax: flexDVKDax, keyCATFlexDecodeSpots: flexDecodeSpots, keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs), keyCATXieguPort: strings.TrimSpace(s.XieguPort), @@ -7731,6 +7763,8 @@ func (a *App) SaveCATSettings(s CATSettings) error { keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle), keyCATPollMs: strconv.Itoa(s.PollMs), keyCATDelayMs: strconv.Itoa(s.DelayMs), + keyCATOffsetOn: offsetOn, + keyCATOffsetHz: strconv.FormatInt(s.OffsetHz, 10), keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), keyCATShareEnabled: shareEnabled, keyCATSharePort: strconv.Itoa(s.SharePort), @@ -8591,6 +8625,9 @@ func (a *App) QSOAudioPlayOnAir() error { } cfg := cfgEarly + // Same as the voice keyer: this goes out over the air, so on a Flex the + // transmit source has to be DAX before the carrier comes up. + a.raiseFlexDVKDax() if err := a.pttKey(cfg); err != nil { applog.Printf("qso-rec: PTT on failed before playback: %v", err) // Keep going — the audio still reaches the rig and the operator may use VOX. @@ -8609,6 +8646,7 @@ func (a *App) QSOAudioPlayOnAir() error { if keyed { go a.dvkUnkeyPTT(gen) } + a.lowerFlexDVKDax() // nothing went out — give the microphone straight back return err } applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm)) @@ -9529,6 +9567,10 @@ func (a *App) DVKPlay(slot int) error { return fmt.Errorf("no recording in slot %d", slot) } cfg, _ := a.GetAudioSettings() + // Before PTT: on a Flex the transmit audio comes from the microphone or from + // DAX, so the source has to be right before the carrier goes up — otherwise + // the first syllables are transmitted as silence. + a.raiseFlexDVKDax() if err := a.pttKey(cfg); err != nil { applog.Printf("dvk: PTT on failed: %v", err) // Keep going — the audio still reaches the rig; the user may use VOX. @@ -9546,11 +9588,77 @@ func (a *App) DVKPlay(slot int) error { if keyed { go a.dvkUnkeyPTT(gen) } + // Nothing is going out, so the microphone must come straight back — the + // status callback that normally does it never fires for a play that + // never started. + a.lowerFlexDVKDax() return err } return nil } +// raiseFlexDVKDax presses SmartSDR's transmit-bar DAX button before a voice +// message goes out, and remembers whether it was already down. +// +// On a FlexRadio the transmit audio comes from EITHER the microphone or DAX, +// never both. A voice keyer plays into the DAX TX stream, so with the button up +// the message is transmitted as silence; with it left down afterwards the +// operator's own microphone is dead. Pressing it by hand around every message is +// exactly the kind of thing that gets forgotten mid-pileup, hence the option. +// +// The PREVIOUS state is what gets restored, not a blanket "off": an operator +// running WSJT-X has DAX down on purpose, and lowering it for them after a voice +// message would break the digital setup to solve a problem they do not have. In +// the case this option exists for — microphone operating, DAX up — restoring the +// previous state is precisely "give me my microphone back". +// +// Silent no-op when the option is off or the radio is not a Flex: this sits on +// the voice-keyer path, which must not fail because a rig cannot do DAX. +func (a *App) raiseFlexDVKDax() { + if a.cat == nil || !a.catFlexDVKDax { + return + } + var was bool + err := a.cat.FlexDo(func(fc cat.FlexController) error { + was = fc.FlexState().TXDAX + if was { + return nil // already down — nothing to press, nothing to restore + } + return fc.SetTXDAX(true) + }) + if err != nil { + applog.Printf("dvk: could not raise the Flex TX DAX button: %v", err) + return + } + if was { + return + } + a.pttMu.Lock() + a.flexDaxRaised = true + a.pttMu.Unlock() + applog.Printf("dvk: Flex TX DAX on for the voice message") +} + +// lowerFlexDVKDax puts the transmit-bar DAX button back where it was, and only +// when raiseFlexDVKDax actually moved it. Called where the voice-message PTT is +// released, so the microphone comes back at the moment the message ends. +func (a *App) lowerFlexDVKDax() { + a.pttMu.Lock() + raised := a.flexDaxRaised + a.flexDaxRaised = false + a.pttMu.Unlock() + if !raised || a.cat == nil { + return + } + if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXDAX(false) }); err != nil { + // Worth saying loudly: the microphone stays dead until it is pressed by + // hand, and an operator whose voice is not going out has no other clue. + applog.Printf("dvk: could not lower the Flex TX DAX button — the microphone stays off air until you press it in SmartSDR: %v", err) + return + } + applog.Printf("dvk: Flex TX DAX off — microphone back on air") +} + // dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end // of the message — but ONLY if no newer key happened since (gen unchanged). A // rapid replay (or a Test PTT) starts a fresh transmission whose key must not @@ -11244,6 +11352,12 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, done(matched, total) return } + // Second pass, at mode-CLASS granularity: LoTW answers with the mode + // GROUP ("DATA"), never the submode it was given, so an FT4/FT8/FT2 + // contact can never match on the exact string and the operator is told + // their own QSO is missing from their log. + classIDs, _ := a.qso.DedupeClassKeyIDs(ctx) + byClass := 0 // Snapshot award-valid confirmations (LoTW + paper QSL — the only two // that count for ARRL awards) so each incoming one is flagged NEW. sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"}) @@ -11263,8 +11377,20 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, date = time.Now().UTC().Format("20060102") } a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat - key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode) - if id, found := keyIDs[key]; found { + minute := q.QSODate.UTC().Format("2006-01-02T15:04") + key := qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode) + classKey := qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode) + id, found := keyIDs[key] + if !found { + // The mode differs but the station, minute, band and mode class + // agree — the same contact under LoTW's group name. An ambiguous + // class key (id 0) is deliberately NOT taken: see DedupeClassKeyIDs. + if cid, ok := classIDs[classKey]; ok && cid != 0 { + id, found = cid, true + byClass++ + } + } + if found { if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil { matched++ } @@ -11274,6 +11400,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, q.LOTWRcvdDate = date if newID, e := a.qso.Add(ctx, q); e == nil { keyIDs[key] = newID // guard against dup records in the report + classIDs[classKey] = newID added++ } } else { @@ -11324,6 +11451,14 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, } else { emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total)) } + if byClass > 0 { + // Said out loud rather than folded silently into the total: these + // matched on the mode CLASS, not the mode. LoTW hands back "DATA" for + // every digital submode, so this is the normal case for FT4/FT8 — but + // an operator reconciling a log deserves to know the match was looser + // than call+minute+band+mode. + emit(fmt.Sprintf(" (%d matched on mode class — LoTW reports the group, e.g. DATA, where your log has the exact mode)", byClass)) + } // Surface confirmations with no local match so the user sees WHICH one // and why (time off by a minute, FT4 logged as MFSK, portable call, or // never logged). Tick "Add not-found" to import them instead. @@ -14014,9 +14149,18 @@ func (a *App) reloadCAT() { } a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond) a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond) + // Transverter offset. Zero when switched off — the manager treats "no offset" + // and "disabled" as the same thing, so the number stays in the settings for + // the next time the transverter goes back in line. + if s.OffsetOn { + a.cat.SetFreqOffset(s.OffsetHz) + } else { + a.cat.SetFreqOffset(0) + } a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots)) a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots a.catFlexDecodeSecs = s.FlexDecodeSecs + a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax a.reloadCATShare(s) if !s.Enabled { a.cat.Stop() @@ -14896,6 +15040,115 @@ func (a *App) RotatorStop() error { } } +// RotorPreset is one quick-turn button on the rotor widget: a short label and +// the azimuth it swings to. +type RotorPreset struct { + Label string `json:"label"` + Azimuth int `json:"azimuth"` +} + +// rotorPresetMax bounds both the number of buttons and the label length. The +// widget lays them out in a fixed grid beside the dial, and a label that does not +// fit is a button whose meaning is guessed rather than read. +const ( + rotorPresetMax = 8 + rotorPresetLabelMax = 6 +) + +// rotorPresetRegions are the out-of-the-box buttons, each with a representative +// point rather than a ready-made azimuth. +// +// The bearing is COMPUTED from the operator's own square. A bearing is only +// meaningful from somewhere: a table of degrees that suits one station points +// every other one at the wrong continent, and "EU 090" is worse than no default +// at all for a station in Japan. With no square known they come out at 0 and the +// operator fills them in — which is what the editor in Settings is for. +var rotorPresetRegions = []struct { + label string + lat, lon float64 +}{ + {"EU", 50, 15}, // central Europe + {"NA", 40, -95}, // central United States + {"SA", -15, -60}, // Brazil + {"VK", -25, 135}, // central Australia + {"JA", 36, 138}, // Japan + {"AF", 0, 20}, // central Africa +} + +// defaultRotorPresets builds the six regions as seen from the operator's grid. +func (a *App) defaultRotorPresets() []RotorPreset { + out := make([]RotorPreset, 0, len(rotorPresetRegions)) + for _, r := range rotorPresetRegions { + az := 0 + if a.opSet { + az = int(initialBearingDeg(a.opLat, a.opLon, r.lat, r.lon) + 0.5) + } + out = append(out, RotorPreset{Label: r.label, Azimuth: az % 360}) + } + return out +} + +// normRotorPresets keeps the list within what the widget can draw and the rotor +// can accept. A blank label drops the button entirely — that is how an operator +// removes one without a delete control. +func normRotorPresets(in []RotorPreset) []RotorPreset { + out := make([]RotorPreset, 0, len(in)) + for _, p := range in { + label := strings.TrimSpace(p.Label) + if label == "" { + continue + } + if len(label) > rotorPresetLabelMax { + label = label[:rotorPresetLabelMax] + } + az := p.Azimuth % 360 + if az < 0 { + az += 360 + } + out = append(out, RotorPreset{Label: label, Azimuth: az}) + if len(out) == rotorPresetMax { + break + } + } + return out +} + +// GetRotorPresets returns the quick-turn buttons, falling back to the regions +// computed from the operator's square so the widget is useful before anyone +// visits the settings. +func (a *App) GetRotorPresets() []RotorPreset { + if raw := a.settingOr(keyRotorPresets, ""); raw != "" { + var saved []RotorPreset + if err := json.Unmarshal([]byte(raw), &saved); err == nil { + // A saved-but-empty list is a deliberate "no buttons", not a missing + // setting — otherwise clearing them all would resurrect the defaults. + return normRotorPresets(saved) + } + } + return a.defaultRotorPresets() +} + +// SaveRotorPresets persists the buttons. +func (a *App) SaveRotorPresets(list []RotorPreset) error { + b, err := json.Marshal(normRotorPresets(list)) + if err != nil { + return err + } + a.setSetting(keyRotorPresets, string(b)) + return nil +} + +// ResetRotorPresets puts back the six regions, recomputed from the operator's +// current square — the way to fix them after moving, or after a first run with +// no grid set. +func (a *App) ResetRotorPresets() []RotorPreset { + def := a.defaultRotorPresets() + if b, err := json.Marshal(def); err == nil { + a.setSetting(keyRotorPresets, string(b)) + } + return def +} + // RotatorPark moves the active rotor to its parked position (PstRotator only; // the 4O3A native and ARCO GS-232 protocols have no park command). func (a *App) RotatorPark() error { diff --git a/changelog.json b/changelog.json index 373abe4..fdc14c5 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,24 @@ "en": [ "The band/mode matrix colours can be chosen in Appearance, starting from the ones your theme already paints. Its legend is translated too.", "TCI sharing: a refused un-key no longer leaves the rig stuck transmitting, and PTT is dropped if the client dies mid-over.", - "Icom: a frequency or mode change whose acknowledgement is lost is sent again, like PTT — losing one made JTDX drop the radio." + "Icom: a frequency or mode change whose acknowledgement is lost is sent again, like PTT — losing one made JTDX drop the radio.", + "FlexRadio: transmit audio can switch to DAX for a voice message and back afterwards, so your microphone is not left off air.", + "Rotor widget: quick-turn buttons you can name and aim yourself, plus an azimuth box where Enter turns the antenna.", + "A station portable in another entity no longer inherits its home county, state and grid — TI8/W2RE was logged in New York.", + "LoTW confirmations for FT2, FT4 and FT8 match again: LoTW answers “DATA”, and the exact mode never lined up with the log.", + "CAT: a transverter offset, so a 28 MHz IF behind a 144 MHz transverter logs, spots and tunes on the band you are really on.", + "Band map: zoom goes down to 2 px/kHz for a whole band in one screen, and each band keeps the zoom you left it at." ], "fr": [ "Les couleurs de la matrice bandes/modes se choisissent dans Apparence, à partir de celles du thème. Sa légende est traduite aussi.", "Partage TCI : un retour en réception refusé ne laisse plus le poste bloqué en émission, et le PTT retombe si le logiciel meurt.", - "Icom : un changement de fréquence ou de mode dont l’accusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX." + "Icom : un changement de fréquence ou de mode dont l’accusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX.", + "FlexRadio : l’audio d’émission peut basculer sur DAX le temps d’un message vocal et revenir après, pour ne pas perdre le micro.", + "Widget rotor : des boutons de rotation rapide que tu nommes et vises toi-même, et un champ azimut où Entrée lance l’antenne.", + "Une station portable dans une autre entité n’hérite plus de son comté, son état et son locator d’origine : TI8/W2RE sortait à New York.", + "Les confirmations LoTW des QSO FT2, FT4 et FT8 se retrouvent : LoTW répond « DATA », et le mode exact ne correspondait jamais au log.", + "CAT : un décalage transverter, pour qu’une FI 28 MHz derrière un transverter 144 MHz logue et accorde sur la vraie bande.", + "Carte de bande : le zoom descend à 2 px/kHz pour voir toute la bande d’un coup, et chaque bande retient son zoom." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc6c7da..abac865 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -94,7 +94,7 @@ import { ShutdownProgress } from '@/components/ShutdownProgress'; import { ClusterGrid } from '@/components/ClusterGrid'; import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot'; import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay'; -import { GetMatrixColors, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App'; +import { GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App'; import { applyMatrixColors } from '@/lib/matrixColors'; import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid'; import { NetControlPanel } from '@/components/NetControlPanel'; @@ -2018,6 +2018,10 @@ export default function App() { // settings dialog closes, which is the only place it changes. const [rowColors, setRowColors] = useState(null); useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]); + // Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the + // row colours: the settings dialog is the only place they change. + const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]); + useEffect(() => { GetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch(() => {}); }, [showSettings]); // Band/mode matrix palette overrides (Settings → Appearance). Stamped onto // rather than held in state: the matrix reads CSS custom properties, so // nothing re-renders and no component has to be told about the colours. Same @@ -6310,8 +6314,10 @@ export default function App() { {/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a rotator is configured or a DX bearing exists. */} {showRotor && (rotatorHeading.enabled || dxPath) && ( -
+
{ RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }} bearing={dxPath?.bearingShort ?? null} headings={beamHeadings} boomHeading={boomHeading} diff --git a/frontend/src/components/BandMap.tsx b/frontend/src/components/BandMap.tsx index 4596914..7e515e5 100644 --- a/frontend/src/components/BandMap.tsx +++ b/frontend/src/components/BandMap.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot'; import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers'; import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay'; +import { writeUiPref } from '@/lib/uiPref'; // BandMap — vertical spectrum panel inspired by Log4OM. // - Full band is always visible; zoom changes pixels-per-kHz, scroll @@ -217,7 +218,37 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot: // Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot // every 2.75 kHz fits without anti-overlap kicking in — comfortable for // most bands. Higher levels are for fine inspection of crowded sub-bands. -const PX_PER_KHZ = [8, 16, 32, 64, 128, 256]; +// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band +// fits without FIT taking the scale away from you: 20 m end to end is 700 px at +// 2 px/kHz, which scrolls in one screen on most displays. +const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256]; +const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32); + +// Zoom is remembered PER BAND, in ONE json key rather than one key per band: +// lib/uiPref keeps an explicit list of the preferences that travel with data/, +// and thirteen entries there to say the same thing would be thirteen chances to +// forget one. +const ZOOM_KEY = 'opslog.bandMapZoom'; + +function readZoomMap(): Record { + try { + const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}'); + return m && typeof m === 'object' ? m : {}; + } catch { + return {}; + } +} + +function readZoom(band: string): number { + const n = readZoomMap()[band]; + return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX; +} + +function writeZoom(band: string, idx: number): void { + const m = readZoomMap(); + m[band] = idx; + writeUiPref(ZOOM_KEY, JSON.stringify(m)); +} const SCALE_W = 56; const PILL_H = 22; // px — height of each callsign pill const PILL_GAP = 32; // px between scale border and first pill (room for leader) @@ -252,7 +283,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, }, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]); const range = BAND_RANGES[band]; const segments = SEGMENT_COLORS[band] ?? []; - const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz + const [zoomIdx, setZoomIdx] = useState(() => readZoom(band)); + // The docked map follows the rig, so a band change must bring up THAT band's + // remembered zoom. + useEffect(() => { setZoomIdx(readZoom(band)); }, [band]); + // Stored from the interaction, not from an effect on zoomIdx: an effect would + // also fire on the band change above, and in the same commit it would still be + // holding the PREVIOUS band's index — writing it over the new band's. Doing it + // where the operator actually turns the wheel keeps the two apart. (Running + // twice under StrictMode is harmless: the same value is stored.) + const changeZoom = useCallback((delta: number) => { + setZoomIdx((z) => { + const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta)); + if (n !== z) writeZoom(band, n); + return n; + }); + }, [band]); const scrollerRef = useRef(null); const [containerH, setContainerH] = useState(400); @@ -421,7 +467,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, if (!range) return; if (e.ctrlKey || e.metaKey) { e.preventDefault(); - setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1)))); + changeZoom(e.deltaY > 0 ? -1 : 1); } }; el.addEventListener('wheel', onWheel, { passive: false }); @@ -476,8 +522,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, // Tick step (where small marks land) and label step (where the kHz // number is printed) are decoupled so the scale shows ~10-20 numeric - // labels per viewport regardless of zoom — at base 8 px/kHz we want - // labels every 25 kHz, not every 250. + // labels per viewport regardless of zoom — at 8 px/kHz we want labels + // every 25 kHz, not every 250. The bare values below are the floor, and + // they are what the 2 px/kHz step lands on: a whole band in one screen + // wants a number every 100 kHz, not every 25. let tickStep = 50; let labelStep = 100; if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; } @@ -502,14 +550,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz, return (
- {t('bmp.map')} · {band} - {fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`} - + ))} +
+ ); + return (
{/* Header — matches the WinKeyer / Voice keyer panels. */} @@ -128,9 +199,13 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
)} + {/* Dial on the left, controls on the right. The controls column is sized to + fit WITHIN the dial's height: the widget sits in a row whose height is + set by the entry strip, so it may grow sideways but never downwards. */} +
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a sibling of the landed beside it. */} -
+
- {/* Short and long path to the DX, in figures. - The bezel already carries the short path as a red marker, but a - marker is a direction, not a number — the same pair sits in the - status bar at 10px and operators reported not being able to read it. - Here because it belongs to the compass: every place that draws one - gets the readout, instead of each caller inventing its own. - Clickable when the caller can turn, like the status bar's. */} -
- {([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => ( -
+ + {/* Quick turns + free azimuth + Stop. */} + {showControls && ( +
+ {/* Two columns so six regions fit beside the dial rather than under + it. An operator with fewer keeps the same compact block. */} + {!!presets?.length && ( +
+ {presets.map((p, i) => ( + + ))} +
+ )} + + {/* Free azimuth: Enter sends, so the whole thing is type-three-digits + -and-go without reaching for the mouse. */} +
+ setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }} + placeholder={t('rotor.azPh')} + title={t('rotor.azTitle')} + disabled={!onGoto} + className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50" + /> + - ))} +
+ + {onStop && ( + + )} + + {/* mt-auto: the readout sits at the FOOT of the column, level with the + bottom of the dial, instead of floating under the Stop button. */} +
{pathReadout}
+ )}
); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index c2ec6d2..5944a06 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -11,6 +11,7 @@ import { GetCATSettings, SaveCATSettings, DiscoverFlexRadios, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop, + GetRotorPresets, SaveRotorPresets, ResetRotorPresets, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, GetAntGeniusSettings, SaveAntGeniusSettings, GetTunerGeniusSettings, SaveTunerGeniusSettings, @@ -1399,16 +1400,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [bandDraft, setBandDraft] = useState(''); const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ - enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, + enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false, yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '', icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, - tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, + tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0, digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001, ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false, }); // While true, the next key press is captured as the PTT hotkey. const [capturingPtt, setCapturingPtt] = useState(false); const [rotors, setRotors] = useState([]); + const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]); + // Whether the presets have actually been READ back yet. + // + // An empty list is a legitimate answer — an operator may want no quick-turn + // buttons at all — so the backend stores it as one. That makes "not loaded + // yet" and "deliberately none" the same value, and a Save landing in the first + // state wiped the buttons for good. Saving is gated on having read them, so + // the only empty list that can ever reach the store is one the operator made. + const [rotorPresetsLoaded, setRotorPresetsLoaded] = useState(false); const [rotatorTesting, setRotatorTesting] = useState(false); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); @@ -1825,6 +1835,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan await reloadClusterServers(); setCatCfg(c); setRotors((r ?? []) as any); + // Loaded HERE, in the loader that runs on mount — not only in the + // event-driven one below. Missing from this one, the state stayed empty + // on a normal open and Save then wrote an empty list over the operator's + // buttons. See rotorPresetsLoaded for the belt to this brace. + try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {} @@ -1868,6 +1883,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan try { setLookup(await GetLookupSettings() as any); } catch {} try { setCatCfg(await GetCATSettings() as any); } catch {} try { setRotors(((await GetRotators()) ?? []) as any); } catch {} + try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {} @@ -2062,6 +2078,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan await SaveLookupSettings(lookup as any); await SaveCATSettings(catCfg as any); await SaveRotators(rotors as any); + // Only once they have been read back — see rotorPresetsLoaded. + if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any); await SaveUltrabeamSettings(ultrabeam as any); await SaveAntGeniusSettings(antgenius as any); await SaveTunerGeniusSettings(tunergenius as any); @@ -2862,6 +2880,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {t('cat.flexDecodeSecsHint')}
)} + )} {catCfg.backend === 'xiegu' && ( @@ -3142,6 +3164,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)} + {/* Transverter offset. OUTSIDE the per-backend blocks on purpose: a + transverter hangs off the IF of whatever radio you own, and the + offset is applied in the CAT manager, above every backend. Tucked + under the OmniRig/Icom section it would have been invisible to the + Yaesu, Kenwood, Xiegu, Flex and TCI operators who need it just as + much. */} + + {catCfg.offset_on && ( +
+ + setCatCfg((s) => ({ ...s, offset_hz: Math.round((parseFloat(e.target.value) || 0) * 1e6) }))} + /> + {t('cat.offsetExample')} +
+ )}
setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} /> + setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, azimuth: Math.max(0, Math.min(359, parseInt(e.target.value, 10) || 0)) } : x)))} /> + ° + +
+ ))} +
+
+ {rotorPresets.length < 8 && ( + + )} + +
+
{rotatorTest && (