Compare commits

...
13 Commits
Author SHA1 Message Date
rouggy e4828482c4 fix(tci): send the sensor subscription in both cases
Two rounds of fixes and the SunSDR's meters are still silent. The one remaining
difference from the protocol document is the case: its examples read
TX_SENSORS_ENABLE:true,200; and ours went out lower-case. Every other command
works lower-case, but nothing proves this newer path does — a case-insensitive
server ignores the duplicate, a sensitive one finally hears it.
2026-08-28 22:54:44 +02:00
rouggy 8acfda4e42 fix(tci): resubscribe to the meters at 'ready', and log what comes back
The report from a real SunSDR: transmit power and SWR still empty after the
sensors fix. The subscription went out once, at connect — while ExpertSDR3 is
still streaming its initial state dump, exactly where a unidirectional control
command can be ignored. It is now renewed every time the server says 'ready'.

The sensor messages also join the logged-on-arrival set, capped like the rest:
the next log will say whether TX_SENSORS ever arrives, which is the question —
a radio that never sends it (AetherSDR may not) and a frame that arrived and
was dropped leave the same blank meters and need opposite fixes.
2026-08-28 22:51:25 +02:00
rouggy db24c38e63 chore: release v0.26.23 2026-08-28 22:45:55 +02:00
rouggy 06868e9ed1 style(settings): three more explanations off the General panel
The SCP paragraph, the digital-grouping parenthetical and the IARU region hint
all move into tooltips on their own controls.
2026-08-28 22:20:31 +02:00
rouggy 3ed643e072 style(settings): tidy the General panel
Per review: the panel-wide 'saved instantly' hint goes; the date-format picker
loses its embedded sample line (kept as the buttons' tooltip) so the row is one
line tall; the IARU region moves up under the date format, before the themes;
and the parentheticals after Distances-in-miles, Check-updates and the
telemetry opt-out go. The telemetry and ClubLog explanations survive as
tooltips — an opt-out about data leaving the machine, and an option changing
how entities resolve, should still say what they do somewhere.
2026-08-28 22:15:53 +02:00
rouggy f0f9898f7c feat(bandmap): draw the band plan of the operator's IARU region
The band maps carried one hard-coded table — Region 1's — so a US operator's
40 m stopped at 7200 with stations spotted at 7250 hanging past the top of the
map, and the SSB wash started 25 kHz early. The region is a station-level fact
stated once (Settings → General, portable pref); lib/bandplan owns the three
tables and publishes changes to the open maps the way the distance unit reaches
the grids.

The tables stay deliberately coarse: a band map's wash is context, not a
regulatory chart, so only the differences an operator notices are split out —
Region 2's 80 m to 4000 and 40 m to 7300 (phone from 7125), Region 3's 80 m to
3900, the wider VHF/UHF allocations. National fine print does not belong in a
background tint.
2026-08-28 21:55:37 +02:00
rouggy 73cae855ed chore(labels): park the label designer and printing on feature/labels
The feature needs more rounds than the next release can wait for, so main goes
back to before it: the packages, bindings, migration, UI and i18n all move to
the feature/labels branch, which holds every commit. The 0.26.23 block keeps
only the Station column.

The 0031 migration may already have run on a machine that launched a dev build;
the two label tables it created are inert and the recorded migration row is
harmless — the runner only applies filenames it has, so re-adding the migration
when the branch merges will skip cleanly there and apply everywhere else.
2026-08-28 21:35:50 +02:00
rouggy de3a115ca6 chore(changelog): move the post-release entries into a 0.26.23 block
The Station column, the Label Designer and the label printing all landed after
v0.26.22 was tagged; the .22 block is byte-identical to the tag again.
2026-08-28 21:31:09 +02:00
rouggy 1d7633484b fix(labels): the manager's address, not the DX's — and one PDF, just opened
Three corrections from the first real session. Routed via a manager, the box
was prefilled with the DX's own address — the one thing that must not go on
that envelope. It now starts empty and the QRZ fetch fills in the MANAGER's;
switching the routing recomputes the box unless the operator has typed in it,
because a hand-checked address is not the app's to replace.

A fetch that came back with no street was overwriting a reviewed address with a
bare country — the cty.dat fallback dressed as an answer. It now refuses to
touch the box and says nothing was found. And the prefill no longer stacks the
same town three times (address + QTH + country all carrying it).

The output is ONE PDF for the whole session, each page at its own label size,
written to the temp dir and opened straight in the viewer — no save dialog: the
file is a print run, not a document to keep.
2026-08-28 20:07:36 +02:00
rouggy 5ee0ade54b feat(labels): print the labels — queue, address review, PDF, log update
The printing session in three steps. The worklist is the paper queue (ADIF
qsl_sent R/Q), grouped by callsign since several QSOs of one station share a
card. Each recipient is reviewed before anything prints: routing first — via
manager when qsl_via says so, direct when an address is known, bureau otherwise
— then the address itself, editable and fetchable from QRZ (the MANAGER's
address when routing says via). What is printed is the reviewed text verbatim,
not a re-resolution that could differ from what was checked.

One PDF per label kind, never one file for all: a roll printer holds one stock
at a time, and a file mixing 29 mm addresses with 62 mm QSO labels could not be
printed at all. Pages are rasterised by the designer's own renderer at the
stock's dpi and carried into the PDF untouched — internal/pdf is a hand-written
image-page writer (DeviceGray + flate: monochrome, lossless, no cgo) because
that is the entire need.

Bureau stations get no address label (no envelope); return labels are printed
one per envelope. Finishing offers the log update: QSL_SENT=Y, the chosen date,
via B or D per routing — through the same BulkUpdateQSL the paper view uses.
2026-08-28 19:58:37 +02:00
rouggy e637f0814d style(labels): fit the preview to the pane
A fixed 720 px cap cut a 98 mm label off at the RST column and wasted half the
window; the scale now follows the centre pane's measured size, with a floor so
small stock never renders unreadably.
2026-08-28 19:46:54 +02:00
rouggy 1a169fdb4f feat(labels): the Label Designer — QSO and address labels for paper QSL
The designer's data model lives in internal/labels: STOCKS are the physical
roll in the printer (geometry in mm, margins, dpi — seeded with the common
Brother DK sizes, the QL family being what prompted the feature), TEMPLATES are
one design each, of two kinds: the QSO label glued on the card, whose repeating
table carries several contacts of the same station, and the address label for
the envelope, whose lines collapse when a variable is empty.

Everything is measured in millimetres — labels are sold in mm, and an operator
lining a design up against a physical sticker thinks in mm; pixels exist only in
the renderer, at the stock's dpi. The canvas renderer is shared between the
editor's preview and the future print path, so there is no second
implementation for the preview to disagree with.

The preview is fed with the log's latest contacts rather than lorem ipsum: real
data shows a too-narrow column immediately.

Printing (PDF, one page per label at exact size) is the next module; nothing
here prints yet.
2026-08-28 19:41:15 +02:00
rouggy 3dc31697cd feat(lotw): name the station callsign on each downloaded confirmation
With 'All my callsigns' the report spans F4BPO, F4BPO/P and TM2Q at once, and a
list mixing them says nothing about which one a confirmation belongs to — which
is the question that option creates.

The column appears only when the rows actually carry a station: the other
services return one, and an empty column for them would be noise.
2026-08-28 17:43:05 +02:00
12 changed files with 246 additions and 84 deletions
+13 -8
View File
@@ -11576,14 +11576,18 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
// with award-style NEW flags computed against the log's prior confirmations.
type ConfirmationItem struct {
Callsign string `json:"callsign"`
QSODate string `json:"qso_date"` // ISO UTC
Band string `json:"band"`
Mode string `json:"mode"`
Country string `json:"country"`
NewDXCC bool `json:"new_dxcc"`
NewBand bool `json:"new_band"`
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
NewSlot bool `json:"new_slot"`
// Station is the callsign the QSO was made UNDER, which the download can now
// span ("All my callsigns"): a list mixing F4BPO, F4BPO/P and TM2Q says
// nothing useful unless each line says which of them it belongs to.
Station string `json:"station"`
QSODate string `json:"qso_date"` // ISO UTC
Band string `json:"band"`
Mode string `json:"mode"`
Country string `json:"country"`
NewDXCC bool `json:"new_dxcc"`
NewBand bool `json:"new_band"`
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
NewSlot bool `json:"new_slot"`
}
// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
@@ -12365,6 +12369,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
}
it := ConfirmationItem{
Callsign: q.Callsign,
Station: strings.ToUpper(strings.TrimSpace(rec["station_callsign"])),
QSODate: q.QSODate.UTC().Format(time.RFC3339),
Band: q.Band,
Mode: q.Mode,
+22
View File
@@ -1,4 +1,26 @@
[
{
"version": "0.26.24",
"date": "",
"en": [
"TCI (SunSDR): the meter subscription is renewed when the radio announces it is ready — sent only at connect, it could fall inside the initial state dump and be ignored, which left the transmit power and SWR empty. The log now also records the subscription and the first sensor frames, so a report can tell “the radio never sends them” from “they arrived and were dropped”."
],
"fr": [
"TCI (SunSDR) : l'abonnement aux mesures est renouvelé quand la radio annonce qu'elle est prête — envoyé seulement à la connexion, il pouvait tomber pendant l'envoi initial de l'état et être ignoré, laissant la puissance et le ROS vides en émission. Le journal enregistre aussi l'abonnement et les premières trames de mesure, pour distinguer « la radio ne les envoie jamais » de « elles arrivaient et étaient perdues »."
]
},
{
"version": "0.26.23",
"date": "",
"en": [
"LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station.",
"The band maps follow your IARU region (Settings → General): Region 2s 40 m runs to 7300 kHz with phone from 7125 and its 80 m to 4000, Region 3s 80 m to 3900 — the band edges and the CW/digital/phone shading all adjust."
],
"fr": [
"LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement.",
"Les cartes de bande suivent votre région IARU (Réglages → Général) : le 40 m de la Région 2 va jusqu'à 7300 kHz avec la phonie dès 7125 et son 80 m jusqu'à 4000, le 80 m de la Région 3 jusqu'à 3900 — les limites de bande et les zones CW/numérique/phonie s'ajustent."
]
},
{
"version": "0.26.22",
"date": "",
+24 -53
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
@@ -114,59 +115,26 @@ interface Props {
keyNav?: boolean;
}
const BAND_RANGES: Record<string, [number, number]> = {
'160m': [1800, 2000],
'80m': [3500, 3800],
'60m': [5350, 5450],
'40m': [7000, 7200],
'30m': [10100, 10150],
'20m': [14000, 14350],
'17m': [18068, 18168],
'15m': [21000, 21450],
'12m': [24890, 24990],
'10m': [28000, 29700],
'6m': [50000, 50500],
'4m': [70000, 70500],
'2m': [144000, 146000],
'70cm': [430000, 440000],
// Band edges and mode segments come from lib/bandplan, which knows the
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
// cut the top 100 kHz of the band off the map.
//
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
// for), not states, so they take categorical hues — never the status tokens.
// All three are drawn from the cool end of the categorical order and laid down
// at low opacity, so the band plan stays background context while the warm
// spot pills keep the foreground. Checked with the palette validator in both
// themes (violet failed the dark protan step; magenta clears every check).
const SEG_COLOR: Record<SegMode, string> = {
cw: 'var(--chart-7)', // magenta
digi: 'var(--chart-1)', // blue
phone: 'var(--chart-2)', // aqua
};
// Sub-band shading: CW / digital / phone.
//
// These are IDENTITIES (which mode the segment is for), not states, so they take
// categorical hues — never the status tokens. They used to use success / info /
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
// is "new band" in this very component's legend, so the whole SSB portion read as
// a giant "new band" wash. Status colours are reserved for status.
//
// All three are drawn from the COOL end of the categorical order and laid down at
// low opacity, so the band plan stays background context while the warm spot pills
// keep the foreground to themselves.
// Checked with the palette validator in BOTH themes. Violet was the first pick
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
// protan reader there, i.e. the same colour. Magenta clears every check on both
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
const SEG_CW = 'var(--chart-7)'; // magenta
const SEG_DIGI = 'var(--chart-1)'; // blue
const SEG_PHONE = 'var(--chart-2)'; // aqua
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
// are read on top of it.
const SEG_OPACITY = 0.13;
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
'60m': [[5350, 5450, SEG_PHONE]],
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
};
// Small coloured dot + label used in the band-map legend strip.
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
return (
@@ -309,8 +277,11 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
return out;
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? [];
// Re-render when the operator changes their IARU region in Settings.
const [, setRegionTick] = useState(0);
useEffect(() => subscribeIaruRegion(() => setRegionTick((n) => n + 1)), []);
const range = bandRange(band);
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
// The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom.
@@ -794,9 +765,9 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
))}
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
<span className="mx-0.5 opacity-40">|</span>
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
<LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
</div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
{t('bmp.footerHint')}
+8 -1
View File
@@ -31,7 +31,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
];
type Confirmation = {
callsign: string; qso_date: string; band: string; mode: string; country: string;
callsign: string; station?: string; qso_date: string; band: string; mode: string; country: string;
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
};
@@ -619,6 +619,10 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
{/* Which of the operator's callsigns the contact was made
under. Only worth a column when the list can hold more than
one — see "All my callsigns" on the download. */}
{shownConfs.some((c) => c.station) && <th className="py-1.5 px-2">{t('qslm.thStation')}</th>}
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
</tr>
@@ -628,6 +632,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
<tr key={i} className="border-b border-border/40">
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
{shownConfs.some((x) => x.station) && (
<td className="py-1 px-2 font-mono text-muted-foreground">{c.station ?? ''}</td>
)}
<td className="py-1 px-2">{c.band}</td>
<td className="py-1 px-2">{c.mode}</td>
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
+29 -17
View File
@@ -81,6 +81,7 @@ import {
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { setUseMiles } from '@/lib/units';
import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
@@ -689,8 +690,7 @@ function TelemetryToggle() {
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={on} disabled={!loaded}
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
{t('settings.telemetry')}
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
<span title={t('settings.telemetryHint')}>{t('settings.telemetry')}</span>
</label>
);
}
@@ -1749,6 +1749,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
// function by the PANELS map, so it must stay hooks-free.
@@ -6777,7 +6778,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
}}
>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectTrigger className="h-8 w-72" title={t('gen.iaruHint')}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
@@ -7147,7 +7148,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const { lang, setLang, t } = useI18n();
return (
<>
<SectionHeader title={t('sec.general')} hint={t('gen.hint')} />
<SectionHeader title={t('sec.general')} />
<div className="space-y-3 max-w-3xl">
<div className="flex items-center gap-3">
<Label className="text-sm w-40">{t('settings.language')}</Label>
@@ -7175,15 +7176,32 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
<button key={code} type="button" title={sample}
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
className={cn('flex flex-col items-start px-3 py-1 text-sm font-medium border-l border-border first:border-l-0',
className={cn('px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0',
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{label}
<span className="text-[10px] font-mono opacity-70">{sample}</span>
</button>
))}
</div>
</div>
<div className="flex items-center gap-2 text-sm">
{/* The IARU region drives the band edges and mode segments the band
maps draw Region 2's 40 m runs to 7300, Region 1's stops at
7200 and nothing else: spots and logging are region-blind. */}
<Label className="text-sm w-40 shrink-0">{t('gen.iaruRegion')}</Label>
<Select value={String(region)} onValueChange={(v) => {
const r = (v === '2' ? 2 : v === '3' ? 3 : 1) as IaruRegion;
setRegion(r); setIaruRegion(r);
}}>
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1">{t('gen.iaruR1')}</SelectItem>
<SelectItem value="2">{t('gen.iaruR2')}</SelectItem>
<SelectItem value="3">{t('gen.iaruR3')}</SelectItem>
</SelectContent>
</Select>
</div>
<ThemeSelector />
<label className="flex items-center gap-2 text-sm cursor-pointer">
@@ -7205,11 +7223,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{/* Super Check Partial / N+1 downloads the community MASTER.SCP list and
shows a two-column callsign helper (partial matches + one-edit calls). */}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('scp.settingsHint')}>
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
{t('scp.enable')}
</label>
<p className="text-[11px] text-muted-foreground">{t('scp.settingsHint')}</p>
{scp.enabled && (
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
@@ -7237,18 +7254,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
<span title={t('gen.groupDigitalHint')}>{t('gen.groupDigital')}</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
{/* Distances are computed in km everywhere and converted at display
time see lib/units. Changing this repaints the columns that
already carry a distance; nothing stored moves. */}
<Checkbox checked={milesUnit} onCheckedChange={(c) => { const v = !!c; setMilesUnit(v); setUseMiles(v); }} />
{t('gen.miles')} <span className="text-xs text-muted-foreground">{t('gen.milesHint')}</span>
{t('gen.miles')}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
{t('gen.checkUpdates')}
</label>
<TelemetryToggle />
@@ -7311,12 +7328,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
}}
className="mt-0.5"
/>
<span>
{t('gen.clubUse')}
<span className="block text-xs text-muted-foreground mt-0.5">
{t('gen.clubDesc')}
</span>
</span>
<span title={t('gen.clubDesc')}>{t('gen.clubUse')}</span>
</label>
<div className="flex items-center gap-3 pl-6">
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
+113
View File
@@ -0,0 +1,113 @@
// IARU band plans, by region.
//
// The band maps used to carry one hard-coded table — Region 1's — so a US or
// Australian operator saw 40 m stop at 7200 while stations sat spotted at 7250,
// and the SSB wash started 25 kHz early. The region is a station-level fact the
// operator states once (Settings → General); everything drawing a band edge or
// a mode segment reads it from here.
//
// The tables are deliberately coarse: a band map's wash is CONTEXT, not a
// regulatory chart. Only the segments that differ between regions in ways an
// operator notices are split out; national fine print (US licence classes,
// beacon slots) does not belong in a background tint.
import { writeUiPref } from '@/lib/uiPref';
export type IaruRegion = 1 | 2 | 3;
export const KEY_IARU_REGION = 'opslog.iaruRegion';
export function iaruRegion(): IaruRegion {
try {
const v = localStorage.getItem(KEY_IARU_REGION);
return v === '2' ? 2 : v === '3' ? 3 : 1;
} catch { return 1; }
}
export function setIaruRegion(r: IaruRegion): void {
writeUiPref(KEY_IARU_REGION, String(r));
listeners.forEach((l) => l());
}
// The band maps capture the plan inside their render, so a change must reach
// them the way the distance unit reaches the grids.
const listeners = new Set<() => void>();
export function subscribeIaruRegion(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
export type SegMode = 'cw' | 'digi' | 'phone';
type Seg = [number, number, SegMode];
interface Plan {
ranges: Record<string, [number, number]>;
segments: Record<string, Seg[]>;
}
// Region 1 — Europe / Africa / Middle East / northern Asia. The baseline the
// other two override.
const R1: Plan = {
ranges: {
'160m': [1800, 2000], '80m': [3500, 3800], '60m': [5350, 5450],
'40m': [7000, 7200], '30m': [10100, 10150], '20m': [14000, 14350],
'17m': [18068, 18168], '15m': [21000, 21450], '12m': [24890, 24990],
'10m': [28000, 29700], '6m': [50000, 50500], '4m': [70000, 70500],
'2m': [144000, 146000], '70cm': [430000, 440000],
},
segments: {
'160m': [[1800, 1838, 'cw'], [1838, 1840, 'digi'], [1840, 2000, 'phone']],
'80m': [[3500, 3580, 'cw'], [3580, 3600, 'digi'], [3600, 3800, 'phone']],
'60m': [[5350, 5450, 'phone']],
'40m': [[7000, 7040, 'cw'], [7040, 7100, 'digi'], [7100, 7200, 'phone']],
'30m': [[10100, 10130, 'cw'], [10130, 10150, 'digi']],
'20m': [[14000, 14070, 'cw'], [14070, 14100, 'digi'], [14100, 14350, 'phone']],
'17m': [[18068, 18095, 'cw'], [18095, 18110, 'digi'], [18110, 18168, 'phone']],
'15m': [[21000, 21070, 'cw'], [21070, 21150, 'digi'], [21150, 21450, 'phone']],
'12m': [[24890, 24915, 'cw'], [24915, 24940, 'digi'], [24940, 24990, 'phone']],
'10m': [[28000, 28070, 'cw'], [28070, 28300, 'digi'], [28300, 29700, 'phone']],
'6m': [[50000, 50100, 'cw'], [50100, 50500, 'phone']],
},
};
// Region 2 — the Americas. 80 m runs to 4000 and 40 m to 7300, with phone from
// 7125 (the whole point of asking the region: a KP4 ragchew on 7250 must not
// hang past the top of the map).
const R2: Plan = {
ranges: {
...R1.ranges,
'80m': [3500, 4000], '40m': [7000, 7300],
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [420000, 450000],
},
segments: {
...R1.segments,
'80m': [[3500, 3570, 'cw'], [3570, 3600, 'digi'], [3600, 4000, 'phone']],
'40m': [[7000, 7040, 'cw'], [7040, 7125, 'digi'], [7125, 7300, 'phone']],
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
},
};
// Region 3 — Asia-Pacific. 80 m to 3900; 40 m as Region 1's shape.
const R3: Plan = {
ranges: {
...R1.ranges,
'80m': [3500, 3900],
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [430000, 450000],
},
segments: {
...R1.segments,
'80m': [[3500, 3535, 'cw'], [3535, 3600, 'digi'], [3600, 3900, 'phone']],
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
},
};
function plan(): Plan {
const r = iaruRegion();
return r === 2 ? R2 : r === 3 ? R3 : R1;
}
export function bandRange(band: string): [number, number] | undefined {
return plan().ranges[band];
}
export function bandSegments(band: string): Seg[] {
return plan().segments[band] ?? [];
}
+6
View File
@@ -130,7 +130,10 @@ const en: Dict = {
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
'wk.catWarnTci': 'The CW keyer is set to the radio, but the CAT backend is "{backend}". Pick TCI in Settings → CAT, or another keying engine.',
'wk.tciHint': "Keying goes through the radio's own macro keyer, over the link already open — no WinKeyer and no second serial port. The radio can stop a message but cannot un-type one, so there is no type-ahead correction here.",
'gen.iaruRegion': 'IARU region', 'gen.iaruHint': '(band plan drawn on the band maps)',
'gen.iaruR1': 'Region 1 — Europe, Africa, Middle East', 'gen.iaruR2': 'Region 2 — Americas', 'gen.iaruR3': 'Region 3 — Asia-Pacific',
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
'qslm.thStation': 'Station',
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
'qslm.lotwDetail': 'QSL details',
'qslm.lotwDetailTitle': 'Ask LoTW for the QSL date and the station details (grid, state, county) as well as the confirmation. LoTW takes about ten times longer to build that report — two minutes against twenty on the same account — and marking a confirmation needs none of it. Forced on when adding the QSOs not found, which have no other source for those fields.',
@@ -630,7 +633,10 @@ const fr: Dict = {
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
'wk.catWarnTci': "Le manipulateur CW est réglé sur la radio, mais le backend CAT est « {backend} ». Choisissez TCI dans Réglages → CAT, ou un autre moteur de manipulation.",
'wk.tciHint': "La manipulation passe par le keyer à macros de la radio, sur la liaison déjà ouverte — ni WinKeyer ni second port série. La radio sait interrompre un message mais pas en effacer la fin, donc pas de correction en frappe anticipée ici.",
'gen.iaruRegion': 'Région IARU', 'gen.iaruHint': '(plan de bande dessiné sur les cartes de bande)',
'gen.iaruR1': 'Région 1 — Europe, Afrique, Moyen-Orient', 'gen.iaruR2': 'Région 2 — Amériques', 'gen.iaruR3': 'Région 3 — Asie-Pacifique',
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
'qslm.thStation': 'Station',
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
'qslm.lotwDetail': 'Détails QSL',
'qslm.lotwDetailTitle': "Demander à LoTW la date du QSL et les détails de la station (locator, état, comté) en plus de la confirmation. LoTW met environ dix fois plus longtemps à construire ce rapport — deux minutes contre vingt sur le même compte — et marquer une confirmation n'en a pas besoin. Forcé quand on ajoute les QSO absents, qui n'ont pas d'autre source pour ces champs.",
+1
View File
@@ -41,6 +41,7 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.distanceMiles', // distances shown in statute miles rather than km
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
+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.26.22';
export const APP_VERSION = '0.26.23';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+26 -2
View File
@@ -165,8 +165,7 @@ func (t *TCI) Connect() error {
if t.spotsEnabled {
debugLog.Printf("TCI: panorama spots are ON — spots will be sent to the radio")
}
_ = t.send("rx_sensors_enable:true,200;")
_ = t.send("tx_sensors_enable:true,200;")
t.subscribeSensors("connect")
if t.spotsEnabled {
// Forget what we thought was on the panorama at the same moment the radio
// is told to drop it. Kept, the memory would suppress the next spot for
@@ -461,6 +460,24 @@ func (t *TCI) SetTXAudioSource(src string) {
}
// send writes a command to the WebSocket (one writer at a time).
// subscribeSensors asks the radio to push its meters. Nothing measures anything
// until this goes out — the S-meter, the transmit power and the SWR are all
// subscription-only (TCI §4.4) — and it is sent at connect AND again at every
// "ready", because a subscription sent during the server's initial dump can be
// dropped. 200 ms is the rate the protocol's own examples use.
func (t *TCI) subscribeSensors(when string) {
// Both cases, deliberately. Every other command this backend sends works in
// lower case, but the meters stayed silent on a real SunSDR through two
// rounds of fixes — and the protocol document's own examples are upper case
// (TX_SENSORS_ENABLE:true,200;). A server that is case-insensitive ignores
// the duplicate; one that is not finally hears the subscription.
e1 := t.send("rx_sensors_enable:true,200;")
e2 := t.send("tx_sensors_enable:true,200;")
_ = t.send("RX_SENSORS_ENABLE:true,200;")
_ = t.send("TX_SENSORS_ENABLE:true,200;")
debugLog.Printf("TCI: sensor subscription sent (%s, both cases): rx=%v tx=%v", when, e1, e2)
}
func (t *TCI) send(cmd string) error {
t.mu.Lock()
c := t.conn
@@ -567,6 +584,13 @@ func (t *TCI) handle(msg string) {
}
case "ready", "start":
t.ready = true
// (Re)subscribe to the meters HERE, not only at connect. ExpertSDR3
// dumps its whole state and then says "ready"; a unidirectional control
// command sent while that dump is still in flight can be ignored, and
// the report from a real SunSDR — transmit meters still empty after the
// connect-time subscription — has exactly that shape. From a goroutine:
// send takes t.mu, which this handler holds.
go t.subscribeSensors("ready")
case "stop":
t.ready = false
case "vfo":
+2 -1
View File
@@ -113,7 +113,8 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
// it. What the radio actually announces after the command settles whether
// this is our reading or its doing, and no amount of reasoning will.
switch name {
case "mute", "sql_enable", "sql_level", "tx_power", "tx_swr", "tune":
case "mute", "sql_enable", "sql_level", "tx_power", "tx_swr", "tune",
"tx_sensors", "rx_sensors", "rx_channel_sensors":
// Logged on arrival so an ANSWER can be told from a SILENCE: the log
// showed the transmit meters being asked for and nothing coming back,
// which on its own proves nothing — a reply that arrived and failed to
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.26.22"
appVersion = "0.26.23"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.