feat(ftmap): a layer for who hears me
The map drew one direction of every path on it: what this receiver decoded. The reverse — which stations are reporting our own transmissions — is the half an operator cannot see from their own radio, and on FT8 it is the half that decides whether calling is worth the cycle. It is the narrowest slice of the PSK Reporter feed there is. The v2 topic is pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/… so putting the operator's callsign in the TRANSMIT level makes the broker send nothing else. For scale, from internal/pskr's own measured numbers: four bands unfiltered is 83 messages a second, filtered on the receiver's square 0.2 to 1.2 a second — one callsign in the transmit level is a handful per FT8 cycle however open the band is. Both grids are in the payload, so the arc is arithmetic and there is no lookup. internal/pskrme, with its own connection, for the same reason internal/pskrtgt has its own: the three want slices of the feed that cannot be filtered out of one another. It also means this keeps working with the band-opening watch off — hanging it off that feed's lifecycle would have made it fail silently for anyone not chasing openings. Nothing is persisted. One entry per STATION inside a fifteen-minute window, carrying its freshest report: PSK Reporter's uploaders batch, many every five minutes, so a tighter window would show a fraction of who actually heard the last few calls. Stop clears the window, or switching the layer back on would redraw who heard us before it was on. On the map the layer is dashed and single-coloured. Solid is what we decoded, dashed is somebody decoding us; colour alone could not carry that distinction next to fourteen band colours. The receivers are rings rather than filled dots for the same reason. Per profile, since the callsign IS the subscription — a switch resubscribes rather than going on reporting who hears the previous station.
This commit is contained in:
@@ -56,6 +56,7 @@ import (
|
||||
"hamlog/internal/powergenius"
|
||||
"hamlog/internal/profile"
|
||||
"hamlog/internal/pskr"
|
||||
"hamlog/internal/pskrme"
|
||||
"hamlog/internal/pskrtgt"
|
||||
"hamlog/internal/psu"
|
||||
"hamlog/internal/qslcard"
|
||||
@@ -773,6 +774,11 @@ type App struct {
|
||||
// The Chase new panel's OWN band filter, as the set switched off. Read per
|
||||
// message like the list above, so an atomic rather than a settings lookup.
|
||||
chaseNewBandsOff atomic.Value // map[string]bool
|
||||
// hearMe is the THIRD PSK Reporter connection: who is reporting our own
|
||||
// transmissions, for the reverse layer on the FT map. Its own connection
|
||||
// for the same reason as pskTgt — a different slice of the feed, and one
|
||||
// that must keep working with the opening watch switched off.
|
||||
hearMe *pskrme.Watcher
|
||||
// pskr is the PSK Reporter MQTT feed, up only while the opening watch is on.
|
||||
// It is the source that makes VHF detection work at all: the cluster and RBN
|
||||
// carry a handful of 6 m spots where PSK Reporter carries hundreds.
|
||||
@@ -1732,6 +1738,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
// PSK Reporter. After the operator's grid is known: without it there is no
|
||||
// distance to measure and no receiver squares to filter on, so it stays down.
|
||||
a.startBandOpenFeed()
|
||||
// The reverse layer. Needs the CALLSIGN rather than the grid, so it goes
|
||||
// after the profile is scoped, which it already is by here.
|
||||
a.startHearMe()
|
||||
// Auto-call. After the watch list and the logbook: the ladder is meaningless
|
||||
// without either, and it must never call anybody on a log it cannot read.
|
||||
a.startAutoCall()
|
||||
@@ -17076,6 +17085,10 @@ func (a *App) reloadAfterProfileSwitch() {
|
||||
// Same reasoning for the satellite tracker: it transmits, and the new profile
|
||||
// may be a different station on a different antenna.
|
||||
a.StopSatelliteTracking()
|
||||
// A new profile is usually a new callsign, and the callsign IS this
|
||||
// subscription — left alone it would go on reporting who hears the previous
|
||||
// station.
|
||||
a.startHearMe()
|
||||
}
|
||||
|
||||
// DuplicateProfile clones an existing profile under newName. Useful when
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
// ── Who hears me (PSK Reporter) ────────────────────────────────────────────
|
||||
//
|
||||
// The FT map draws what this station decodes. This is the other direction:
|
||||
// which stations are reporting our own transmissions, which is the half an
|
||||
// operator cannot see from their own receiver and the half that decides whether
|
||||
// calling is worth the cycle.
|
||||
//
|
||||
// See internal/pskrme for why it costs almost nothing — the operator's callsign
|
||||
// goes in the topic's TRANSMIT level, so the broker sends nothing else.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/pskrme"
|
||||
)
|
||||
|
||||
// keyHearMeOn is per-profile, not global: a second profile is usually a second
|
||||
// callsign, and the answer to "who hears me" is not the same one.
|
||||
const keyHearMeOn = "hearme.on"
|
||||
|
||||
// GetHearMe reports whether the feed is wanted.
|
||||
func (a *App) GetHearMe() bool {
|
||||
if a.settings == nil || !a.settingsScoped.Load() {
|
||||
return false
|
||||
}
|
||||
v, _ := a.settings.Get(a.ctx, keyHearMeOn)
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
// SetHearMe turns the feed on or off and brings the subscription with it.
|
||||
func (a *App) SetHearMe(on bool) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyHearMeOn, boolStr(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
a.startHearMe()
|
||||
if on {
|
||||
// Not an error: the feed is wanted and will come up as soon as there is
|
||||
// a callsign to watch for, and saying so beats a silent switch.
|
||||
if strings.TrimSpace(a.opCall) == "" {
|
||||
return fmt.Errorf("set the station callsign first — the feed watches for it")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startHearMe rebuilds the watcher from the setting. Called at startup, when
|
||||
// the setting changes, and on a profile switch — the callsign is what it
|
||||
// subscribes to, so a new profile needs a new subscription.
|
||||
func (a *App) startHearMe() {
|
||||
if a.hearMe != nil {
|
||||
a.hearMe.Stop()
|
||||
a.hearMe = nil
|
||||
}
|
||||
if !a.GetHearMe() {
|
||||
return
|
||||
}
|
||||
call := strings.ToUpper(strings.TrimSpace(a.opCall))
|
||||
if call == "" {
|
||||
applog.Printf("pskrme: no station callsign — nothing to watch for")
|
||||
return
|
||||
}
|
||||
w := pskrme.New(pskrme.Config{MyCall: call, Logf: applog.Printf})
|
||||
if err := w.Start(); err != nil {
|
||||
applog.Printf("pskrme: feed did not start: %v", err)
|
||||
return
|
||||
}
|
||||
a.hearMe = w
|
||||
}
|
||||
|
||||
// GetWhoHearsMe is the map layer: one entry per station that has reported us
|
||||
// inside the window, carrying its own square so the arc is arithmetic.
|
||||
func (a *App) GetWhoHearsMe() []pskrme.Report {
|
||||
if a.hearMe == nil {
|
||||
return nil
|
||||
}
|
||||
return a.hearMe.Reports()
|
||||
}
|
||||
|
||||
// GetHearMeStatus tells a working feed from a silent one: a connection that is
|
||||
// up and reporting nothing looks exactly like a broken one until you can see a
|
||||
// number moving.
|
||||
func (a *App) GetHearMeStatus() pskrme.Status {
|
||||
if a.hearMe == nil {
|
||||
return pskrme.Status{Watching: strings.ToUpper(strings.TrimSpace(a.opCall))}
|
||||
}
|
||||
return a.hearMe.Status()
|
||||
}
|
||||
+4
-2
@@ -16,7 +16,8 @@
|
||||
"The FT map takes one colour for every decode, next to the basemap buttons. The band palette is calibrated against a plain map — 60m navy and 70cm olive all but vanish over the satellite imagery — and it is only useful to somebody watching several bands at once, which most operators are not. Leave it empty for the colour per band, as before. The legend then keeps the band names and drops the swatches, since which bands are up is still worth knowing.",
|
||||
"\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. It only hid the card before, and the check behind it runs every five minutes, so the same notice came back four times an hour all evening for a version already declined. The delay is recorded against that version, so a newer release is different news and appears at once — a deferral can never bury an update. The cross hides it for an hour, and opening About still answers whatever was deferred, because asking is asking.",
|
||||
"Stop stays lit for the whole rotation instead of blinking. Keeping the wind out took a four-degree step, but four degrees is four seconds of travel on a real rotor, so between two of them nothing confirmed the mast was still turning and the button went dark and came back the whole way round. Getting in stays strict — two steps the same way, which weather never gives — while staying in now needs only continued progress in that direction: one degree the same way is not a gust when the rotor is already under power.",
|
||||
"The frequency plan is checked against AMSAT's live FM and image lists. AO-123 gets the 67.0 Hz tone its uplink needs, SO-50 says in its label that 74.4 Hz arms the timer before 67.0 can key it, and eight SSTV satellites are added — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S and RS83S — as receive-only entries, with SSTV downlinks added to SONATE-2 and QMR-KWT-2. Every FM bird's uplink, downlink and tone now agrees with the published table."
|
||||
"The frequency plan is checked against AMSAT's live FM and image lists. AO-123 gets the 67.0 Hz tone its uplink needs, SO-50 says in its label that 74.4 Hz arms the timer before 67.0 can key it, and eight SSTV satellites are added — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S and RS83S — as receive-only entries, with SSTV downlinks added to SONATE-2 and QMR-KWT-2. Every FM bird's uplink, downlink and tone now agrees with the published table.",
|
||||
"The FT map can now show who hears YOU. \"Who hears me\" draws, in dashed cyan, every station reporting your own transmissions to PSK Reporter, alongside the solid arcs of what you decode — the direction you cannot see from your own receiver, and on FT8 the one that says whether calling is worth the cycle. It costs almost nothing: your callsign goes in the topic's transmit level, so the broker sends nothing else, and it works with the band-opening watch switched off. Off until asked for, and each station appears once with its freshest report inside a fifteen-minute window."
|
||||
],
|
||||
"fr": [
|
||||
"SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui s’accorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et l’antenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam n’ont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et s’explique maintenant : c’est la position de rangement, et le prochain accord les fait ressortir tout seuls.",
|
||||
@@ -32,7 +33,8 @@
|
||||
"La carte FT accepte une couleur unique pour tous les décodages, à côté des boutons de fond de carte. La palette par bande est calibrée sur une carte claire — le bleu marine du 60 m et l’olive du 70 cm disparaissent presque sur l’imagerie satellite — et elle ne sert qu’à qui surveille plusieurs bandes à la fois, ce qui n’est pas le cas de la plupart. Laissez-la vide pour retrouver la couleur par bande. La légende conserve alors les noms de bandes et abandonne les pastilles, car savoir quelles bandes sont ouvertes reste utile.",
|
||||
"« Plus tard » sur une notification de mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Le bouton ne faisait que masquer la fenêtre, et la vérification derrière tourne toutes les cinq minutes : la même notification revenait quatre fois par heure toute la soirée pour une version déjà refusée. Le report est enregistré pour cette version précise, donc une version plus récente est une autre nouvelle et s’affiche immédiatement — un report ne peut jamais enterrer une mise à jour. La croix masque pendant une heure, et ouvrir « À propos » répond toujours, même sur une version reportée : demander, c’est demander.",
|
||||
"Le bouton Stop reste allumé pendant toute la rotation au lieu de clignoter. Écarter le vent demandait un écart de quatre degrés, mais quatre degrés représentent quatre secondes de course sur un vrai rotor : entre deux d’entre eux, plus rien ne confirmait que le pylône tournait encore et le bouton s’éteignait puis se rallumait tout du long. L’entrée reste stricte — deux écarts dans le même sens, ce que la météo ne produit jamais — tandis que le maintien ne demande plus qu’une progression continue dans ce sens : un degré du même côté n’est pas une rafale quand le rotor est déjà sous tension.",
|
||||
"Le plan de fréquences est vérifié contre les listes FM et image d’AMSAT. AO-123 reçoit le ton de 67,0 Hz qu’exige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse l’ouvrir, et huit satellites SSTV sont ajoutés — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S et RS83S — en réception seule, avec une descente SSTV ajoutée à SONATE-2 et QMR-KWT-2. La montée, la descente et le ton de chaque satellite FM correspondent désormais à la table publiée."
|
||||
"Le plan de fréquences est vérifié contre les listes FM et image d’AMSAT. AO-123 reçoit le ton de 67,0 Hz qu’exige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse l’ouvrir, et huit satellites SSTV sont ajoutés — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S et RS83S — en réception seule, avec une descente SSTV ajoutée à SONATE-2 et QMR-KWT-2. La montée, la descente et le ton de chaque satellite FM correspondent désormais à la table publiée.",
|
||||
"La carte FT peut désormais montrer qui VOUS entend. « Qui m’entend » trace en pointillés cyan chaque station qui rapporte vos propres émissions à PSK Reporter, à côté des arcs pleins de ce que vous décodez — la direction qu’on ne peut pas voir depuis son propre récepteur, et en FT8 celle qui dit si appeler vaut le cycle. Le coût est quasi nul : votre indicatif est placé dans le niveau émission du topic, donc le broker n’envoie rien d’autre, et cela fonctionne même avec la veille d’ouverture de bande éteinte. Éteint par défaut, et chaque station apparaît une fois avec son rapport le plus récent dans une fenêtre de quinze minutes."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||
@@ -8,6 +8,8 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { loadMapView, saveMapView, MAP_VIEW_FT } from '@/lib/mapView';
|
||||
import { loadMapBase, saveMapBase, MAP_BASE_FT } from '@/lib/mapBase';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { GetHearMe, SetHearMe, GetWhoHearsMe } from '../../wailsjs/go/main/App';
|
||||
import { Ear } from 'lucide-react';
|
||||
|
||||
// FT Map — the live decode feed as geography: every station decoded in the
|
||||
// last half hour, an arc from the operator's own square to theirs, coloured by
|
||||
@@ -56,6 +58,18 @@ const COL_KEY = 'opslog.ftMapColour';
|
||||
// treated as no choice at all rather than driving the swatch to black.
|
||||
const asHex = (v: string) => (/^#[0-9a-f]{6}$/i.test(v.trim()) ? v.trim() : '');
|
||||
|
||||
// One station reporting our own transmissions, from PSK Reporter.
|
||||
type Heard = { call: string; grid: string; band: string; mode: string; snr: number; at: string };
|
||||
|
||||
// The reverse layer is DASHED, and in one colour whatever the band.
|
||||
//
|
||||
// The two layers answer opposite questions and must not be readable as one
|
||||
// palette: solid is what this receiver heard, dashed is what someone else
|
||||
// heard of us. Colour alone would not do it — the decode arcs already use
|
||||
// fourteen of them, and a fifteenth would just look like another band.
|
||||
const HEARD_COLOUR = '#22d3ee';
|
||||
const HEARD_DASH = '5 4';
|
||||
|
||||
const MAX_ARCS = 300;
|
||||
const MAX_AGE_MS = 30 * 60_000;
|
||||
|
||||
@@ -69,6 +83,11 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
// Held in refs so the redraw below does not have to list them as dependencies
|
||||
// and rebuild every arc whenever the parent re-renders.
|
||||
const [colour, setColour] = useState(() => asHex(localStorage.getItem(COL_KEY) ?? ''));
|
||||
// Whether the reverse feed is wanted lives in the DB, not here: it is what
|
||||
// starts an MQTT subscription, so the backend has to be the one that knows.
|
||||
const [hearMe, setHearMe] = useState(false);
|
||||
const [heard, setHeard] = useState<Heard[]>([]);
|
||||
const [heardBusy, setHeardBusy] = useState(false);
|
||||
const selectRef = useRef(onSelect);
|
||||
const callRef = useRef(onCall);
|
||||
useEffect(() => { selectRef.current = onSelect; callRef.current = onCall; }, [onSelect, onCall]);
|
||||
@@ -84,6 +103,10 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
// Its own group: the reverse layer refreshes on its own clock, and clearing
|
||||
// the decode arcs to redraw it would throw away three hundred polylines
|
||||
// every twenty seconds for nothing.
|
||||
const heardLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const baseRef = useRef<L.TileLayer | null>(null);
|
||||
const labelsRef = useRef<L.TileLayer | null>(null);
|
||||
const [basemap, setBasemap] = useState<BasemapKey>(() =>
|
||||
@@ -111,6 +134,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
});
|
||||
mapRef.current = m;
|
||||
layerRef.current = L.layerGroup().addTo(m);
|
||||
heardLayerRef.current = L.layerGroup().addTo(m);
|
||||
// Leaflet measures its container ONCE, when the map is created, and then
|
||||
// draws tiles for that size for ever. This panel is mounted the moment its
|
||||
// tab is selected — before the flex layout has settled — and the window can
|
||||
@@ -129,6 +153,7 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
m.remove();
|
||||
mapRef.current = null;
|
||||
layerRef.current = null;
|
||||
heardLayerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -148,6 +173,34 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
saveMapBase(MAP_BASE_FT, basemap);
|
||||
}, [basemap]);
|
||||
|
||||
useEffect(() => { GetHearMe().then((v) => setHearMe(!!v)).catch(() => {}); }, []);
|
||||
|
||||
// Polled rather than pushed: the reports arrive from the broker in batches
|
||||
// whenever an uploader gets round to it, and a fifteen-minute window redrawn
|
||||
// every twenty seconds is as live as the data underneath it actually is.
|
||||
const loadHeard = useCallback(() => {
|
||||
GetWhoHearsMe().then((r: any) => setHeard((Array.isArray(r) ? r : []) as Heard[])).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!hearMe) { setHeard([]); return; }
|
||||
loadHeard();
|
||||
const id = window.setInterval(loadHeard, 20_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [hearMe, loadHeard]);
|
||||
|
||||
const toggleHearMe = async () => {
|
||||
setHeardBusy(true);
|
||||
const next = !hearMe;
|
||||
try {
|
||||
await SetHearMe(next);
|
||||
setHearMe(next);
|
||||
} catch {
|
||||
// The usual cause is no station callsign, which is what it subscribes
|
||||
// to. Read the state back rather than assuming either way.
|
||||
try { setHearMe(!!(await GetHearMe())); } catch { /* leave it */ }
|
||||
} finally { setHeardBusy(false); }
|
||||
};
|
||||
|
||||
// The arcs, redrawn when the decode list changes. Newest last so they paint
|
||||
// on top; opacity falls with age so the map reads as "now" with a memory.
|
||||
useEffect(() => {
|
||||
@@ -214,6 +267,37 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
}
|
||||
}, [decodes, myGrid, colour]);
|
||||
|
||||
// The reverse layer. Drawn from OUR square outwards to each station that
|
||||
// reported us, dashed, so the direction of the claim is legible: a solid arc
|
||||
// is something this receiver decoded, a dashed one is somebody else
|
||||
// decoding us.
|
||||
useEffect(() => {
|
||||
const layer = heardLayerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
const from = gridToLatLon(myGrid);
|
||||
if (!from || !hearMe) return;
|
||||
const now = Date.now();
|
||||
for (const h of heard) {
|
||||
const to = gridToLatLon(h.grid);
|
||||
if (!to) continue;
|
||||
const ageMin = Math.max(0, Math.round((now - Date.parse(h.at)) / 60_000));
|
||||
const label = `${h.call} · ${h.grid} · ${h.snr > 0 ? '+' : ''}${h.snr} dB · ${h.band}${ageMin > 0 ? ` · ${ageMin}'` : ''}`;
|
||||
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
||||
L.polyline(pts as L.LatLngExpression[][], {
|
||||
color: HEARD_COLOUR, weight: 1.2, opacity: 0.7, dashArray: HEARD_DASH, smoothFactor: 0,
|
||||
}).addTo(layer);
|
||||
// A ring, not a disc: the decode dots are filled, and a receiver of ours
|
||||
// is a different kind of thing in the same place on the map.
|
||||
L.circleMarker([to.lat, to.lon], {
|
||||
radius: 4, color: HEARD_COLOUR, weight: 1.6, fillOpacity: 0,
|
||||
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||
L.circleMarker([to.lat, to.lon], {
|
||||
radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
|
||||
}).bindTooltip(label, { direction: 'top' }).addTo(layer);
|
||||
}
|
||||
}, [heard, hearMe, myGrid]);
|
||||
|
||||
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
|
||||
return (
|
||||
// isolate: Leaflet stacks its panes and controls up to z-index 1000, which
|
||||
@@ -246,7 +330,28 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
|
||||
onClick={() => { setColour(''); writeUiPref(COL_KEY, ''); }}
|
||||
className="px-1 text-[11px] text-muted-foreground hover:text-foreground">↺</button>
|
||||
)}
|
||||
<span className="mx-0.5 w-px self-stretch bg-border" />
|
||||
{/* The reverse layer. A switch, not a filter: it starts a subscription
|
||||
at the broker, so it is off until asked for. */}
|
||||
<button type="button" onClick={toggleHearMe} disabled={heardBusy}
|
||||
title={t('ftmap.hearMeTip')}
|
||||
className={cn('flex items-center gap-1 px-1.5 h-6 rounded text-[11px] disabled:opacity-50',
|
||||
hearMe ? 'font-semibold' : 'text-muted-foreground hover:bg-muted')}
|
||||
style={hearMe ? { color: HEARD_COLOUR } : undefined}>
|
||||
<Ear className="size-3" />
|
||||
{t('ftmap.hearMe')}
|
||||
{hearMe && <span className="tabular-nums opacity-80">{heard.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
{/* What the dashed arcs are. In the legend rather than a tooltip because
|
||||
it is the only thing on the map that is not a decode of ours, and an
|
||||
unexplained second style is worse than none. */}
|
||||
{hearMe && heard.length > 0 && (
|
||||
<div className="absolute bottom-2 right-2 z-[1000] flex items-center gap-1.5 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border text-[11px]">
|
||||
<span className="inline-block w-4 border-t-2 border-dashed" style={{ borderColor: HEARD_COLOUR }} />
|
||||
{t('ftmap.hearMeLegend', { n: heard.length })}
|
||||
</div>
|
||||
)}
|
||||
{/* Band legend — only the bands actually on screen. With one colour
|
||||
forced it keeps the band NAMES and drops the swatches: which bands
|
||||
are up is still worth knowing, a colour key that no longer maps to
|
||||
|
||||
@@ -199,7 +199,7 @@ const en: Dict = {
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.',
|
||||
// FTx decodes panel (Tools -> FT decodes)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'ftmap.colour': 'One colour for every decode, whatever the band', 'ftmap.colourPerBand': 'Back to the colour per band', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only', 'dec.sortTip': 'Sort this slot by this column. Click again to reverse it, once more for the order the decoder heard them in.',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'ftmap.colour': 'One colour for every decode, whatever the band', 'ftmap.colourPerBand': 'Back to the colour per band', 'ftmap.hearMe': 'Who hears me', 'ftmap.hearMeTip': 'Draw the stations reporting your own transmissions to PSK Reporter, dashed, alongside what you decode. Watches one topic — your callsign — so it costs almost nothing.', 'ftmap.hearMeLegend': 'reporting you ({n})', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} says {dec}, the rig is on {rig}', 'dec.bandDriftApp': 'the decoder', 'dec.bandDriftTip': 'The decoding application is announcing a band the radio is not on — it has most likely lost its CAT link and is repeating the last frequency it knew. Every decode below carries that band, so the NEW / NEW BAND verdicts are judged against it.', 'dec.cqOnly': 'CQ only', 'dec.sortTip': 'Sort this slot by this column. Click again to reverse it, once more for the order the decoder heard them in.',
|
||||
'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents',
|
||||
'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message',
|
||||
'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX',
|
||||
@@ -840,7 +840,7 @@ const fr: Dict = {
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants qu’aucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès qu’elle est spottée. Les virgules et les espaces marchent aussi.',
|
||||
// Panneau des decodes FTx (Outils -> Decodes FT)
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'ftmap.colour': 'Une seule couleur pour tous les décodages, quelle que soit la bande', 'ftmap.colourPerBand': 'Revenir à la couleur par bande', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement', 'dec.sortTip': 'Trier ce créneau sur cette colonne. Un second clic inverse, un troisième rend l’ordre dans lequel le décodeur les a entendus.',
|
||||
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'ftmap.colour': 'Une seule couleur pour tous les décodages, quelle que soit la bande', 'ftmap.colourPerBand': 'Revenir à la couleur par bande', 'ftmap.hearMe': 'Qui m’entend', 'ftmap.hearMeTip': 'Trace en pointillés les stations qui rapportent vos émissions à PSK Reporter, à côté de ce que vous décodez. Un seul topic surveillé — votre indicatif — donc un coût quasi nul.', 'ftmap.hearMeLegend': 'vous rapportent ({n})', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.bandDrift': '{app} annonce {dec}, le poste est sur {rig}', 'dec.bandDriftApp': 'le décodeur', 'dec.bandDriftTip': 'Le logiciel de décodage annonce une bande sur laquelle la radio n’est pas — il a très probablement perdu sa liaison CAT et répète la dernière fréquence connue. Tous les décodages ci-dessous portent cette bande, et les verdicts NOUVEAU / NOUVELLE BANDE sont jugés dessus.', 'dec.cqOnly': 'CQ seulement', 'dec.sortTip': 'Trier ce créneau sur cette colonne. Un second clic inverse, un troisième rend l’ordre dans lequel le décodeur les a entendus.',
|
||||
'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents',
|
||||
'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message',
|
||||
'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX',
|
||||
|
||||
Vendored
+9
@@ -14,6 +14,7 @@ import {bandopen} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {dxped} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {pskrme} from '../models';
|
||||
import {powergenius} from '../models';
|
||||
import {pskrtgt} from '../models';
|
||||
import {pskr} from '../models';
|
||||
@@ -517,6 +518,10 @@ export function GetGridCacheStatus():Promise<main.GridCacheStatus>;
|
||||
|
||||
export function GetGridScopeSettings():Promise<main.GridScopeSettings>;
|
||||
|
||||
export function GetHearMe():Promise<boolean>;
|
||||
|
||||
export function GetHearMeStatus():Promise<pskrme.Status>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetKenwoodState():Promise<cat.KenwoodTXState>;
|
||||
@@ -667,6 +672,8 @@ export function GetWebPublishStatus():Promise<main.WebPublishStatus>;
|
||||
|
||||
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||
|
||||
export function GetWhoHearsMe():Promise<Array<pskrme.Report>>;
|
||||
|
||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||
|
||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
@@ -1235,6 +1242,8 @@ export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||
|
||||
export function SetFlexRSTChaseEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetHearMe(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetKenwoodAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetKenwoodAGC(arg1:string):Promise<void>;
|
||||
|
||||
@@ -966,6 +966,14 @@ export function GetGridScopeSettings() {
|
||||
return window['go']['main']['App']['GetGridScopeSettings']();
|
||||
}
|
||||
|
||||
export function GetHearMe() {
|
||||
return window['go']['main']['App']['GetHearMe']();
|
||||
}
|
||||
|
||||
export function GetHearMeStatus() {
|
||||
return window['go']['main']['App']['GetHearMeStatus']();
|
||||
}
|
||||
|
||||
export function GetIcomState() {
|
||||
return window['go']['main']['App']['GetIcomState']();
|
||||
}
|
||||
@@ -1266,6 +1274,10 @@ export function GetWhatsNew() {
|
||||
return window['go']['main']['App']['GetWhatsNew']();
|
||||
}
|
||||
|
||||
export function GetWhoHearsMe() {
|
||||
return window['go']['main']['App']['GetWhoHearsMe']();
|
||||
}
|
||||
|
||||
export function GetWinkeyerSettings() {
|
||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||
}
|
||||
@@ -2402,6 +2414,10 @@ export function SetFlexRSTChaseEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1);
|
||||
}
|
||||
|
||||
export function SetHearMe(arg1) {
|
||||
return window['go']['main']['App']['SetHearMe'](arg1);
|
||||
}
|
||||
|
||||
export function SetKenwoodAFGain(arg1) {
|
||||
return window['go']['main']['App']['SetKenwoodAFGain'](arg1);
|
||||
}
|
||||
|
||||
@@ -5451,6 +5451,74 @@ export namespace pskr {
|
||||
|
||||
}
|
||||
|
||||
export namespace pskrme {
|
||||
|
||||
export class Report {
|
||||
call: string;
|
||||
grid: string;
|
||||
band: string;
|
||||
mode: string;
|
||||
snr: number;
|
||||
freq_hz: number;
|
||||
// Go type: time
|
||||
at: any;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Report(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.call = source["call"];
|
||||
this.grid = source["grid"];
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.snr = source["snr"];
|
||||
this.freq_hz = source["freq_hz"];
|
||||
this.at = this.convertValues(source["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 class Status {
|
||||
enabled: boolean;
|
||||
online: boolean;
|
||||
reports: number;
|
||||
watching: string;
|
||||
error: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Status(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.online = source["online"];
|
||||
this.reports = source["reports"];
|
||||
this.watching = source["watching"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace pskrtgt {
|
||||
|
||||
export class Bin {
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// Package pskrme answers the other half of the decodes map: who is hearing ME.
|
||||
//
|
||||
// The FT map draws what this station decodes, which is one direction of every
|
||||
// path on it. The reverse — which stations are reporting our own transmissions
|
||||
// — is the half an operator cannot see from their own receiver at all, and on
|
||||
// FT8 it is the half that decides whether calling is worth the cycle.
|
||||
//
|
||||
// It is the narrowest possible slice of the PSK Reporter feed. The v2 topic is
|
||||
//
|
||||
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/…
|
||||
//
|
||||
// so putting the operator's callsign in the TX level makes the broker send
|
||||
// nothing else. That is the whole reason this is cheap: internal/pskr measured
|
||||
// 83 messages a second for four bands unfiltered, and 0.2 to 1.2 a second once
|
||||
// filtered on the receiver's square — one callsign in the transmit level is a
|
||||
// handful of messages per FT8 cycle, however open the band is.
|
||||
//
|
||||
// Its own connection, like internal/pskrtgt has its own: the three want
|
||||
// different slices of the feed, and none of them can be filtered out of
|
||||
// another's. It also means this works with the band-opening watch switched off,
|
||||
// which matters — tying it to that feed's lifecycle would have made it fail
|
||||
// silently for anyone not chasing openings.
|
||||
//
|
||||
// Nothing is persisted. A report older than the window is dropped on the next
|
||||
// read, and an empty window means nobody has reported us recently, which is the
|
||||
// honest answer rather than a stale map.
|
||||
package pskrme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// DefaultBroker is PSK Reporter's public MQTT endpoint, TLS.
|
||||
const DefaultBroker = "tls://mqtt.pskreporter.info:1884"
|
||||
|
||||
// Window is how long a report keeps counting.
|
||||
//
|
||||
// Fifteen minutes. PSK Reporter's uploaders batch, many of them every five, so
|
||||
// a tighter window shows a fraction of the stations that actually heard the
|
||||
// last few calls — internal/pskrtgt widened its own to ten for exactly that
|
||||
// reason. This one is looser still because it feeds a MAP: a receiver that
|
||||
// heard us twelve minutes ago is a path worth seeing on it, where the same
|
||||
// report as a live "can he hear me" verdict would be stale.
|
||||
const Window = 15 * time.Minute
|
||||
|
||||
// Report is one station's reception of us, reduced to what a map needs.
|
||||
type Report struct {
|
||||
Call string `json:"call"` // who reported us
|
||||
Grid string `json:"grid"` // their square, from the message itself
|
||||
Band string `json:"band"`
|
||||
Mode string `json:"mode"`
|
||||
SNR int `json:"snr"` // how they heard us, their report
|
||||
FreqHz int64 `json:"freq_hz"` // where we were when they did
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// Status is what the panel needs to tell a working feed from a silent one.
|
||||
type Status struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Online bool `json:"online"`
|
||||
Reports uint64 `json:"reports"` // accepted since start
|
||||
Watching string `json:"watching"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// Config is what the watcher needs to run.
|
||||
type Config struct {
|
||||
Broker string
|
||||
// MyCall is the callsign to watch for in the TRANSMIT level. Without one
|
||||
// there is no subscription to make: a wildcard there would be the whole
|
||||
// feed, which is the one thing this package exists not to do.
|
||||
MyCall string
|
||||
Logf func(string, ...any)
|
||||
}
|
||||
|
||||
// Watcher owns the connection, its one subscription, and the sliding window.
|
||||
type Watcher struct {
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
client mqtt.Client
|
||||
running bool
|
||||
topic string
|
||||
|
||||
reports []Report
|
||||
received uint64
|
||||
lastErr string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Watcher {
|
||||
if cfg.Broker == "" {
|
||||
cfg.Broker = DefaultBroker
|
||||
}
|
||||
if cfg.Logf == nil {
|
||||
cfg.Logf = func(string, ...any) {}
|
||||
}
|
||||
cfg.MyCall = strings.ToUpper(strings.TrimSpace(cfg.MyCall))
|
||||
return &Watcher{cfg: cfg}
|
||||
}
|
||||
|
||||
// message is the payload, the same shape internal/pskr documents.
|
||||
type message struct {
|
||||
Freq int64 `json:"f"`
|
||||
Mode string `json:"md"`
|
||||
SNR int `json:"rp"`
|
||||
TxCall string `json:"sc"`
|
||||
TxGrid string `json:"sl"`
|
||||
RxCall string `json:"rc"`
|
||||
RxGrid string `json:"rl"`
|
||||
Band string `json:"b"`
|
||||
}
|
||||
|
||||
// Start brings the subscription up. Safe to call on a running watcher.
|
||||
func (w *Watcher) Start() error {
|
||||
w.mu.Lock()
|
||||
if w.running {
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
call := w.cfg.MyCall
|
||||
w.mu.Unlock()
|
||||
if call == "" {
|
||||
return fmt.Errorf("pskrme: no station callsign")
|
||||
}
|
||||
|
||||
opts := mqtt.NewClientOptions().
|
||||
AddBroker(w.cfg.Broker).
|
||||
SetClientID(fmt.Sprintf("opslog-hearme-%d", time.Now().UnixNano())).
|
||||
SetCleanSession(true).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetConnectRetryInterval(30 * time.Second).
|
||||
SetConnectTimeout(15 * time.Second).
|
||||
SetOrderMatters(false)
|
||||
// Subscribed on every connect, reconnects included: the session is clean, so
|
||||
// the broker remembers nothing and a silent reconnect would leave a feed
|
||||
// that looks up and delivers nothing for the rest of the evening.
|
||||
opts.OnConnect = func(c mqtt.Client) {
|
||||
// Both wildcards deliberate: every band and every mode. The filter that
|
||||
// matters is the callsign, and an operator wants to know who hears them
|
||||
// wherever they happen to be.
|
||||
topic := "pskr/filter/v2/+/+/" + call + "/#"
|
||||
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
||||
w.setErr(tok.Error().Error())
|
||||
w.cfg.Logf("pskrme: subscribing to %s failed: %v", topic, tok.Error())
|
||||
return
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.topic = topic
|
||||
w.lastErr = ""
|
||||
w.mu.Unlock()
|
||||
w.cfg.Logf("pskrme: watching who reports %s", call)
|
||||
}
|
||||
opts.OnConnectionLost = func(_ mqtt.Client, err error) {
|
||||
w.setErr(err.Error())
|
||||
w.cfg.Logf("pskrme: connection lost: %v", err)
|
||||
}
|
||||
|
||||
client := mqtt.NewClient(opts)
|
||||
if tok := client.Connect(); tok.Wait() && tok.Error() != nil {
|
||||
return fmt.Errorf("pskrme: connect: %w", tok.Error())
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.client, w.running = client, true
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop drops the connection and everything collected. A feed turned off must
|
||||
// not leave a map showing who heard us before it was.
|
||||
func (w *Watcher) Stop() {
|
||||
w.mu.Lock()
|
||||
client, running := w.client, w.running
|
||||
w.client, w.running = nil, false
|
||||
w.reports = nil
|
||||
w.topic = ""
|
||||
w.mu.Unlock()
|
||||
if running && client != nil {
|
||||
client.Disconnect(250)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) setErr(msg string) {
|
||||
w.mu.Lock()
|
||||
w.lastErr = msg
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// handle runs on the MQTT goroutine, so it does the least possible.
|
||||
func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
||||
var msg message
|
||||
if err := json.Unmarshal(m.Payload(), &msg); err != nil {
|
||||
return
|
||||
}
|
||||
// The topic filter already guarantees the transmitter, but a receiver with
|
||||
// no callsign or no square cannot be drawn and is not a report of anything.
|
||||
if strings.TrimSpace(msg.RxCall) == "" || len(strings.TrimSpace(msg.RxGrid)) < 4 {
|
||||
return
|
||||
}
|
||||
r := Report{
|
||||
Call: strings.ToUpper(strings.TrimSpace(msg.RxCall)),
|
||||
Grid: strings.ToUpper(strings.TrimSpace(msg.RxGrid)),
|
||||
Band: strings.ToLower(strings.TrimSpace(msg.Band)),
|
||||
Mode: strings.ToUpper(strings.TrimSpace(msg.Mode)),
|
||||
SNR: msg.SNR,
|
||||
FreqHz: msg.Freq,
|
||||
At: time.Now(),
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.reports = append(w.reports, r)
|
||||
w.received++
|
||||
// A cap as well as the window, so a pathological feed cannot grow this
|
||||
// without bound between two reads.
|
||||
if len(w.reports) > 4000 {
|
||||
w.reports = w.reports[len(w.reports)-2000:]
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// Reports is who has heard us inside the window, freshest report per callsign.
|
||||
//
|
||||
// One entry per STATION, not per message: the same receiver uploading every
|
||||
// five minutes is one pair of ears on the map, and its latest report is the one
|
||||
// that says whether the path is still there.
|
||||
func (w *Watcher) Reports() []Report {
|
||||
cutoff := time.Now().Add(-Window)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
// Pruned on read rather than on a timer: the only thing that cares about the
|
||||
// window is whoever is looking.
|
||||
kept := w.reports[:0]
|
||||
for _, r := range w.reports {
|
||||
if r.At.After(cutoff) {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
w.reports = kept
|
||||
|
||||
byCall := map[string]int{}
|
||||
out := make([]Report, 0, len(kept))
|
||||
for _, r := range kept {
|
||||
if i, seen := byCall[r.Call]; seen {
|
||||
if r.At.After(out[i].At) {
|
||||
out[i] = r
|
||||
}
|
||||
continue
|
||||
}
|
||||
byCall[r.Call] = len(out)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Status reports the connection, for the panel.
|
||||
func (w *Watcher) Status() Status {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
online := w.running && w.client != nil && w.client.IsConnected()
|
||||
return Status{
|
||||
Enabled: w.running,
|
||||
Online: online,
|
||||
Reports: w.received,
|
||||
Watching: w.cfg.MyCall,
|
||||
Error: w.lastErr,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package pskrme
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// One station uploading every five minutes must be ONE pair of ears on the map,
|
||||
// showing its freshest report — not four arcs to the same square, and not the
|
||||
// oldest of them deciding whether the path still looks open.
|
||||
func TestReportsKeepsTheFreshestPerStation(t *testing.T) {
|
||||
w := New(Config{MyCall: "F4BPO"})
|
||||
now := time.Now()
|
||||
w.reports = []Report{
|
||||
{Call: "OH5CX", Grid: "KP30", SNR: -18, At: now.Add(-9 * time.Minute)},
|
||||
{Call: "W1AW", Grid: "FN31", SNR: -5, At: now.Add(-2 * time.Minute)},
|
||||
{Call: "OH5CX", Grid: "KP30", SNR: -11, At: now.Add(-1 * time.Minute)},
|
||||
}
|
||||
got := w.Reports()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d stations, want 2: %+v", len(got), got)
|
||||
}
|
||||
for _, r := range got {
|
||||
if r.Call == "OH5CX" && r.SNR != -11 {
|
||||
t.Errorf("OH5CX kept the %d dB report, want the freshest (-11)", r.SNR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A report older than the window is gone, and gone from the slice too: the map
|
||||
// must not show a path that stopped existing a quarter of an hour ago, and the
|
||||
// window is what keeps this from growing all evening.
|
||||
func TestReportsDropsWhatIsPastTheWindow(t *testing.T) {
|
||||
w := New(Config{MyCall: "F4BPO"})
|
||||
now := time.Now()
|
||||
w.reports = []Report{
|
||||
{Call: "OLD", Grid: "JN36", At: now.Add(-Window - time.Minute)},
|
||||
{Call: "NEW", Grid: "JN36", At: now.Add(-time.Minute)},
|
||||
}
|
||||
got := w.Reports()
|
||||
if len(got) != 1 || got[0].Call != "NEW" {
|
||||
t.Fatalf("got %+v, want only NEW", got)
|
||||
}
|
||||
if len(w.reports) != 1 {
|
||||
t.Errorf("the stale report is still held: %d kept", len(w.reports))
|
||||
}
|
||||
}
|
||||
|
||||
// Turning the feed off clears what it collected. Left in place, switching it
|
||||
// back on would redraw a map of who heard us before it was on.
|
||||
func TestStopForgetsTheReports(t *testing.T) {
|
||||
w := New(Config{MyCall: "F4BPO"})
|
||||
w.reports = []Report{{Call: "OH5CX", Grid: "KP30", At: time.Now()}}
|
||||
w.Stop()
|
||||
if got := w.Reports(); len(got) != 0 {
|
||||
t.Errorf("got %+v after Stop, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// No callsign, no subscription: the transmit level would be a wildcard, which
|
||||
// is the entire feed — the one thing this package exists not to ask for.
|
||||
func TestStartRefusesWithoutACallsign(t *testing.T) {
|
||||
if err := New(Config{}).Start(); err == nil {
|
||||
t.Fatal("started with no callsign")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user