feat: added versionning & About window
This commit is contained in:
+114
-15
@@ -39,6 +39,7 @@ import type { adif as adifModels, lookup as lookupModels, cat as catModels } fro
|
||||
import type { QSOForm, WorkedBeforeView, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
|
||||
import { Menubar, type Menu } from '@/components/Menubar';
|
||||
import { APP_VERSION, APP_AUTHOR } from '@/version';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||
@@ -90,9 +91,10 @@ type CATState = Omit<catModels.RigState, 'convertValues'>;
|
||||
|
||||
const DEFAULT_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm','23cm'];
|
||||
const DEFAULT_MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE'];
|
||||
// Modes the QSO recorder captures (voice + CW). Mirrors recordableMode() in
|
||||
// app.go — digital modes carry no useful audio and are never recorded.
|
||||
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV','CW']);
|
||||
// Modes the QSO recorder captures (phone only). Mirrors recordableMode() in
|
||||
// app.go — digital modes carry no useful audio, and CW has no DAX audio on Flex,
|
||||
// so neither is recorded (no REC badge / timer for them).
|
||||
const RECORDABLE_MODES = new Set(['SSB','USB','LSB','AM','FM','DV']);
|
||||
|
||||
const emptyDetails: DetailsState = {
|
||||
state: '', cnty: '', address: '',
|
||||
@@ -564,6 +566,16 @@ export default function App() {
|
||||
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
|
||||
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
|
||||
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
|
||||
// Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds)
|
||||
// until a reply is entered or it's stopped. Persisted as UI prefs.
|
||||
const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1');
|
||||
const [wkAutoCallSecs, setWkAutoCallSecs] = useState(() => Number(localStorage.getItem('opslog.wkAutoCallSecs')) || 3);
|
||||
const wkAutoCallRef = useRef(wkAutoCall);
|
||||
const wkAutoCallSecsRef = useRef(wkAutoCallSecs);
|
||||
const autoCallGenRef = useRef(0); // bump to cancel the running loop
|
||||
const autoCallMacroRef = useRef(-1); // macro index currently auto-repeating (-1 = none)
|
||||
useEffect(() => { wkAutoCallRef.current = wkAutoCall; }, [wkAutoCall]);
|
||||
useEffect(() => { wkAutoCallSecsRef.current = wkAutoCallSecs; }, [wkAutoCallSecs]);
|
||||
// F1-F12 macro shortcuts active only when the keyer is enabled + connected.
|
||||
const wkActiveRef = useRef(false);
|
||||
const wkEscClearsRef = useRef(true);
|
||||
@@ -665,6 +677,7 @@ export default function App() {
|
||||
// close so the next plain "Preferences" launch reverts to default.
|
||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [deletingAll, setDeletingAll] = useState(false);
|
||||
const [ctyRefreshing, setCtyRefreshing] = useState(false);
|
||||
const [refsDownloading, setRefsDownloading] = useState(false);
|
||||
@@ -1092,13 +1105,27 @@ export default function App() {
|
||||
}
|
||||
if (!lk.band && s.band) setBand(s.band);
|
||||
|
||||
// Mode resolution priority: digital watering-hole → CAT's DATA → CAT mode.
|
||||
// Mode resolution.
|
||||
// FlexRadio reports the exact mode for voice/CW (USB/LSB/CW…) → trust it.
|
||||
// But all digital sub-modes share one radio mode (DIGU/DIGL → "DATA"), so
|
||||
// when it's digital we still pick FT8/FT4/RTTY from the frequency's
|
||||
// watering hole (e.g. 14.080 → FT4), else the operator's default.
|
||||
// OmniRig & other rigs often can't tell digital from SSB on a digital
|
||||
// freq, so for them we infer from the frequency regardless of reported mode.
|
||||
if (!lk.mode) {
|
||||
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
|
||||
let nextMode = '';
|
||||
if (inferred) nextMode = inferred;
|
||||
else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
|
||||
else if (s.mode) nextMode = s.mode;
|
||||
if (s.backend === 'flex') {
|
||||
if (s.mode === 'DATA') {
|
||||
nextMode = (s.freq_hz ? inferDigitalMode(s.freq_hz) : '') || digitalDefaultRef.current || 'FT8';
|
||||
} else if (s.mode) {
|
||||
nextMode = s.mode;
|
||||
}
|
||||
} else {
|
||||
const inferred = s.freq_hz ? inferDigitalMode(s.freq_hz) : '';
|
||||
if (inferred) nextMode = inferred;
|
||||
else if (s.mode === 'DATA') nextMode = digitalDefaultRef.current || 'FT8';
|
||||
else if (s.mode) nextMode = s.mode;
|
||||
}
|
||||
if (nextMode) {
|
||||
setMode(nextMode);
|
||||
applyModePreset(nextMode); // flip 599↔59 on mode change (respects edits)
|
||||
@@ -1191,6 +1218,12 @@ export default function App() {
|
||||
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
||||
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? ''));
|
||||
});
|
||||
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
|
||||
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
|
||||
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
|
||||
const call = String(p?.call ?? '');
|
||||
if (applyUdpCall(call)) restartRecordingForNewTarget(call);
|
||||
});
|
||||
const unsubProg = EventsOn('import:progress', (p: any) => {
|
||||
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
|
||||
});
|
||||
@@ -1208,7 +1241,7 @@ export default function App() {
|
||||
else setError('UDP auto-log: ' + msg);
|
||||
}
|
||||
});
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubProg?.(); unsubLog?.(); };
|
||||
return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -1307,8 +1340,41 @@ export default function App() {
|
||||
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish
|
||||
void save();
|
||||
}
|
||||
function wkSendMacro(i: number) { const m = wkMacros[i]; if (m) wkSend(m.text); }
|
||||
// stopAutoCall cancels any running auto-call loop.
|
||||
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
|
||||
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||
// gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
|
||||
async function runAutoCall(i: number) {
|
||||
const gen = ++autoCallGenRef.current;
|
||||
autoCallMacroRef.current = i;
|
||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||
while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) {
|
||||
const m = wkMacros[i];
|
||||
if (!m) break;
|
||||
await wkSend(m.text);
|
||||
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
|
||||
for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish
|
||||
if (gen !== autoCallGenRef.current) break;
|
||||
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
|
||||
}
|
||||
}
|
||||
function wkSendMacro(i: number) {
|
||||
const m = wkMacros[i];
|
||||
if (!m) return;
|
||||
if (wkAutoCallRef.current) runAutoCall(i); // loop this macro until a reply / stop
|
||||
else wkSend(m.text);
|
||||
}
|
||||
wkSendMacroRef.current = wkSendMacro;
|
||||
function wkToggleAutoCall(on: boolean) {
|
||||
setWkAutoCall(on);
|
||||
writeUiPref('opslog.wkAutoCall', on ? '1' : '0');
|
||||
if (!on) stopAutoCall();
|
||||
}
|
||||
function wkSetAutoCallSecs(n: number) {
|
||||
const v = Math.max(0, Math.min(120, n || 0));
|
||||
setWkAutoCallSecs(v);
|
||||
writeUiPref('opslog.wkAutoCallSecs', String(v));
|
||||
}
|
||||
// send-on-type: key the typed chars verbatim (no variable substitution).
|
||||
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
|
||||
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
|
||||
@@ -1660,6 +1726,9 @@ export default function App() {
|
||||
// still replace it. Without this, clicking a cluster spot froze the call:
|
||||
// applyUdpCall saw current != lastUdpCall and refused every later UDP call.
|
||||
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
|
||||
// A callsign appeared (someone answered the CQ, or a spot was clicked) →
|
||||
// stop auto-calling so we don't key over the contact.
|
||||
if (v.trim() !== '') stopAutoCall();
|
||||
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
||||
// on every status packet. If it matches what's already in the entry,
|
||||
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
|
||||
@@ -1777,7 +1846,7 @@ export default function App() {
|
||||
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
|
||||
]},
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about', disabled: true },
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
|
||||
|
||||
@@ -1797,6 +1866,7 @@ export default function App() {
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
case 'tools.downloadRefs': downloadRefs(); break;
|
||||
case 'help.about': setShowAbout(true); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2196,7 +2266,7 @@ export default function App() {
|
||||
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
||||
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||
<span className="font-bold text-[15px] tracking-tight">OpsLog</span>
|
||||
<span className="text-[11px] text-muted-foreground">v0.1</span>
|
||||
<span className="text-[11px] text-muted-foreground cursor-pointer hover:text-foreground" onClick={() => setShowAbout(true)} title="About OpsLog">v{APP_VERSION}</span>
|
||||
</div>
|
||||
|
||||
<Menubar menus={menus} onAction={handleMenu} />
|
||||
@@ -2438,6 +2508,26 @@ export default function App() {
|
||||
<FirstRunModal onDone={() => { setShowFirstRun(false); loadStation(); refresh(); }} />
|
||||
)}
|
||||
|
||||
{showAbout && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAbout(false)}>
|
||||
<div className="w-full max-w-sm rounded-xl border border-border bg-card shadow-2xl p-6 text-center animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-2 mb-1">
|
||||
<div className="size-3 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||
<h2 className="text-xl font-bold tracking-tight">OpsLog</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Ham-radio logbook</p>
|
||||
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
|
||||
<p className="mt-3 text-sm">
|
||||
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">73 & good DX</p>
|
||||
<button onClick={() => setShowAbout(false)} className="mt-5 h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
||||
success toast; both auto-dismiss. */}
|
||||
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
||||
@@ -2557,7 +2647,11 @@ export default function App() {
|
||||
{/* Right of the entry (Log4OM-style): F1 Stats (matrix) + F2-F5 detail
|
||||
tabs, then reserved free space. Hidden in compact mode. */}
|
||||
{!compact && (
|
||||
<div className="w-[560px] shrink-0 min-h-0 flex flex-col">
|
||||
// relative + absolute inner: the panel's content can't grow the row, so
|
||||
// the row height is set by the ENTRY STRIP. A taller tab (Awards/F3) then
|
||||
// scrolls inside this fixed height instead of pushing everything down.
|
||||
<div className="w-[560px] shrink-0 min-h-0 relative">
|
||||
<div className="absolute inset-0 flex flex-col min-h-0">
|
||||
<DetailsPanel
|
||||
callsign={callsign}
|
||||
prefix={prefix}
|
||||
@@ -2574,6 +2668,7 @@ export default function App() {
|
||||
onTab={setDetailTab}
|
||||
keyerActive={(wkEnabled && wkStatus.connected) || dvkEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
@@ -2608,7 +2703,7 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{wkEnabled && (
|
||||
<div className="w-[500px] shrink-0 min-h-0">
|
||||
<div className="w-[380px] shrink-0 min-h-0">
|
||||
<WinkeyerPanel
|
||||
status={wkStatus}
|
||||
ports={wkPorts}
|
||||
@@ -2623,12 +2718,16 @@ export default function App() {
|
||||
onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
|
||||
onSend={wkSend}
|
||||
onSendMacro={wkSendMacro}
|
||||
onStop={() => WinkeyerStop().catch(() => {})}
|
||||
onStop={() => { stopAutoCall(); WinkeyerStop().catch(() => {}); }}
|
||||
onClose={() => wkSetEnabled(false)}
|
||||
sendOnType={wkSendOnType}
|
||||
onToggleSendOnType={wkToggleSendOnType}
|
||||
onSendRaw={wkSendRaw}
|
||||
onBackspace={wkBackspace}
|
||||
autoCall={wkAutoCall}
|
||||
autoCallSecs={wkAutoCallSecs}
|
||||
onToggleAutoCall={wkToggleAutoCall}
|
||||
onSetAutoCallSecs={wkSetAutoCallSecs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,11 +31,17 @@ export type AwardDef = {
|
||||
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
|
||||
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
|
||||
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
|
||||
or_rules?: AwardOrRule[];
|
||||
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
|
||||
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
|
||||
total: number; builtin?: boolean;
|
||||
};
|
||||
|
||||
type AwardOrRule = {
|
||||
field: string; match_by?: string; exact_match?: boolean; pattern?: string;
|
||||
leading_str?: string; trailing_str?: string; prefix?: string;
|
||||
};
|
||||
|
||||
type AwardRef = {
|
||||
code: string; name: string; dxcc: number; group: string; subgrp: string;
|
||||
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
|
||||
@@ -162,7 +168,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
if (!open) return;
|
||||
setErr('');
|
||||
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
|
||||
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
|
||||
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields(((f ?? []) as string[]).slice().sort((a, b) => a.localeCompare(b))); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
|
||||
.catch((e) => setErr(String(e?.message ?? e)));
|
||||
loadMeta();
|
||||
}, [open]);
|
||||
@@ -344,6 +350,46 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
||||
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
|
||||
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||
</div>
|
||||
|
||||
{/* Additional OR searches: a QSO earns a reference if the
|
||||
primary rule OR any of these match. */}
|
||||
<div className="border-t pt-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] text-muted-foreground">Additional searches <span className="font-semibold">(OR)</span> — also match the reference if any of these hit</p>
|
||||
<Button size="sm" variant="outline" className="h-7"
|
||||
onClick={() => patch({ or_rules: [...(cur.or_rules ?? []), { field: cur.field || 'note', match_by: 'pattern', pattern: '', prefix: '' }] })}>
|
||||
<Plus className="size-3.5" /> Add OR
|
||||
</Button>
|
||||
</div>
|
||||
{(cur.or_rules ?? []).map((r, ri) => {
|
||||
const upd = (p: Partial<AwardOrRule>) => patch({ or_rules: (cur.or_rules ?? []).map((x, j) => (j === ri ? { ...x, ...p } : x)) });
|
||||
const del = () => patch({ or_rules: (cur.or_rules ?? []).filter((_, j) => j !== ri) });
|
||||
return (
|
||||
<div key={ri} className="rounded-md border border-border bg-muted/20 p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">OR — search in</span>
|
||||
<Select value={r.field} onValueChange={(v) => upd({ field: v })}>
|
||||
<SelectTrigger className="h-7 text-xs w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
{['code', 'description', 'pattern'].map((m) => (
|
||||
<label key={m} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name={`orby-${ri}`} checked={(r.match_by || 'code') === m} onChange={() => upd({ match_by: m })} className="accent-primary" /> {m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer"><Checkbox checked={!!r.exact_match} onCheckedChange={(c) => upd({ exact_match: !!c })} /> exact</label>
|
||||
<button className="ml-auto text-destructive hover:opacity-70" onClick={del} title="Remove this OR search"><Trash2 className="size-4" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_120px] gap-2">
|
||||
<Input className="h-7 font-mono text-xs" value={r.pattern ?? ''} onChange={(e) => upd({ pattern: e.target.value })} placeholder="regex — group 1 = reference (e.g. \b(\d{2})\d{3}\b for postal → dept)" />
|
||||
<Input className="h-7 font-mono text-xs" value={r.prefix ?? ''} onChange={(e) => upd({ prefix: e.target.value })} placeholder="prefix (D)" title="Prepended to each found reference, e.g. 74 → D74" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -29,9 +29,13 @@ interface Props {
|
||||
// of these and it's already filled (e.g. VE9CF → state NB), the award counts
|
||||
// automatically — we surface that so the operator needn't pick it by hand.
|
||||
fieldValues?: Record<string, string>;
|
||||
// Height of the selector. Default is a fixed 210px (the QSO editor modal);
|
||||
// the live entry panel passes "flex-1 min-h-0" so it fills the available
|
||||
// space instead of overflowing and forcing a scrollbar.
|
||||
heightClass?: string;
|
||||
}
|
||||
|
||||
export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props) {
|
||||
export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightClass = 'h-[210px]' }: Props) {
|
||||
const [defs, setDefs] = useState<AwardDef[]>([]);
|
||||
const [metas, setMetas] = useState<Record<string, Meta>>({});
|
||||
const [awardCode, setAwardCode] = useState('POTA');
|
||||
@@ -172,7 +176,7 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 h-[210px]">
|
||||
<div className={`flex gap-2 ${heightClass}`}>
|
||||
{/* Left panel */}
|
||||
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -121,6 +122,40 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
|
||||
export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, bands, tab, onTab, keyerActive }: Props) {
|
||||
const [internalOpen, setInternalOpen] = useState<TabName>('stats');
|
||||
const open = tab ?? internalOpen; // controlled when `tab` is provided
|
||||
|
||||
// Live award detection: run the SAME engine used at log time over the current
|
||||
// contact (callsign + looked-up address/state/zones) so award references the
|
||||
// QSO will earn — e.g. WAPC matching "Beijing" inside the address — are
|
||||
// surfaced and auto-added the moment you enter a call or click a spot, instead
|
||||
// of only appearing after logging. Pickable matches are merged into award_refs
|
||||
// (idempotent; award_refs is NOT a dependency, so removing one by hand sticks).
|
||||
const [detected, setDetected] = useState<Array<{ code: string; ref: string; name?: string; pickable?: boolean }>>([]);
|
||||
useEffect(() => {
|
||||
if (open !== 'awards' || !callsign.trim()) { setDetected([]); return; }
|
||||
const t = window.setTimeout(async () => {
|
||||
try {
|
||||
const q: any = {
|
||||
callsign, band, mode,
|
||||
address: details.address ?? '', state: details.state ?? '', cnty: details.cnty ?? '',
|
||||
cont: details.cont ?? '', dxcc: details.dxcc, cqz: details.cqz, ituz: details.ituz,
|
||||
qso_date: new Date().toISOString(),
|
||||
};
|
||||
const all = ((await ComputeQSOAwardRefs(q)) ?? []) as any[];
|
||||
setDetected(all as any);
|
||||
const cur = details.award_refs ?? '';
|
||||
const have = new Set(cur.split(';').filter(Boolean));
|
||||
let next = cur;
|
||||
for (const r of all) {
|
||||
if (!r.pickable) continue;
|
||||
const entry = `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`;
|
||||
if (!have.has(entry)) { next = next ? `${next};${entry}` : entry; have.add(entry); }
|
||||
}
|
||||
if (next !== cur) onChange({ award_refs: next });
|
||||
} catch { /* leave detection empty on failure */ }
|
||||
}, 400);
|
||||
return () => window.clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, callsign, details.address, details.state, details.cnty, details.dxcc, details.cqz, details.ituz, band, mode]);
|
||||
// Bearing/distance from operator's home grid to the remote station.
|
||||
// Recomputed only when either grid actually changes.
|
||||
const path = useMemo(() => {
|
||||
@@ -179,7 +214,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="overflow-y-auto min-h-0">
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{open === 'stats' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||
@@ -257,13 +292,24 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
||||
)}
|
||||
|
||||
{open === 'awards' && (
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="px-3 py-2.5 h-full flex flex-col min-h-0">
|
||||
<AwardRefSelector
|
||||
dxcc={details.dxcc}
|
||||
value={details.award_refs ?? ''}
|
||||
onChange={(v) => onChange({ award_refs: v })}
|
||||
fieldValues={{ state: details.state ?? '', cnty: details.cnty ?? '' }}
|
||||
heightClass="flex-1 min-h-0"
|
||||
/>
|
||||
{detected.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
||||
<span className="font-medium text-foreground/70">Detected — this contact will count for:</span>{' '}
|
||||
{detected.map((r) => (
|
||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
GetListsSettings, SaveListsSettings,
|
||||
GetCATSettings, SaveCATSettings,
|
||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||
@@ -445,6 +445,44 @@ function TelemetryToggle() {
|
||||
);
|
||||
}
|
||||
|
||||
// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
|
||||
// (fills the IP/port). Self-contained so it can own its state (rendered inside
|
||||
// the hook-less CATPanel).
|
||||
function FlexDiscover({ onPick }: { onPick: (ip: string, port: number) => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [found, setFound] = useState<Array<{ ip: string; port: number; model?: string; nickname?: string }>>([]);
|
||||
const [msg, setMsg] = useState('');
|
||||
async function scan() {
|
||||
setBusy(true); setMsg('');
|
||||
try {
|
||||
const r = ((await DiscoverFlexRadios()) ?? []) as any[];
|
||||
setFound(r as any);
|
||||
if (r.length === 0) setMsg('No radio found — check it\'s on the same network, or enter the IP manually.');
|
||||
} catch (e: any) {
|
||||
setMsg(String(e?.message ?? e));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-2 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={scan} disabled={busy}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Wifi className="size-3.5" />} Detect radios
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">listens for FlexRadio broadcast on the LAN</span>
|
||||
</div>
|
||||
{found.map((r) => (
|
||||
<button key={r.ip} type="button" onClick={() => onPick(r.ip, r.port || 4992)}
|
||||
className="w-full text-left text-xs rounded border border-border px-2 py-1 hover:bg-accent/50">
|
||||
<span className="font-mono font-semibold">{r.ip}</span>
|
||||
{r.model ? <span className="text-muted-foreground"> · {r.model}</span> : ''}
|
||||
{r.nickname ? <span className="text-muted-foreground"> ({r.nickname})</span> : ''}
|
||||
</button>
|
||||
))}
|
||||
{msg && <div className="text-[11px] text-muted-foreground">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||
const label = SECTION_LABELS[id] ?? id;
|
||||
const IconCmp = Icon ?? Construction;
|
||||
@@ -492,7 +530,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
const [bandDraft, setBandDraft] = useState('');
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, poll_ms: 250, delay_ms: 0,
|
||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8',
|
||||
});
|
||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||
@@ -1634,9 +1672,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<>
|
||||
<SectionHeader
|
||||
title="CAT interface"
|
||||
hint="Reads the rig's current frequency / band / mode and pushes them into the entry strip in real time. Requires OmniRig (free) installed and configured separately for your rig — OpsLog just talks to it."
|
||||
hint="Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug)."
|
||||
/>
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={catCfg.enabled} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||
Enable CAT
|
||||
@@ -1648,11 +1686,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
<Select value={catCfg.backend} onValueChange={(v) => setCatCfg((s) => ({ ...s, backend: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="omnirig">OmniRig (Windows COM)</SelectItem>
|
||||
<SelectItem value="flex" disabled>Flex SmartSDR (coming soon)</SelectItem>
|
||||
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
||||
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<div className="space-y-1">
|
||||
<Label>OmniRig rig slot</Label>
|
||||
<Select value={String(catCfg.omnirig_rig)} onValueChange={(v) => setCatCfg((s) => ({ ...s, omnirig_rig: parseInt(v) as 1 | 2 }))}>
|
||||
@@ -1663,6 +1702,30 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>FlexRadio IP</Label>
|
||||
<Input placeholder="192.168.1.50" value={catCfg.flex_host ?? ''}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_host: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Port</Label>
|
||||
<Input type="number" value={catCfg.flex_port || 4992}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
|
||||
</div>
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||||
Show cluster spots on the panadapter <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>Poll interval (ms)</Label>
|
||||
<Input
|
||||
@@ -1679,6 +1742,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, delay_ms: parseInt(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Default digital mode (when rig reports DIG)</Label>
|
||||
<Select
|
||||
@@ -1694,6 +1759,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={catModeBeforeFreq}
|
||||
@@ -1708,6 +1775,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
||||
OmniRig only reports generic "DIG" for digital modes — <strong>Default digital mode</strong>
|
||||
{' '}is the specific mode OpsLog will surface (and log).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in
|
||||
real time from the radio (no polling, no second-click mode bug). Use <strong>Detect
|
||||
radios</strong> or enter the IP. <strong>Default digital mode</strong> is what OpsLog
|
||||
logs when the slice is in a digital mode (DIGU/DIGL).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,10 @@ interface Props {
|
||||
onToggleSendOnType: (on: boolean) => void;
|
||||
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
|
||||
onBackspace: () => void; // remove last not-yet-keyed char
|
||||
autoCall: boolean; // repeat the clicked macro on a timer
|
||||
autoCallSecs: number; // gap (s) after the message before repeating
|
||||
onToggleAutoCall: (on: boolean) => void;
|
||||
onSetAutoCallSecs: (n: number) => void;
|
||||
}
|
||||
|
||||
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
|
||||
@@ -48,6 +52,7 @@ export function WinkeyerPanel({
|
||||
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
|
||||
onSend, onSendMacro, onStop, onClose,
|
||||
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
|
||||
autoCall, autoCallSecs, onToggleAutoCall, onSetAutoCallSecs,
|
||||
}: Props) {
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [speed, setSpeed] = useState(wpm);
|
||||
@@ -172,6 +177,25 @@ export function WinkeyerPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auto-call: repeat the clicked macro (e.g. F1 CQ) automatically until
|
||||
someone answers. The seconds box is the gap AFTER the message. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||
title="After you click a macro (e.g. F1 CQ), resend it on a loop — message, then the gap, then repeat — until a callsign is entered or you press Stop">
|
||||
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||
Auto-call
|
||||
</label>
|
||||
<span className="text-[11px] text-muted-foreground">gap</span>
|
||||
<div className="flex items-center gap-1 h-7 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="Seconds to wait after the message before resending">
|
||||
<input type="number" min={0} max={120}
|
||||
className="w-9 bg-transparent text-sm font-mono font-bold tabular-nums text-right outline-none"
|
||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||
</div>
|
||||
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>}
|
||||
</div>
|
||||
|
||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{macros.map((m, i) => (
|
||||
|
||||
@@ -0,0 +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.1';
|
||||
|
||||
// Author / credits, shown in Help → About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
Vendored
+3
-1
@@ -3,10 +3,10 @@
|
||||
import {adif} from '../models';
|
||||
import {qso} from '../models';
|
||||
import {main} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {profile} from '../models';
|
||||
import {award} from '../models';
|
||||
import {awardref} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {winkeyer} from '../models';
|
||||
@@ -91,6 +91,8 @@ export function DisconnectAllClusters():Promise<void>;
|
||||
|
||||
export function DisconnectClusterServer(arg1:number):Promise<void>;
|
||||
|
||||
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
|
||||
|
||||
export function DownloadAllReferenceLists():Promise<string>;
|
||||
|
||||
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
@@ -154,6 +154,10 @@ export function DisconnectClusterServer(arg1) {
|
||||
return window['go']['main']['App']['DisconnectClusterServer'](arg1);
|
||||
}
|
||||
|
||||
export function DiscoverFlexRadios() {
|
||||
return window['go']['main']['App']['DiscoverFlexRadios']();
|
||||
}
|
||||
|
||||
export function DownloadAllReferenceLists() {
|
||||
return window['go']['main']['App']['DownloadAllReferenceLists']();
|
||||
}
|
||||
|
||||
@@ -104,6 +104,30 @@ export namespace award {
|
||||
this.confirmed = source["confirmed"];
|
||||
}
|
||||
}
|
||||
export class OrRule {
|
||||
field: string;
|
||||
match_by?: string;
|
||||
exact_match?: boolean;
|
||||
pattern?: string;
|
||||
leading_str?: string;
|
||||
trailing_str?: string;
|
||||
prefix?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new OrRule(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.field = source["field"];
|
||||
this.match_by = source["match_by"];
|
||||
this.exact_match = source["exact_match"];
|
||||
this.pattern = source["pattern"];
|
||||
this.leading_str = source["leading_str"];
|
||||
this.trailing_str = source["trailing_str"];
|
||||
this.prefix = source["prefix"];
|
||||
}
|
||||
}
|
||||
export class Def {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -126,6 +150,7 @@ export namespace award {
|
||||
multi?: boolean;
|
||||
dynamic?: boolean;
|
||||
add_prefixes?: string[];
|
||||
or_rules?: OrRule[];
|
||||
dxcc_filter: number[];
|
||||
valid_bands?: string[];
|
||||
valid_modes?: string[];
|
||||
@@ -164,6 +189,7 @@ export namespace award {
|
||||
this.multi = source["multi"];
|
||||
this.dynamic = source["dynamic"];
|
||||
this.add_prefixes = source["add_prefixes"];
|
||||
this.or_rules = this.convertValues(source["or_rules"], OrRule);
|
||||
this.dxcc_filter = source["dxcc_filter"];
|
||||
this.valid_bands = source["valid_bands"];
|
||||
this.valid_modes = source["valid_modes"];
|
||||
@@ -175,7 +201,26 @@ export namespace award {
|
||||
this.total = source["total"];
|
||||
this.builtin = source["builtin"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
export class Ref {
|
||||
ref: string;
|
||||
name?: string;
|
||||
@@ -340,6 +385,28 @@ export namespace awardref {
|
||||
|
||||
export namespace cat {
|
||||
|
||||
export class FlexRadio {
|
||||
ip: string;
|
||||
port: number;
|
||||
model: string;
|
||||
nickname: string;
|
||||
serial: string;
|
||||
callsign: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new FlexRadio(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.ip = source["ip"];
|
||||
this.port = source["port"];
|
||||
this.model = source["model"];
|
||||
this.nickname = source["nickname"];
|
||||
this.serial = source["serial"];
|
||||
this.callsign = source["callsign"];
|
||||
}
|
||||
}
|
||||
export class RigState {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
@@ -830,6 +897,9 @@ export namespace main {
|
||||
enabled: boolean;
|
||||
backend: string;
|
||||
omnirig_rig: number;
|
||||
flex_host: string;
|
||||
flex_port: number;
|
||||
flex_spots: boolean;
|
||||
poll_ms: number;
|
||||
delay_ms: number;
|
||||
digital_default: string;
|
||||
@@ -843,6 +913,9 @@ export namespace main {
|
||||
this.enabled = source["enabled"];
|
||||
this.backend = source["backend"];
|
||||
this.omnirig_rig = source["omnirig_rig"];
|
||||
this.flex_host = source["flex_host"];
|
||||
this.flex_port = source["flex_port"];
|
||||
this.flex_spots = source["flex_spots"];
|
||||
this.poll_ms = source["poll_ms"];
|
||||
this.delay_ms = source["delay_ms"];
|
||||
this.digital_default = source["digital_default"];
|
||||
|
||||
Reference in New Issue
Block a user