chore: release v0.19.3
This commit is contained in:
@@ -47,6 +47,7 @@ import (
|
|||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
|
"hamlog/internal/solar"
|
||||||
"hamlog/internal/ultrabeam"
|
"hamlog/internal/ultrabeam"
|
||||||
"hamlog/internal/winkeyer"
|
"hamlog/internal/winkeyer"
|
||||||
|
|
||||||
@@ -420,6 +421,7 @@ type App struct {
|
|||||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||||
|
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||||
|
|
||||||
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
// NET Control: persistent net definitions/rosters (global JSON) + the live
|
||||||
// session (in-memory only — active stations currently in QSO).
|
// session (in-memory only — active stations currently in QSO).
|
||||||
@@ -874,6 +876,16 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Live space-weather (solar flux, sunspots, A/K indices) for the header strip
|
||||||
|
// and per-QSO stamping. Refreshes in the background; pushes a UI event on each
|
||||||
|
// update so the strip re-reads.
|
||||||
|
a.solar = solar.NewManager(func() {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "solar:update")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
a.solar.Start()
|
||||||
|
|
||||||
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
|
// Digital Voice Keyer + QSO recorder (WASAPI). Idle until used.
|
||||||
a.audioMgr = audio.NewManager(func() {
|
a.audioMgr = audio.NewManager(func() {
|
||||||
st := a.dvkStatus()
|
st := a.dvkStatus()
|
||||||
@@ -1116,6 +1128,9 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
a.udp.StopAll()
|
a.udp.StopAll()
|
||||||
}
|
}
|
||||||
|
if a.solar != nil {
|
||||||
|
a.solar.Stop()
|
||||||
|
}
|
||||||
// Stop CAT so the backend disconnects cleanly. Critical for the Icom network
|
// Stop CAT so the backend disconnects cleanly. Critical for the Icom network
|
||||||
// backend: without this the rig never gets a disconnect and holds its single
|
// backend: without this the rig never gets a disconnect and holds its single
|
||||||
// control session for minutes, refusing every new login (even from the Icom
|
// control session for minutes, refusing every new login (even from the Icom
|
||||||
@@ -1643,6 +1658,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
||||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||||
a.applyQSLDefaults(&q)
|
a.applyQSLDefaults(&q)
|
||||||
|
a.applySolar(&q) // stamp SFI / A / K (and SSN as an extra) from live space-weather
|
||||||
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
// Fill the contacted operator's e-mail from the (cached) lookup so the
|
||||||
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
// recording can be auto-sent. Cheap: the entry already looked the call up.
|
||||||
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
if strings.TrimSpace(q.Email) == "" && a.lookup != nil {
|
||||||
@@ -1666,6 +1682,58 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySolar stamps the current space-weather onto a QSO before it's saved: the
|
||||||
|
// standard ADIF SFI / A_INDEX / K_INDEX fields (only when the QSO doesn't already
|
||||||
|
// carry them), and the sunspot number as an APP_OPSLOG_SSN extra (SSN has no
|
||||||
|
// standard ADIF field). No-op when the feed hasn't loaded yet.
|
||||||
|
func (a *App) applySolar(q *qso.QSO) {
|
||||||
|
if a.solar == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d := a.solar.Get()
|
||||||
|
if !d.OK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.SFI == nil {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(d.SFI), 64); err == nil {
|
||||||
|
q.SFI = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.AIndex == nil {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(d.AIndex), 64); err == nil {
|
||||||
|
q.AIndex = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.KIndex == nil {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(d.KIndex), 64); err == nil {
|
||||||
|
q.KIndex = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ssn := strings.TrimSpace(d.SSN); ssn != "" {
|
||||||
|
if q.Extras == nil {
|
||||||
|
q.Extras = map[string]string{}
|
||||||
|
}
|
||||||
|
if _, ok := q.Extras["APP_OPSLOG_SSN"]; !ok {
|
||||||
|
q.Extras["APP_OPSLOG_SSN"] = ssn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSolarData returns the latest space-weather snapshot for the header strip.
|
||||||
|
func (a *App) GetSolarData() solar.Data {
|
||||||
|
if a.solar == nil {
|
||||||
|
return solar.Data{}
|
||||||
|
}
|
||||||
|
return a.solar.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshSolar forces an immediate re-fetch of the space-weather feed.
|
||||||
|
func (a *App) RefreshSolar() {
|
||||||
|
if a.solar != nil {
|
||||||
|
go a.solar.Refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// StationInfoComputed bundles the data we resolve live from the
|
// StationInfoComputed bundles the data we resolve live from the
|
||||||
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
|
// profile's callsign + grid: country, ARRL DXCC#, CQ zone, ITU zone,
|
||||||
// lat/lon. Used by the Settings UI to show the "what will be stamped on
|
// lat/lon. Used by the Settings UI to show the "what will be stamped on
|
||||||
|
|||||||
+41
-1
@@ -26,6 +26,7 @@ import {
|
|||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
GetCATSettings,
|
GetCATSettings,
|
||||||
|
GetSolarData,
|
||||||
OperatingDefaultForBand,
|
OperatingDefaultForBand,
|
||||||
LogUDPLoggedADIF,
|
LogUDPLoggedADIF,
|
||||||
ListCountries,
|
ListCountries,
|
||||||
@@ -371,6 +372,17 @@ export default function App() {
|
|||||||
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
|
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
|
||||||
// rig is off so power-ON can't work).
|
// rig is off so power-ON can't work).
|
||||||
const [catBackend, setCatBackend] = useState('');
|
const [catBackend, setCatBackend] = useState('');
|
||||||
|
// Live space-weather (solar flux / sunspots / A / K) for the header strip.
|
||||||
|
// Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow
|
||||||
|
// fallback poll. These same numbers are stamped onto each logged QSO.
|
||||||
|
const [solar, setSolar] = useState<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => { GetSolarData().then((d) => setSolar(d ?? null)).catch(() => {}); };
|
||||||
|
load();
|
||||||
|
const off = EventsOn('solar:update', load);
|
||||||
|
const id = window.setInterval(load, 60 * 60 * 1000); // hourly fallback; the event refreshes it live
|
||||||
|
return () => { off(); window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
||||||
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
||||||
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
|
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
|
||||||
@@ -3222,7 +3234,7 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
) : (
|
) : (
|
||||||
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
<header className="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
||||||
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
<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)]" />
|
<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)]" />
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
@@ -3445,6 +3457,34 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Space-weather / propagation — compact, in the header. Live from N0NBH
|
||||||
|
(hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
|
||||||
|
onto each logged QSO. Always renders one element so the grid columns
|
||||||
|
stay aligned (empty span until the feed loads). */}
|
||||||
|
{solar && solar.ok ? (
|
||||||
|
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
||||||
|
title={`${t('prop.title')}${solar.updated ? ' · ' + solar.updated : ''}`}>
|
||||||
|
{(() => {
|
||||||
|
const geo = String(solar.geomag_field || '').toUpperCase();
|
||||||
|
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
|
||||||
|
: /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
|
||||||
|
const it = (label: string, val: any, cls = 'text-foreground') => (
|
||||||
|
<span className="inline-flex items-baseline gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
|
||||||
|
<span className={cn('font-bold text-[12px]', cls)}>{(val ?? '') === '' ? '—' : val}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (<>
|
||||||
|
{it('SFI', solar.sfi)}
|
||||||
|
{it('SSN', solar.ssn)}
|
||||||
|
{it('A', solar.a_index)}
|
||||||
|
{it('K', solar.k_index)}
|
||||||
|
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
|
||||||
|
</>);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : <span />}
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||||
<Clock className="size-3" />
|
<Clock className="size-3" />
|
||||||
{utcNow}<span className="text-[10px]">UTC</span>
|
{utcNow}<span className="text-[10px]">UTC</span>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Dict = Record<string, string>;
|
|||||||
|
|
||||||
const en: Dict = {
|
const en: Dict = {
|
||||||
// Menu bar
|
// Menu bar
|
||||||
|
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
||||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||||
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
||||||
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
||||||
@@ -223,6 +224,7 @@ const en: Dict = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fr: Dict = {
|
const fr: Dict = {
|
||||||
|
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
||||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||||
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
||||||
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.19.2';
|
export const APP_VERSION = '0.19.3';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+5
@@ -11,6 +11,7 @@ import {awardref} from '../models';
|
|||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {solar} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
@@ -344,6 +345,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
|||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
|
export function GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
@@ -592,6 +595,8 @@ export function QuitApp():Promise<void>;
|
|||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -650,6 +650,10 @@ export function GetSecretStatus() {
|
|||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSolarData() {
|
||||||
|
return window['go']['main']['App']['GetSolarData']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStartupStatus() {
|
export function GetStartupStatus() {
|
||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
@@ -1146,6 +1150,10 @@ export function RefreshCtyDat() {
|
|||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshSolar() {
|
||||||
|
return window['go']['main']['App']['RefreshSolar']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ReloadUDPIntegrations() {
|
export function ReloadUDPIntegrations() {
|
||||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3298,6 +3298,66 @@ export namespace qso {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace solar {
|
||||||
|
|
||||||
|
export class Data {
|
||||||
|
sfi: string;
|
||||||
|
ssn: string;
|
||||||
|
a_index: string;
|
||||||
|
k_index: string;
|
||||||
|
xray: string;
|
||||||
|
geomag_field: string;
|
||||||
|
aurora: string;
|
||||||
|
muf: string;
|
||||||
|
updated: string;
|
||||||
|
source: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
// Go type: time
|
||||||
|
fetched_at: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Data(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.sfi = source["sfi"];
|
||||||
|
this.ssn = source["ssn"];
|
||||||
|
this.a_index = source["a_index"];
|
||||||
|
this.k_index = source["k_index"];
|
||||||
|
this.xray = source["xray"];
|
||||||
|
this.geomag_field = source["geomag_field"];
|
||||||
|
this.aurora = source["aurora"];
|
||||||
|
this.muf = source["muf"];
|
||||||
|
this.updated = source["updated"];
|
||||||
|
this.source = source["source"];
|
||||||
|
this.ok = source["ok"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 namespace udp {
|
export namespace udp {
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|||||||
+70
-6
@@ -61,9 +61,12 @@ const (
|
|||||||
eqslBaseURL = "https://www.eQSL.cc"
|
eqslBaseURL = "https://www.eQSL.cc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// eqslAdiHrefRe pulls the generated .adi path out of the DownloadInBox reply,
|
// eqslAdiHrefRe pulls the generated .adi link out of the DownloadInBox reply.
|
||||||
// e.g. `<A HREF="/qslcard/downloadedfiles/xxxx.adi">`.
|
// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes,
|
||||||
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href="(/[^"]+\.adi)"`)
|
// e.g. `<A HREF=/downloadedFiles/xxxx.adi>` or `<a href="…/xxxx.adi">`, and the
|
||||||
|
// path may be root-relative or a bare filename. Match case-insensitively with
|
||||||
|
// optional quoting; resolve to an absolute URL afterwards.
|
||||||
|
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.adi)`)
|
||||||
|
|
||||||
// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs =
|
// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs =
|
||||||
// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since
|
// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since
|
||||||
@@ -97,18 +100,79 @@ func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg Ser
|
|||||||
}
|
}
|
||||||
m := eqslAdiHrefRe.FindStringSubmatch(page)
|
m := eqslAdiHrefRe.FindStringSubmatch(page)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
// eQSL reports problems inline ("Error: …", or "no QSLs" wording).
|
// eQSL reports problems inline ("Error: …", "You have no … confirmations").
|
||||||
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslReason(page))
|
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so
|
||||||
|
// the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\
|
||||||
|
// xxx.adi`); normalise those. It may also be absolute or a bare filename.
|
||||||
|
link := strings.TrimSpace(m[1])
|
||||||
|
link = strings.ReplaceAll(link, `\`, "/")
|
||||||
|
link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…"
|
||||||
|
var fileURL string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(strings.ToLower(link), "http"):
|
||||||
|
fileURL = link
|
||||||
|
case strings.HasPrefix(link, "/"):
|
||||||
|
fileURL = eqslBaseURL + link
|
||||||
|
default:
|
||||||
|
fileURL = eqslBaseURL + "/" + link
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: fetch the generated .adi file.
|
// Step 2: fetch the generated .adi file.
|
||||||
adif, err := eqslGet(ctx, client, eqslBaseURL+m[1])
|
adif, err := eqslGet(ctx, client, fileURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
|
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
|
||||||
}
|
}
|
||||||
return adif, nil
|
return adif, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's
|
||||||
|
// own error/notice text if present, else a short tag-stripped snippet of the page
|
||||||
|
// so the real wording (e.g. "You have no Inbox items") is visible in the log.
|
||||||
|
func eqslNoLinkReason(page string) string {
|
||||||
|
low := strings.ToLower(page)
|
||||||
|
for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} {
|
||||||
|
if i := strings.Index(low, marker); i >= 0 {
|
||||||
|
seg := page[i:]
|
||||||
|
if len(seg) > 160 {
|
||||||
|
seg = seg[:160]
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(stripTags(seg)), " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txt := strings.Join(strings.Fields(stripTags(page)), " ")
|
||||||
|
if len(txt) > 200 {
|
||||||
|
txt = txt[:200]
|
||||||
|
}
|
||||||
|
if txt == "" {
|
||||||
|
txt = "empty response"
|
||||||
|
}
|
||||||
|
return txt
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripTags removes HTML tags for a readable one-line diagnostic.
|
||||||
|
func stripTags(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '<':
|
||||||
|
depth++
|
||||||
|
case '>':
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if depth == 0 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on
|
// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on
|
||||||
// a non-200 status.
|
// a non-200 status.
|
||||||
func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) {
|
func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) {
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Package solar fetches live space-weather / propagation numbers (solar flux,
|
||||||
|
// sunspot number, A/K indices, X-ray, geomagnetic field) from N0NBH's public
|
||||||
|
// hamqsl.com XML feed, caches them, and refreshes periodically. OpsLog shows the
|
||||||
|
// numbers in a header strip and stamps SFI / A_INDEX / K_INDEX onto each logged
|
||||||
|
// QSO (SSN has no standard ADIF field — the app stores it as an extra).
|
||||||
|
package solar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// feedURL is N0NBH's solar data as XML. Free, no key.
|
||||||
|
const feedURL = "https://www.hamqsl.com/solarxml.php"
|
||||||
|
|
||||||
|
// Data is the parsed snapshot exposed to the UI (strings — some fields read
|
||||||
|
// "No Report" or carry units, so we keep them verbatim and parse to numbers only
|
||||||
|
// when stamping a QSO).
|
||||||
|
type Data struct {
|
||||||
|
SFI string `json:"sfi"` // solar flux index
|
||||||
|
SSN string `json:"ssn"` // sunspot number
|
||||||
|
AIndex string `json:"a_index"` // geomagnetic A index
|
||||||
|
KIndex string `json:"k_index"` // geomagnetic K index
|
||||||
|
XRay string `json:"xray"` // X-ray flux class (e.g. C1.5)
|
||||||
|
GeomagField string `json:"geomag_field"` // QUIET / UNSETTLED / STORM…
|
||||||
|
Aurora string `json:"aurora"`
|
||||||
|
MUF string `json:"muf"`
|
||||||
|
Updated string `json:"updated"` // source timestamp text
|
||||||
|
Source string `json:"source"` // e.g. N0NBH
|
||||||
|
OK bool `json:"ok"` // last fetch succeeded
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// xmlSolar mirrors the hamqsl solarxml.php document.
|
||||||
|
type xmlSolar struct {
|
||||||
|
XMLName xml.Name `xml:"solar"`
|
||||||
|
SolarData struct {
|
||||||
|
Source string `xml:"source"`
|
||||||
|
Updated string `xml:"updated"`
|
||||||
|
SolarFlux string `xml:"solarflux"`
|
||||||
|
AIndex string `xml:"aindex"`
|
||||||
|
KIndex string `xml:"kindex"`
|
||||||
|
XRay string `xml:"xray"`
|
||||||
|
Sunspots string `xml:"sunspots"`
|
||||||
|
Aurora string `xml:"aurora"`
|
||||||
|
GeomagField string `xml:"geomagfield"`
|
||||||
|
MUF string `xml:"muf"`
|
||||||
|
} `xml:"solardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch performs one GET + parse of the feed.
|
||||||
|
func Fetch(ctx context.Context, client *http.Client) (Data, error) {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, fmt.Errorf("solar: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, fmt.Errorf("solar: read: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return Data{}, fmt.Errorf("solar: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return parseSolar(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSolar turns the feed's XML bytes into a Data (split out so it can be
|
||||||
|
// unit-tested without a network call).
|
||||||
|
func parseSolar(body []byte) (Data, error) {
|
||||||
|
var x xmlSolar
|
||||||
|
if err := xml.Unmarshal(body, &x); err != nil {
|
||||||
|
return Data{}, fmt.Errorf("solar: parse: %w", err)
|
||||||
|
}
|
||||||
|
s := x.SolarData
|
||||||
|
return Data{
|
||||||
|
SFI: strings.TrimSpace(s.SolarFlux),
|
||||||
|
SSN: strings.TrimSpace(s.Sunspots),
|
||||||
|
AIndex: strings.TrimSpace(s.AIndex),
|
||||||
|
KIndex: strings.TrimSpace(s.KIndex),
|
||||||
|
XRay: strings.TrimSpace(s.XRay),
|
||||||
|
GeomagField: strings.TrimSpace(s.GeomagField),
|
||||||
|
Aurora: strings.TrimSpace(s.Aurora),
|
||||||
|
MUF: strings.TrimSpace(s.MUF),
|
||||||
|
Updated: strings.TrimSpace(s.Updated),
|
||||||
|
Source: strings.TrimSpace(s.Source),
|
||||||
|
OK: true,
|
||||||
|
FetchedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager caches the latest Data and refreshes it on a timer.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
data Data
|
||||||
|
client *http.Client
|
||||||
|
onChange func()
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager builds a manager. onChange (optional) fires after each refresh so
|
||||||
|
// the host can push a UI event.
|
||||||
|
func NewManager(onChange func()) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
client: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
onChange: onChange,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start fetches now and every hour until Stop (the feed updates roughly hourly).
|
||||||
|
func (m *Manager) Start() {
|
||||||
|
go func() {
|
||||||
|
m.refresh()
|
||||||
|
t := time.NewTicker(1 * time.Hour)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
m.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop ends the refresh loop.
|
||||||
|
func (m *Manager) Stop() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.once.Do(func() { close(m.stop) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the latest snapshot.
|
||||||
|
func (m *Manager) Get() Data {
|
||||||
|
if m == nil {
|
||||||
|
return Data{}
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh forces an immediate fetch (used by a manual "refresh" binding).
|
||||||
|
func (m *Manager) Refresh() { m.refresh() }
|
||||||
|
|
||||||
|
func (m *Manager) refresh() {
|
||||||
|
d, err := Fetch(context.Background(), m.client)
|
||||||
|
m.mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
m.data.OK = false
|
||||||
|
m.data.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
m.data = d
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if m.onChange != nil {
|
||||||
|
m.onChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package solar
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A trimmed sample of the real N0NBH hamqsl.com solarxml.php document.
|
||||||
|
const sampleXML = `<?xml version="1.0"?>
|
||||||
|
<solar>
|
||||||
|
<solardata>
|
||||||
|
<source url="http://www.hamqsl.com/solar.html">N0NBH</source>
|
||||||
|
<updated>08 Jul 2025 2100 GMT</updated>
|
||||||
|
<solarflux>170</solarflux>
|
||||||
|
<aindex>5</aindex>
|
||||||
|
<kindex>2</kindex>
|
||||||
|
<xray>C1.5</xray>
|
||||||
|
<sunspots>150</sunspots>
|
||||||
|
<geomagfield>QUIET</geomagfield>
|
||||||
|
<muf>18.5</muf>
|
||||||
|
</solardata>
|
||||||
|
</solar>`
|
||||||
|
|
||||||
|
func TestParseSolar(t *testing.T) {
|
||||||
|
d, err := parseSolar([]byte(sampleXML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if !d.OK {
|
||||||
|
t.Fatalf("expected OK")
|
||||||
|
}
|
||||||
|
checks := map[string]string{
|
||||||
|
"SFI": d.SFI, "SSN": d.SSN, "A": d.AIndex, "K": d.KIndex,
|
||||||
|
"XRay": d.XRay, "Geomag": d.GeomagField, "Source": d.Source,
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"SFI": "170", "SSN": "150", "A": "5", "K": "2",
|
||||||
|
"XRay": "C1.5", "Geomag": "QUIET", "Source": "N0NBH",
|
||||||
|
}
|
||||||
|
for k, got := range checks {
|
||||||
|
if got != want[k] {
|
||||||
|
t.Errorf("%s = %q, want %q", k, got, want[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSolarBadXML(t *testing.T) {
|
||||||
|
if _, err := parseSolar([]byte("not xml")); err == nil {
|
||||||
|
t.Fatalf("expected an error for non-XML input")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.19.2"
|
appVersion = "0.19.3"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user