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
|
||||
keyExtHRDLogAutoUpload = "extsvc.hrdlog.auto_upload"
|
||||
keyExtHRDLogUploadMode = "extsvc.hrdlog.upload_mode"
|
||||
keyExtHRDLogOnAir = "extsvc.hrdlog.on_air" // publish frequency/mode/rig live on hrdlog.net
|
||||
|
||||
keyExtEQSLUsername = "extsvc.eqsl.username"
|
||||
keyExtEQSLPassword = "extsvc.eqsl.password"
|
||||
@@ -9912,7 +9913,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode, keyExtHRDLogOnAir,
|
||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote)
|
||||
@@ -9966,6 +9967,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
Code: m[keyExtHRDLogCode],
|
||||
AutoUpload: m[keyExtHRDLogAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtHRDLogUploadMode]),
|
||||
OnAir: m[keyExtHRDLogOnAir] == "1",
|
||||
}
|
||||
// Default the HRDLog callsign to the active profile's call when unset.
|
||||
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),
|
||||
keyExtHRDLogAutoUpload: hlAuto,
|
||||
keyExtHRDLogUploadMode: hlMode,
|
||||
keyExtHRDLogOnAir: boolStr(cfg.HRDLog.OnAir),
|
||||
|
||||
keyExtEQSLUsername: strings.ToUpper(strings.TrimSpace(cfg.EQSL.Username)),
|
||||
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} />
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -77,37 +77,19 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||
</div>
|
||||
<div className="p-3 flex gap-4 items-start">
|
||||
<div className="flex flex-col gap-2">
|
||||
<RotorCompass
|
||||
bearing={bearing ?? null}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
centerLat={centerLat ?? null}
|
||||
centerLon={centerLon ?? null}
|
||||
rotorEnabled={hd.ok}
|
||||
rotors={hd.rotors}
|
||||
activeRotor={hd.active}
|
||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||
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>
|
||||
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||||
app carries it rather than each caller drawing its own. */}
|
||||
<RotorCompass
|
||||
bearing={bearing ?? null}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
centerLat={centerLat ?? null}
|
||||
centerLon={centerLon ?? null}
|
||||
rotorEnabled={hd.ok}
|
||||
rotors={hd.rotors}
|
||||
activeRotor={hd.active}
|
||||
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||
onGoto={(az) => turn(az)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="font-mono">
|
||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||
|
||||
@@ -1331,6 +1331,7 @@ export namespace extsvc {
|
||||
upload_flags: string[];
|
||||
write_log: boolean;
|
||||
auto_upload: boolean;
|
||||
on_air: boolean;
|
||||
upload_mode: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -1355,6 +1356,7 @@ export namespace extsvc {
|
||||
this.upload_flags = source["upload_flags"];
|
||||
this.write_log = source["write_log"];
|
||||
this.auto_upload = source["auto_upload"];
|
||||
this.on_air = source["on_air"];
|
||||
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)
|
||||
}
|
||||
+19
-17
@@ -65,23 +65,25 @@ const (
|
||||
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||
type ServiceConfig struct {
|
||||
APIKey string `json:"api_key"`
|
||||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||
Code string `json:"code"` // HRDLog: account upload code
|
||||
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||
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
|
||||
AutoUpload bool `json:"auto_upload"`
|
||||
UploadMode UploadMode `json:"upload_mode"`
|
||||
APIKey string `json:"api_key"`
|
||||
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||
Code string `json:"code"` // HRDLog: account upload code
|
||||
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||
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
|
||||
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"`
|
||||
}
|
||||
|
||||
// normalised returns the config with whitespace trimmed and a valid upload
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -143,3 +144,59 @@ func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (st
|
||||
}
|
||||
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