Compare commits

..
2 Commits
Author SHA1 Message Date
rouggy 3da1d71323 chore: release v0.23.1 2026-08-02 20:29:30 +02:00
rouggy 72f692aa04 chore: release v0.23.0 2026-08-02 20:03:16 +02:00
15 changed files with 506 additions and 97 deletions
+35 -6
View File
@@ -13460,12 +13460,12 @@ func boolStr(b bool) string {
// StationDevice is one configured relay board for the Station Control tab.
type StationDevice struct {
ID string `json:"id"`
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
Type string `json:"type"` // "webswitch" | "kmtronic" | "dingtian" | "denkovi" | "usbrelay"
Name string `json:"name"`
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
Pass string `json:"pass,omitempty"`
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
User string `json:"user,omitempty"` // KMTronic HTTP auth; Dingtian HTTP session ID (blank = none)
Pass string `json:"pass,omitempty"` // KMTronic HTTP auth; Dingtian relay password (09999)
Channels int `json:"channels,omitempty"` // usbrelay/denkovi/dingtian: number of relays
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
}
@@ -13473,10 +13473,13 @@ type StationDevice struct {
// except the Denkovi (4 or 8) and the generic USB-serial board, whose channel
// count the user picks.
func deviceRelayCount(d StationDevice) int {
if d.Type == "usbrelay" || d.Type == "denkovi" {
if d.Type == "usbrelay" || d.Type == "denkovi" || d.Type == "dingtian" {
if d.Channels >= 1 {
return d.Channels
}
if d.Type == "dingtian" {
return 2 // the smallest board; the real count comes from its status reply
}
return 8
}
return relayCountFor(d.Type)
@@ -13504,6 +13507,9 @@ func buildDeviceDriver(d StationDevice) relaydev.Device {
switch d.Type {
case "kmtronic":
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
case "dingtian":
// User carries the optional HTTP session ID, Pass the relay password.
return relaydev.NewDingtian(d.Host, d.User, d.Pass, deviceRelayCount(d))
case "denkovi":
// Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname.
return relaydev.NewDenkovi(d.Host, deviceRelayCount(d))
@@ -13607,7 +13613,7 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
}
for i := range devs {
switch devs[i].Type {
case "kmtronic", "denkovi", "usbrelay", "webswitch":
case "kmtronic", "dingtian", "denkovi", "usbrelay", "webswitch":
// known type — keep
default:
devs[i].Type = "webswitch"
@@ -15470,6 +15476,12 @@ type SpotStatus struct {
// when that database has been downloaded); POTA from the spot's tagged park.
NewCounty bool `json:"new_county"`
NewPOTA bool `json:"new_pota"`
// NewPfx flags a CQ WPX prefix never worked before, and Pfx is that prefix.
// Also orthogonal: a common entity on a worked band can still carry a prefix
// that has never been in the log, which is exactly what a WPX chaser is
// scanning the cluster for.
NewPfx bool `json:"new_pfx"`
Pfx string `json:"pfx,omitempty"`
}
// ClusterSpotStatuses takes a batch of spots and returns slot status for
@@ -15525,6 +15537,16 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
// lookup) and worked POTA parks. Both built once per batch.
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
// Worked WPX prefixes, derived from the callsigns we already loaded — no
// extra query. Derived rather than read from the stored PFX column: that
// column is only filled when an import supplied it, and deriving keeps this
// in step with the WPX award, which does the same thing.
workedPfx := make(map[string]struct{}, len(workedCalls))
for c := range workedCalls {
if p := award.WPXPrefix(c); p != "" {
workedPfx[p] = struct{}{}
}
}
for i, q := range spots {
out[i] = SpotStatus{
Call: q.Call,
@@ -15534,6 +15556,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
out[i].WorkedCall = true
}
// NEW PFX: the spot's CQ WPX prefix, never worked before.
if p := award.WPXPrefix(q.Call); p != "" {
out[i].Pfx = p
if _, done := workedPfx[p]; !done {
out[i].NewPfx = true
}
}
// NEW POTA: the spot's tagged park, never worked before.
if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
if _, done := workedPOTA[ref]; !done {
+30
View File
@@ -1,4 +1,34 @@
[
{
"version": "0.23.1",
"date": "",
"en": [
"DX cluster: NEW COUNTY is green, like NEW POTA.",
"CW: pressing ESC during a macro no longer turns the report from 599 into 59 when \"ESC clears the callsign too\" is on."
],
"fr": [
"Cluster DX : NOUVEAU COMTÉ passe en vert, comme NOUVEAU POTA.",
"CW : appuyer sur Échap pendant une macro ne fait plus passer le report de 599 à 59 quand l'option « Échap efface aussi l'indicatif » est cochée."
]
},
{
"version": "0.23.0",
"date": "",
"en": [
"Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.",
"ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.",
"DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.",
"Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on. (Thanks HB9HBY)",
"DX cluster: NEW PFX marks a spot whose CQ WPX prefix has never been worked, in the status column and as a filter."
],
"fr": [
"Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.",
"Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.",
"Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.",
"Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours. (Merci HB9HBY)",
"Cluster DX : NOUVEAU PFX signale un spot dont le préfixe CQ WPX n'a jamais été contacté, dans la colonne de statut et en filtre."
]
},
{
"version": "0.22.9",
"date": "",
+42 -12
View File
@@ -492,6 +492,14 @@ export default function App() {
const [bandRx, setBandRx] = useState('20m');
const [countries, setCountries] = useState<string[]>([]);
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
// Refs for the report defaults. applyModePreset is called from long-lived
// closures — the global key handler is the one that bit us — whose dependency
// lists deliberately exclude the mode, so reading the state variables there
// gives whatever they were when that closure was built.
const modePresetsRef = useRef(modePresets);
useEffect(() => { modePresetsRef.current = modePresets; }, [modePresets]);
const rstListsRef = useRef(rstLists);
useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
const [rstSent, setRstSent] = useState('59');
const [rstRcvd, setRstRcvd] = useState('59');
const [grid, setGrid] = useState('');
@@ -1382,7 +1390,7 @@ export default function App() {
// county or a new park is not a DXCC state, it is another dimension of the
// same spot — which is why the grid shows them as separate badges. Filtering
// is an OR across all of them, as it already was for a worked callsign.
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county';
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx';
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
// Mode filter chips. Empty set = show every mode. Categories map the
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
@@ -1478,7 +1486,7 @@ export default function App() {
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
// different slots don't share the same colour.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus);
@@ -2112,11 +2120,22 @@ export default function App() {
setModePresets(l.modes);
const names = l.modes.map((m) => m.name);
setModes(names);
setMode((cur) => names.includes(cur) ? cur : names[0]);
const preset = l.modes.find((m) => m.name === mode) ?? l.modes[0];
if (preset && !rstUserEditedRef.current) {
if (preset.default_rst_sent) setRstSent(preset.default_rst_sent);
if (preset.default_rst_rcvd) setRstRcvd(preset.default_rst_rcvd);
// Resolve the mode we are ACTUALLY on, then take that mode's report.
//
// This used to read the `mode` state variable, which this callback
// captures once (its dep list is empty) — so it was still the initial
// 'SSB' — and fell back to l.modes[0], SSB again, when the lookup
// missed. An operator who launched OpsLog with the rig already in CW
// and did not touch the VFO got 59/59 and kept it: nothing else runs
// until the CAT state actually changes.
const cur = names.includes(modeRef.current) ? modeRef.current : names[0];
setMode(cur);
if (!rstUserEditedRef.current) {
const lists: RSTLists = { phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] };
const p = l.modes.find((m) => m.name === cur);
const fallback = rstOptions(cur, lists)[0] || '';
setRstSent(p?.default_rst_sent || fallback);
setRstRcvd(p?.default_rst_rcvd || fallback);
}
}
} catch {}
@@ -2176,8 +2195,9 @@ export default function App() {
// Prefer the user's configured preset RST; otherwise fall back to the mode
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
// so switching SSB→CW flips 59→599 even without a configured preset.
const p = modePresets.find((x) => x.name === m);
const fallback = rstOptions(m, rstLists)[0] || '';
// Read through the refs, never the state: see their declaration.
const p = modePresetsRef.current.find((x) => x.name === m);
const fallback = rstOptions(m, rstListsRef.current)[0] || '';
setRstSent(p?.default_rst_sent || fallback);
setRstRcvd(p?.default_rst_rcvd || fallback);
}
@@ -2450,6 +2470,8 @@ export default function App() {
worked_call: !!(r as any).worked_call,
new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
};
}
return next;
@@ -2905,6 +2927,8 @@ export default function App() {
worked_call: !!(r as any).worked_call,
new_county: !!(r as any).new_county,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
};
}
return next;
@@ -3013,7 +3037,11 @@ export default function App() {
setWb(null); // clear the Worked-before grid for the just-cleared callsign
setLookupError('');
rstUserEditedRef.current = false;
applyModePreset(mode);
// modeRef, not `mode`: resetEntry is reached from the global key handler,
// whose effect does not re-run on a mode change. Pressing ESC during a CW
// macro therefore reset the report using the mode as it was when that
// handler was built — 'SSB' — and turned 599 into 59.
applyModePreset(modeRef.current);
setDetails((d) => ({
...d,
state: '', cnty: '', address: '', lat: undefined, lon: undefined,
@@ -4340,7 +4368,8 @@ export default function App() {
const matches = clusterStatusFilter.has(st)
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|| (!!e?.new_county && clusterStatusFilter.has('new-county'));
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
|| (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'));
if (!matches) return false;
}
if (clusterHideWorked) {
@@ -4459,7 +4488,8 @@ export default function App() {
// Same colours as the badges in the grid — a filter that does not
// look like what it selects has to be learned twice.
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
]).map((s) => {
const on = clusterStatusFilter.has(s.k);
+64 -63
View File
@@ -53,6 +53,8 @@ export type SpotStatusEntry = {
worked_call?: boolean;
new_county?: boolean;
new_pota?: boolean;
new_pfx?: boolean;
pfx?: string;
};
type Props = {
@@ -99,47 +101,42 @@ function statusFor(p: any): SpotStatusEntry | undefined {
];
}
// statusBadge maps a resolved spot status to a short labelled badge for the
// Status column, using the same colours as the per-cell fills (NEW DXCC =
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when
// there's nothing notable to show.
type Badge = { text: string; fg: string; bg: string; bd: string };
// Spot status is shown by COLOURING THE TEXT, never by a pill or a badge.
//
// The pills were dropped: a rounded box has its own height and padding, so it
// sat off the row's baseline and the callsign inside it no longer lined up with
// the plain callsigns above and below. A whole column of them read as chrome
// rather than as data. Same reasoning — and the same semantic tokens — as the
// Y/N/R QSL columns in lib/qslStatus.ts.
//
// Which cell is coloured IS the message, so one colour is enough for all three:
//
// yellow call → new DXCC yellow band → new band yellow mode → new mode
// blue call → already worked
const NEW = 'var(--warning)'; // yellow: something here is new
const WKD = 'var(--info)'; // blue: this callsign is already in the log
// tok builds a badge from a semantic status token (success/warning/caution/
// danger/info/neutral) so the colours adapt to every theme via CSS variables,
// instead of the hard-coded light-theme pastels that looked wrong in dark mode.
function tok(name: string, text: string): Badge {
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
}
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
// Status badges) when the slot is notable, instead of flooding the whole cell with
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
// null → plain text, no pill.
function cellChip(value: any, name: string | null): any {
// cellText renders a cell value in an optional colour. An empty value keeps the
// muted dash the grid used before, so blank cells still read as "nothing here"
// rather than as a gap.
function cellText(value: any, color: string | null): any {
const txt = value === undefined || value === null || value === '' ? '' : String(value);
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
// Inherit the column's font size (no fixed 9px / height) so a pill around a
// callsign stays the same size as the plain callsigns next to it — just tinted
// and rounded, not shrunk.
return (
<span style={{
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
border: `1px solid var(--${name}-border)`, fontWeight: 700,
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
}}>{txt}</span>
);
if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
}
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null {
// statusColor is the colour for a resolved status in the Status column. County,
// POTA and prefix keep their own tokens: they are orthogonal to the band/mode/
// DXCC story and an operator filters on them separately.
function statusColor(s: SpotStatusEntry | undefined): string | null {
switch (s?.status) {
case 'new': return tok('danger', t('clg2.newDxcc'));
case 'new-band': return tok('warning', t('clg2.newBand'));
case 'new-mode': return tok('caution', t('clg2.newMode'));
case 'new-slot': return tok('caution', t('clg2.newSlot'));
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null;
case 'new':
case 'new-band':
case 'new-mode':
case 'new-slot':
return NEW;
default:
return s?.worked_call ? WKD : null;
}
}
@@ -166,15 +163,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
defaultVisible: true,
cellClass: 'font-mono',
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND /
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text;
// a plain spot inherits the theme's normal text colour so callsigns blend in
// with the rest of the row instead of always shouting a colour.
// NEW DXCC → yellow call. Already worked → blue call. Anything else keeps
// the theme's normal ink so ordinary callsigns don't shout.
cellRenderer: (p: any) => {
const s = statusFor(p);
if (s?.status === 'new') return cellChip(p.value, 'danger');
const color = s?.worked_call ? 'var(--info)' : undefined;
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null;
return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
},
tooltipValueGetter: (p: any) => {
const s = statusFor(p);
@@ -185,9 +179,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.status'), colId: 'status',
headerName: t('clg2.c.status'), width: 120, sortable: true,
defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others)
// is obvious at the row level, not just a single coloured cell. NEW COUNTY
// and NEW POTA are orthogonal, so they stack as extra badges.
// Spells the status out in words so NEW SLOT (and the others) is obvious at
// the row level, not just a single coloured cell NEW SLOT in particular
// colours no cell at all, since neither the band nor the mode is new on its
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
valueGetter: (p: any) => {
const s = statusFor(p);
const parts: string[] = [];
@@ -198,25 +193,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
if (s?.new_county) parts.push(t('clg2.newCounty'));
if (s?.new_pota) parts.push(t('clg2.newPota'));
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
return parts.join(' ');
},
cellRenderer: (p: any) => {
const s = statusFor(p);
const badges: Badge[] = [];
const b = statusBadge(t, s);
if (b) badges.push(b);
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty')));
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota')));
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
const parts: { text: string; color: string }[] = [];
const main = statusColor(s);
if (main) {
const label = s?.status === 'new' ? t('clg2.newDxcc')
: s?.status === 'new-band' ? t('clg2.newBand')
: s?.status === 'new-mode' ? t('clg2.newMode')
: s?.status === 'new-slot' ? t('clg2.newSlot')
: t('clg2.wkdCall');
parts.push({ text: label, color: main });
}
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--success)' });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' });
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return (
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{badges.map((bd, i) => (
<span key={i} style={{
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1,
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`,
fontWeight: 700, fontSize: 9,
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{bd.text}</span>
<span style={{ whiteSpace: 'nowrap' }}>
{parts.map((pt, i) => (
<span key={i} style={{ color: pt.color, fontWeight: 700 }}>
{i > 0 ? ' · ' : ''}{pt.text}
</span>
))}
</span>
);
@@ -249,8 +250,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
defaultVisible: true,
cellClass: 'font-mono',
// NEW BAND for this entity → small warning pill around the band text.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null),
// NEW BAND for this entity → the band text turns yellow.
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null),
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
},
{
@@ -263,7 +264,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
// for the entity. NEW SLOT means band AND mode were each worked before (just
// not together), so highlighting the mode cell would wrongly imply "CW is new";
// that case is signalled by the Status badge alone.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null),
cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null),
tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status;
if (st === 'new-mode') return t('clg2.tipNewMode');
@@ -3298,6 +3298,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Button variant="outline" size="sm" onClick={addAmp}>
<Plus className="size-3.5 mr-1" /> {t('amp.add')}
</Button>
</div>
</>
);
@@ -33,13 +33,15 @@ type DevStatus = { id: string; name: string; type: string; connected: boolean; e
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
const GRID_GAP = 16; // px between cards, both axes
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' };
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
// the generic USB-serial board, whose channel count the user picks.
const chanCount = (d: Device): number =>
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
: (RELAY_COUNT[d.type] ?? 5);
function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
@@ -552,6 +554,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
onChange({ ...device, channels, labels });
};
const isKM = device.type === 'kmtronic';
const isDingtian = device.type === 'dingtian';
const isDenkovi = device.type === 'denkovi';
const isUsbRelay = device.type === 'usbrelay';
// COM ports for the generic USB-serial relay picker.
@@ -602,6 +605,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<SelectContent>
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
</SelectContent>
@@ -612,13 +616,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
</div>
</div>
{(isDenkovi || isUsbRelay) && (
{(isDenkovi || isUsbRelay || isDingtian) && (
<div className="space-y-1 max-w-[10rem]">
<Label>{t('station.channels')}</Label>
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
{(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
))}
</SelectContent>
@@ -661,11 +665,28 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
</div>
) : (
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
<div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
<Label>{t('station.host')}</Label>
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
</div>
{/* Dingtian: both are OFF on a factory board — the session ID only when
"HTTP Session" is enabled in its web page, the password only when a
relay password is set. */}
{isDingtian && (
<>
<div className="space-y-1">
<Label>{t('station.dtSession')}</Label>
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
onChange={(e) => onChange({ ...device, user: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('station.dtPwd')}</Label>
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
</div>
</>
)}
{isKM && (
<>
<div className="space-y-1">
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.22.9';
export const APP_VERSION = '0.23.1';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+4
View File
@@ -2904,6 +2904,8 @@ export namespace main {
worked_call: boolean;
new_county: boolean;
new_pota: boolean;
new_pfx: boolean;
pfx?: string;
static createFrom(source: any = {}) {
return new SpotStatus(source);
@@ -2920,6 +2922,8 @@ export namespace main {
this.worked_call = source["worked_call"];
this.new_county = source["new_county"];
this.new_pota = source["new_pota"];
this.new_pfx = source["new_pfx"];
this.pfx = source["pfx"];
}
}
export class StartupStatus {
+86
View File
@@ -0,0 +1,86 @@
# ACOM built-in ATU — what we know, and why OpsLog does not drive it
Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry
trace. Written down so nobody repeats the work. **Conclusion first: the tuner
cannot be started over the serial link, and the attempt to work around that was
abandoned.**
## The tuner is PA state 8
Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the
ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed
twice, front-panel TUNE, with the byte-level trace running:
```
14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF
14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz)
14:47:24.006 state 8 step 2
14:47:25.201 state 8 step 4
14:47:27.457 state 8 step 5
14:47:30.092 state 5 STANDBY ← accord finished
```
Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`.
Step 0 means *armed, waiting for RF* — in the first of the two runs the
amplifier sat there **30 seconds** untouched before the operator transmitted.
`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this.
## The command does not exist in the state-command family
Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a
**verified** STANDBY (an earlier sweep was worthless because its second code
parked the amp in SERVICE, where it silently refuses everything — 17 false
negatives). Result:
| byte | effect |
|------|--------|
| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` |
| 0x04 | → SERVICE (state 4) |
| 0x05 | → STANDBY (state 5) |
| 0x06 | → OPERATE (state 6) |
| 0x0A | → OFF (state 10) |
| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged |
The action byte **is** the target PA state — four independent confirmations.
Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified
STANDBY, and does nothing.** State 8 is reachable from the front panel only.
Going further would mean varying the other bytes of the frame. That is a large
space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth
it.
## The workaround that was built and removed
Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and
supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait
for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in,
Settings → Amplifier) and **removed** after the first hardware test:
- the power change did not take effect, and
- **the PTT was not released when the tune finished.**
A logger that can leave a transceiver keyed is not acceptable, and the whole
thing was scaffolding around a protocol we do not actually control. Removed at
the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la
bidouille."*
If it is ever revisited, the unexplained part is why the tune-finished detection
did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should
have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier.
## Loose ends worth knowing
- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with
it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while
the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a
tuner flag for a while purely because of this — it toggles on its own several
times a minute. Do not read tail bytes without checking byte 6.
- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit,
not a command acknowledgement.
- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal
tune and during transmission.** `errText` renders those as "ERROR — see
display" and "Remove drive power", so an ACOM operator probably sees a false
alarm. This was never confirmed with the amplifier in OPERATE (it was in
STANDBY throughout, where drive genuinely is an error) — worth a look with a
clean log before touching the table.
+6 -1
View File
@@ -77,9 +77,14 @@ var models = map[string]model{
}
// paStatusNames maps the PAstatus nibble to a display string.
// 8 is absent from the state list the ACOM-Controller project recovered; it is
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
var paStatusNames = map[int]string{
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
}
// acomBands maps the band nibble to a band label.
+120
View File
@@ -7,6 +7,10 @@
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
// - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET
// CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&;
// status: GET /relay_cgi_load.cgi. Both answer &-separated fields.
// (IOT Relay Programming Manual V1.9.8.1, §3.)
//
// A Device presents the same surface to the app regardless of wire protocol.
package relaydev
@@ -173,3 +177,119 @@ func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
}
return out, nil
}
// ── Dingtian IOT relay (DTWONDER) ──────────────────────────────────────
//
// The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we
// use the HTTP GET CGI, which needs no connection state and matches how the
// other network boards here are driven.
//
// Both endpoints answer &-separated fields, first one 0 on success:
//
// /relay_cgi_load.cgi → &0&4&1&0&1&0&
// result, count, r1…rN
// /relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0& → &0&0&0&1&0&
// result, type, relay, on, time
//
// NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the
// Device interface is 1-based like every other board here.
type dingtian struct {
host string
session string // optional: the board can require "Cookie: session=<id>"
pwd string // CGI password, 09999; "0" (or blank) when none is set
count int
}
// NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID
// when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay
// password (blank or "0" when none).
func NewDingtian(host, session, pwd string, count int) Device {
if count <= 0 {
count = 2
}
if strings.TrimSpace(pwd) == "" {
pwd = "0"
}
return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count}
}
func (d *dingtian) Count() int { return d.count }
func (d *dingtian) Close() error { return nil } // stateless HTTP, nothing to release
// getCGI issues the GET with the session cookie the board may require.
func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if d.session != "" {
req.Header.Set("Cookie", "session="+d.session)
}
resp, err := httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// dtFields splits an &-separated CGI reply into its fields, dropping the empty
// ones the leading and trailing "&" produce.
func dtFields(body []byte) []string {
var out []string
for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}
func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > d.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
}
state := 0
if on {
state = 1
}
// type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle),
// and time is then unused.
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&",
d.host, relay-1, state, d.pwd))
if err != nil {
return err
}
// A wrong password or session answers 200 with a non-zero result (e.g.
// "&302&/&"), so the HTTP status alone does not tell us it worked.
if f := dtFields(body); len(f) == 0 || f[0] != "0" {
return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID",
strings.TrimSpace(string(body)))
}
return nil
}
func (d *dingtian) Status(ctx context.Context) ([]bool, error) {
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi_load.cgi", d.host))
if err != nil {
return nil, err
}
f := dtFields(body)
if len(f) < 2 || f[0] != "0" {
return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body)))
}
// The board reports its own relay count; trust it over the configured one so
// a mis-set channel count doesn't silently hide relays.
if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 {
d.count = n
}
out := make([]bool, d.count)
for i := 0; i < d.count && i+2 < len(f); i++ {
out[i] = f[i+2] == "1"
}
return out, nil
}
+73
View File
@@ -99,3 +99,76 @@ func TestKMTronicSetURL(t *testing.T) {
}
_ = d.Set(context.Background(), 1, false) // → /FF0100
}
// Dingtian — the wire examples come straight from the IOT Relay Programming
// Manual V1.9.8.1 §3.1/§3.2. The two traps pinned here: the URL relay index is
// ZERO-based while the Device interface is 1-based, and a refusal (bad password
// or session) answers HTTP 200 with a non-zero first field.
func TestDingtianStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/relay_cgi_load.cgi" {
t.Errorf("unexpected status path %q", r.URL.Path)
}
// Manual's own example: ok, 4 relays, 1 on, 2 off, 3 on, 4 off.
_, _ = w.Write([]byte("&0&4&1&0&1&0&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
// The board said 4 relays even though 2 were configured — its count wins.
want := []bool{true, false, true, false}
if len(st) != len(want) {
t.Fatalf("got %d relays, want %d", len(st), len(want))
}
for i := range want {
if st[i] != want[i] {
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
}
}
if d.Count() != 4 {
t.Errorf("Count() = %d, want 4 (taken from the board)", d.Count())
}
}
func TestDingtianSetUsesZeroBasedIndexAndSession(t *testing.T) {
var gotQuery, gotCookie string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
gotCookie = r.Header.Get("Cookie")
_, _ = w.Write([]byte("&0&0&2&1&0&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "12345678", "4660", 4)
if err := d.Set(context.Background(), 3, true); err != nil {
t.Fatal(err)
}
if !strings.Contains(gotQuery, "relay=2") {
t.Errorf("relay 3 must go out as relay=2 (zero-based); query was %q", gotQuery)
}
if !strings.Contains(gotQuery, "on=1") || !strings.Contains(gotQuery, "type=0") ||
!strings.Contains(gotQuery, "pwd=4660") {
t.Errorf("unexpected query %q", gotQuery)
}
if gotCookie != "session=12345678" {
t.Errorf("session cookie = %q, want session=12345678", gotCookie)
}
}
func TestDingtianSetRefusalIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Manual §3.4.4: a bad session still answers 200 OK.
_, _ = w.Write([]byte("&302&/&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
if err := d.Set(context.Background(), 1, true); err == nil {
t.Fatal("a refused command answered HTTP 200 and was reported as success")
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.22.9"
appVersion = "0.23.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.
+13 -4
View File
@@ -57,10 +57,19 @@ Configure the host / password in Settings → Antenna Genius.
## Relay boards (Station Control)
USB relay boards for antenna/accessory switching, with named buttons in Station
Control: **Denkovi** (4/8 relays, FT245) and generic **CH340 / LCUS**
USB-serial boards. A Test-connection button and detection feedback live in the
setup panel.
Relay boards for antenna/accessory switching, with named buttons in Station
Control:
- **WebSwitch 1216H** (5 relays, LAN) and **KMTronic** 8-relay WEB (LAN, with
optional HTTP authentication).
- **Dingtian IOT relay** (2 to 32 relays, LAN or WiFi), driven over its HTTP
CGI. Give its IP address and pick the relay count; the **relay password** and
**session ID** fields are only needed if you enabled them on the board's own
web page — a factory board needs neither.
- **Denkovi** USB (4/8 relays, FT245) and generic **CH340 / LCUS** USB-serial
boards.
A Test-connection button and detection feedback live in the setup panel.
## Station Control