fix(rotator): put the SP/LP readout inside the compass itself
It went under the Station Control rotator; the compass that wanted it is the docked one beside the entry strip. Both are the same RotorCompass, so the readout moves INTO that component: every compass in the app carries it, and no caller draws its own. The bezel already showed the short path as a red marker, but a marker is a direction, not a number — and the numbers in the status bar are 10px, which is what prompted this. Clickable only when the caller passed onGoto, since a compass rendered without one cannot turn anything and a button that does nothing is worse than a label.
This commit is contained in:
@@ -354,6 +354,7 @@ const (
|
|||||||
keyExtHRDLogCode = "extsvc.hrdlog.code" // HRDLog account upload code
|
keyExtHRDLogCode = "extsvc.hrdlog.code" // HRDLog account upload code
|
||||||
keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload"
|
keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload"
|
||||||
keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode"
|
keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode"
|
||||||
|
keyExtHRDLogOnAir = "extsvc.hrdlog.on_air" // publish frequency/mode/rig live on hrdlog.net
|
||||||
|
|
||||||
keyExtEQSLUsername = "extsvc.eqsl.username"
|
keyExtEQSLUsername = "extsvc.eqsl.username"
|
||||||
keyExtEQSLPassword = "extsvc.eqsl.password"
|
keyExtEQSLPassword = "extsvc.eqsl.password"
|
||||||
@@ -9912,7 +9913,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
||||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||||
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
||||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode, keyExtHRDLogOnAir,
|
||||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||||
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
|
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
|
||||||
@@ -9966,6 +9967,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
Code: m[keyExtHRDLogCode],
|
Code: m[keyExtHRDLogCode],
|
||||||
AutoUpload: m[keyExtHRDLogAutoUpload] == "1",
|
AutoUpload: m[keyExtHRDLogAutoUpload] == "1",
|
||||||
UploadMode: extsvc.UploadMode(m[keyExtHRDLogUploadMode]),
|
UploadMode: extsvc.UploadMode(m[keyExtHRDLogUploadMode]),
|
||||||
|
OnAir: m[keyExtHRDLogOnAir] == "1",
|
||||||
}
|
}
|
||||||
// Default the HRDLog callsign to the active profile's call when unset.
|
// Default the HRDLog callsign to the active profile's call when unset.
|
||||||
if out.HRDLog.Callsign == "" && a.profiles != nil {
|
if out.HRDLog.Callsign == "" && a.profiles != nil {
|
||||||
@@ -10095,6 +10097,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
keyExtHRDLogCode: strings.TrimSpace(cfg.HRDLog.Code),
|
keyExtHRDLogCode: strings.TrimSpace(cfg.HRDLog.Code),
|
||||||
keyExtHRDLogAutoUpload: hlAuto,
|
keyExtHRDLogAutoUpload: hlAuto,
|
||||||
keyExtHRDLogUploadMode: hlMode,
|
keyExtHRDLogUploadMode: hlMode,
|
||||||
|
keyExtHRDLogOnAir: boolStr(cfg.HRDLog.OnAir),
|
||||||
|
|
||||||
keyExtEQSLUsername: strings.ToUpper(strings.TrimSpace(cfg.EQSL.Username)),
|
keyExtEQSLUsername: strings.ToUpper(strings.TrimSpace(cfg.EQSL.Username)),
|
||||||
keyExtEQSLPassword: cfg.EQSL.Password,
|
keyExtEQSLPassword: cfg.EQSL.Password,
|
||||||
|
|||||||
@@ -188,6 +188,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
); })}
|
); })}
|
||||||
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{/* Short and long path to the DX, in figures.
|
||||||
|
The bezel already carries the short path as a red marker, but a
|
||||||
|
marker is a direction, not a number — the same pair sits in the
|
||||||
|
status bar at 10px and operators reported not being able to read it.
|
||||||
|
Here because it belongs to the compass: every place that draws one
|
||||||
|
gets the readout, instead of each caller inventing its own.
|
||||||
|
Clickable when the caller can turn, like the status bar's. */}
|
||||||
|
<div className="flex gap-1.5 mt-2 font-mono">
|
||||||
|
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
||||||
|
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
||||||
|
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
||||||
|
className={
|
||||||
|
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors ' +
|
||||||
|
(az == null
|
||||||
|
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
||||||
|
: onGoto
|
||||||
|
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
||||||
|
: 'border-border text-muted-foreground cursor-default')
|
||||||
|
}>
|
||||||
|
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex gap-4 items-start">
|
<div className="p-3 flex gap-4 items-start">
|
||||||
<div className="flex flex-col gap-2">
|
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||||||
|
app carries it rather than each caller drawing its own. */}
|
||||||
<RotorCompass
|
<RotorCompass
|
||||||
bearing={bearing ?? null}
|
bearing={bearing ?? null}
|
||||||
headings={hd.ok ? [hd.azimuth] : []}
|
headings={hd.ok ? [hd.azimuth] : []}
|
||||||
@@ -89,25 +90,6 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||||
onGoto={(az) => turn(az)}
|
onGoto={(az) => turn(az)}
|
||||||
/>
|
/>
|
||||||
{/* Short and long path to the station being worked, repeated here at a
|
|
||||||
readable size: the same pair sits in the status bar, where it is 10px
|
|
||||||
and several operators reported not being able to read it.
|
|
||||||
Clickable, like the ones up there — a heading you can see and not
|
|
||||||
act on would be a step backwards. */}
|
|
||||||
<div className="flex gap-1.5 font-mono">
|
|
||||||
{([['SP', bearing], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
|
||||||
<button key={lbl} type="button" disabled={az == null}
|
|
||||||
onClick={() => az != null && turn(az)}
|
|
||||||
title={az == null ? '' : t('station.rotateTo', { az: Math.round(az) })}
|
|
||||||
className={cn('flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors',
|
|
||||||
az == null
|
|
||||||
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
|
||||||
: 'border-info-border text-info-muted-foreground hover:bg-info-muted')}>
|
|
||||||
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<div className="font-mono">
|
<div className="font-mono">
|
||||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||||
|
|||||||
@@ -1331,6 +1331,7 @@ export namespace extsvc {
|
|||||||
upload_flags: string[];
|
upload_flags: string[];
|
||||||
write_log: boolean;
|
write_log: boolean;
|
||||||
auto_upload: boolean;
|
auto_upload: boolean;
|
||||||
|
on_air: boolean;
|
||||||
upload_mode: string;
|
upload_mode: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -1355,6 +1356,7 @@ export namespace extsvc {
|
|||||||
this.upload_flags = source["upload_flags"];
|
this.upload_flags = source["upload_flags"];
|
||||||
this.write_log = source["write_log"];
|
this.write_log = source["write_log"];
|
||||||
this.auto_upload = source["auto_upload"];
|
this.auto_upload = source["auto_upload"];
|
||||||
|
this.on_air = source["on_air"];
|
||||||
this.upload_mode = source["upload_mode"];
|
this.upload_mode = source["upload_mode"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// HRDLog "ON AIR" — publishing the live frequency, mode and rig on hrdlog.net,
|
||||||
|
// so the site shows "F4BPO is on air 14,074,000 FT8 IC-7300" while the station
|
||||||
|
// is operating.
|
||||||
|
//
|
||||||
|
// The endpoint and its field names come from HRDLog's own library
|
||||||
|
// (github.com/iw1qlh/HRDLOG-net-library), not from reading someone's traffic.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/extsvc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// onAirEvery is how often the status is refreshed. HRDLog drops a station from
|
||||||
|
// the on-air list when it stops hearing from it, so this is a heartbeat, not a
|
||||||
|
// change notification.
|
||||||
|
const onAirEvery = 2 * time.Minute
|
||||||
|
|
||||||
|
// hrdlogOnAirLoop publishes while the option is on and the rig is readable.
|
||||||
|
func (a *App) hrdlogOnAirLoop() {
|
||||||
|
defer func() { _ = recover() }() // never crash the app from a status broadcast
|
||||||
|
t := time.NewTicker(onAirEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
a.publishHRDLogOnAir()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishHRDLogOnAir sends one update. Silent when switched off, unconfigured,
|
||||||
|
// or when the rig has nothing to report — an announcement with no frequency
|
||||||
|
// would say the operator is on air on 0 Hz.
|
||||||
|
func (a *App) publishHRDLogOnAir() {
|
||||||
|
cfg := a.loadExternalServices().HRDLog
|
||||||
|
if !cfg.OnAir || strings.TrimSpace(cfg.Callsign) == "" || strings.TrimSpace(cfg.Code) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := a.cat.State()
|
||||||
|
if !st.Connected || st.FreqHz <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The rig NAME as the operator knows it — that string is what the site
|
||||||
|
// displays. The active profile's MyRig first, since it is what every QSO is
|
||||||
|
// stamped with; the CAT backend's own model only when the profile has none.
|
||||||
|
radio := ""
|
||||||
|
if a.profiles != nil {
|
||||||
|
if pr, err := a.profiles.Active(a.ctx); err == nil {
|
||||||
|
radio = strings.TrimSpace(pr.MyRig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if radio == "" {
|
||||||
|
radio = strings.TrimSpace(st.Rig)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
msg, err := extsvc.SendHRDLogOnAir(ctx, nil, cfg.Callsign, cfg.Code, st.FreqHz, st.Mode, radio)
|
||||||
|
if err != nil {
|
||||||
|
// Logged, never surfaced: this is a broadcast the operator did not ask
|
||||||
|
// for at this instant, and a toast every two minutes on a flaky link
|
||||||
|
// would be worse than the missing status.
|
||||||
|
applog.Printf("hrdlog on-air: %v (%s)", err, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("hrdlog on-air: %s %.3f MHz %s (%s) — %s",
|
||||||
|
cfg.Callsign, float64(st.FreqHz)/1e6, st.Mode, radio, msg)
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ type ServiceConfig struct {
|
|||||||
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
||||||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||||
AutoUpload bool `json:"auto_upload"`
|
AutoUpload bool `json:"auto_upload"`
|
||||||
|
// OnAir: HRDLog only — publish the live frequency, mode and rig on the site.
|
||||||
|
OnAir bool `json:"on_air"`
|
||||||
UploadMode UploadMode `json:"upload_mode"`
|
UploadMode UploadMode `json:"upload_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -143,3 +144,59 @@ func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (st
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("Credentials accepted — %s", callsign), nil
|
return fmt.Sprintf("Credentials accepted — %s", callsign), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hrdlogOnAirURL is HRDLog's live-status endpoint — what puts "F4BPO is on air
|
||||||
|
// 14,074,000 FM IC-7300" on the site's front page.
|
||||||
|
//
|
||||||
|
// Endpoint and field names taken from HRDLog's own library (github.com/iw1qlh/
|
||||||
|
// HRDLOG-net-library, HrdProtocol.SendOnAirAsync), not from guesswork: it posts
|
||||||
|
// Frequency in Hz, Mode, Radio, Callsign, Code and App.
|
||||||
|
//
|
||||||
|
// HTTPS where that library uses plain HTTP. The upload code is a credential and
|
||||||
|
// has no business crossing the network in clear; the sibling NewEntry endpoint
|
||||||
|
// on the same host already serves TLS.
|
||||||
|
const hrdlogOnAirURL = "https://robot.hrdlog.net/OnAir.aspx"
|
||||||
|
|
||||||
|
// SendHRDLogOnAir publishes the current frequency, mode and rig.
|
||||||
|
//
|
||||||
|
// Deliberately fire-and-forget in spirit: it is a status broadcast, so a
|
||||||
|
// failure is logged and never surfaced as an error the operator must clear —
|
||||||
|
// but the message is returned so the caller can log WHAT the site said rather
|
||||||
|
// than only that something went wrong.
|
||||||
|
func SendHRDLogOnAir(ctx context.Context, client *http.Client, callsign, code string, freqHz int64, mode, radio string) (string, error) {
|
||||||
|
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if callsign == "" || code == "" {
|
||||||
|
return "", fmt.Errorf("hrdlog: callsign and upload code required")
|
||||||
|
}
|
||||||
|
if freqHz <= 0 {
|
||||||
|
return "", fmt.Errorf("hrdlog: no frequency to announce")
|
||||||
|
}
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("Callsign", callsign)
|
||||||
|
form.Set("Code", code)
|
||||||
|
form.Set("App", hrdlogApp)
|
||||||
|
form.Set("Frequency", strconv.FormatInt(freqHz, 10))
|
||||||
|
form.Set("Mode", strings.TrimSpace(mode))
|
||||||
|
form.Set("Radio", strings.TrimSpace(radio))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hrdlogOnAirURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hrdlog on-air: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 15 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hrdlog on-air: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return msg, fmt.Errorf("hrdlog on-air: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user