Compare commits

...
7 Commits
Author SHA1 Message Date
rouggy 380472bda8 feat(qsl): copy a card design from another profile
A second profile is usually the same operator with a different rig or a
different locator — same callsign, same cards. Having to redraw a design
because the antenna changed is work nobody should do.

The designer now lists the designs the active profile cannot see and copies a
chosen one into it. Designs it already sees — its own, and the shared ones —
are left out: they are not something to copy.

A COPY, not a move or a share. The original profile keeps its design untouched
and the duplicate is free to diverge, which it usually will: a second profile
exists because something differs, and that something often ends up on the card.

The PICTURES are copied too, and that is the part worth getting right. Stored
documents reference photos by name relative to the template's own asset folder,
so duplicating the JSON alone would point the new design at files that are not
in its folder. It is validated against its own folder afterwards, and a failure
rolls the row back rather than leaving a design whose pictures are missing.
Sharing the source's folder was the other option and a worse one: deleting
either design would then have emptied the other.
2026-08-13 18:08:14 +02:00
rouggy 4e2a5877d6 fix(rigctld): stop refusing two commands JTDX sends in Fake It
From a user's log, repeating throughout the session:

	rigctld: split off failed: cat: this radio's backend cannot set split
	rigctld: unimplemented command "X PKTUSB -1"

Both are answered with an error, and JTDX treats a rig-control error as fatal:
it abandoned a transmission 0.86 seconds into a 13.8-second frame.

"S 0" — split OFF. Fake It uses no split, and JTDX still sends this to be sure.
A backend that cannot SET split was refusing a request that was ALREADY
satisfied. It now succeeds when the rig is simplex, without touching the rig at
all. Arming still fails loudly: there the request is real and unmet, and a
client that believes it transmits up the band while the radio sits on the DX's
frequency is exactly what that refusal exists to prevent.

"X <mode>" — set_split_mode. Sent during ordinary setup, Fake It included,
where there is no second VFO to give a mode to. Accepted now: applied when a
split is in force, and a plain success otherwise, because the transmit VFO
already has that mode — it is the same VFO. get_split_mode answers to match.

Both regressions fail without the fix with the exact codes from that log,
RPRT -9 and RPRT -11.
2026-08-13 15:57:58 +02:00
rouggy d44a971acf chore(rigctld): trace the client exchange
Only PTT transitions were recorded. So when JTDX aborted a transmission
0.86 seconds into a 13.8-second frame, the log showed the abort and the Fake It
frequency restore around it — and not the command that preceded them, which is
the one thing needed to tell whether OpsLog answered something the client could
not accept.

Behind the existing CAT wire-trace switch rather than a new one: this is a line
per poll and would drown an ordinary log, and an operator chasing a rig-control
fault turns that trace on already.
2026-08-13 15:48:05 +02:00
rouggy fabd1becce fix(amp): couple a chosen GROUP, not every amplifier
The first version was a single switch meaning "command them all", and that is
wrong the moment a station has three: two SPE on a combiner and a PowerGenius
on another antenna would all go into OPERATE together, keying an amplifier that
has nothing to do with the pair.

It is now a set. Each amplifier is ticked into the group or not, the group is
stored as a list of ids, and an amplifier outside it keeps its own buttons —
which is the entire point of it being a set.

A group of fewer than two members is stored as none: one amplifier coupled to
itself would make every command fan out to a single member for ever.

A remembered member that is no longer running is skipped rather than failing
the command — deleting one amplifier must not break the button on the other.

An amplifier saved without an id cannot join, and the panel says so instead of
quietly omitting it from the list.
2026-08-13 15:24:43 +02:00
rouggy 345be94c65 fix(amp): put the coupling switch where it can be seen
It sat above the first amplifier card. With three cards configured — the case
it exists for — that is a scroll away from anything the operator is looking at,
and it was reported as missing rather than misplaced.

Moved next to Add amplifier: coupling belongs to the SET of amplifiers, not to
any one of them, so it belongs with the other set-level control.
2026-08-13 15:03:46 +02:00
rouggy d0c6e420d2 feat(amp): command linked amplifiers together for a combiner
The SPE CO1-2 combiner is an RF device: it sums two amplifiers and commands
nothing. So "combined" operation is really two amplifiers that must be held in
the same state, and one left in STANDBY while the other keys means the combiner
sees power on a single input.

A switch in Settings → Amplifier makes ON, OFF and OPERATE act on every
configured amplifier. Offered only with two or more: coupling one amplifier to
itself is a switch that cannot do anything.

The METERS stay per amplifier, deliberately. Two amps combined are still two
amps, and an operator watching for one of them to run away needs to see them
apart — a summed bar would hide exactly the fault worth catching.

Fanned out in AmpOperate/AmpPower rather than in the UI: the card and the docked
widget both call these, and a coupling built into one would be missing from the
other, which on a combiner means one amplifier keyed and one not. The clicked
amplifier goes first, so a partial failure still did what the operator asked
before it stopped.

While linked, an amplifier with no power command — a PGXL on its direct link —
is skipped silently rather than reported: it would make a successful pair look
broken.
2026-08-13 14:02:34 +02:00
rouggy f0026b8bd3 chore: open 0.25.1 2026-08-13 13:51:38 +02:00
13 changed files with 505 additions and 18908 deletions
+63
View File
@@ -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)
}
}
}
+111
View File
@@ -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
}
+104
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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": "",
+35 -1
View File
@@ -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">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold">Saved templates</h3> <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) => (
+2 -2
View File
@@ -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',
+8
View File
@@ -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>;
+16
View File
@@ -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);
} }
+18
View File
@@ -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;
+52 -1
View File
@@ -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
+52
View File
@@ -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)
}
}