Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380472bda8 | ||
|
|
4e2a5877d6 | ||
|
|
d44a971acf | ||
|
|
fabd1becce | ||
|
|
345be94c65 | ||
|
|
d0c6e420d2 | ||
|
|
f0026b8bd3 |
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The coupling is a SET, not a global switch.
|
||||||
|
//
|
||||||
|
// A station can run a combiner pair AND a third amplifier that has nothing to
|
||||||
|
// do with it — two SPE on the combiner, a PowerGenius on another antenna. A
|
||||||
|
// global flag would send that third one into OPERATE alongside them.
|
||||||
|
func TestAmpTargetsFollowTheGroup(t *testing.T) {
|
||||||
|
a := &App{}
|
||||||
|
a.ampInsts = map[string]*ampInst{"spe1": {}, "spe2": {}, "pgxl": {}}
|
||||||
|
group := []string{"spe1", "spe2"}
|
||||||
|
|
||||||
|
// A member commands the whole group, itself first.
|
||||||
|
got := a.ampTargets("spe2", group)
|
||||||
|
if len(got) != 2 || got[0] != "spe2" || got[1] != "spe1" {
|
||||||
|
t.Errorf("member = %v, want [spe2 spe1] — the clicked one first", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The amplifier OUTSIDE the group keeps its buttons to itself.
|
||||||
|
if got := a.ampTargets("pgxl", group); len(got) != 1 || got[0] != "pgxl" {
|
||||||
|
t.Errorf("outsider = %v, want just [pgxl]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No group at all: everyone is on their own.
|
||||||
|
if got := a.ampTargets("spe1", nil); len(got) != 1 || got[0] != "spe1" {
|
||||||
|
t.Errorf("no group = %v, want just [spe1]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group remembering an amplifier that is gone must not carry it: it would
|
||||||
|
// fail the command for a member that no longer exists.
|
||||||
|
if got := a.ampTargets("spe1", []string{"spe1", "deleted"}); len(got) != 1 || got[0] != "spe1" {
|
||||||
|
t.Errorf("stale member = %v, want it dropped", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One amplifier coupled to itself is not a group — storing it would make every
|
||||||
|
// command fan out to a single member for ever, which is just noise.
|
||||||
|
func TestLinkedAmpsNeedsTwo(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{[]string{"a", "b"}, 2},
|
||||||
|
{[]string{"a"}, 0},
|
||||||
|
{[]string{" ", "a"}, 0}, // blanks are not members
|
||||||
|
{nil, 0},
|
||||||
|
} {
|
||||||
|
clean := make([]string, 0, len(tc.in))
|
||||||
|
for _, id := range tc.in {
|
||||||
|
if id != "" && id != " " {
|
||||||
|
clean = append(clean, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(clean) < 2 {
|
||||||
|
clean = nil
|
||||||
|
}
|
||||||
|
if len(clean) != tc.want {
|
||||||
|
t.Errorf("SetLinkedAmps(%v) would keep %d, want %d", tc.in, len(clean), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16226,6 +16226,19 @@ func (a *App) ampInstByID(id string) *ampInst {
|
|||||||
|
|
||||||
// AmpOperate puts the given amp in OPERATE (true) or STANDBY (false).
|
// AmpOperate puts the given amp in OPERATE (true) or STANDBY (false).
|
||||||
func (a *App) AmpOperate(id string, on bool) error {
|
func (a *App) AmpOperate(id string, on bool) error {
|
||||||
|
// Fan out here rather than in the UI: the card and the docked widget both
|
||||||
|
// call this, and a coupling implemented in one of them would be missing from
|
||||||
|
// the other — which on a combiner means one amplifier keyed and one not.
|
||||||
|
var firstErr error
|
||||||
|
for _, tid := range a.ampTargets(id, a.GetLinkedAmps()) {
|
||||||
|
if err := a.ampOperateOne(tid, on); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ampOperateOne(id string, on bool) error {
|
||||||
inst := a.ampInstByID(id)
|
inst := a.ampInstByID(id)
|
||||||
if inst == nil {
|
if inst == nil {
|
||||||
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
|
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
|
||||||
@@ -16250,6 +16263,18 @@ func (a *App) AmpPower(id string, on bool) (err error) {
|
|||||||
applog.Printf("amp %s: power %v failed: %v", id, on, err)
|
applog.Printf("amp %s: power %v failed: %v", id, on, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
linked := a.GetLinkedAmps()
|
||||||
|
targets := a.ampTargets(id, linked)
|
||||||
|
var firstErr error
|
||||||
|
for _, tid := range targets {
|
||||||
|
if e := a.ampPowerOne(tid, on, len(targets) > 1); e != nil && firstErr == nil {
|
||||||
|
firstErr = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ampPowerOne(id string, on, linked bool) error {
|
||||||
inst := a.ampInstByID(id)
|
inst := a.ampInstByID(id)
|
||||||
if inst == nil {
|
if inst == nil {
|
||||||
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
|
return fmt.Errorf("amplifier not running — check Settings → Amplifier")
|
||||||
@@ -16266,6 +16291,12 @@ func (a *App) AmpPower(id string, on bool) (err error) {
|
|||||||
}
|
}
|
||||||
return inst.acom.PowerOff()
|
return inst.acom.PowerOff()
|
||||||
}
|
}
|
||||||
|
// Not an error worth surfacing when linked: a PGXL alongside two SPEs simply
|
||||||
|
// has no power command on its direct link, and reporting that as a failure
|
||||||
|
// would make a successful pair look broken.
|
||||||
|
if linked {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("power on/off is not available for this amplifier")
|
return fmt.Errorf("power on/off is not available for this amplifier")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17810,3 +17841,83 @@ func (a *App) SetCompactHeight(h int) {
|
|||||||
wruntime.WindowSetMinSize(a.ctx, 900, h)
|
wruntime.WindowSetMinSize(a.ctx, 900, h)
|
||||||
wruntime.WindowSetSize(a.ctx, w, h)
|
wruntime.WindowSetSize(a.ctx, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keyAmpsLinked couples the configured amplifiers so ON, OFF and OPERATE act on
|
||||||
|
// all of them at once — the SPE CO1-2 combiner case.
|
||||||
|
//
|
||||||
|
// The combiner is an RF device: it sums two amplifiers and commands nothing. So
|
||||||
|
// "combined" operation is really two amplifiers that must be kept in the same
|
||||||
|
// state, and leaving one in OPERATE while the other sits in STANDBY is exactly
|
||||||
|
// what must not happen — the combiner would see power on one input only.
|
||||||
|
const keyAmpsLinked = "amps.linked"
|
||||||
|
|
||||||
|
// GetLinkedAmps returns the ids of the amplifiers commanded together.
|
||||||
|
//
|
||||||
|
// A SET, not a global flag. A station can have a combiner pair AND a third
|
||||||
|
// amplifier that has nothing to do with it — two SPE on the combiner and a
|
||||||
|
// PowerGenius on another antenna — and a global switch would send that third
|
||||||
|
// one into OPERATE alongside them.
|
||||||
|
func (a *App) GetLinkedAmps() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, id := range strings.Split(a.settingOr(keyAmpsLinked, ""), ",") {
|
||||||
|
if id = strings.TrimSpace(id); id != "" {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLinkedAmps records which amplifiers are coupled.
|
||||||
|
func (a *App) SetLinkedAmps(ids []string) error {
|
||||||
|
clean := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id = strings.TrimSpace(id); id != "" {
|
||||||
|
clean = append(clean, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One amplifier coupled to itself is not a group; store nothing rather than
|
||||||
|
// a set that would make ampTargets fan out to a single member for ever.
|
||||||
|
if len(clean) < 2 {
|
||||||
|
clean = nil
|
||||||
|
}
|
||||||
|
a.setSetting(keyAmpsLinked, strings.Join(clean, ","))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ampTargets returns the amplifier ids a command should reach: the one asked
|
||||||
|
// for, or every running amplifier when they are linked.
|
||||||
|
//
|
||||||
|
// The METERS are deliberately untouched by this — each amplifier keeps its own
|
||||||
|
// bars. Two amps combined are still two amps, and an operator watching for one
|
||||||
|
// of them to run away needs to see them apart.
|
||||||
|
func (a *App) ampTargets(id string, linked []string) []string {
|
||||||
|
inGroup := false
|
||||||
|
for _, l := range linked {
|
||||||
|
if l == id {
|
||||||
|
inGroup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An amplifier outside the group keeps its own buttons to itself — that is
|
||||||
|
// the whole point of the group being a set.
|
||||||
|
if !inGroup {
|
||||||
|
return []string{id}
|
||||||
|
}
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
defer a.ampsMu.Unlock()
|
||||||
|
out := make([]string, 0, len(linked))
|
||||||
|
// The one that was asked for goes FIRST, so a partial failure still did what
|
||||||
|
// the operator clicked before it stopped.
|
||||||
|
out = append(out, id)
|
||||||
|
for _, l := range linked {
|
||||||
|
if l == id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only amplifiers actually running: a group remembering one that has been
|
||||||
|
// deleted or switched off must not fail the whole command for it.
|
||||||
|
if _, ok := a.ampInsts[l]; ok {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
+105
-1
@@ -31,7 +31,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
keyQSLEmailSubject = "qsl.email_subject"
|
keyQSLEmailSubject = "qsl.email_subject"
|
||||||
keyQSLEmailBody = "qsl.email_body"
|
keyQSLEmailBody = "qsl.email_body"
|
||||||
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
keyQSLAutoSend = "qsl.auto_send" // "1" → render+send an eQSL on log when an e-mail and default template exist
|
||||||
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
|
keyQSLDefaultMsg = "qsl.default_message" // fallback QSL message printed on the card when the QSO's own QSLMSG is empty
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -748,3 +748,107 @@ func sampleQSO() qso.QSO {
|
|||||||
QSLMsg: "TNX FB QSO — 73!",
|
QSLMsg: "TNX FB QSO — 73!",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QSLForeignTemplate is one card design belonging to ANOTHER profile, offered
|
||||||
|
// for copying into the active one.
|
||||||
|
type QSLForeignTemplate struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ProfileID int64 `json:"profile_id"`
|
||||||
|
ProfileName string `json:"profile_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLListOtherProfileTemplates lists the designs the ACTIVE profile cannot see.
|
||||||
|
//
|
||||||
|
// A second profile is usually the same operator with a different rig or a
|
||||||
|
// different locator — same callsign, same cards. Redrawing a design because the
|
||||||
|
// antenna changed is work nobody should have to do, and the designs a profile
|
||||||
|
// already sees (its own, and the shared ones) are deliberately left out: they
|
||||||
|
// are not something to copy.
|
||||||
|
func (a *App) QSLListOtherProfileTemplates() ([]QSLForeignTemplate, error) {
|
||||||
|
if a.qslTemplates == nil || a.profiles == nil {
|
||||||
|
return nil, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
active := int64(-1)
|
||||||
|
if p, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
active = p.ID
|
||||||
|
}
|
||||||
|
names := map[int64]string{}
|
||||||
|
if list, err := a.ListProfiles(); err == nil {
|
||||||
|
for _, p := range list {
|
||||||
|
names[p.ID] = p.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all, err := a.qslTemplates.List(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []QSLForeignTemplate{}
|
||||||
|
for _, r := range all {
|
||||||
|
// Shared designs (no profile) already appear in every profile's list.
|
||||||
|
if r.ProfileID == nil || *r.ProfileID == active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, QSLForeignTemplate{
|
||||||
|
ID: r.ID, Name: r.Name, ProfileID: *r.ProfileID,
|
||||||
|
ProfileName: firstNonEmptyStr(names[*r.ProfileID], fmt.Sprintf("profile %d", *r.ProfileID)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QSLCopyTemplateToActiveProfile duplicates another profile's design into the
|
||||||
|
// active one and returns the new id.
|
||||||
|
//
|
||||||
|
// A COPY, not a move or a share. The original profile keeps its design
|
||||||
|
// untouched, and the copy is free to diverge — a second profile usually exists
|
||||||
|
// because something differs, and that something often ends up on the card.
|
||||||
|
func (a *App) QSLCopyTemplateToActiveProfile(id int64) (int64, error) {
|
||||||
|
if a.qslTemplates == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
src, err := a.qslTemplates.Get(a.ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// The stored document references photos by NAME, relative to the template's
|
||||||
|
// own asset folder — so copying the JSON alone would point the new design at
|
||||||
|
// files that are not in its folder, and the save would fail validation.
|
||||||
|
//
|
||||||
|
// The files are copied too, giving the duplicate its own folder. Sharing the
|
||||||
|
// source's would mean deleting one design silently emptied the other.
|
||||||
|
rec := qslcard.Record{Name: src.Name + " (copy)", JSON: src.JSON}
|
||||||
|
if p, e := a.profiles.Active(a.ctx); e == nil {
|
||||||
|
rec.ProfileID = &p.ID
|
||||||
|
}
|
||||||
|
// Saved first: a new template needs its id before it can own a folder.
|
||||||
|
if err := a.qslTemplates.Save(a.ctx, &rec); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
srcDir := qslcard.TemplateDir(a.qslDir(), id)
|
||||||
|
dstDir := qslcard.TemplateDir(a.qslDir(), rec.ID)
|
||||||
|
if err := copyDirContents(srcDir, dstDir); err != nil && !os.IsNotExist(err) {
|
||||||
|
// Roll back rather than leave a design whose pictures are missing.
|
||||||
|
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||||
|
return 0, fmt.Errorf("copy template assets: %w", err)
|
||||||
|
}
|
||||||
|
t, err := qslcard.Parse([]byte(rec.JSON))
|
||||||
|
if err != nil {
|
||||||
|
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := qslcard.Validate(t, qslcard.PhotoExistsIn(dstDir)); err != nil {
|
||||||
|
_ = a.qslTemplates.Delete(a.ctx, rec.ID)
|
||||||
|
return 0, fmt.Errorf("copied design is incomplete: %w", err)
|
||||||
|
}
|
||||||
|
applog.Printf("qsl: copied template %q (id %d) into the active profile as %q (id %d)",
|
||||||
|
src.Name, id, rec.Name, rec.ID)
|
||||||
|
return rec.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyStr(a, b string) string {
|
||||||
|
if strings.TrimSpace(a) != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
-18902
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,20 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.25.1",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Amplifiers: tick the ones sharing a combiner and ON, OFF and OPERATE act on all of them at once. Each keeps its own meters.",
|
||||||
|
"Shared CAT: with the wire trace on, every command a client sends and the answer given are logged.",
|
||||||
|
"Shared CAT: JTDX and WSJT-X no longer get an error for turning split off on a rig that has none, or for setting the transmit mode — which made JTDX abandon a transmission.",
|
||||||
|
"QSL designer: a card design can be copied from another profile, pictures included — a second profile for a different rig no longer means redrawing it."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Amplificateurs : coche ceux qui partagent un combiner et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses mesures.",
|
||||||
|
"CAT partagé : avec la trace activée, chaque commande envoyée par un client et la réponse donnée sont journalisées.",
|
||||||
|
"CAT partagé : JTDX et WSJT-X ne reçoivent plus d erreur en désactivant un split inexistant ni en réglant le mode d émission — ce qui faisait abandonner une émission à JTDX.",
|
||||||
|
"Concepteur QSL : un modèle de carte peut être copié depuis un autre profil, images comprises — un second profil pour une autre radio n oblige plus à le redessiner."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.25.0",
|
"version": "0.25.0",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -1274,6 +1274,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
||||||
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||||||
const [amps, setAmps] = useState<AmpUI[]>([]);
|
const [amps, setAmps] = useState<AmpUI[]>([]);
|
||||||
|
const [linkedAmps, setLinkedAmps] = useState<string[]>([]);
|
||||||
|
|
||||||
// WinKeyer CW keyer settings + macro editor.
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
type WKMac = { label: string; text: string };
|
type WKMac = { label: string; text: string };
|
||||||
@@ -1571,6 +1572,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
(async () => {
|
(async () => {
|
||||||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||||||
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
||||||
|
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
||||||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||||
})();
|
})();
|
||||||
// Poll the feed while the panel is open: a live count is the only thing that
|
// Poll the feed while the panel is open: a live count is the only thing that
|
||||||
@@ -3602,6 +3604,38 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
|
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* WHICH amplifiers are coupled, not whether coupling is on.
|
||||||
|
A station can run a combiner pair AND a third amplifier that has
|
||||||
|
nothing to do with it — two SPE on the combiner, a PowerGenius on
|
||||||
|
another antenna — so a single switch would send that third one into
|
||||||
|
OPERATE alongside them. Shown from two amplifiers up. */}
|
||||||
|
{amps.length > 1 && (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<div className="text-sm">{t('amp.linked')}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('amp.linkedHint')}</p>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||||
|
{amps.filter((x) => (x.id ?? '') !== '').map((x) => {
|
||||||
|
const on = linkedAmps.includes(x.id!);
|
||||||
|
return (
|
||||||
|
<label key={x.id} className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={on} onCheckedChange={(c) => {
|
||||||
|
const next = c ? [...linkedAmps, x.id!] : linkedAmps.filter((v) => v !== x.id);
|
||||||
|
setLinkedAmps(next);
|
||||||
|
SetLinkedAmps(next).catch(() => {});
|
||||||
|
}} />
|
||||||
|
{x.name?.trim() || x.type}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* An amplifier saved without an id cannot be a member — say so
|
||||||
|
rather than silently leaving it out of the list. */}
|
||||||
|
{amps.some((x) => (x.id ?? '') === '') && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('amp.linkedSaveFirst')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
|
||||||
|
QSLListOtherProfileTemplates, QSLCopyTemplateToActiveProfile,
|
||||||
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
|
||||||
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
|
||||||
} from '../../../wailsjs/go/main/App';
|
} from '../../../wailsjs/go/main/App';
|
||||||
@@ -59,6 +60,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
const [saved, setSaved] = useState<QSLTemplateInfo[]>([]);
|
||||||
|
const [foreign, setForeign] = useState<any[]>([]);
|
||||||
const [previews, setPreviews] = useState<Record<number, string>>({});
|
const [previews, setPreviews] = useState<Record<number, string>>({});
|
||||||
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
|
||||||
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
|
||||||
@@ -88,6 +90,8 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
if (url) p[t.id] = url;
|
if (url) p[t.id] = url;
|
||||||
}));
|
}));
|
||||||
setPreviews(p);
|
setPreviews(p);
|
||||||
|
// The designs this profile cannot see, offered for copying.
|
||||||
|
try { setForeign(((await QSLListOtherProfileTemplates()) ?? []) as any[]); } catch { /* nothing to offer */ }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
@@ -274,7 +278,29 @@ export function QslDesignerModal({ open, onClose }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-semibold">Saved templates</h3>
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-sm font-semibold">Saved templates</h3>
|
||||||
|
{/* Designs owned by another profile. A second profile is usually
|
||||||
|
the same operator with a different rig or locator — same
|
||||||
|
callsign, same cards — and redrawing one because the antenna
|
||||||
|
changed is work nobody should have to do. */}
|
||||||
|
{!!foreign.length && (
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={async (e) => {
|
||||||
|
const id = parseInt(e.target.value, 10);
|
||||||
|
if (!id) return;
|
||||||
|
try { await QSLCopyTemplateToActiveProfile(id); await refreshSaved(); }
|
||||||
|
catch (err) { setError(String(err)); }
|
||||||
|
}}
|
||||||
|
className="h-7 rounded-md border border-border bg-background px-2 text-xs">
|
||||||
|
<option value="">Copy from another profile…</option>
|
||||||
|
{foreign.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.profile_name} — {f.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
{!saved.length && <p className="text-xs text-muted-foreground">None yet.</p>}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{saved.map((t) => (
|
{saved.map((t) => (
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ const en: Dict = {
|
|||||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
||||||
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
||||||
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
||||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier', 'amp.password': 'Remote code', 'amp.passwordPh': 'blank on LAN', 'amp.passwordHint': 'PowerGenius XL only: needed when reaching the amp remotely — it then announces "AUTH" and rejects every command ("Unauthorized") until you log in. Leave blank on the local network.', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an ACOM) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (ACOM) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.',
|
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.linked': 'Amplifiers commanded together', 'amp.linkedHint': 'Tick the ones sharing a combiner: ON, OFF and OPERATE will act on all of them at once, since one left in STANDBY would feed power to a single input. Any amplifier left unticked keeps its own buttons. Each keeps its own meters.', 'amp.linkedSaveFirst': 'Save first — an amplifier needs an id before it can join a group.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier', 'amp.password': 'Remote code', 'amp.passwordPh': 'blank on LAN', 'amp.passwordHint': 'PowerGenius XL only: needed when reaching the amp remotely — it then announces "AUTH" and rejects every command ("Unauthorized") until you log in. Leave blank on the local network.', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an ACOM) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (ACOM) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.',
|
||||||
'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
|
'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
|
||||||
// Password encryption
|
// Password encryption
|
||||||
'gen.pwEnc': 'Password encryption',
|
'gen.pwEnc': 'Password encryption',
|
||||||
@@ -616,7 +616,7 @@ const fr: Dict = {
|
|||||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||||
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
||||||
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
||||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur', 'amp.password': 'Code distant', 'amp.passwordPh': 'vide en LAN', 'amp.passwordHint': "PowerGenius XL uniquement : nécessaire pour joindre l'ampli à distance — il annonce alors « AUTH » et refuse toute commande (« Unauthorized ») tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un ACOM) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (ACOM) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.",
|
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.linked': 'Amplificateurs commandés ensemble', 'amp.linkedHint': "Coche ceux qui partagent un combiner : ON, OFF et OPERATE agiront sur tous à la fois, puisqu'un ampli resté en STANDBY n'alimenterait qu'une seule entrée. Un amplificateur non coché garde ses propres boutons. Chacun garde ses mesures.", 'amp.linkedSaveFirst': "Enregistre d'abord — un amplificateur a besoin d'un identifiant pour rejoindre un groupe.", 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur', 'amp.password': 'Code distant', 'amp.passwordPh': 'vide en LAN', 'amp.passwordHint': "PowerGenius XL uniquement : nécessaire pour joindre l'ampli à distance — il annonce alors « AUTH » et refuse toute commande (« Unauthorized ») tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un ACOM) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (ACOM) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.",
|
||||||
'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
|
'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
|
||||||
// Chiffrement des mots de passe
|
// Chiffrement des mots de passe
|
||||||
'gen.pwEnc': 'Chiffrement des mots de passe',
|
'gen.pwEnc': 'Chiffrement des mots de passe',
|
||||||
|
|||||||
Vendored
+8
@@ -446,6 +446,8 @@ export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
|||||||
|
|
||||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
|
|
||||||
|
export function GetLinkedAmps():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
|
export function GetLiveOpenings():Promise<Array<bandopen.Opening>>;
|
||||||
@@ -768,6 +770,8 @@ export function PopulateBuiltinReferences(arg1:string):Promise<number>;
|
|||||||
|
|
||||||
export function PublishLogNow():Promise<string>;
|
export function PublishLogNow():Promise<string>;
|
||||||
|
|
||||||
|
export function QSLCopyTemplateToActiveProfile(arg1:number):Promise<number>;
|
||||||
|
|
||||||
export function QSLDefaultTemplateID():Promise<number>;
|
export function QSLDefaultTemplateID():Promise<number>;
|
||||||
|
|
||||||
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
export function QSLDeleteTemplate(arg1:number):Promise<void>;
|
||||||
@@ -782,6 +786,8 @@ export function QSLGetEmailTemplates():Promise<main.QSLEmailTemplates>;
|
|||||||
|
|
||||||
export function QSLGetTemplate(arg1:number):Promise<string>;
|
export function QSLGetTemplate(arg1:number):Promise<string>;
|
||||||
|
|
||||||
|
export function QSLListOtherProfileTemplates():Promise<Array<main.QSLForeignTemplate>>;
|
||||||
|
|
||||||
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
|
export function QSLListTemplates():Promise<Array<main.QSLTemplateInfo>>;
|
||||||
|
|
||||||
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
|
export function QSLPhotoDataURL(arg1:number,arg2:string):Promise<string>;
|
||||||
@@ -996,6 +1002,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetLinkedAmps(arg1:Array<string>):Promise<void>;
|
||||||
|
|
||||||
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<void>;
|
||||||
|
|
||||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -834,6 +834,10 @@ export function GetIcomState() {
|
|||||||
return window['go']['main']['App']['GetIcomState']();
|
return window['go']['main']['App']['GetIcomState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLinkedAmps() {
|
||||||
|
return window['go']['main']['App']['GetLinkedAmps']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetListsSettings() {
|
export function GetListsSettings() {
|
||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
@@ -1478,6 +1482,10 @@ export function PublishLogNow() {
|
|||||||
return window['go']['main']['App']['PublishLogNow']();
|
return window['go']['main']['App']['PublishLogNow']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSLCopyTemplateToActiveProfile(arg1) {
|
||||||
|
return window['go']['main']['App']['QSLCopyTemplateToActiveProfile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function QSLDefaultTemplateID() {
|
export function QSLDefaultTemplateID() {
|
||||||
return window['go']['main']['App']['QSLDefaultTemplateID']();
|
return window['go']['main']['App']['QSLDefaultTemplateID']();
|
||||||
}
|
}
|
||||||
@@ -1506,6 +1514,10 @@ export function QSLGetTemplate(arg1) {
|
|||||||
return window['go']['main']['App']['QSLGetTemplate'](arg1);
|
return window['go']['main']['App']['QSLGetTemplate'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function QSLListOtherProfileTemplates() {
|
||||||
|
return window['go']['main']['App']['QSLListOtherProfileTemplates']();
|
||||||
|
}
|
||||||
|
|
||||||
export function QSLListTemplates() {
|
export function QSLListTemplates() {
|
||||||
return window['go']['main']['App']['QSLListTemplates']();
|
return window['go']['main']['App']['QSLListTemplates']();
|
||||||
}
|
}
|
||||||
@@ -1934,6 +1946,10 @@ export function SetKenwoodKeySpeed(arg1) {
|
|||||||
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetLinkedAmps(arg1) {
|
||||||
|
return window['go']['main']['App']['SetLinkedAmps'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetMotorFollow(arg1, arg2, arg3) {
|
export function SetMotorFollow(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
|
return window['go']['main']['App']['SetMotorFollow'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2785,6 +2785,24 @@ export namespace main {
|
|||||||
this.data_b64 = source["data_b64"];
|
this.data_b64 = source["data_b64"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class QSLForeignTemplate {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
profile_id: number;
|
||||||
|
profile_name: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new QSLForeignTemplate(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.profile_id = source["profile_id"];
|
||||||
|
this.profile_name = source["profile_name"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class QSLPresetInfo {
|
export class QSLPresetInfo {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ package rigctld
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hamlog/internal/cat"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -244,7 +245,18 @@ func (s *Server) serve(c net.Conn) {
|
|||||||
s.log("rigctld: client %s disconnected", c.RemoteAddr())
|
s.log("rigctld: client %s disconnected", c.RemoteAddr())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp, quit := s.handle(strings.TrimSpace(line))
|
req := strings.TrimSpace(line)
|
||||||
|
resp, quit := s.handle(req)
|
||||||
|
// The whole exchange, when tracing is on.
|
||||||
|
//
|
||||||
|
// Only PTT transitions were ever recorded, so when JTDX aborted a
|
||||||
|
// transmission mid-frame the log showed the abort and not the command
|
||||||
|
// that preceded it — the one thing needed to tell whether OpsLog answered
|
||||||
|
// something the client could not accept. Behind the same switch as the CAT
|
||||||
|
// wire trace: this is one line per poll and would drown an ordinary log.
|
||||||
|
if req != "" && cat.CIVTraceEnabled() {
|
||||||
|
s.log("rigctld: %s → %q ⇒ %q", c.RemoteAddr(), req, strings.TrimRight(resp, "\r\n"))
|
||||||
|
}
|
||||||
if resp != "" {
|
if resp != "" {
|
||||||
if _, err := w.WriteString(resp); err != nil {
|
if _, err := w.WriteString(resp); err != nil {
|
||||||
return
|
return
|
||||||
@@ -409,6 +421,17 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
return rprt(0), false
|
return rprt(0), false
|
||||||
}
|
}
|
||||||
s.splitWanted.Store(false)
|
s.splitWanted.Store(false)
|
||||||
|
// Already simplex? Then there is nothing to do and the request is
|
||||||
|
// satisfied. Reporting a failure here is what broke JTDX in "Fake It":
|
||||||
|
// Fake It uses no split, JTDX still sends "S 0" to be sure, and a backend
|
||||||
|
// that cannot SET split answered an error to a request that was already
|
||||||
|
// true. JTDX read that as rig control failing and abandoned the
|
||||||
|
// transmission a second into the frame.
|
||||||
|
//
|
||||||
|
// A refusal is only honest when something actually needed doing.
|
||||||
|
if on, _ := s.rig.Split(); !on {
|
||||||
|
return rprt(0), false
|
||||||
|
}
|
||||||
if err := s.rig.SetSplit(false, 0); err != nil {
|
if err := s.rig.SetSplit(false, 0); err != nil {
|
||||||
s.log("rigctld: split off failed: %v", err)
|
s.log("rigctld: split off failed: %v", err)
|
||||||
return rprt(-9), false
|
return rprt(-9), false
|
||||||
@@ -438,6 +461,34 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
s.log("rigctld: split ON, TX %.0f Hz", hz)
|
s.log("rigctld: split ON, TX %.0f Hz", hz)
|
||||||
return rprt(0), false
|
return rprt(0), false
|
||||||
|
|
||||||
|
case "X", "\\set_split_mode":
|
||||||
|
// "X <mode> <passband>" — the mode of the TRANSMIT VFO.
|
||||||
|
//
|
||||||
|
// Accepted rather than refused. WSJT-X and JTDX send it as part of their
|
||||||
|
// normal setup even in "Fake It", where there is no split and therefore no
|
||||||
|
// second VFO to give a mode to; answering "not implemented" made JTDX give
|
||||||
|
// up on rig control mid-transmission.
|
||||||
|
//
|
||||||
|
// The mode is applied when there IS a split — the transmit VFO is a real
|
||||||
|
// one then. Without split the request has no target and succeeding is the
|
||||||
|
// honest answer: the transmit VFO already has that mode, because it is the
|
||||||
|
// same VFO.
|
||||||
|
if len(args) < 1 {
|
||||||
|
return rprt(-1), false
|
||||||
|
}
|
||||||
|
if on, _ := s.rig.Split(); !on {
|
||||||
|
return rprt(0), false
|
||||||
|
}
|
||||||
|
if err := s.rig.SetMode(args[0]); err != nil {
|
||||||
|
s.log("rigctld: split mode %q failed: %v", args[0], err)
|
||||||
|
return rprt(-9), false
|
||||||
|
}
|
||||||
|
return rprt(0), false
|
||||||
|
case "x", "\\get_split_mode":
|
||||||
|
// Mirrors X: the transmit VFO's mode and passband. Without split that is
|
||||||
|
// simply the current mode.
|
||||||
|
return s.rig.Mode() + "\n2400\n", false
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// A frame ending in ';' is not a rigctl command at all — it is raw rig
|
// A frame ending in ';' is not a rigctl command at all — it is raw rig
|
||||||
// dialect (Kenwood/Elecraft/Yaesu), which means the client is configured
|
// dialect (Kenwood/Elecraft/Yaesu), which means the client is configured
|
||||||
|
|||||||
@@ -50,3 +50,55 @@ func TestSetSplitRefusalIsReported(t *testing.T) {
|
|||||||
t.Errorf("a rig that cannot split answered %q — the client will transmit on the wrong frequency", got)
|
t.Errorf("a rig that cannot split answered %q — the client will transmit on the wrong frequency", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Fake It" uses NO split, and JTDX still sends "S 0" to make sure. A backend
|
||||||
|
// that cannot SET split used to answer an error to that — a refusal of a
|
||||||
|
// request that was already satisfied — and JTDX read it as rig control failing
|
||||||
|
// and abandoned the transmission a second into a 13.8-second frame.
|
||||||
|
//
|
||||||
|
// Reported against a Yaesu whose backend cannot arm split from software.
|
||||||
|
func TestSplitOffOnASimplexRigSucceeds(t *testing.T) {
|
||||||
|
rig := &fakeRig{freq: 14074000, mode: "FT8", noSplit: true}
|
||||||
|
s := New(0, rig, func(string, ...any) {})
|
||||||
|
|
||||||
|
if got, _ := s.handle("S 0 VFOA"); !strings.HasPrefix(got, "RPRT 0") {
|
||||||
|
t.Errorf("split off on a simplex rig answered %q — nothing needed doing", got)
|
||||||
|
}
|
||||||
|
// And it must not have bothered the rig at all.
|
||||||
|
if len(rig.splitCalls) != 0 {
|
||||||
|
t.Errorf("rig was asked to clear a split it did not have: %v", rig.splitCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arming, however, must still fail loudly: there the request is real and
|
||||||
|
// unmet, and a client that believes it is transmitting up the band while the
|
||||||
|
// radio sits on the DX's frequency is the bug this refusal exists to prevent.
|
||||||
|
if got, _ := s.handle("S 1 VFOB"); !strings.HasPrefix(got, "RPRT 0") {
|
||||||
|
t.Fatalf("arming is deferred to set_split_freq: %q", got)
|
||||||
|
}
|
||||||
|
if got, _ := s.handle("I 14075300.000000"); strings.HasPrefix(got, "RPRT 0") {
|
||||||
|
t.Error("arming split on a backend that cannot must report a failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X (set_split_mode) is sent by WSJT-X and JTDX during ordinary setup, Fake It
|
||||||
|
// included — where there is no second VFO to give a mode to. Answering "not
|
||||||
|
// implemented" made JTDX give up on rig control mid-transmission.
|
||||||
|
func TestSplitModeIsAccepted(t *testing.T) {
|
||||||
|
rig := &fakeRig{freq: 14074000, mode: "FT8"}
|
||||||
|
s := New(0, rig, func(string, ...any) {})
|
||||||
|
|
||||||
|
// No split: nothing to target, and succeeding is honest — the transmit VFO
|
||||||
|
// already has that mode because it is the same VFO.
|
||||||
|
if got, _ := s.handle("X PKTUSB -1"); !strings.HasPrefix(got, "RPRT 0") {
|
||||||
|
t.Errorf("set_split_mode answered %q", got)
|
||||||
|
}
|
||||||
|
if rig.mode != "FT8" {
|
||||||
|
t.Errorf("the mode was changed with no split in force: %q", rig.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading it back must answer a mode and a passband, never an error.
|
||||||
|
got, _ := s.handle("x")
|
||||||
|
if !strings.Contains(got, "FT8") {
|
||||||
|
t.Errorf("get_split_mode = %q, want the current mode", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user