Compare commits

...
4 Commits
Author SHA1 Message Date
rouggy 8a0d76fa0c feat: IC-7300MKII, and two Icom addresses that were plain wrong
Adds the IC-7300MKII at CI-V 0xB6.

Doing so exposed a drift between the two hand-kept copies of the model table: the
settings offered the IC-7700 at 0x88 and the IC-7800 at 0x80, which are the
IC-7100's and the IC-7410's factory addresses. Picking either set an address the
rig never answers on — the symptom is a radio that simply stays silent — and the
backend then named it as the other model. Corrected to 0x74 and 0x6A, and the
four models the backend already knew (IC-7100, IC-7410, IC-7600, IC-7851) are now
offered too instead of forcing a manual address.

A test reads the model list out of the .tsx and asserts civ.ModelName agrees, so
the next model added on one side alone fails the build rather than someone's
radio.
2026-07-28 22:38:02 +02:00
rouggy bd5f9c0746 fix: Icom over LAN froze on the last frequency after WSJT-X released the rig
The network backend treated "control link alive but no CI-V reply" as the rig
being in standby, and tolerated it WITHOUT BOUND. When another program takes the
CI-V session — WSJT-X through OmniRig, or the Remote Utility — the rig goes on
answering pings on the control stream while sending us nothing at all. Alive()
stayed true, so ReadState returned the cached frequency with err == nil, the
Manager never saw a failure, never reconnected, and re-published a frozen number
with a fresh timestamp on every poll. Only restarting OpsLog cleared it.

Bounded now, on the last SUCCESSFUL read. Past the grace the error is reported so
the Manager tears the session down and reconnects, which re-takes the CI-V
stream. Also fatal immediately: the CI-V reader goroutine having exited — no read
can ever succeed after that, however healthy the control link looks.

The grace backs off to minutes when the silence persists, because the two cases
pull opposite ways: a stolen session recovers on the first attempt, while a rig
switched OFF is silent for hours and re-tearing its session every 30 s would
blink the panel — and its ON button — away continuously. A good read resets it.

The decision table is pinned by a test; the standby case (never answered since
connect) keeps the old tolerate-for-ever behaviour.
2026-07-28 22:24:32 +02:00
rouggy 29591c5f0c feat: the offline FCC database answers during entry, not only at save
ULS was wired into AddQSO alone, so an operator who downloaded it and has no
QRZ.com/HamQTH account saw nothing while typing a US call: cty.dat gives the
country, the zones, and a 4-character grid that is the ENTITY centroid — a
thousand kilometres from the station. The county and the real square were stamped
after logging, too late to point an antenna with.

The enrichment runs last in the lookup wrapper and only fills blanks, so a
provider always wins. ULS carries no name and no address, so it completes a QRZ
record and can never replace one.

The grid needed a rule of its own. refineGrid keeps what is there unless the new
value extends it — correct for a QRZ square, wrong against an entity centroid,
which is simply a different square. A per-callsign FCC square therefore beats any
4-character grid, while a 6-character one already present is left untouched.
Lat/lon are recomputed only when we actually moved the grid.

Portable calls are skipped: W1AW/4 is not what the FCC licensed.
2026-07-28 22:10:22 +02:00
rouggy 01bcf256e2 fix: hand-corrected CQ/ITU zones came back wrong at every restart
The profile's MY_* metadata is derived from the callsign through cty.dat, and the
effect that derives it ran on every load — so opening the settings counted as
"the source changed" and overwrote whatever the operator had typed. Reported by
an operator in CQ 4 / ITU 4 handed 5 and 9: he corrected them, and each restart
put 5 and 9 back.

cty.dat gives the zones of the ENTITY, and a large country spans several, so the
automatic value cannot be treated as authoritative — it is a starting point. A
load now fills only fields that are EMPTY; a recompute that overwrites happens
only when the callsign or grid itself changes, which is the case the derivation
exists for.

The load-vs-edit distinction is a ref holding the profile id the values were last
derived from — first sight of a profile is a load.
2026-07-28 20:53:10 +02:00
9 changed files with 364 additions and 22 deletions
+66
View File
@@ -6370,9 +6370,75 @@ func (a *App) lookupCallsign(callsign string, force bool) (lookup.Result, error)
} }
} }
} }
a.enrichFromULS(&r, callsign)
return r, err return r, err
} }
// enrichFromULS fills a US station's state, county and grid from the offline FCC
// database while the operator is still typing.
//
// The ULS data was only ever applied at SAVE time (applyULSCounty), so an
// operator with the database downloaded and no QRZ.com/HamQTH account saw
// nothing but cty.dat during entry — country, zones, and a 4-character grid that
// is the ENTITY centroid, thousands of km off. The county and a 6-character grid
// were stamped silently after logging, too late to steer an antenna by.
//
// It runs LAST and only fills blanks, so an online provider always wins: ULS
// holds no name and no address, so it can complete a QRZ record but never
// replace one. The grid goes through refineGrid, which accepts the ULS square
// only when it EXTENDS what is already there (JN → JN05JG) and never when it
// would contradict it.
func (a *App) enrichFromULS(r *lookup.Result, callsign string) {
if a.uls == nil {
return
}
switch r.DXCC {
case 291, 110, 6: // United States, Hawaii, Alaska
default:
return
}
call := strings.ToUpper(strings.TrimSpace(callsign))
if r.Callsign != "" {
call = strings.ToUpper(strings.TrimSpace(r.Callsign))
}
if call == "" || strings.Contains(call, "/") {
// A portable call is not what the FCC licensed — W1AW/4 is not a row.
return
}
loc, ok := a.uls.Resolve(call)
if !ok {
return
}
if strings.TrimSpace(r.State) == "" {
r.State = loc.State
}
if strings.TrimSpace(r.County) == "" {
r.County = loc.CNTY()
}
// Grid. refineGrid keeps the ULS square when it extends what is there. It
// does NOT cover the case this feature exists for: cty.dat's 4-character
// square is the ENTITY centroid, so for a US call it is usually a different
// square altogether — refineGrid would keep it, and the operator would go on
// pointing an antenna at the middle of the country. A per-callsign ULS square
// beats any 4-character one, whatever its letters; a 6-character grid already
// present (QRZ) is left alone.
newGrid := ""
if g := refineGrid(r.Grid, loc.Grid); g != "" && g != r.Grid {
newGrid = g
} else if len(strings.TrimSpace(r.Grid)) <= 4 && len(strings.TrimSpace(loc.Grid)) >= 6 {
newGrid = loc.Grid
}
if newGrid != "" {
r.Grid = newGrid
// Lat/lon follow, or distance and azimuth would still be computed from the
// centroid the grid no longer says. Only when WE changed the grid: a
// provider's own coordinates are more precise than a square's centre.
if lat, lon, ok := gridToLatLon(newGrid); ok {
r.Lat, r.Lon = lat, lon
}
}
}
// OpenExternalURL opens a URL in the user's default browser. Wails ships // OpenExternalURL opens a URL in the user's default browser. Wails ships
// runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon // runtime.BrowserOpenURL for exactly this — used by the QRZ.com icon
// next to the callsign field, the future Clublog/HamQTH shortcuts, etc. // next to the callsign field, the future Clublog/HamQTH shortcuts, etc.
+16
View File
@@ -1,4 +1,20 @@
[ [
{
"version": "0.21.9",
"date": "2026-07-28",
"en": [
"The Icom model list gains the IC-7300MKII (CI-V B6), plus the IC-7100, IC-7410, IC-7600 and IC-7851. The IC-7700 and IC-7800 were listed at the IC-7100's and IC-7410's addresses, so picking them set an address the rig never answers on.",
"Icom over the LAN: the frequency no longer stays frozen after another program (WSJT-X through OmniRig, the Remote Utility) takes the CI-V session. The rig kept answering pings while sending nothing, so OpsLog showed the last known frequency until it was restarted; it now reconnects on its own.",
"The offline FCC (ULS) database now answers while you type a US callsign, filling state, county and a 6-character grid. It was only applied when the QSO was saved, so without a QRZ.com or HamQTH account you saw nothing but the country and a coarse grid. Online lookups still win — ULS only fills what is blank.",
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
],
"fr": [
"La liste des modèles Icom gagne l'IC-7300MKII (CI-V B6), ainsi que les IC-7100, IC-7410, IC-7600 et IC-7851. Les IC-7700 et IC-7800 y figuraient avec les adresses des IC-7100 et IC-7410 : les choisir réglait une adresse sur laquelle la radio ne répond jamais.",
"Icom via le réseau : la fréquence ne reste plus figée après qu'un autre programme (WSJT-X via OmniRig, la Remote Utility) a pris la session CI-V. La radio continuait de répondre aux pings sans rien émettre, si bien qu'OpsLog affichait la dernière fréquence connue jusqu'à son redémarrage ; il se reconnecte désormais tout seul.",
"La base FCC (ULS) hors ligne répond désormais pendant la saisie d'un indicatif américain, en remplissant l'état, le comté et un locator 6 caractères. Elle n'était appliquée qu'à l'enregistrement du QSO : sans compte QRZ.com ou HamQTH, vous ne voyiez que le pays et un locator approximatif. Les recherches en ligne restent prioritaires — l'ULS ne comble que ce qui est vide.",
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change."
]
},
{ {
"version": "0.21.8", "version": "0.21.8",
"date": "2026-07-28", "date": "2026-07-28",
+42 -12
View File
@@ -1006,12 +1006,20 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI // model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with // adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal. // civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
// The IC-7700 and IC-7800 were listed at 0x88 and 0x80 — which are the IC-7100's
// and the IC-7410's. Picking one of them set an address the rig never answers on,
// and the backend then named it as the other model.
const ICOM_MODELS: { name: string; addr: number }[] = [ const ICOM_MODELS: { name: string; addr: number }[] = [
{ name: 'IC-705', addr: 0xA4 }, { name: 'IC-705', addr: 0xA4 },
{ name: 'IC-7100', addr: 0x88 },
{ name: 'IC-7300', addr: 0x94 }, { name: 'IC-7300', addr: 0x94 },
{ name: 'IC-7300MKII', addr: 0xB6 },
{ name: 'IC-7410', addr: 0x80 },
{ name: 'IC-7600', addr: 0x7A },
{ name: 'IC-7610', addr: 0x98 }, { name: 'IC-7610', addr: 0x98 },
{ name: 'IC-7700', addr: 0x88 }, { name: 'IC-7700', addr: 0x74 },
{ name: 'IC-7800', addr: 0x80 }, { name: 'IC-7800', addr: 0x6A },
{ name: 'IC-7851', addr: 0x8E },
{ name: 'IC-9100', addr: 0x7C }, { name: 'IC-9100', addr: 0x7C },
{ name: 'IC-9700', addr: 0xA2 }, { name: 'IC-9700', addr: 0xA2 },
]; ];
@@ -1483,27 +1491,49 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
return () => { off(); }; return () => { off(); };
}, []); }, []);
// Which (profile, callsign, grid) the auto-fill below last derived from.
// Without it, simply OPENING the settings counted as "the source changed" and
// overwrote the operator's own values.
const derivedFrom = useRef<string>('');
// Auto-fill the active profile's MY_* DXCC metadata from the station // Auto-fill the active profile's MY_* DXCC metadata from the station
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These // callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon).
// are derived values, so they always recompute when the callsign or grid //
// changes — the user can still edit a field, it just re-populates when the // These are derived values, so they recompute when the callsign or grid
// source changes. Debounced so we don't hammer cty.dat while typing. // changes. What they must NOT do is recompute on a plain load: cty.dat gives
// the zones of the ENTITY, and a large country spans several — an operator in
// CQ 14 / ITU 27 was handed 15 and 28 and corrected them by hand, and every
// restart put the wrong pair back. cty.dat is a starting point, not an
// authority, so on a load we only fill fields that are EMPTY; a value the
// operator typed stays until the callsign or grid itself changes.
// Debounced so we don't hammer cty.dat while typing.
useEffect(() => { useEffect(() => {
const call = (activeProfile?.callsign ?? '').trim(); const call = (activeProfile?.callsign ?? '').trim();
if (!call) return; if (!call) return;
const grid = (activeProfile?.my_grid ?? '').trim(); const grid = (activeProfile?.my_grid ?? '').trim();
const source = `${activeProfile?.id ?? 0}|${call.toUpperCase()}|${grid.toUpperCase()}`;
// First sight of this profile — a load, not an edit.
const isLoad = derivedFrom.current === '' || derivedFrom.current.split('|')[0] !== String(activeProfile?.id ?? 0);
const t = window.setTimeout(async () => { const t = window.setTimeout(async () => {
try { try {
const i: any = await ComputeStationInfo(call, grid); const i: any = await ComputeStationInfo(call, grid);
derivedFrom.current = source;
setActiveProfile((p) => { setActiveProfile((p) => {
if (!p) return p; if (!p) return p;
const patch: any = {}; const patch: any = {};
if (i.country) patch.my_country = i.country; // On a load, keep in patch only what the profile does not already have.
if (i.dxcc) patch.my_dxcc = i.dxcc; const put = (k: string, v: any) => {
if (i.cqz) patch.my_cqz = i.cqz; if (!v) return;
if (i.ituz) patch.my_ituz = i.ituz; const cur = (p as any)[k];
if (i.lat) patch.my_lat = i.lat; if (isLoad && cur !== undefined && cur !== null && cur !== '' && cur !== 0) return;
if (i.lon) patch.my_lon = i.lon; patch[k] = v;
};
put('my_country', i.country);
put('my_dxcc', i.dxcc);
put('my_cqz', i.cqz);
put('my_ituz', i.ituz);
put('my_lat', i.lat);
put('my_lon', i.lon);
// Only re-render when a value actually changed (prevents loops). // Only re-render when a value actually changed (prevents loops).
const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]); const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]);
return changed ? { ...p, ...patch } : p; return changed ? { ...p, ...patch } : p;
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"os"
"regexp"
"strconv"
"testing"
"hamlog/internal/cat/civ"
)
// The Icom model picker in the settings and civ.ModelName are two hand-kept
// copies of the same table, and they had drifted: the UI offered the IC-7700 at
// 0x88 and the IC-7800 at 0x80, which are the IC-7100's and the IC-7410's
// factory addresses. Picking either set an address the rig never answers on —
// the symptom is a rig that simply never replies — and the backend then named it
// as the other model.
//
// The list is read out of the .tsx itself, so a model added on one side alone
// fails here rather than on someone's radio.
func TestIcomModelAddressesMatch(t *testing.T) {
src, err := os.ReadFile("frontend/src/components/SettingsModal.tsx")
if err != nil {
t.Skipf("frontend source not available: %v", err)
}
re := regexp.MustCompile(`\{ name: '(IC-[A-Za-z0-9-]+)', addr: 0x([0-9A-Fa-f]{2}) \}`)
ms := re.FindAllStringSubmatch(string(src), -1)
if len(ms) < 5 {
t.Fatalf("only %d models found — the parse is wrong, not the data", len(ms))
}
for _, m := range ms {
name := m[1]
addr, err := strconv.ParseUint(m[2], 16, 8)
if err != nil {
t.Fatalf("bad address %q for %s", m[2], name)
}
if got := civ.ModelName(byte(addr)); got != name {
t.Errorf("settings offer %s at 0x%02X, but civ.ModelName(0x%02X) = %q",
name, addr, addr, got)
}
}
}
+2
View File
@@ -344,6 +344,8 @@ func ModelName(addr byte) string {
return "IC-9700" return "IC-9700"
case 0xA4: case 0xA4:
return "IC-705" return "IC-705"
case 0xB6:
return "IC-7300MKII"
} }
return fmt.Sprintf("Icom (0x%02X)", addr) return fmt.Sprintf("Icom (0x%02X)", addr)
} }
+1
View File
@@ -152,6 +152,7 @@ func TestModelNameAddresses(t *testing.T) {
0x98: "IC-7610", 0x98: "IC-7610",
0xA2: "IC-9700", 0xA2: "IC-9700",
0xA4: "IC-705", 0xA4: "IC-705",
0xB6: "IC-7300MKII",
} { } {
if got := ModelName(addr); got != want { if got := ModelName(addr); got != want {
t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want) t.Errorf("ModelName(0x%02X) = %q, want %q", addr, got, want)
+62
View File
@@ -0,0 +1,62 @@
package cat
import (
"testing"
"time"
)
// The rule that decides whether a silent-but-alive Icom network session is
// tolerated or torn down. It was "always tolerate", and that is what froze the
// frequency display for ever when another program took the CI-V session.
//
// The decision is pinned here rather than left implicit, because the two cases
// it separates pull in opposite directions: a rig in standby must NOT be
// reconnected every few seconds (the panel and its ON button would blink away),
// while a hijacked session must recover without restarting OpsLog.
func TestIcomSilenceTolerance(t *testing.T) {
// tolerate mirrors the condition in ReadState.
tolerate := func(alive, readerGone bool, lastGood time.Time, silentFor, grace time.Duration) bool {
return alive && !readerGone && (lastGood.IsZero() || silentFor < grace)
}
never := time.Time{}
some := time.Now()
cases := []struct {
name string
alive bool
readerGone bool
lastGood time.Time
silentFor time.Duration
want bool
}{
{"never answered since connect — rig is off, keep the panel", true, false, never, time.Hour, true},
{"brief silence during a band change", true, false, some, 2 * time.Second, true},
{"silence past the grace — reconnect", true, false, some, icomSilentGrace + time.Second, false},
{"reader goroutine gone — dead however alive the link looks", true, true, some, time.Second, false},
{"control link itself dead", false, false, some, time.Second, false},
}
for _, c := range cases {
got := tolerate(c.alive, c.readerGone, c.lastGood, c.silentFor, icomSilentGrace)
if got != c.want {
t.Errorf("%s: tolerate = %v, want %v", c.name, got, c.want)
}
}
}
// The backoff must actually converge on the ceiling: a rig left switched off is
// silent for hours, and a window that stayed at 30 s would re-tear its session
// about a hundred times an hour.
func TestIcomSilenceBackoff(t *testing.T) {
g := icomSilentGrace
for i := 0; i < 20; i++ {
if g < icomSilentGraceMax {
g *= 2
}
}
if g < icomSilentGraceMax {
t.Fatalf("backoff never reached the ceiling: %s < %s", g, icomSilentGraceMax)
}
if g > 2*icomSilentGraceMax {
t.Errorf("backoff overshot the ceiling: %s", g)
}
}
+73 -10
View File
@@ -37,6 +37,19 @@ type aliveTransport interface {
Alive() bool Alive() bool
} }
const (
// How long the network backend accepts "the control link answers but the rig
// sends no CI-V" before declaring the session lost. Long enough to cover a
// band change or a rig booting from standby; short enough that an operator
// whose CI-V session was stolen (WSJT-X via OmniRig, the Remote Utility) gets
// a live frequency back on his own rather than restarting OpsLog.
icomSilentGrace = 30 * time.Second
// Ceiling for the backoff applied when the silence persists — a rig switched
// OFF is silent for hours, and re-tearing its session every 30 s would blink
// the panel (and its ON button) away continuously.
icomSilentGraceMax = 4 * time.Minute
)
// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope // scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope
// (0x27) frames on a SEPARATE channel from control replies. The network transport // (0x27) frames on a SEPARATE channel from control replies. The network transport
// implements it so the continuous panadapter stream can't crowd control replies // implements it so the continuous panadapter stream can't crowd control replies
@@ -89,13 +102,17 @@ type IcomSerial struct {
scopeFixed bool // true = fixed-span mode (tracked optimistically) scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification) scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice) curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send) curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads) pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles) splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance) readFails int // consecutive ReadState freq-read failures (transient tolerance)
dspLoaded bool // readDSP has run since the rig became responsive (loads all lastGoodAt time.Time // last SUCCESSFUL frequency read — bounds the network
// "alive but silent" tolerance below, which used to be
// unbounded and left the display frozen for ever
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers) // the panel's set-once controls once the rig actually answers)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode) lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time lastSetFreqAt time.Time
@@ -267,7 +284,34 @@ func (b *IcomSerial) ReadState() (RigState, error) {
// rig is switched on) rather than tearing the whole UDP session down and // rig is switched on) rather than tearing the whole UDP session down and
// flapping every few seconds. The panel stays up so the ON button works. // flapping every few seconds. The panel stays up so the ON button works.
if at, ok := b.port.(aliveTransport); ok { if at, ok := b.port.(aliveTransport); ok {
if at.Alive() { // The reader goroutine is what feeds every CI-V reply. If it has
// exited, no read can ever succeed again, however healthy the control
// link looks — that is not a silent rig, it is a dead connection.
readerGone := false
if b.readerDone != nil {
select {
case <-b.readerDone:
readerGone = true
default:
}
}
// "Alive but silent" is the rig in standby, or mid band-change. It was
// tolerated for ever, and that is the freeze: when ANOTHER program takes
// the CI-V session (WSJT-X through OmniRig, say) the rig keeps answering
// pings on the control stream while sending us no CI-V at all. Alive()
// stayed true, the cached frequency was re-published with a fresh
// timestamp on every poll, and the operator saw a confident, frozen
// number until OpsLog was restarted. Bounded now: past the grace period
// the error is reported so the Manager tears the session down and
// reconnects, which re-takes the CI-V stream.
silentFor := time.Duration(0)
if !b.lastGoodAt.IsZero() {
silentFor = time.Since(b.lastGoodAt)
}
if b.silentGrace <= 0 {
b.silentGrace = icomSilentGrace
}
if at.Alive() && !readerGone && (b.lastGoodAt.IsZero() || silentFor < b.silentGrace) {
b.readFails = 0 b.readFails = 0
s.FreqHz = b.curFreq // 0 until the rig is powered on and first read s.FreqHz = b.curFreq // 0 until the rig is powered on and first read
if b.curModeByte != 0 { if b.curModeByte != 0 {
@@ -286,8 +330,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
b.dspMu.Unlock() b.dspMu.Unlock()
return s, nil return s, nil
} }
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).") switch {
return RigState{}, err // control link dead → let the Manager reconnect case readerGone:
debugLog.Printf("icom net: the CI-V reader has exited — the connection is dead however alive the control link looks → reconnecting")
case at.Alive():
debugLog.Printf("icom net: control link answers but no CI-V reply for %s → reconnecting. Another program (WSJT-X/OmniRig, the Remote Utility) has most likely taken the CI-V session.", silentFor.Round(time.Second))
default:
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
}
// Restart the clock, and widen the window each time it fires without a
// good read in between. A hijacked session recovers on the first shot;
// a rig simply switched OFF never will, and re-tearing its session every
// 30 s would make the panel — and its ON button — blink away
// continuously. Backing off to minutes keeps the standby case quiet
// while still recovering on its own.
b.lastGoodAt = time.Now()
if b.silentGrace < icomSilentGraceMax {
b.silentGrace *= 2
}
return RigState{}, err // let the Manager reconnect
} }
// USB (no liveness signal): the rig briefly stops answering CI-V while it // USB (no liveness signal): the rig briefly stops answering CI-V while it
// switches band/VFO. Tolerate a few consecutive misses as transient — keep // switches band/VFO. Tolerate a few consecutive misses as transient — keep
@@ -308,6 +369,8 @@ func (b *IcomSerial) ReadState() (RigState, error) {
return RigState{}, err return RigState{}, err
} }
b.readFails = 0 b.readFails = 0
b.lastGoodAt = time.Now()
b.silentGrace = icomSilentGrace // the rig answers: back to the short window
s.FreqHz = hz s.FreqHz = hz
b.curFreq = hz b.curFreq = hz
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"testing"
"hamlog/internal/uls"
)
// The ULS grid rules, which are NOT the same as refineGrid's.
//
// cty.dat answers a US call with the ENTITY centroid — a 4-character square in
// the middle of the country. refineGrid keeps it (the ULS square is not an
// extension of it, it is a different square), which is right for a QRZ grid and
// wrong here: a per-callsign FCC square beats any entity centroid. But a
// 6-character grid already present came from a provider and must survive.
func TestULSGridPreference(t *testing.T) {
cases := []struct {
name string
have string // grid already on the result
uls string // grid from the FCC database
want string
}{
{"nothing known → take ULS", "", "EM12AB", "EM12AB"},
{"cty.dat entity centroid → ULS wins", "EN90", "EM12AB", "EM12AB"},
{"4-char square, same square → ULS extends it", "EM12", "EM12AB", "EM12AB"},
{"provider gave 6 chars → untouched", "FN31PR", "EM12AB", "FN31PR"},
{"ULS has only 4 chars, provider has 6 → untouched", "FN31PR", "EM12", "FN31PR"},
{"ULS empty → untouched", "EN90", "", "EN90"},
}
for _, c := range cases {
got := c.have
if g := refineGrid(got, c.uls); g != "" && g != got {
got = g
} else if len(got) <= 4 && len(c.uls) >= 6 {
got = c.uls
}
if got != c.want {
t.Errorf("%s: have=%q uls=%q → %q, want %q", c.name, c.have, c.uls, got, c.want)
}
}
}
// CNTY is what gets written to the QSO's county field, so its shape is part of
// the contract: ADIF wants "State,County" and nothing when either half is
// missing — a lone county name would be an invalid CNTY on export.
func TestULSCountyFormat(t *testing.T) {
cases := []struct {
loc uls.Location
want string
}{
{uls.Location{State: "MA", County: "Middlesex"}, "MA,Middlesex"},
{uls.Location{State: "", County: "Middlesex"}, ""},
{uls.Location{State: "MA", County: ""}, ""},
}
for _, c := range cases {
if got := c.loc.CNTY(); got != c.want {
t.Errorf("Location%+v.CNTY() = %q, want %q", c.loc, got, c.want)
}
}
}