Compare commits
3
Commits
9156acea5f
...
82a2c6cb7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82a2c6cb7f | ||
|
|
24eaf597fd | ||
|
|
14a22ddb66 |
+9
-15
@@ -1096,23 +1096,17 @@ export default function App() {
|
|||||||
// offline. Only shown when live-status publishing is enabled (Settings→General).
|
// offline. Only shown when live-status publishing is enabled (Settings→General).
|
||||||
const [liveStatusOn, setLiveStatusOn] = useState(false);
|
const [liveStatusOn, setLiveStatusOn] = useState(false);
|
||||||
const [onAir, setOnAir] = useState(false);
|
const [onAir, setOnAir] = useState(false);
|
||||||
const lastQsoAtRef = useRef(0);
|
|
||||||
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
|
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const LIVE_WINDOW = 5 * 60 * 1000; // 5 min, matches the backend
|
// Read the ON-AIR state straight from the backend (single source of truth:
|
||||||
const evalOnAir = () => setOnAir(liveStatusOn && lastQsoAtRef.current > 0 && (Date.now() - lastQsoAtRef.current) < LIVE_WINDOW);
|
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
|
||||||
const off = EventsOn('qso:logged', () => { lastQsoAtRef.current = Date.now(); evalOnAir(); });
|
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
|
||||||
// Seed from the DB at launch so a QSO logged just before starting OpsLog still
|
const refresh = () => LiveLastQSOAgeSec()
|
||||||
// counts (otherwise the badge showed offline until the next contact).
|
.then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
|
||||||
LiveLastQSOAgeSec().then((sec: number) => {
|
.catch(() => {});
|
||||||
if (typeof sec === 'number' && sec >= 0) {
|
refresh();
|
||||||
const at = Date.now() - sec * 1000;
|
const off = EventsOn('qso:logged', refresh);
|
||||||
if (at > lastQsoAtRef.current) lastQsoAtRef.current = at;
|
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
|
||||||
evalOnAir();
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
evalOnAir();
|
|
||||||
const id = window.setInterval(evalOnAir, 10 * 1000); // flip to offline within ~10s of the window elapsing
|
|
||||||
return () => { off(); window.clearInterval(id); };
|
return () => { off(); window.clearInterval(id); };
|
||||||
}, [liveStatusOn]);
|
}, [liveStatusOn]);
|
||||||
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||||
|
|||||||
+2
-2
@@ -793,7 +793,7 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
|
|||||||
return fmt.Errorf("missing id or key")
|
return fmt.Errorf("missing id or key")
|
||||||
}
|
}
|
||||||
var extrasJSON sql.NullString
|
var extrasJSON sql.NullString
|
||||||
if err := r.db.QueryRowContext(ctx, `SELECT extras FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
|
if err := r.db.QueryRowContext(ctx, `SELECT extras_json FROM qso WHERE id = ?`, id).Scan(&extrasJSON); err != nil {
|
||||||
return fmt.Errorf("load extras: %w", err)
|
return fmt.Errorf("load extras: %w", err)
|
||||||
}
|
}
|
||||||
m := decodeExtras(extrasJSON.String)
|
m := decodeExtras(extrasJSON.String)
|
||||||
@@ -806,7 +806,7 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
|
|||||||
m[key] = value
|
m[key] = value
|
||||||
}
|
}
|
||||||
if _, err := r.db.ExecContext(ctx,
|
if _, err := r.db.ExecContext(ctx,
|
||||||
`UPDATE qso SET extras = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE qso SET extras_json = ?, updated_at = ? WHERE id = ?`,
|
||||||
encodeExtras(m), db.NowISO(), id); err != nil {
|
encodeExtras(m), db.NowISO(), id); err != nil {
|
||||||
return fmt.Errorf("set extra %s: %w", key, err)
|
return fmt.Errorf("set extra %s: %w", key, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-4
@@ -87,12 +87,29 @@ func (a *App) seedLiveLastQSO() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
|
// liveLastQSOTime is the authoritative "last contact" instant for this operator:
|
||||||
// none is known — the UI uses it to seed the "on air" badge at launch.
|
// the most recent of the in-memory stamp (this session's local logs, updated
|
||||||
func (a *App) LiveLastQSOAgeSec() int {
|
// instantly) AND the DB (a contact that arrived via the SHARED logbook from another
|
||||||
|
// station, or one logged before launch). Used by both the published status and the
|
||||||
|
// UI badge so on-air/offline is right in every multi-op case.
|
||||||
|
func (a *App) liveLastQSOTime() time.Time {
|
||||||
a.liveActMu.Lock()
|
a.liveActMu.Lock()
|
||||||
last := a.liveLastQSOAt
|
last := a.liveLastQSOAt
|
||||||
a.liveActMu.Unlock()
|
a.liveActMu.Unlock()
|
||||||
|
if a.qso != nil {
|
||||||
|
if op, _ := a.liveStatusOperator(); op != "" {
|
||||||
|
if t, ok := a.qso.LastQSOTime(a.ctx, op); ok && t.After(last) {
|
||||||
|
last = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveLastQSOAgeSec returns seconds since this operator's last logged QSO, or -1 if
|
||||||
|
// none is known — the UI polls it for the "on air" badge.
|
||||||
|
func (a *App) LiveLastQSOAgeSec() int {
|
||||||
|
last := a.liveLastQSOTime()
|
||||||
if last.IsZero() {
|
if last.IsZero() {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
@@ -182,8 +199,8 @@ func (a *App) publishLiveStatus() {
|
|||||||
if mode == "" {
|
if mode == "" {
|
||||||
mode = a.liveMode
|
mode = a.liveMode
|
||||||
}
|
}
|
||||||
lastQSO := a.liveLastQSOAt
|
|
||||||
a.liveActMu.Unlock()
|
a.liveActMu.Unlock()
|
||||||
|
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
|
||||||
// Online = a new contact was logged within the window. An operator who leaves
|
// Online = a new contact was logged within the window. An operator who leaves
|
||||||
// the log open but stops working shows offline after `liveOnlineWindow`; the
|
// the log open but stops working shows offline after `liveOnlineWindow`; the
|
||||||
// next QSO flips them back on. never-logged (zero time) → offline.
|
// next QSO flips them back on. never-logged (zero time) → offline.
|
||||||
|
|||||||
+61
-10
@@ -96,8 +96,23 @@ func bandInList(bands []string, band string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// relayAction is one relay's computed desired state for this evaluation.
|
||||||
|
type relayAction struct {
|
||||||
|
dev string
|
||||||
|
relay int
|
||||||
|
want bool
|
||||||
|
}
|
||||||
|
|
||||||
// applyRelayAuto evaluates every rule against the current frequency/band and
|
// applyRelayAuto evaluates every rule against the current frequency/band and
|
||||||
// switches only the relays whose desired state changed since the last apply.
|
// switches only the relays that are NOT already in the wanted position. Two things
|
||||||
|
// it deliberately does NOT do, which used to make the relay clunk on every
|
||||||
|
// launch/close:
|
||||||
|
// - Never acts on an UNKNOWN frequency/band. When the CAT disconnects (app close)
|
||||||
|
// the frequency drops to 0; reading that as "out of range" and switching the
|
||||||
|
// relay off — then back on at the next launch — was the whole bug.
|
||||||
|
// - Never commands a relay already in the right position: on the first evaluation
|
||||||
|
// after launch/save it reads the boards' LIVE state, so a relay that's already
|
||||||
|
// correct is left untouched instead of being re-sent.
|
||||||
func (a *App) applyRelayAuto(freqHz int64, band string) {
|
func (a *App) applyRelayAuto(freqHz int64, band string) {
|
||||||
a.relayAutoMu.Lock()
|
a.relayAutoMu.Lock()
|
||||||
defer a.relayAutoMu.Unlock()
|
defer a.relayAutoMu.Unlock()
|
||||||
@@ -110,8 +125,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
|||||||
a.relayAutoLast = map[string]bool{}
|
a.relayAutoLast = map[string]bool{}
|
||||||
}
|
}
|
||||||
khz := float64(freqHz) / 1000.0
|
khz := float64(freqHz) / 1000.0
|
||||||
|
band = strings.TrimSpace(band)
|
||||||
|
|
||||||
changed := false
|
// Compute desired states, skipping rules whose input is unknown right now.
|
||||||
|
var acts []relayAction
|
||||||
|
needLive := false
|
||||||
for _, r := range cfg.Rules {
|
for _, r := range cfg.Rules {
|
||||||
if r.Relay < 1 {
|
if r.Relay < 1 {
|
||||||
continue
|
continue
|
||||||
@@ -119,8 +137,11 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
|||||||
var want bool
|
var want bool
|
||||||
switch r.Mode {
|
switch r.Mode {
|
||||||
case "freq":
|
case "freq":
|
||||||
|
if freqHz <= 0 {
|
||||||
|
continue // no known frequency (CAT off/closing) → leave the relay as-is
|
||||||
|
}
|
||||||
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
if r.FreqLoKHz <= 0 && r.FreqHiKHz <= 0 {
|
||||||
continue // unconfigured range → leave the relay alone
|
continue // unconfigured range
|
||||||
}
|
}
|
||||||
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
lo, hi := r.FreqLoKHz, r.FreqHiKHz
|
||||||
if hi < lo {
|
if hi < lo {
|
||||||
@@ -128,6 +149,9 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
|||||||
}
|
}
|
||||||
want = khz >= lo && khz <= hi
|
want = khz >= lo && khz <= hi
|
||||||
case "band":
|
case "band":
|
||||||
|
if band == "" {
|
||||||
|
continue // no known band → leave the relay as-is
|
||||||
|
}
|
||||||
if len(r.Bands) == 0 {
|
if len(r.Bands) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -135,16 +159,43 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
|||||||
default:
|
default:
|
||||||
continue // "off"/empty → not managed
|
continue // "off"/empty → not managed
|
||||||
}
|
}
|
||||||
|
acts = append(acts, relayAction{r.DeviceID, r.Relay, want})
|
||||||
key := relayAutoKey(r.DeviceID, r.Relay)
|
if _, ok := a.relayAutoLast[relayAutoKey(r.DeviceID, r.Relay)]; !ok {
|
||||||
if last, ok := a.relayAutoLast[key]; ok && last == want {
|
needLive = true
|
||||||
continue // no change → don't hammer the board
|
|
||||||
}
|
}
|
||||||
if err := a.StationSetRelay(r.DeviceID, r.Relay, want); err != nil {
|
}
|
||||||
applog.Printf("relay auto: set %s relay %d = %v failed: %v", r.DeviceID, r.Relay, want, err)
|
if len(acts) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First evaluation after launch/save: read the boards' LIVE relay states once
|
||||||
|
// so we don't re-command a relay that's already in the wanted position.
|
||||||
|
var live map[string]bool
|
||||||
|
if needLive {
|
||||||
|
live = map[string]bool{}
|
||||||
|
for _, ds := range a.GetStationStatus() {
|
||||||
|
for _, rl := range ds.Relays {
|
||||||
|
live[relayAutoKey(ds.ID, rl.Number)] = rl.On
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for _, ac := range acts {
|
||||||
|
key := relayAutoKey(ac.dev, ac.relay)
|
||||||
|
cur, known := a.relayAutoLast[key]
|
||||||
|
if !known && live != nil {
|
||||||
|
cur, known = live[key]
|
||||||
|
}
|
||||||
|
if known && cur == ac.want {
|
||||||
|
a.relayAutoLast[key] = ac.want // already in position — record it, don't switch
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := a.StationSetRelay(ac.dev, ac.relay, ac.want); err != nil {
|
||||||
|
applog.Printf("relay auto: set %s relay %d = %v failed: %v", ac.dev, ac.relay, ac.want, err)
|
||||||
continue // don't cache a failed write — retry next change
|
continue // don't cache a failed write — retry next change
|
||||||
}
|
}
|
||||||
a.relayAutoLast[key] = want
|
a.relayAutoLast[key] = ac.want
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user