Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dbc773343 | ||
|
|
ec347e1b7a | ||
|
|
b0bbe3e402 | ||
|
|
85061ab673 | ||
|
|
ab68e4a84e | ||
|
|
f6532b2e85 | ||
|
|
8685dbd6cf | ||
|
|
b7c87def5b |
@@ -195,6 +195,7 @@ const (
|
||||
keyAudioQSOPlayGain = "audio.qso_play_gain" // QSO-recording playback level %
|
||||
keyAudioPTTMethod = "audio.ptt_method" // "none" (VOX) | "rts" | "dtr"
|
||||
keyAudioPTTPort = "audio.ptt_port" // COM port for serial PTT
|
||||
keyAudioPTTData = "audio.ptt_data" // keyer audio arrives on the rig DATA/USB input
|
||||
keyAudioFormat = "audio.qso_format" // "wav" | "mp3"
|
||||
keyAudioFromGain = "audio.from_gain" // From Radio (RX) mix level, percent
|
||||
keyAudioMicGain = "audio.mic_gain" // mic mix level, percent
|
||||
@@ -8529,11 +8530,15 @@ type AudioSettings struct {
|
||||
PrerollSeconds int `json:"preroll_seconds"` // rolling pre-roll (default 8)
|
||||
PTTMethod string `json:"ptt_method"` // "none" (VOX) | "rts" | "dtr"
|
||||
PTTPort string `json:"ptt_port"` // COM port for serial PTT
|
||||
Format string `json:"format"` // "wav" | "mp3"
|
||||
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
||||
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
||||
TXGain int `json:"tx_gain"` // voice-keyer playback level %, default 100
|
||||
QSOPlayGain int `json:"qso_play_gain"` // QSO-recording playback level %, default 100
|
||||
// PTTData: the keyer's audio reaches the radio on its DATA/USB input, not
|
||||
// the microphone socket. CAT keying only — it changes which transmit
|
||||
// command is sent (a Kenwood TS-590 takes TX1 instead of TX).
|
||||
PTTData bool `json:"ptt_data"`
|
||||
Format string `json:"format"` // "wav" | "mp3"
|
||||
FromGain int `json:"from_gain"` // From Radio (RX) mix level %, default 100
|
||||
MicGain int `json:"mic_gain"` // mic mix level %, default 100
|
||||
TXGain int `json:"tx_gain"` // voice-keyer playback level %, default 100
|
||||
QSOPlayGain int `json:"qso_play_gain"` // QSO-recording playback level %, default 100
|
||||
}
|
||||
|
||||
// ListAudioInputDevices / ListAudioOutputDevices enumerate WASAPI endpoints
|
||||
@@ -8590,7 +8595,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyAudioFromRadio, keyAudioToRadio, keyAudioRecDevice, keyAudioListenDevice,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioFormat,
|
||||
keyAudioQSORecord, keyAudioQSODir, keyAudioPreroll, keyAudioPTTMethod, keyAudioPTTPort, keyAudioPTTData, keyAudioFormat,
|
||||
keyAudioFromGain, keyAudioMicGain, keyAudioTXGain, keyAudioQSOPlayGain)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -8602,6 +8607,7 @@ func (a *App) GetAudioSettings() (AudioSettings, error) {
|
||||
out.PTTMethod = v
|
||||
}
|
||||
out.PTTPort = m[keyAudioPTTPort]
|
||||
out.PTTData = m[keyAudioPTTData] == "1"
|
||||
out.FromRadio = m[keyAudioFromRadio]
|
||||
out.ToRadio = m[keyAudioToRadio]
|
||||
out.RecordingDevice = m[keyAudioRecDevice]
|
||||
@@ -8672,6 +8678,7 @@ func (a *App) SaveAudioSettings(s AudioSettings) error {
|
||||
keyAudioPreroll: strconv.Itoa(s.PrerollSeconds),
|
||||
keyAudioPTTMethod: pttMethod,
|
||||
keyAudioPTTPort: strings.TrimSpace(s.PTTPort),
|
||||
keyAudioPTTData: boolStr(s.PTTData),
|
||||
keyAudioFormat: format,
|
||||
keyAudioFromGain: strconv.Itoa(s.FromGain),
|
||||
keyAudioMicGain: strconv.Itoa(s.MicGain),
|
||||
@@ -10599,7 +10606,7 @@ func (a *App) pttKey(cfg AudioSettings) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("CAT not initialized")
|
||||
}
|
||||
if err := a.cat.SetPTT(true); err != nil {
|
||||
if err := a.cat.SetPTTSource(true, cfg.PTTData); err != nil {
|
||||
applog.Printf("ptt: CAT SetPTT failed: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
[
|
||||
{
|
||||
"version": "0.27.10",
|
||||
"date": "",
|
||||
"en": [
|
||||
"Widget order (Settings → Appearance): the row to the right of the entry can be rearranged by dragging — the whole row is the handle, and a line shows where it will land. QSO entry and the F1-F5 panel head the list, locked — they are not part of that row and nothing should be allowed to push what you type into behind a rotator dial. A widget you have switched off keeps its place and comes back where you left it, and the main view follows as you drag.",
|
||||
"Rotator, GS-232 over a serial port: the port is opened once and kept, instead of being reopened for every command. An Arduino-based controller — K3NG’s firmware, the ERC family — RESETS when its serial port is opened, so OpsLog was rebooting it several times a second and every command landed in the bootloader: a controller that answered a terminal perfectly reported “no reply to C” here. A freshly opened port is now left to boot before the first command, stale bytes from a previous exchange are discarded, and a failed exchange releases the port so the next one starts clean."
|
||||
],
|
||||
"fr": [
|
||||
"Ordre des widgets (Réglages → Apparence) : la rangée à droite de la saisie se réorganise par glisser-déposer — toute la ligne se saisit, et un trait montre où elle atterrira. La saisie du QSO et le panneau F1-F5 ouvrent la liste, verrouillés — ils ne font pas partie de cette rangée, et rien ne doit pouvoir repousser ce dans quoi vous tapez derrière une boussole de rotor. Un widget désactivé garde sa place et revient là où vous l’aviez laissé, et la vue principale suit pendant que vous glissez.",
|
||||
"Rotor, GS-232 sur port série : le port est ouvert une fois et conservé, au lieu d’être rouvert à chaque commande. Un contrôleur à base d’Arduino — le firmware K3NG, la famille ERC — REDÉMARRE à l’ouverture de son port série : OpsLog le redémarrait donc plusieurs fois par seconde et chaque commande tombait dans le bootloader. Un contrôleur qui répondait parfaitement à un terminal annonçait ici « no reply to C ». Un port fraîchement ouvert a désormais le temps de démarrer avant la première commande, les octets résiduels d’un échange précédent sont écartés, et un échange en échec libère le port pour que le suivant reparte propre."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.9",
|
||||
"date": "",
|
||||
"en": [
|
||||
"FT decodes warn when the decoding application announces a band the radio is not on — the signature of a lost CAT link, where it repeats the last frequency it knew and every decode after that carries a stale band. Nothing downstream could tell, so NEW BAND was being judged against a band the operator had left. OpsLog says it rather than deciding: a second receiver on another band is a real setup, and it costs that one only a line to read past.",
|
||||
"Voice keyer with CAT keying: an option saying the keyer’s audio arrives on the radio’s DATA / USB input rather than the microphone socket. A Kenwood TS-590 has two transmit commands — TX opens the front mic, TX1 the rear ACC2/USB — so a keyer playing through the rig’s own sound card was transmitting dead air while the radio listened to a microphone nobody was speaking into. Shown on the Kenwood backend only — no other radio family draws the distinction — and the Test PTT button exercises the same path."
|
||||
],
|
||||
"fr": [
|
||||
"Les FT decodes signalent quand le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — la signature d’une liaison CAT perdue, où il répète la dernière fréquence connue et où tous les décodages suivants portent une bande périmée. Rien en aval ne pouvait s’en apercevoir : NOUVELLE BANDE était donc jugé sur une bande quittée. OpsLog le dit sans décider à votre place : un second récepteur sur une autre bande est une configuration légitime, et il ne lui en coûte qu’une ligne à ignorer.",
|
||||
"Voice keyer avec PTT CAT : une option indiquant que l’audio du keyer arrive sur l’entrée DATA / USB de la radio et non sur la prise micro. Un Kenwood TS-590 a deux commandes d’émission — TX ouvre le micro de face avant, TX1 l’ACC2/USB — si bien qu’un keyer jouant par la carte son du poste émettait dans le vide pendant que la radio écoutait un micro devant lequel personne ne parlait. Affichée sur le backend Kenwood uniquement — aucune autre famille de postes ne fait cette distinction — et le bouton Test PTT emprunte le même chemin."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.8",
|
||||
"date": "",
|
||||
|
||||
+42
-12
@@ -59,6 +59,7 @@ import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||
import { ListRadios, SetActiveRadio } from '../wailsjs/go/main/App';
|
||||
import { formatDistance } from '@/lib/units';
|
||||
import { WIDGET_KEYS } from '@/components/AppearancePanel';
|
||||
import { bandForMHz } from '@/lib/bandplan';
|
||||
import { EventsOn, BrowserOpenURL, WindowMinimise, WindowToggleMaximise, WindowIsMaximised, Quit } from '../wailsjs/runtime/runtime';
|
||||
import type { adif as adifModels, lookup as lookupModels, cat as catModels } from '../wailsjs/go/models';
|
||||
@@ -761,6 +762,32 @@ export default function App() {
|
||||
// Several amps side by side make a wide widget, so an operator running two
|
||||
// picks the one he watches while transmitting. Declared here because the poll
|
||||
// below runs faster while the widget is open.
|
||||
// Widget order (Settings → Appearance). The row is a flex container, so the
|
||||
// ORDER property moves a widget without touching the tree: every condition,
|
||||
// every ref and every hook stays where it was, and a widget that is switched
|
||||
// off simply is not there to be ordered.
|
||||
//
|
||||
// QSO entry and the F1-F5 panel are not in this list on purpose — they are
|
||||
// not in this row at all, and an operator cannot be allowed to push the thing
|
||||
// they type into behind a rotator dial.
|
||||
const [widgetOrder, setWidgetOrder] = useState<string[]>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('opslog.widgetOrder');
|
||||
const arr = raw ? JSON.parse(raw) : null;
|
||||
if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr;
|
||||
} catch { /* corrupt pref → the default order */ }
|
||||
return [...WIDGET_KEYS];
|
||||
});
|
||||
useEffect(() => EventsOn('widgets:order', (keys: any) => {
|
||||
if (Array.isArray(keys)) setWidgetOrder(keys.filter((k: any) => typeof k === 'string'));
|
||||
}), []);
|
||||
// A key the saved order has never heard of (a widget added since) goes to the
|
||||
// end rather than to the front, where it would jump the queue on every
|
||||
// upgrade.
|
||||
const wOrder = (k: string) => {
|
||||
const i = widgetOrder.indexOf(k);
|
||||
return i < 0 ? WIDGET_KEYS.length + (WIDGET_KEYS as readonly string[]).indexOf(k) : i;
|
||||
};
|
||||
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
|
||||
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
|
||||
// Poll fast only while the amplifier widget is open: its meters must track TX
|
||||
@@ -6328,6 +6355,9 @@ export default function App() {
|
||||
txState={txState}
|
||||
txStates={txStates}
|
||||
spotStatus={spotStatus as any}
|
||||
// Only while CAT is actually connected: an empty band means "nothing to
|
||||
// compare with", never "the rig is on no band".
|
||||
rigBand={catState.connected ? (catState.band || '') : ''}
|
||||
myCall={station.callsign}
|
||||
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
|
||||
// a Reply, which is the same thing as double-clicking the line in their
|
||||
@@ -7437,7 +7467,7 @@ export default function App() {
|
||||
{/* Multi-op "who's on air" widget: every operator on the shared logbook,
|
||||
their freq/mode (colour-coded) and OpsLog version. */}
|
||||
{showLiveStations && dbConn?.backend === 'mysql' && (
|
||||
<div className="w-[248px] shrink-0 min-h-0 relative">
|
||||
<div className="w-[248px] shrink-0 min-h-0 relative" style={{ order: wOrder('livestations') }}>
|
||||
<div className="absolute inset-0 flex flex-col min-h-0 rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-3 h-8 border-b border-border shrink-0">
|
||||
<Radio className="size-3.5 text-primary" />
|
||||
@@ -7481,7 +7511,7 @@ export default function App() {
|
||||
// relative + absolute inner: the chat takes the row height (set by the
|
||||
// entry strip) WITHOUT its message list growing the row, like the
|
||||
// Stats panel. The list scrolls inside this fixed height.
|
||||
<div className="w-[280px] shrink-0 min-h-0 relative">
|
||||
<div className="w-[280px] shrink-0 min-h-0 relative" style={{ order: wOrder('chat') }}>
|
||||
<div className="absolute inset-0 flex flex-col min-h-0">
|
||||
<ChatPanel msgs={chatMsgs} online={chatOnline} myCall={station.callsign}
|
||||
onSend={chatSend} onClose={() => setChatOpen(false)} />
|
||||
@@ -7493,7 +7523,7 @@ export default function App() {
|
||||
controls column, so the widget is just the dial and needs only its
|
||||
width. */}
|
||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')}>
|
||||
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}>
|
||||
<RotorCompass
|
||||
presets={rotorCompact ? undefined : rotorPresets}
|
||||
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
@@ -7513,7 +7543,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{showMotorAnt && ubStatus.enabled && (
|
||||
<div className="w-[230px] shrink-0 min-h-0">
|
||||
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('motorant') }}>
|
||||
<MotorAntennaWidget
|
||||
ant={ubStatus}
|
||||
refetch={pokeUbStatus}
|
||||
@@ -7524,7 +7554,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{showAntGenius && agEnabled && (
|
||||
<div className="w-[230px] shrink-0 min-h-0">
|
||||
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('antgenius') }}>
|
||||
<AntGeniusPanel
|
||||
status={agStatus}
|
||||
onActivate={agActivate}
|
||||
@@ -7537,7 +7567,7 @@ export default function App() {
|
||||
// One column per amplifier shown, so two amps stand side by side
|
||||
// rather than making the widget twice as tall as the dock row.
|
||||
<div className="shrink-0 min-h-0"
|
||||
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}>
|
||||
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px`, order: wOrder('amp') }}>
|
||||
<AmpWidget
|
||||
amps={ampSts}
|
||||
flex={flexAmp}
|
||||
@@ -7547,7 +7577,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{showTuner && tgEnabled && (
|
||||
<div className="w-[230px] shrink-0 min-h-0">
|
||||
<div className="w-[230px] shrink-0 min-h-0" style={{ order: wOrder('tuner') }}>
|
||||
<TunerGeniusPanel
|
||||
status={tgStatus}
|
||||
onTune={tgTune}
|
||||
@@ -7559,7 +7589,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{showScp && scpEnabled && (
|
||||
<div className="w-[240px] shrink-0 min-h-0">
|
||||
<div className="w-[240px] shrink-0 min-h-0" style={{ order: wOrder('scp') }}>
|
||||
<ScpPanel
|
||||
result={scpResult}
|
||||
currentCall={callsign}
|
||||
@@ -7570,7 +7600,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{chaseNewOn && showChaseNew && (
|
||||
<div className="w-[420px] shrink-0 min-h-0">
|
||||
<div className="w-[420px] shrink-0 min-h-0" style={{ order: wOrder('chasenew') }}>
|
||||
{/* Same reflex as clicking a cluster spot: the callsign into the
|
||||
entry, and the rig onto the frequency it was decoded on. */}
|
||||
<ChaseNewPanel onPick={(sp) => {
|
||||
@@ -7583,7 +7613,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{dvkEnabled && (
|
||||
<div className="w-[320px] shrink-0 min-h-0">
|
||||
<div className="w-[320px] shrink-0 min-h-0" style={{ order: wOrder('dvk') }}>
|
||||
<DvkPanel
|
||||
messages={dvkMsgs}
|
||||
status={dvkStat}
|
||||
@@ -7599,7 +7629,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{wkEnabled && (
|
||||
<div className="w-[380px] shrink-0 min-h-0">
|
||||
<div className="w-[380px] shrink-0 min-h-0" style={{ order: wOrder('winkeyer') }}>
|
||||
<WinkeyerPanel
|
||||
// A rig keyer has no serial status of its own: it is connected
|
||||
// exactly when its CAT backend is. Yaesu was missing from this
|
||||
@@ -7643,7 +7673,7 @@ export default function App() {
|
||||
{/* QRZ photo: when the keyer is open it sits to its right at natural
|
||||
(capped) width, shrinking the keyer panel rather than hiding it. */}
|
||||
{lookupResult?.image_url && (
|
||||
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')}>
|
||||
<div className={cn('min-w-0 flex items-center', (wkEnabled || dvkEnabled) ? 'shrink-0' : 'flex-1')} style={{ order: wOrder('photo') }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lookupResult.image_url && setPhotoModal(lookupResult.image_url)}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { EventsEmit } from '../../wailsjs/runtime/runtime';
|
||||
import { GripVertical, Lock } from 'lucide-react';
|
||||
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -290,6 +292,116 @@ export function AppearancePanel() {
|
||||
)}
|
||||
|
||||
<MatrixColorsSection />
|
||||
<WidgetOrderSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The row of widgets to the right of the entry, in the order they appear.
|
||||
//
|
||||
// Flexbox does the moving in the main view — this list only decides the order
|
||||
// property each one gets. That is why a widget switched OFF still holds its
|
||||
// place here: it comes back where the operator left it rather than at the end.
|
||||
export const WIDGET_KEYS = [
|
||||
'livestations', 'chat', 'rotor', 'motorant', 'antgenius',
|
||||
'amp', 'tuner', 'scp', 'chasenew', 'dvk', 'winkeyer', 'photo',
|
||||
] as const;
|
||||
|
||||
const WIDGET_LABELS: Record<string, string> = {
|
||||
livestations: 'wo.livestations', chat: 'wo.chat', rotor: 'wo.rotor',
|
||||
motorant: 'wo.motorant', antgenius: 'wo.antgenius', amp: 'wo.amp',
|
||||
tuner: 'wo.tuner', scp: 'wo.scp', chasenew: 'wo.chasenew',
|
||||
dvk: 'wo.dvk', winkeyer: 'wo.winkeyer', photo: 'wo.photo',
|
||||
};
|
||||
|
||||
function readWidgetOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('opslog.widgetOrder');
|
||||
const arr = raw ? JSON.parse(raw) : null;
|
||||
if (Array.isArray(arr)) {
|
||||
// A key from an older build that no longer exists is dropped; a widget
|
||||
// added since joins the end. An old preference can never hide a new one.
|
||||
const known = arr.filter((k: any) => (WIDGET_KEYS as readonly string[]).includes(k));
|
||||
return [...known, ...WIDGET_KEYS.filter((k) => !known.includes(k))];
|
||||
}
|
||||
} catch { /* corrupt pref → the default order */ }
|
||||
return [...WIDGET_KEYS];
|
||||
}
|
||||
|
||||
function WidgetOrderSection() {
|
||||
const { t } = useI18n();
|
||||
const [order, setOrder] = useState<string[]>(readWidgetOrder);
|
||||
const dragKey = useRef<string | null>(null);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
// Where the row would land. Drawn as a line above the target rather than by
|
||||
// colouring it: the question a dragging hand asks is "between which two", and
|
||||
// a highlighted row answers a different one.
|
||||
const [over, setOver] = useState<string | null>(null);
|
||||
|
||||
const commit = (keys: string[]) => {
|
||||
setOrder(keys);
|
||||
try { localStorage.setItem('opslog.widgetOrder', JSON.stringify(keys)); } catch { /* private mode */ }
|
||||
// The main view listens: an order is meant to be watched as it is dragged,
|
||||
// not discovered after closing Preferences.
|
||||
EventsEmit('widgets:order', keys);
|
||||
};
|
||||
const moveTo = (from: string, to: string) => {
|
||||
if (from === to) return;
|
||||
const next = order.filter((k) => k !== from);
|
||||
const at = next.indexOf(to);
|
||||
next.splice(at < 0 ? next.length : at, 0, from);
|
||||
commit(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('wo.title')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('wo.hint')}</p>
|
||||
<div className="space-y-1 max-w-md">
|
||||
{/* The two that cannot move, shown so the order reads as the whole row
|
||||
rather than as a list that mysteriously starts at the third item. */}
|
||||
{['wo.entry', 'wo.details'].map((k) => (
|
||||
<div key={k}
|
||||
className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-sm text-muted-foreground">
|
||||
<Lock className="size-3.5 shrink-0 opacity-60" />
|
||||
<span className="flex-1 min-w-0 truncate">{t(k)}</span>
|
||||
</div>
|
||||
))}
|
||||
{order.map((k) => (
|
||||
// The WHOLE row is the handle, not the grip alone: a list whose rows
|
||||
// can only be moved by a 16-pixel icon is a list most people conclude
|
||||
// cannot be moved. The grip stays as the sign that it can.
|
||||
<div key={k} draggable
|
||||
onDragStart={(e) => { dragKey.current = k; setDragging(k); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragEnd={() => { dragKey.current = null; setDragging(null); setOver(null); }}
|
||||
onDragOver={(e) => {
|
||||
if (!dragKey.current) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (over !== k) setOver(k);
|
||||
}}
|
||||
onDragLeave={() => { if (over === k) setOver(null); }}
|
||||
onDrop={(e) => {
|
||||
if (!dragKey.current) return;
|
||||
e.preventDefault();
|
||||
moveTo(dragKey.current, k);
|
||||
setOver(null);
|
||||
}}
|
||||
title={t('wo.drag')}
|
||||
className={cn('flex items-center gap-2 rounded-md border bg-card px-2 py-1.5 text-sm select-none',
|
||||
'cursor-grab active:cursor-grabbing transition-shadow',
|
||||
dragging === k ? 'opacity-50 border-primary shadow-lg' : 'border-border hover:border-foreground/30',
|
||||
// The landing line, on the edge the row would take.
|
||||
over === k && dragging !== k && 'shadow-[inset_0_3px_0_0_var(--primary)]')}>
|
||||
<GripVertical className="size-4 shrink-0 text-muted-foreground/50" />
|
||||
<span className="flex-1 min-w-0 truncate">{t(WIDGET_LABELS[k] ?? k)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" onClick={() => commit([...WIDGET_KEYS])}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline">
|
||||
{t('wo.reset')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// come from the same resolver the cluster uses, so a call means the same thing in
|
||||
// both panels rather than being judged twice by two rules.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||
import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, Ban, Columns2, Bot } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { chaseAllows } from '@/lib/spotDisplay';
|
||||
@@ -95,6 +95,9 @@ interface Props {
|
||||
// receiver reported last, which is a coin toss — each pane needs its own.
|
||||
txStates?: Record<string, TxMsg>;
|
||||
spotStatus: Record<string, StatusEntry>;
|
||||
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
||||
// the decoder announces — see the drift warning.
|
||||
rigBand?: string;
|
||||
onCall: (d: Decode) => void;
|
||||
myCall?: string;
|
||||
// Drop every decode and transmit message held for this panel. The list is a
|
||||
@@ -539,7 +542,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Column widths, dragged in the header and shared by every row. Persisted
|
||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||
@@ -597,6 +600,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
|
||||
// The mode currently on the air, for the slot clock. The newest decode knows
|
||||
// best; between overs the transmit state still does.
|
||||
// A decoder that has lost its CAT link keeps announcing the last dial
|
||||
// frequency it knew, and every decode after that carries a stale band. Nothing
|
||||
// downstream can tell: the entity verdicts, the band filter and the FT map all
|
||||
// believe what the decoder said, and an operator ends up reading NEW BAND for a
|
||||
// band they are not on. (Seen for real: MSHV lost CAT, kept saying 80 m, and
|
||||
// Korea showed as a new band because on 80 m it would have been.)
|
||||
//
|
||||
// Said, not decided. Using the rig's band instead would be wrong for anyone
|
||||
// decoding a second receiver on another band, and a warning costs that setup
|
||||
// nothing but a line it can read past.
|
||||
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
|
||||
const bandDrift = !!rigBand && !!decoderBand
|
||||
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
|
||||
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
||||
|
||||
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
||||
const liveTr = trSeconds(liveMode, decodes.length ? decodes[decodes.length - 1].tr_period : undefined);
|
||||
|
||||
@@ -733,6 +751,18 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
what the transmit state reports, so it is right the moment anything
|
||||
is heard and keeps running when the band goes quiet. */}
|
||||
<PeriodClock trSec={liveTr} mode={liveMode} />
|
||||
{bandDrift && (
|
||||
<span
|
||||
title={t('dec.bandDriftTip')}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
{t('dec.bandDrift', {
|
||||
app: driftInstance || t('dec.bandDriftApp'),
|
||||
dec: decoderBand.toUpperCase(),
|
||||
rig: (rigBand ?? '').toUpperCase(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span className="w-px h-5 bg-border/60 mx-1" />
|
||||
|
||||
<button type="button" className={chip(cqOnly, 'success')} onClick={() => setCqOnly(!cqOnly)}>
|
||||
|
||||
@@ -1702,13 +1702,13 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
type AudioSettings = {
|
||||
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
|
||||
qso_record: boolean; qso_dir: string; preroll_seconds: number;
|
||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
|
||||
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; ptt_data?: boolean; format: 'wav' | 'mp3';
|
||||
from_gain: number; mic_gain: number; tx_gain: number; qso_play_gain: number;
|
||||
};
|
||||
type AudioDev = { id: string; name: string; default: boolean };
|
||||
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
|
||||
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
|
||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
|
||||
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', ptt_data: false, format: 'wav',
|
||||
from_gain: 100, mic_gain: 100, tx_gain: 100, qso_play_gain: 100,
|
||||
});
|
||||
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
|
||||
@@ -7251,6 +7251,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Kenwood only, because only a Kenwood acts on it: TX1 is that
|
||||
family's second transmit command. Every other backend keys the
|
||||
one way it knows, so showing the box there would be a switch
|
||||
that changes nothing — the same dead furniture as ANT2 on a
|
||||
radio with one socket. */}
|
||||
{audioCfg.ptt_method === 'cat' && catCfg.backend === 'kenwood' && (
|
||||
<>
|
||||
<span />
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer" title={t('aud.pttDataHint')}>
|
||||
<Checkbox className="mt-0.5" checked={!!audioCfg.ptt_data}
|
||||
onCheckedChange={(c) => setAudioField({ ptt_data: !!c })} />
|
||||
<span>{t('aud.pttData')}</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
|
||||
<>
|
||||
<Label className="text-sm">{t('aud.pttPort')}</Label>
|
||||
|
||||
@@ -131,7 +131,7 @@ const en: Dict = {
|
||||
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
||||
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
|
||||
'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)',
|
||||
'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.ruleToSend': 'To be sent', 'appr.ruleConfirmed': 'Confirmed', 'appr.ruleSent': 'QSL sent', 'appr.ruleWorked': 'Worked, nothing sent', 'appr.chQsl': 'Paper QSL', 'wo.title': 'Widget order', 'wo.hint': 'The row to the right of the entry, in the order it appears. Drag to rearrange. A widget you have switched off keeps its place and comes back where you left it.', 'wo.drag': 'Drag to move', 'wo.reset': 'Reset to the default order', 'wo.entry': 'QSO entry', 'wo.details': 'Extra information (F1-F5)', 'wo.livestations': 'Who is on air', 'wo.chat': 'Chat', 'wo.rotor': 'Rotator compass', 'wo.motorant': 'Motorised antenna', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplifier', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'CW keyer', 'wo.photo': 'Operator photo', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'appr.zebra': 'Alternate row colours in Recent QSOs', 'appr.zebraHint': '(one row in two on a slightly different background — switch it off and every row is the same colour)', 'appr.zebraColor': 'Alternate row', 'appr.zebraAuto': 'Follow the theme', 'appr.bandmapLotw': 'Mark LoTW users on the band map', 'appr.bandmapLotwHint': '(the same L badge the cluster list uses)',
|
||||
'appr.matrixEnable': 'Choose the band/mode matrix colours', 'appr.matrixHint': '(the PH/CW/DIG grid in Stats — off, each theme uses its own)',
|
||||
'appr.matrixSample': 'Sample', 'appr.matrixReset': 'Back to the theme’s colours',
|
||||
// Matrix legend + colour names. One set of labels for the grid's legend, its
|
||||
@@ -162,7 +162,7 @@ const en: Dict = {
|
||||
'mx.tipThisCall': 'already worked with this callsign',
|
||||
'mx.tipThisCallConf': 'already confirmed with this callsign',
|
||||
// FTx decodes panel (Tools -> FT decodes)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only',
|
||||
'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents',
|
||||
'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message',
|
||||
'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX',
|
||||
@@ -535,7 +535,7 @@ const en: Dict = {
|
||||
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
|
||||
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
|
||||
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
|
||||
'aud.dvkTitle': 'Voice keyer messages (F1–F12)', 'aud.deleteMsg': 'Delete this message', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
||||
'aud.dvkTitle': 'Voice keyer messages (F1–F12)', 'aud.deleteMsg': 'Delete this message', 'aud.pttData': 'Kenwood: the keyer’s audio arrives on the DATA / USB input', 'aud.pttDataHint': 'Sends TX1 (ACC2/USB) instead of TX (front microphone) — the TS-590 family’s second transmit command. Without it the radio transmits while listening to a microphone nobody is speaking into. Kenwood only: no other backend has the distinction.', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
||||
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
|
||||
'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop',
|
||||
'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ',
|
||||
@@ -651,7 +651,7 @@ const fr: Dict = {
|
||||
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
||||
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
||||
'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)',
|
||||
'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.ruleToSend': 'À envoyer', 'appr.ruleConfirmed': 'Confirmé', 'appr.ruleSent': 'QSL envoyée', 'appr.ruleWorked': 'Contacté, rien envoyé', 'appr.chQsl': 'QSL papier', 'wo.title': 'Ordre des widgets', 'wo.hint': 'La rangée à droite de la saisie, dans son ordre d’affichage. Glissez pour réorganiser. Un widget désactivé garde sa place et revient là où vous l’aviez laissé.', 'wo.drag': 'Glisser pour déplacer', 'wo.reset': 'Rétablir l’ordre par défaut', 'wo.entry': 'Saisie du QSO', 'wo.details': 'Informations complémentaires (F1-F5)', 'wo.livestations': 'Qui est à l’air', 'wo.chat': 'Chat', 'wo.rotor': 'Boussole rotor', 'wo.motorant': 'Antenne motorisée', 'wo.antgenius': 'Antenna Genius', 'wo.amp': 'Amplificateur', 'wo.tuner': 'Tuner Genius', 'wo.scp': 'Super Check Partial', 'wo.chasenew': 'Chase new', 'wo.dvk': 'Voice keyer', 'wo.winkeyer': 'Manipulateur CW', 'wo.photo': 'Photo de l’opérateur', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'appr.zebra': 'Alterner la couleur des lignes dans QSO récents', 'appr.zebraHint': '(une ligne sur deux sur un fond légèrement différent — désactive et toutes les lignes ont la même couleur)', 'appr.zebraColor': 'Ligne alternée', 'appr.zebraAuto': 'Suivre le thème', 'appr.bandmapLotw': 'Marquer les utilisateurs LoTW sur la band map', 'appr.bandmapLotwHint': '(le même badge L que la liste du cluster)',
|
||||
'appr.matrixEnable': 'Choisir les couleurs de la matrice bandes/modes', 'appr.matrixHint': '(la grille PH/CW/DIG des Stats — décoché, chaque thème garde les siennes)',
|
||||
'appr.matrixSample': 'Aperçu', 'appr.matrixReset': 'Revenir aux couleurs du thème',
|
||||
// Légende de la matrice + noms des couleurs. Un seul jeu de libellés pour la
|
||||
@@ -682,7 +682,7 @@ const fr: Dict = {
|
||||
'mx.tipThisCall': 'déjà contacté avec cet indicatif',
|
||||
'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',
|
||||
// Panneau des decodes FTx (Outils -> Decodes FT)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement',
|
||||
'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents',
|
||||
'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message',
|
||||
'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX',
|
||||
@@ -1037,7 +1037,7 @@ const fr: Dict = {
|
||||
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
|
||||
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
|
||||
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
|
||||
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F12)', 'aud.deleteMsg': 'Supprimer ce message', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
||||
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F12)', 'aud.deleteMsg': 'Supprimer ce message', 'aud.pttData': 'Kenwood : l’audio du keyer arrive sur l’entrée DATA / USB', 'aud.pttDataHint': 'Envoie TX1 (ACC2/USB) au lieu de TX (micro de face avant) — la seconde commande d’émission de la famille TS-590. Sans cela la radio émet en écoutant un micro devant lequel personne ne parle. Kenwood uniquement : aucun autre backend n’a cette distinction.', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
||||
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
|
||||
'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter',
|
||||
'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ',
|
||||
|
||||
@@ -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.27.8';
|
||||
export const APP_VERSION = '0.27.10';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
@@ -1924,6 +1924,7 @@ export namespace main {
|
||||
preroll_seconds: number;
|
||||
ptt_method: string;
|
||||
ptt_port: string;
|
||||
ptt_data: boolean;
|
||||
format: string;
|
||||
from_gain: number;
|
||||
mic_gain: number;
|
||||
@@ -1945,6 +1946,7 @@ export namespace main {
|
||||
this.preroll_seconds = source["preroll_seconds"];
|
||||
this.ptt_method = source["ptt_method"];
|
||||
this.ptt_port = source["ptt_port"];
|
||||
this.ptt_data = source["ptt_data"];
|
||||
this.format = source["format"];
|
||||
this.from_gain = source["from_gain"];
|
||||
this.mic_gain = source["mic_gain"];
|
||||
|
||||
@@ -256,6 +256,33 @@ func (m *Manager) SetPTT(on bool) error {
|
||||
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
||||
}
|
||||
|
||||
// dataPTTSetter is implemented by a backend that can key the DATA input rather
|
||||
// than the microphone. A Kenwood TS-590 has two transmit commands and takes its
|
||||
// audio from a different socket for each: TX (or TX0) opens the front mic, TX1
|
||||
// the rear ACC2/USB. Send the wrong one and the radio transmits in silence,
|
||||
// because the audio arriving on USB is simply not the input it is listening to.
|
||||
type dataPTTSetter interface {
|
||||
SetPTTData(on bool) error
|
||||
}
|
||||
|
||||
// SetPTTSource keys the transmitter, saying WHERE the audio is coming from.
|
||||
//
|
||||
// data=true means "the audio reaches the radio on its data/USB input" — what a
|
||||
// voice keyer playing through the rig's own sound card needs. A backend that
|
||||
// draws no distinction (every rig where one PTT is all there is) falls back to
|
||||
// the ordinary key, so nothing changes for it.
|
||||
func (m *Manager) SetPTTSource(on, data bool) error {
|
||||
if !data {
|
||||
return m.SetPTT(on)
|
||||
}
|
||||
return m.exec(func(b Backend) error {
|
||||
if d, ok := b.(dataPTTSetter); ok {
|
||||
return d.SetPTTData(on)
|
||||
}
|
||||
return b.SetPTT(on)
|
||||
})
|
||||
}
|
||||
|
||||
// splitSetter is implemented by the backends that can arm split AND place the
|
||||
// transmit frequency. Both together: arming without setting the dial transmits
|
||||
// on whatever the transmit VFO happened to hold, which is worse than refusing.
|
||||
|
||||
@@ -632,6 +632,28 @@ func (k *Kenwood) SetPTT(on bool) error {
|
||||
return k.write("RX;")
|
||||
}
|
||||
|
||||
// SetPTTData keys the transmitter on the DATA input: TX1 on a TS-590, which is
|
||||
// ACC2/USB rather than the front microphone. The radio's own manual is explicit
|
||||
// that the parameter chooses the input — "0: SEND (normal transmission using
|
||||
// the MIC input), 1: DATA SEND (ACC2/USB input)" — so a voice keyer playing
|
||||
// into the rig's USB codec has to say TX1 or it transmits dead air while the
|
||||
// radio listens to a microphone nobody is speaking into.
|
||||
//
|
||||
// Unkeying is the same RX either way; there is no data-flavoured stop.
|
||||
func (k *Kenwood) SetPTTData(on bool) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
}
|
||||
k.tx = on
|
||||
if on {
|
||||
k.txAt = time.Now()
|
||||
return k.write("TX1;")
|
||||
}
|
||||
return k.write("RX;")
|
||||
}
|
||||
|
||||
func (k *Kenwood) write(cmd string) error {
|
||||
if k.port == nil {
|
||||
return fmt.Errorf("kenwood: not connected")
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
@@ -71,32 +72,99 @@ func NewSerial(comPort string, baud int) *Client {
|
||||
return &Client{ComPort: comPort, Baud: baud}
|
||||
}
|
||||
|
||||
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
||||
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
|
||||
// reply line.
|
||||
// bootSettle is how long a freshly opened serial port is left alone before the
|
||||
// first command.
|
||||
//
|
||||
// An Arduino-based controller — K3NG's firmware, the ERC family — RESETS when
|
||||
// the serial port is opened: the DTR line pulses its reset pin, and the
|
||||
// bootloader then holds the processor for a second or more. A command sent into
|
||||
// that window is simply lost, which is exactly how a controller that answers
|
||||
// PuTTY perfectly reports "no reply" here.
|
||||
const bootSettle = 2 * time.Second
|
||||
|
||||
// heldPort is an open serial port, kept between calls.
|
||||
//
|
||||
// The package holds it rather than the Client because the callers build a FRESH
|
||||
// Client for every poll (one per heading request), and the port has to outlive
|
||||
// them. Reopening per command is what made an Arduino controller reboot several
|
||||
// times a second and never answer anything. A serial port is a single-owner
|
||||
// resource in any case: two clients for COM5 would be two handles on one cable.
|
||||
type heldPort struct {
|
||||
p serial.Port
|
||||
openedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
portsMu sync.Mutex
|
||||
openPorts = map[string]*heldPort{}
|
||||
)
|
||||
|
||||
// acquire returns the open port for com, opening it if needed.
|
||||
func acquire(com string, baud int) (*heldPort, error) {
|
||||
portsMu.Lock()
|
||||
defer portsMu.Unlock()
|
||||
if h, ok := openPorts[com]; ok && h.p != nil {
|
||||
return h, nil
|
||||
}
|
||||
if baud <= 0 {
|
||||
baud = 9600
|
||||
}
|
||||
sp, err := serial.Open(com, &serial.Mode{BaudRate: baud})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open rotator %s @ %d baud: %w", com, baud, err)
|
||||
}
|
||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||
h := &heldPort{p: sp, openedAt: time.Now()}
|
||||
openPorts[com] = h
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// drop closes and forgets a port, so the next call opens a fresh one. Called
|
||||
// when an exchange fails: a half-spoken conversation is worse than a new one.
|
||||
func drop(com string) {
|
||||
portsMu.Lock()
|
||||
defer portsMu.Unlock()
|
||||
if h, ok := openPorts[com]; ok {
|
||||
if h.p != nil {
|
||||
_ = h.p.Close()
|
||||
}
|
||||
delete(openPorts, com)
|
||||
}
|
||||
}
|
||||
|
||||
// roundTrip sends one CR-terminated command and (when wantReply) reads one
|
||||
// CR/LF-terminated reply line. Serial keeps its port open between calls; TCP
|
||||
// dials per call, which is what the ARCO's LAN side expects.
|
||||
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||
var conn io.ReadWriteCloser
|
||||
if c.ComPort != "" {
|
||||
baud := c.Baud
|
||||
if baud <= 0 {
|
||||
baud = 9600
|
||||
}
|
||||
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: baud})
|
||||
h, err := acquire(c.ComPort, c.Baud)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open rotator %s @ %d baud: %w", c.ComPort, baud, err)
|
||||
return "", err
|
||||
}
|
||||
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||
conn = sp
|
||||
// Let a just-reset controller finish booting before speaking to it.
|
||||
if wait := bootSettle - time.Since(h.openedAt); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
conn = h.p
|
||||
// Whatever is already in the buffer belongs to the last exchange — the
|
||||
// trailing LF of the previous reply, or a line the controller volunteered
|
||||
// while nobody was reading. Read as the answer to THIS command it would
|
||||
// be an answer to the wrong question.
|
||||
drain(h.p)
|
||||
} else {
|
||||
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
|
||||
}
|
||||
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||
defer nc.Close()
|
||||
conn = nc
|
||||
}
|
||||
defer conn.Close()
|
||||
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
|
||||
if c.ComPort != "" {
|
||||
drop(c.ComPort)
|
||||
}
|
||||
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||
}
|
||||
if !wantReply {
|
||||
@@ -121,11 +189,29 @@ func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||
}
|
||||
line := strings.TrimSpace(sb.String())
|
||||
if line == "" {
|
||||
// Silence may mean the port is fine and the controller is not, or that
|
||||
// the handle is stale (a USB adapter unplugged and replugged). Let go of
|
||||
// it so the next attempt starts from a clean open rather than repeating
|
||||
// the same silence for ever.
|
||||
if c.ComPort != "" {
|
||||
drop(c.ComPort)
|
||||
}
|
||||
return "", fmt.Errorf("no reply to %q", cmd)
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// drain empties whatever is waiting, without blocking for long.
|
||||
func drain(sp serial.Port) {
|
||||
buf := make([]byte, 128)
|
||||
for i := 0; i < 4; i++ {
|
||||
n, err := sp.Read(buf)
|
||||
if n == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
|
||||
// (overlap rotators accept >360); we normalise to [0,360).
|
||||
func (c *Client) GoTo(az int) error {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package gs232
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Real replies, as the controllers actually send them — a K3NG answering "C"
|
||||
// with "+0140" and CR+LF among them (reported from a live controller).
|
||||
func TestAzimuthReplies(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{"+0140\r\n", 140}, // GS-232A, K3NG firmware
|
||||
{"+0000\r", 0}, // due north
|
||||
{"+0359\r\n", 359}, // just short of it
|
||||
{"AZ=140\r\n", 140}, // GS-232B flavour
|
||||
{"AZ=140 EL=000\r\n", 140}, // GS-232B with elevation on the same line
|
||||
{"\r\n+0075\r\n", 75}, // a leftover terminator ahead of the answer
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := azRe.FindStringSubmatch(strings.TrimSpace(c.raw))
|
||||
if m == nil {
|
||||
t.Errorf("no azimuth found in %q", c.raw)
|
||||
continue
|
||||
}
|
||||
got, _ := strconv.Atoi(m[1])
|
||||
if got%360 != c.want {
|
||||
t.Errorf("%q parsed as %d, want %d", c.raw, got%360, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.27.8"
|
||||
appVersion = "0.27.10"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user