fix issues

This commit is contained in:
2026-06-13 16:55:57 +02:00
parent 00cab6b204
commit d3ba7c71f4
5 changed files with 61 additions and 23 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"Bash(git config *)", "Bash(git config *)",
"Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)", "Bash(ls \"/c/Program Files/Git/mingw64/bin/git-credential-manager\"*.exe)",
"Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)", "Bash(ls \"/c/Program Files/Git/mingw64/libexec/git-core/git-credential-manager\"*.exe)",
"Bash(which git-credential-manager *)" "Bash(which git-credential-manager *)",
"Bash(gofmt -w internal/ultrabeam/ultrabeam.go)"
] ]
} }
} }
+15 -11
View File
@@ -1208,7 +1208,13 @@ export default function App() {
} }
return out.replace(/\s+/g, ' ').trim(); return out.replace(/\s+/g, ' ').trim();
} }
function wkSend(rawText: string) { setWkSent(''); WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e))); } function wkSend(rawText: string) {
setWkSent('');
WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> in a macro (e.g. "73 TU <LOGQSO>") logs the contact after sending.
// resolveCW already strips the token from the keyed text (unknown var → "").
if (/<LOGQSO>/i.test(rawText)) void save();
}
function wkSendMacro(i: number) { const m = wkMacros[i]; if (m) wkSend(m.text); } function wkSendMacro(i: number) { const m = wkMacros[i]; if (m) wkSend(m.text); }
wkSendMacroRef.current = wkSendMacro; wkSendMacroRef.current = wkSendMacro;
// send-on-type: key the typed chars verbatim (no variable substitution). // send-on-type: key the typed chars verbatim (no variable substitution).
@@ -1298,6 +1304,7 @@ export default function App() {
const loggedDxcc = typeof payload.dxcc === 'number' ? payload.dxcc : 0; const loggedDxcc = typeof payload.dxcc === 'number' ? payload.dxcc : 0;
await AddQSO(payload); await AddQSO(payload);
resetEntry(); resetEntry();
callsignRef.current?.focus(); // return focus to the call field, wherever it was (e.g. Name)
await refresh(); await refresh();
// Refresh the Worked-before matrix so the just-logged band/mode flips to // Refresh the Worked-before matrix so the just-logged band/mode flips to
// "worked" — resetEntry cleared it, so re-fetch for the logged call (a // "worked" — resetEntry cleared it, so re-fetch for the logged call (a
@@ -1832,12 +1839,12 @@ export default function App() {
); );
const rstTxBlock = ( const rstTxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label> <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST tx</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} /> <Combobox value={rstSent} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div> </div>
); );
const rstRxBlock = ( const rstRxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label> <div className="flex flex-col w-20"><Label className="mb-1 h-3.5">RST rx</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} /> <Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div> </div>
); );
// Deferred-entry date: only shown when the start time is locked (back-entering // Deferred-entry date: only shown when the start time is locked (back-entering
@@ -2372,17 +2379,14 @@ export default function App() {
{/* "You have been spotted" banner — shows when our own callsign appears {/* "You have been spotted" banner — shows when our own callsign appears
in a cluster spot (Log4OM-style). Floated as a bottom-center overlay in a cluster spot. Floated top-centre (with the other notifications),
so it never shifts the layout (push-down / spring-back) and never never shifts the layout; auto-hides 3s after the last self-spot. */}
covers the entry fields; auto-hides 3s after the last self-spot. */}
{!compact && selfSpot && ( {!compact && selfSpot && (
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-bottom-2"> <div className="fixed top-12 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-100 text-amber-900 px-3.5 py-2 text-xs shadow-lg animate-in fade-in slide-in-from-top-2">
<RadioTower className="size-3.5 shrink-0" /> <RadioTower className="size-3.5 shrink-0" />
<span> <span>
You've been spotted by <strong className="font-mono">{selfSpot.spotter || '?'}</strong> Spotted by <strong className="font-mono">{selfSpot.spotter || '?'}</strong>
{' '}on <strong className="font-mono">{selfSpot.freqKHz?.toFixed(1)} kHz</strong> {selfSpot.comment ? <span className="text-amber-800"> with {selfSpot.comment}</span> : null}
{selfSpot.band ? ` (${selfSpot.band})` : ''}
{selfSpot.comment ? <span className="text-amber-800"> — {selfSpot.comment}</span> : null}
</span> </span>
<div className="flex-1" /> <div className="flex-1" />
<button className="text-amber-700 hover:text-amber-900" title="Dismiss" onClick={() => setSelfSpot(null)}> <button className="text-amber-700 hover:text-amber-900" title="Dismiss" onClick={() => setSelfSpot(null)}>
+1 -1
View File
@@ -1836,7 +1836,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="border-t border-border/60 pt-3"> <div className="border-t border-border/60 pt-3">
<Label className="text-sm font-medium">CW message macros (F1…)</Label> <Label className="text-sm font-medium">CW message macros (F1…)</Label>
<p className="text-[11px] text-muted-foreground mb-2"> <p className="text-[11px] text-muted-foreground mb-2">
Use variables: <span className="font-mono">&lt;MY_CALL&gt; &lt;CALL&gt; &lt;STX&gt; &lt;STRX&gt; &lt;MY_NAME&gt; &lt;HIS_NAME&gt; &lt;MY_QTH&gt; &lt;GRID&gt; &lt;CONT_TX&gt; &lt;n&gt;</span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call. Use variables: <span className="font-mono">&lt;MY_CALL&gt; &lt;CALL&gt; &lt;STX&gt; &lt;STRX&gt; &lt;MY_NAME&gt; &lt;HIS_NAME&gt; &lt;MY_QTH&gt; &lt;GRID&gt; &lt;CONT_TX&gt; &lt;n&gt;</span> (cut numbers: 9→N, 0→T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call. <span className="font-mono">&lt;LOGQSO&gt;</span> = log the QSO when the macro is sent (e.g. <span className="font-mono">73 TU &lt;LOGQSO&gt;</span>).
</p> </p>
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1"> <div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
{Array.from({ length: 12 }).map((_, i) => { {Array.from({ length: 12 }).map((_, i) => {
+13 -5
View File
@@ -6,7 +6,7 @@ import { cn } from '@/lib/utils';
// only an exact (case-insensitive) match — otherwise it reverts, so the field // only an exact (case-insensitive) match — otherwise it reverts, so the field
// can't hold a typo'd value that isn't in the list. // can't hold a typo'd value that isn't in the list.
export function Combobox({ export function Combobox({
value, onChange, options, placeholder, className, allowFreeText = false, value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false,
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
@@ -14,6 +14,10 @@ export function Combobox({
placeholder?: string; placeholder?: string;
className?: string; className?: string;
allowFreeText?: boolean; allowFreeText?: boolean;
// Commit each keystroke to the parent immediately (not just on blur). Use for
// fields read live by other actions — e.g. RST, so a CW macro sent without
// leaving the field uses the value just typed.
commitOnType?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
@@ -41,9 +45,13 @@ export function Combobox({
// Defer so a click on an option registers first. // Defer so a click on an option registers first.
setTimeout(() => { setTimeout(() => {
setOpen(false); setOpen(false);
const exact = options.find((o) => o.toLowerCase() === query.trim().toLowerCase()); const trimmed = query.trim();
if (exact) { onChange(exact); setQuery(exact); } const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase());
else if (allowFreeText) { onChange(query.trim()); } // Only fire onChange when the value actually changed — committing an
// unchanged value on a plain tab-through would wrongly flag the field as
// "user-edited" (e.g. RST, which then blocks the CW/SSB 599↔59 default).
if (exact) { if (exact !== value) onChange(exact); setQuery(exact); }
else if (allowFreeText) { if (trimmed !== value) onChange(trimmed); }
else { setQuery(value); } // revert typo else { setQuery(value); } // revert typo
}, 120); }, 120);
} }
@@ -56,7 +64,7 @@ export function Combobox({
// Focus selects the text so a keystroke replaces it — but does NOT // Focus selects the text so a keystroke replaces it — but does NOT
// open the list (so tabbing in doesn't pop the dropdown). // open the list (so tabbing in doesn't pop the dropdown).
onFocus={(e) => { setQuery(value); e.currentTarget.select(); }} onFocus={(e) => { setQuery(value); e.currentTarget.select(); }}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }} onChange={(e) => { setQuery(e.target.value); setOpen(true); if (commitOnType) onChange(e.target.value); }}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={(e) => { onKeyDown={(e) => {
if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); } if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); }
+30 -5
View File
@@ -5,6 +5,7 @@ package ultrabeam
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"log" "log"
"net" "net"
@@ -12,6 +13,17 @@ import (
"time" "time"
) )
// Connection tuning. Remote operation (the antenna controller reached over the
// internet, not the LAN) sees real latency and jitter, so the read timeout is
// generous and a few transient timeouts are tolerated before the link is torn
// down — otherwise a single slow reply dropped the whole connection and the
// client churned reconnect/disconnect.
const (
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
)
// Protocol constants // Protocol constants
const ( const (
STX byte = 0xF5 // 245 decimal STX byte = 0xF5 // 245 decimal
@@ -110,6 +122,7 @@ func (c *Client) pollLoop() {
defer ticker.Stop() defer ticker.Stop()
pollCount := 0 pollCount := 0
pollFails := 0 // consecutive failed status polls (transient timeouts tolerated)
for { for {
select { select {
@@ -120,7 +133,8 @@ func (c *Client) pollLoop() {
c.connMu.Lock() c.connMu.Lock()
if c.conn == nil { if c.conn == nil {
log.Printf("Ultrabeam: Not connected, attempting connection...") log.Printf("Ultrabeam: Not connected, attempting connection...")
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second) dialer := net.Dialer{Timeout: 5 * time.Second, KeepAlive: ubKeepAlive}
conn, err := dialer.Dial("tcp", net.JoinHostPort(c.host, fmt.Sprintf("%d", c.port)))
if err != nil { if err != nil {
log.Printf("Ultrabeam: Connection failed: %v", err) log.Printf("Ultrabeam: Connection failed: %v", err)
c.connMu.Unlock() c.connMu.Unlock()
@@ -133,6 +147,7 @@ func (c *Client) pollLoop() {
} }
c.conn = conn c.conn = conn
c.reader = bufio.NewReader(c.conn) c.reader = bufio.NewReader(c.conn)
pollFails = 0
log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port) log.Printf("Ultrabeam: Connected to %s:%d", c.host, c.port)
} }
c.connMu.Unlock() c.connMu.Unlock()
@@ -140,8 +155,17 @@ func (c *Client) pollLoop() {
// Query status // Query status
status, err := c.queryStatus() status, err := c.queryStatus()
if err != nil { if err != nil {
log.Printf("Ultrabeam: Failed to query status: %v", err) // A single slow/lost reply over a remote link is normal — keep
// Close connection and retry // the connection (and the last status) for a few tries before
// tearing it down, so we don't churn reconnect/disconnect.
var ne net.Error
transient := errors.As(err, &ne) && ne.Timeout()
pollFails++
if transient && pollFails < ubMaxPollTimeout {
log.Printf("Ultrabeam: status timeout (%d/%d), keeping link: %v", pollFails, ubMaxPollTimeout, err)
continue
}
log.Printf("Ultrabeam: Failed to query status, reconnecting: %v", err)
c.connMu.Lock() c.connMu.Lock()
if c.conn != nil { if c.conn != nil {
c.conn.Close() c.conn.Close()
@@ -156,6 +180,7 @@ func (c *Client) pollLoop() {
c.statusMu.Unlock() c.statusMu.Unlock()
continue continue
} }
pollFails = 0
// Mark as connected // Mark as connected
status.Connected = true status.Connected = true
@@ -318,8 +343,8 @@ func (c *Client) sendCommand(cmd byte, data []byte) ([]byte, error) {
return nil, fmt.Errorf("failed to write: %w", err) return nil, fmt.Errorf("failed to write: %w", err)
} }
// Read reply with timeout // Read reply with timeout (generous — tolerates remote-link latency).
c.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) // Reduced from 2s to 1s c.conn.SetReadDeadline(time.Now().Add(ubReadTimeout))
// Read until we get a complete packet // Read until we get a complete packet
var buffer []byte var buffer []byte