Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0d29659cb | ||
|
|
6f9b996db8 | ||
|
|
9dcab0568b | ||
|
|
a8e870e098 | ||
|
|
7e6e1335e3 | ||
|
|
3fe14c2c77 | ||
|
|
e9816241e8 |
@@ -3103,8 +3103,7 @@ func (a *App) RestartApp() error {
|
|||||||
// --wait-pid, so it waits for this process to actually END rather than for a
|
// --wait-pid, so it waits for this process to actually END rather than for a
|
||||||
// window of time to pass: shutting down closes a logbook and a CAT session,
|
// window of time to pass: shutting down closes a logbook and a CAT session,
|
||||||
// and on a remote database that takes as long as it takes.
|
// and on a remote database that takes as long as it takes.
|
||||||
cmd := exec.Command(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
|
cmd := relaunchCmd(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||||
cmd.Dir = filepath.Dir(exe)
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("relaunch OpsLog: %w", err)
|
return fmt.Errorf("relaunch OpsLog: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -697,21 +697,25 @@ func (a *App) applySatRadio(tp sat.Transponder) {
|
|||||||
if err != nil || len(m) == 0 {
|
if err != nil || len(m) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The downlink is received, so it takes that band's RX antenna; the uplink
|
// Each slice gets BOTH antennas of ITS OWN band. Only two of the four were
|
||||||
// is transmitted, so it takes that band's TX antenna.
|
// set — the downlink's receive and the uplink's transmit — which left the
|
||||||
|
// downlink slice with no txant. It never keys, so nothing was wrong on the
|
||||||
|
// air, but a half-configured slice uses whatever antenna it was last left
|
||||||
|
// on the moment transmit focus moves to it.
|
||||||
downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
|
downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
|
||||||
rxAnt := m[downBand].RX
|
down, up := m[downBand], m[upBand]
|
||||||
txAnt := m[upBand].TX
|
if strings.TrimSpace(down.RX) == "" && strings.TrimSpace(down.TX) == "" &&
|
||||||
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" {
|
strings.TrimSpace(up.RX) == "" && strings.TrimSpace(up.TX) == "" {
|
||||||
// Worth a line: an operator who HAS configured the pair and still sees
|
// Worth a line: an operator who HAS configured the pair and still sees
|
||||||
// the wrong antenna has no other way to tell a setting he never made
|
// the wrong antenna has no other way to tell a setting he never made
|
||||||
// from a lookup that missed.
|
// from a lookup that missed.
|
||||||
applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand)
|
applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applog.Printf("sat: antennas rx=%q (%s) tx=%q (%s)", rxAnt, downBand, txAnt, upBand)
|
applog.Printf("sat: antennas down %s rx=%q tx=%q, up %s rx=%q tx=%q",
|
||||||
|
downBand, down.RX, down.TX, upBand, up.RX, up.TX)
|
||||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
return fc.SatAntennas(rxAnt, txAnt)
|
return fc.SatAntennas(down.RX, down.TX, up.RX, up.TX)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
|
// Not fatal: a rig that is not a Flex has no such thing, and a pass with
|
||||||
// the wrong antenna is still a pass.
|
// the wrong antenna is still a pass.
|
||||||
|
|||||||
+55
-36
@@ -1,46 +1,65 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.27.24",
|
||||||
|
"en": [
|
||||||
|
"The sky plot and the position readout move to their own column, left of the map. Width is draggable, and the column folds away like the one on the right.",
|
||||||
|
"Each block of the right-hand column now has a heading and folds away on its own, remembered between sessions.",
|
||||||
|
"The tune panel shows the centre frequency of the transponder. The Doppler-corrected one stays beside Tracking, where the radio is.",
|
||||||
|
"Badges on the satellite panel for the band, the Doppler offset, an inverting transponder, the passband width and a pass's peak elevation.",
|
||||||
|
"The satellite ground track is visible on every basemap — it was drawn in a colour the map could not use and came out near-white.",
|
||||||
|
"The decode panel no longer warns about band drift on a second slice: it now compares against every band the radio is receiving on, not just the transmit band.",
|
||||||
|
"OpsLog appears again after an update. The relaunch was starting the new build with its window hidden, so it ran with no window at all.",
|
||||||
|
"Each satellite slice now gets both of its antennas, RX and TX, from its own band — the downlink slice was left with no transmit antenna at all.",
|
||||||
|
"The satellite ground track no longer draws a straight line across the map when it crosses the antimeridian."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Le tracé du ciel et la position passent dans leur propre colonne, à gauche de la carte. Largeur réglable, et la colonne se replie comme celle de droite.",
|
||||||
|
"Chaque bloc de la colonne de droite a désormais un titre et se replie séparément, avec son état retenu.",
|
||||||
|
"Le panneau d’accord affiche la fréquence centrale du transpondeur. La valeur corrigée du Doppler reste à côté de Tracking, là où est la radio.",
|
||||||
|
"Des pastilles sur le panneau satellite pour la bande, l’écart Doppler, un transpondeur inverseur, la largeur de bande passante et le pic d’élévation d’une passe.",
|
||||||
|
"Le tracé au sol des satellites est visible sur tous les fonds de carte — il était dessiné dans une couleur inexploitable et sortait presque blanc.",
|
||||||
|
"Le panneau de décodages n’avertit plus d’une dérive de bande sur une seconde tranche : la comparaison porte sur toutes les bandes reçues, pas seulement celle d’émission.",
|
||||||
|
"OpsLog réapparaît après une mise à jour. La relance démarrait la nouvelle version avec sa fenêtre masquée, donc sans aucune fenêtre.",
|
||||||
|
"Chaque slice satellite reçoit désormais ses deux antennes, RX et TX, depuis sa propre bande — la slice de descente restait sans antenne d’émission.",
|
||||||
|
"Le tracé au sol du satellite ne trace plus une ligne droite en travers de la carte lorsqu’il franchit l’antiméridien."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.27.23",
|
"version": "0.27.23",
|
||||||
"date": "",
|
"date": "",
|
||||||
"en": [
|
"en": [
|
||||||
"SteppIR: a Calibrate button, beside Retract. It drives every element to its end stop so the controller re-learns where zero is — the cure for an antenna that tunes to the wrong length after a power cut mid-move, after the elements were pushed by hand, or after a motor slipped. It takes minutes and the antenna is unusable until it finishes, so it asks first. Ultrabeam controllers have no such command and say so rather than pretending. Retracting the elements was already there and now explains itself: it is the storage position, and the next tune brings them back out on its own.",
|
"SteppIR: a Calibrate button beside Retract, on Station Control.",
|
||||||
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments.",
|
"On the rotor dial, the beam no longer takes the long way round when it crosses north on a 450° rotator.",
|
||||||
"The rotor widget stops claiming the antenna is turning every time the wind moves it. A threshold alone could not tell the difference — a gust pushes a beam well past four degrees and back, and each excursion counted, so Stop lit and went out all evening on an antenna that had not turned. What separates a rotation from the weather is not how far the reading moves but which way: a rotor under power advances, gust after gust reverses. A step now counts only when the previous one went the same way, so movement is announced one poll later on a mast that takes half a minute to cross a pass, and never at all on a windy afternoon.",
|
"The rotor widget stops lighting Stop when the wind moves the antenna, and stops going dark while it is genuinely turning.",
|
||||||
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. It was shipped with nothing but an APRS digipeater, because the transponder database marks that transponder inactive: it runs to an announced schedule rather than continuously, which from a database looks exactly like a dead one. The label says \"(scheduled)\" so nobody wonders why it is quiet. The generator now also lists the satellites it refused on that ground and kept nothing else for, so the next regeneration is not silent about them.",
|
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. The label says \"(scheduled)\": it runs to an announced schedule, not continuously.",
|
||||||
"A frequency OpsLog got wrong can now be corrected on a station that already has the satellite file. Until now the file was copied out on the first run and was the operator's from then on, so a mistake we shipped — LilacSat-2 with no FM transponder — had become their data and could never be mended. The plan now adds what is missing, brings up to date what was never edited, and leaves alone what was: an entry you corrected by hand outranks anything shipped, and the log names the ones it stood down on. The first run after this cannot tell the two apart, so it takes the shipped plan and copies your file to satellites.json.bak first — after that your edits are recognised precisely and survive every release.",
|
"A frequency OpsLog shipped wrong can now be corrected on a station that already has the satellite file. Entries you edited by hand are left alone.",
|
||||||
"The per-band antennas are applied to the satellite slices at last. The map is keyed by the band name in capitals, the tracker looked its two bands up in lower case, and so it read no antenna at all out of a table the operator had filled in — a 70 cm downlink stayed on the 2 m transverter. The log now names the band and the antenna it resolved, so a setting never made can be told apart from a lookup that missed.",
|
"The per-band FlexRadio antennas are applied to the satellite slices. A 70 cm downlink was staying on the 2 m transverter.",
|
||||||
"Tracking now shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude, in the same strip as the two frequencies. The elevation goes dim below the horizon, so a bird still being followed on its way up cannot be read as workable.",
|
"Tracking shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude.",
|
||||||
"The two satellite lists in the settings are sorted by name, numerically — AO-27, AO-91, AO-123. The left one came in the order the frequency file happens to be written and the right one in the order they were clicked, so finding one bird among sixteen meant reading all sixteen. A satellite renamed on getting its OSCAR number, as LILACSAT-2 became LO-90, is also recognised in a followed list that still holds the old name instead of being listed as having no elements.",
|
"The two satellite lists in the settings are sorted by name, numerically. A satellite renamed on getting its OSCAR number is recognised under its old name too.",
|
||||||
"The grid-square map filters by one mode, by band and by satellite. The FTx button is gone: it grouped FT8, FT4 and FT2 together, while the question the map answers is where a single mode has been heard. The named modes are read from the log itself, so each one is offered only if there is something behind it — and FT2 appears for whoever is already using it, with nothing to change here the day it becomes a registered mode. The band list is the station's own plus anything worked outside it, and the satellite list only appears once a square has been worked through a bird.",
|
"The grid-square map filters by one mode, by band and by satellite. The mode and satellite lists come from the log itself.",
|
||||||
"Saving the satellite settings now refreshes the Satellites tab. It read the followed list once when it was opened, so un-following a satellite left it in the pass table and in the dropdown until the tab was reopened — which looks exactly like a setting that failed to save. The lowest pass, the window and the locator are picked up the same way, and a satellite that is no longer followed hands the selection to the first one that is.",
|
"Saving the satellite settings refreshes the Satellites tab — un-following a satellite left it in the pass table until the tab was reopened.",
|
||||||
"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.",
|
"The FT map takes one colour for every decode, from the corner above it. Leave it empty for the colour per band.",
|
||||||
"\"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.",
|
"\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. A newer release still appears at once.",
|
||||||
"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 satellite frequency plan is checked against AMSAT's live FM and image lists: AO-123 gets its 67.0 Hz tone, SO-50 notes the 74.4 Hz arming tone, and eight SSTV satellites are added.",
|
||||||
"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 show who hears YOU — the stations reporting your transmissions to PSK Reporter, as filled diamonds in a colour of their own. Off until asked for.",
|
||||||
"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.",
|
"The FT map's controls no longer cover it: basemaps clear of the zoom on the left, colour and \"Who hears me\" on the right."
|
||||||
"\"Who hears me\" is marks alone, no arcs. With a few dozen receivers reporting, a line to each made a fan out of one square that buried the decode arcs beside it — and the lines said nothing, since every one of these paths starts in the same place. The mark is a hollow diamond, fading with age: shape rather than colour carries the difference, the decode dots being filled circles and the arcs having used fourteen colours already.",
|
|
||||||
"The FT map's controls stop covering it. The basemap buttons had crept over Leaflet's zoom control, so the minus button took clicks meant for Street, and the row had grown with the colour picker and the reverse layer until it reached the middle of a world map — covering the Atlantic to save a top-right corner that was empty the whole time. Basemaps stay on the left, clear of the zoom; the colour and \"Who hears me\" move to the right.",
|
|
||||||
"The who-hears-me diamonds are filled and take a colour of their own, chosen beside the switch that turns them on. Filled with a hairline white edge, the trick the home marker already uses: a ring is an outline drawn over whatever is beneath it, and eight pixels of it over the satellite imagery was barely there. The colour is separate from the decode one because a single control would have forced both layers into one colour — the very distinction it took a shape to make."
|
|
||||||
],
|
],
|
||||||
"fr": [
|
"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.",
|
"SteppIR : un bouton Calibrer à côté de Rétracter, sur Station Control.",
|
||||||
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand l’antenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et l’animation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans l’autre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de l’antenne. Les deux lobes d’une antenne bidirectionnelle sont traités séparément, puisqu’ils ne franchissent pas le nord au même moment.",
|
"Sur le cadran du rotor, le faisceau ne fait plus le tour complet en passant le nord sur un rotor 450°.",
|
||||||
"Le widget rotor n’annonce plus une antenne en rotation chaque fois que le vent la bouge. Un simple seuil ne pouvait pas faire la différence — une rafale pousse une beam bien au-delà de quatre degrés puis la ramène, et chaque écart comptait : Stop s’allumait et s’éteignait toute la soirée sur une antenne qui n’avait pas tourné. Ce qui distingue une rotation de la météo n’est pas l’amplitude mais le sens : un rotor sous tension avance, une rafale revient. Un écart ne compte donc que si le précédent allait dans le même sens — la rotation est annoncée un relevé plus tard sur un pylône qui met trente secondes à traverser, et plus du tout par vent fort.",
|
"Le widget rotor n’allume plus Stop quand le vent bouge l’antenne, et ne s’éteint plus pendant qu’elle tourne vraiment.",
|
||||||
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Il était livré avec un simple digipeater APRS, parce que la base de transpondeurs marque ce transpondeur comme inactif : il fonctionne selon un calendrier annoncé plutôt qu’en continu, ce qui, vu d’une base de données, ressemble exactement à un transpondeur mort. Le libellé indique « (scheduled) » pour que personne ne se demande pourquoi il est muet. Le générateur liste désormais aussi les satellites qu’il a écartés pour cette raison sans rien garder d’autre, afin que la prochaine régénération ne les passe plus sous silence.",
|
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Le libellé indique « (scheduled) » : il fonctionne selon un calendrier annoncé, pas en continu.",
|
||||||
"Une fréquence qu’OpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusqu’ici ce fichier était copié au premier lancement puis appartenait à l’opérateur, si bien qu’une erreur de notre part — LilacSat-2 sans transpondeur FM — devenait sa donnée et ne pouvait plus être réparée. Le plan ajoute maintenant ce qui manque, met à jour ce qui n’a jamais été modifié, et laisse intact ce qui l’a été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il s’est effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie d’abord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version.",
|
"Une fréquence livrée fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Les entrées modifiées à la main sont préservées.",
|
||||||
"Les antennes par bande sont enfin appliquées aux slices satellite. La table est indexée par le nom de bande en majuscules, le suivi cherchait ses deux bandes en minuscules, et il ne lisait donc aucune antenne dans un tableau que l’opérateur avait rempli — une descente 70 cm restait sur le transverter 2 m. Le journal indique désormais la bande et l’antenne retenues, pour distinguer un réglage jamais fait d’une recherche qui a échoué.",
|
"Les antennes FlexRadio par bande sont appliquées aux slices satellite. Une descente 70 cm restait sur le transverter 2 m.",
|
||||||
"Le suivi affiche désormais où est le satellite à côté de là où pointe l’antenne : azimut, élévation, distance et altitude, dans le même bandeau que les deux fréquences. L’élévation s’estompe sous l’horizon, pour qu’un satellite encore suivi avant son lever ne soit pas pris pour un satellite travaillable.",
|
"Le suivi affiche où est le satellite à côté de là où pointe l’antenne : azimut, élévation, distance et altitude.",
|
||||||
"Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres — AO-27, AO-91, AO-123. Celle de gauche suivait l’ordre du fichier de fréquences et celle de droite l’ordre des clics : retrouver un satellite parmi seize obligeait à lire les seize. Un satellite renommé lors de l’attribution de son numéro OSCAR, comme LILACSAT-2 devenu LO-90, est aussi reconnu dans une liste de suivis qui porte encore l’ancien nom, au lieu d’y apparaître sans éléments.",
|
"Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres. Un satellite renommé lors de l’attribution de son numéro OSCAR est aussi reconnu sous son ancien nom.",
|
||||||
"La carte des carrés locator se filtre par mode précis, par bande et par satellite. Le bouton FTx disparaît : il regroupait FT8, FT4 et FT2, alors que la question à laquelle répond cette carte est de savoir où un seul mode a été entendu. Les modes proposés sont lus dans le journal, donc chacun n’apparaît que s’il y a quelque chose derrière — et FT2 est proposé à qui l’utilise déjà, sans rien à changer ici le jour où il deviendra un mode officiel. La liste des bandes est celle de la station, complétée par ce qui a été travaillé en dehors, et la liste des satellites n’apparaît qu’une fois un carré travaillé par satellite.",
|
"La carte des carrés locator se filtre par mode précis, par bande et par satellite. Les listes de modes et de satellites viennent du journal.",
|
||||||
"Enregistrer les réglages satellite rafraîchit désormais l’onglet Satellites. Il lisait la liste des suivis une seule fois à son ouverture : retirer un satellite le laissait dans le tableau des passes et dans la liste déroulante jusqu’à la réouverture de l’onglet — ce qui ressemble exactement à un réglage qui ne s’est pas enregistré. L’élévation minimale, la fenêtre de prévision et le locator sont pris en compte de la même façon, et un satellite qui n’est plus suivi passe la sélection au premier qui l’est.",
|
"Enregistrer les réglages satellite rafraîchit l’onglet Satellites — retirer un satellite le laissait dans le tableau des passes jusqu’à la réouverture.",
|
||||||
"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.",
|
"La carte FT accepte une couleur unique pour tous les décodages, depuis le coin en haut à droite. Laissez vide pour la couleur par bande.",
|
||||||
"« 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.",
|
"« Plus tard » sur une mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Une version plus récente s’affiche quand même aussitôt.",
|
||||||
"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 satellite est vérifié contre les listes FM et image d’AMSAT : AO-123 reçoit son ton de 67,0 Hz, SO-50 signale le ton d’armement de 74,4 Hz, et huit satellites SSTV sont ajoutés.",
|
||||||
"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 montrer qui VOUS entend — les stations qui rapportent vos émissions à PSK Reporter, en losanges pleins d’une couleur propre. Éteint par défaut.",
|
||||||
"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.",
|
"Les contrôles de la carte FT ne la recouvrent plus : fonds de carte dégagés du zoom à gauche, couleur et « Qui m’entend » à droite."
|
||||||
"« Qui m’entend » n’affiche plus que des marques, sans arcs. Avec quelques dizaines de récepteurs, un trait vers chacun formait un éventail depuis un seul carré qui enterrait les arcs de décodage voisins — et ces traits ne disaient rien, puisque tous ces chemins partent du même endroit. La marque est un losange creux qui s’estompe avec l’âge : c’est la forme, et non la couleur, qui porte la différence, les points de décodage étant des cercles pleins et les arcs utilisant déjà quatorze couleurs.",
|
|
||||||
"Les contrôles de la carte FT cessent de la recouvrir. Les boutons de fond de carte avaient débordé sur le zoom de Leaflet — le bouton moins prenait les clics destinés à Street — et la rangée s’était allongée avec le sélecteur de couleur et la couche inverse jusqu’à atteindre le milieu d’une carte du monde, masquant l’Atlantique pour épargner un coin haut-droit resté vide tout ce temps. Les fonds de carte restent à gauche, dégagés du zoom ; la couleur et « Qui m’entend » passent à droite.",
|
|
||||||
"Les losanges de « qui m’entend » sont pleins et prennent leur propre couleur, choisie à côté de l’interrupteur qui les allume. Pleins avec un liseré blanc d’un pixel, le procédé qu’utilise déjà le marqueur de la station : un cercle creux n’est qu’un contour tracé sur ce qui se trouve dessous, et huit pixels de contour sur l’imagerie satellite se voyaient à peine. La couleur est distincte de celle des décodages, car un seul réglage aurait forcé les deux couches dans une même couleur — précisément la distinction qu’il a fallu une forme pour établir."
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6644,6 +6644,9 @@ export default function App() {
|
|||||||
// Only while CAT is actually connected: an empty band means "nothing to
|
// Only while CAT is actually connected: an empty band means "nothing to
|
||||||
// compare with", never "the rig is on no band".
|
// compare with", never "the rig is on no band".
|
||||||
rigBand={catState.connected ? (catState.band || '') : ''}
|
rigBand={catState.connected ? (catState.band || '') : ''}
|
||||||
|
// Every band the radio is listening on, so a second slice on a second
|
||||||
|
// band does not read as a decoder that has lost CAT.
|
||||||
|
rigBands={catState.connected ? (catState.rx_bands ?? []) : []}
|
||||||
myCall={station.callsign}
|
myCall={station.callsign}
|
||||||
myGrid={station.my_grid}
|
myGrid={station.my_grid}
|
||||||
// A DOUBLE click answers the station: it hands the decode back to
|
// A DOUBLE click answers the station: it hands the decode back to
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ interface Props {
|
|||||||
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
// The band the RIG is on, when CAT is connected. Only ever compared with what
|
||||||
// the decoder announces — see the drift warning.
|
// the decoder announces — see the drift warning.
|
||||||
rigBand?: string;
|
rigBand?: string;
|
||||||
|
// Every band the radio has a receiver on. A Flex running two slices has
|
||||||
|
// two, and a decoder on either of them is not drifting.
|
||||||
|
rigBands?: string[];
|
||||||
onCall: (d: Decode) => void;
|
onCall: (d: Decode) => void;
|
||||||
// A single click: take the station without transmitting — fill the entry, and
|
// A single click: take the station without transmitting — fill the entry, and
|
||||||
// point the panels at it. Absent, a click falls back to onCall.
|
// point the panels at it. Absent, a click falls back to onCall.
|
||||||
@@ -641,7 +644,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, rigBands, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
// Column widths, dragged in the header and shared by every row. Persisted
|
// Column widths, dragged in the header and shared by every row. Persisted
|
||||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||||
@@ -778,9 +781,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
|||||||
// Said, not decided. Using the rig's band instead would be wrong for anyone
|
// Said, not decided. Using the rig's band instead would be wrong for anyone
|
||||||
// decoding a second receiver on another band, and a warning costs that setup
|
// decoding a second receiver on another band, and a warning costs that setup
|
||||||
// nothing but a line it can read past.
|
// nothing but a line it can read past.
|
||||||
|
//
|
||||||
|
// Compared against every band the radio is RECEIVING on, not the transmit
|
||||||
|
// band. Two slices on two bands with a decoder on each is a normal setup,
|
||||||
|
// and it made this warning lie: with slice A on 20 m, slice B on 40 m and
|
||||||
|
// transmit focus on B, the 20 m decoder was told the rig was on 40 m while
|
||||||
|
// the slice it listens to was on 20 m all along. The warning is for a
|
||||||
|
// decoder announcing a band NOTHING on the radio is on, which is what a
|
||||||
|
// lost CAT link actually looks like.
|
||||||
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
|
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
|
||||||
const bandDrift = !!rigBand && !!decoderBand
|
const onAir = (rigBands && rigBands.length ? rigBands : (rigBand ? [rigBand] : []))
|
||||||
&& rigBand.toLowerCase() !== decoderBand.toLowerCase();
|
.map((b) => b.toLowerCase());
|
||||||
|
const bandDrift = onAir.length > 0 && !!decoderBand
|
||||||
|
&& !onAir.includes(decoderBand.toLowerCase());
|
||||||
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
|
||||||
|
|
||||||
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass } from 'lucide-react';
|
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen,
|
||||||
|
PanelLeftClose, PanelLeftOpen, Radar, Compass,
|
||||||
|
ChevronDown, Clock, Crosshair, SlidersHorizontal, ListOrdered } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||||
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
||||||
@@ -73,6 +75,40 @@ const SIDE_W_KEY = 'opslog.satSideWidth';
|
|||||||
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
||||||
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
||||||
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
const SKY_SHOWN_KEY = 'opslog.satSkyShown';
|
||||||
|
// The sky plot's column, to the LEFT of the map.
|
||||||
|
//
|
||||||
|
// It used to sit in the readout column, where it competed for width with the
|
||||||
|
// numbers and pushed the pass table off the bottom of the screen. There was
|
||||||
|
// an empty margin on the other side of the map the whole time, and a plot
|
||||||
|
// that wants to be square is exactly what belongs in a column of its own.
|
||||||
|
const SKY_W_KEY = 'opslog.satSkyWidth';
|
||||||
|
const SKY_W_DEFAULT = 260, SKY_W_MIN = 180, SKY_W_MAX = 480;
|
||||||
|
|
||||||
|
// Each block of the readout column, open or shut, remembered separately: an
|
||||||
|
// operator working FM birds never looks at the linear passband and one
|
||||||
|
// chasing a schedule never looks at the range rate.
|
||||||
|
const SEC_KEYS = {
|
||||||
|
pass: 'opslog.satSecPass',
|
||||||
|
where: 'opslog.satSecWhere',
|
||||||
|
tune: 'opslog.satSecTune',
|
||||||
|
passes: 'opslog.satSecPasses',
|
||||||
|
} as const;
|
||||||
|
type SecId = keyof typeof SEC_KEYS;
|
||||||
|
|
||||||
|
// The ground track, drawn canvas-safe.
|
||||||
|
//
|
||||||
|
// It was `var(--info)`, and this map renders with preferCanvas: a CSS
|
||||||
|
// variable handed to a canvas strokeStyle is not a colour, so the browser
|
||||||
|
// kept whatever was set last and the track came out a pale near-white that
|
||||||
|
// vanished over the imagery and the deserts alike. Every other map in this
|
||||||
|
// app passes hex for the same reason.
|
||||||
|
//
|
||||||
|
// Drawn twice: a dark casing underneath, then the bright line on top. That is
|
||||||
|
// how a road is drawn on a map, and for the same reason — one colour cannot
|
||||||
|
// hold up over both a pale sea and a dark continent, but a colour with an
|
||||||
|
// outline can.
|
||||||
|
const TRACK_INK = '#38bdf8';
|
||||||
|
const TRACK_CASING = '#0b1220';
|
||||||
|
|
||||||
// Four decimals — a hundred hertz, which is what a linear transponder is
|
// Four decimals — a hundred hertz, which is what a linear transponder is
|
||||||
// actually tuned to.
|
// actually tuned to.
|
||||||
@@ -469,10 +505,45 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
|
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
|
||||||
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
|
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
|
||||||
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
|
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
|
||||||
|
const [skyW, setSkyW] = useState(() => {
|
||||||
|
const n = parseFloat(localStorage.getItem(SKY_W_KEY) || '');
|
||||||
|
return Number.isFinite(n) && n >= SKY_W_MIN && n <= SKY_W_MAX ? n : SKY_W_DEFAULT;
|
||||||
|
});
|
||||||
|
useEffect(() => { writeUiPref(SKY_W_KEY, String(Math.round(skyW))); }, [skyW]);
|
||||||
|
// Open by default, every one of them: a panel that starts shut is a feature
|
||||||
|
// nobody finds. Shutting one is a decision, and it is remembered.
|
||||||
|
const [secOpen, setSecOpen] = useState<Record<SecId, boolean>>(() => ({
|
||||||
|
pass: localStorage.getItem(SEC_KEYS.pass) !== '0',
|
||||||
|
where: localStorage.getItem(SEC_KEYS.where) !== '0',
|
||||||
|
tune: localStorage.getItem(SEC_KEYS.tune) !== '0',
|
||||||
|
passes: localStorage.getItem(SEC_KEYS.passes) !== '0',
|
||||||
|
}));
|
||||||
|
const toggleSec = (id: SecId) => setSecOpen((m) => {
|
||||||
|
const next = { ...m, [id]: !m[id] };
|
||||||
|
writeUiPref(SEC_KEYS[id], next[id] ? '1' : '0');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Dragging the grip. Measured from where the pointer STARTED rather than from
|
// Dragging the grip. Measured from where the pointer STARTED rather than from
|
||||||
// the container, and with the pointer captured — without the capture the map
|
// the container, and with the pointer captured — without the capture the map
|
||||||
// underneath swallows the moves the instant the cursor crosses it.
|
// underneath swallows the moves the instant the cursor crosses it.
|
||||||
|
const startSkyDrag = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
const x0 = e.clientX;
|
||||||
|
const w0 = skyW;
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
// Plus, not minus: this handle is on the right of what it resizes.
|
||||||
|
setSkyW(Math.min(SKY_W_MAX, Math.max(SKY_W_MIN, Math.round(w0 + (ev.clientX - x0)))));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove);
|
||||||
|
window.removeEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
const startSideDrag = (e: React.PointerEvent) => {
|
const startSideDrag = (e: React.PointerEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
@@ -562,7 +633,10 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
if (track.length > 1) {
|
if (track.length > 1) {
|
||||||
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
|
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
|
||||||
L.polyline(pts as L.LatLngExpression[][], {
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0,
|
color: TRACK_CASING, weight: 4, opacity: 0.4, smoothFactor: 0, interactive: false,
|
||||||
|
}).addTo(layer);
|
||||||
|
L.polyline(pts as L.LatLngExpression[][], {
|
||||||
|
color: TRACK_INK, weight: 1.8, opacity: 0.95, dashArray: '5 4', smoothFactor: 0, interactive: false,
|
||||||
}).addTo(layer);
|
}).addTo(layer);
|
||||||
}
|
}
|
||||||
const wanted = new Set(shown.map((b) => b.name));
|
const wanted = new Set(shown.map((b) => b.name));
|
||||||
@@ -784,13 +858,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
>
|
>
|
||||||
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
{/* The left column, with the same kind of control as the right one.
|
||||||
|
|
||||||
|
It was a Radar icon, from when this toggled a plot inside the
|
||||||
|
readout column. It now opens and shuts a column of the window, so
|
||||||
|
it says so the way the other one does — the two are the same
|
||||||
|
gesture and an operator should not have to learn them twice. */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="sm"
|
variant="ghost" size="sm" className="h-7 px-1.5"
|
||||||
className={cn('h-7 px-1.5', skyShown && 'text-success')}
|
|
||||||
onClick={() => setSkyShown((v) => !v)}
|
onClick={() => setSkyShown((v) => !v)}
|
||||||
title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
|
title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
|
||||||
>
|
>
|
||||||
<Radar className="size-3.5" />
|
{skyShown ? <PanelLeftClose className="size-3.5" /> : <PanelLeftOpen className="size-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
{/* Put the whole window on the map. On a laptop the readout takes a
|
{/* Put the whole window on the map. On a laptop the readout takes a
|
||||||
third of the screen, and there are moments — watching a footprint
|
third of the screen, and there are moments — watching a footprint
|
||||||
@@ -807,6 +886,108 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
|
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
|
||||||
|
|
||||||
<div className="flex gap-1 flex-1 min-h-0">
|
<div className="flex gap-1 flex-1 min-h-0">
|
||||||
|
{/* WHERE IT IS: the sky plot and the position, in a column of their
|
||||||
|
own to the left of the map.
|
||||||
|
|
||||||
|
The plot is the sky seen from underneath — the centre is
|
||||||
|
straight up, the rim is the horizon, north is at the top — and
|
||||||
|
one glance says whether the pass comes over the roof or along
|
||||||
|
the treeline. The numbers below it are the same answer to the
|
||||||
|
digit: azimuth, elevation, distance, height. They belong
|
||||||
|
together, and having them apart meant reading a bearing off one
|
||||||
|
side of the window and finding it on the other.
|
||||||
|
|
||||||
|
Both were in the readout column, where a plot that wants to be
|
||||||
|
square competed for width with everything else and pushed the
|
||||||
|
pass table off the bottom of the screen — while the margin on
|
||||||
|
this side of the map sat empty the whole time. */}
|
||||||
|
{skyShown && (
|
||||||
|
<div className="shrink-0 flex flex-col gap-1 min-h-0 overflow-y-auto" style={{ width: skyW }}>
|
||||||
|
<div className="rounded-lg border border-border bg-card p-2">
|
||||||
|
<SkyPlot
|
||||||
|
track={sky}
|
||||||
|
az={tuning?.az ?? null}
|
||||||
|
el={tuning?.el ?? null}
|
||||||
|
name={bird?.name}
|
||||||
|
visible={!!tuning?.visible}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Where it is, right now. Its elevation goes in the heading: above
|
||||||
|
or below the horizon is the one thing worth knowing with the
|
||||||
|
block shut. */}
|
||||||
|
<Section
|
||||||
|
title={t('sat.secWhere')}
|
||||||
|
icon={Crosshair}
|
||||||
|
open={secOpen.where}
|
||||||
|
onToggle={() => toggleSec('where')}
|
||||||
|
right={tuning && !secOpen.where ? (
|
||||||
|
<Pill tone={tuning.visible ? 'success' : 'muted'}>
|
||||||
|
{fmtDeg(tuning.el)} {tuning.visible ? t('sat.up') : t('sat.below')}
|
||||||
|
</Pill>
|
||||||
|
) : undefined}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
|
||||||
|
sub={tuning ? compass(tuning.az) : ''} />
|
||||||
|
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
|
||||||
|
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
|
||||||
|
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||||||
|
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
|
||||||
|
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
|
||||||
|
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approaching or receding, which is the sign of the whole Doppler
|
||||||
|
correction and the one number that explains why the frequencies
|
||||||
|
are moving the way they are. */}
|
||||||
|
{!!tuning && !bird?.geostationary && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{tuning.range_rate < 0
|
||||||
|
? <ArrowUp className="size-3 text-success" />
|
||||||
|
: <ArrowDown className="size-3 text-warning" />}
|
||||||
|
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
|
||||||
|
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Where the antenna is, beside where the satellite is. The two
|
||||||
|
differing is a rotator still slewing; the two differing for a
|
||||||
|
long time is a rotator that is stuck, and that is worth being
|
||||||
|
able to see without walking outside. */}
|
||||||
|
{tracking?.rot_on && (
|
||||||
|
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
|
||||||
|
{/* No elevation when none is being driven: an undriven zero
|
||||||
|
draws an antenna lying on the horizon, which is a bearing
|
||||||
|
and not the absence of one. */}
|
||||||
|
<span className="font-medium">
|
||||||
|
{tracking.rot_az_only
|
||||||
|
? fmtDeg(tracking.rot_az)
|
||||||
|
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
|
||||||
|
</span>
|
||||||
|
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
|
||||||
|
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{skyShown && (
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
title={t('sat.skyWidthTip')}
|
||||||
|
onPointerDown={startSkyDrag}
|
||||||
|
onDoubleClick={() => setSkyW(SKY_W_DEFAULT)}
|
||||||
|
className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
|
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
|
||||||
own panes and controls up to z-index 1000, which without a stacking
|
own panes and controls up to z-index 1000, which without a stacking
|
||||||
context of their own float over Preferences and every dialog in the
|
context of their own float over Preferences and every dialog in the
|
||||||
@@ -832,13 +1013,29 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
|
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
|
||||||
style={{ width: sideW }}>
|
style={{ width: sideW }}>
|
||||||
{/* The pass. The first thing an operator looks at and the reason they
|
{/* The pass. The first thing an operator looks at and the reason they
|
||||||
sit down: how long have I got, and how high does it get. */}
|
sit down: how long have I got, and how high does it get.
|
||||||
<div className={cn('rounded-lg border bg-card p-2',
|
|
||||||
inPass ? 'border-success/60' : 'border-border')}>
|
The countdown is repeated in the heading, so shutting this block
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
still leaves the one number it exists for. */}
|
||||||
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
|
<Section
|
||||||
<ModeBadge mode={tp?.mode} className="self-center" />
|
title={bird?.name ?? '—'}
|
||||||
</div>
|
icon={Clock}
|
||||||
|
open={secOpen.pass}
|
||||||
|
onToggle={() => toggleSec('pass')}
|
||||||
|
right={(
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ModeBadge mode={tp?.mode} />
|
||||||
|
{/* Only while it is SHUT. Open, the countdown is already
|
||||||
|
there in full a line below, and a heading that repeats
|
||||||
|
the body is just noise. */}
|
||||||
|
{!secOpen.pass && !bird?.geostationary && pass?.has_pass && (
|
||||||
|
<Pill tone={inPass ? 'success' : 'muted'}>
|
||||||
|
{inPass ? t('sat.los') : t('sat.aos')} {fmtCountdown((inPass ? losMs : aosMs) - now)}
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
|
||||||
{bird?.geostationary ? (
|
{bird?.geostationary ? (
|
||||||
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
|
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
|
||||||
@@ -869,97 +1066,35 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
|
|
||||||
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||||||
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
|
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
|
||||||
|
{/* The one number that decides whether the pass is worth
|
||||||
|
sitting down for, coloured like the pass table colours
|
||||||
|
it: a 70° pass overhead and a 12° scrape are not the
|
||||||
|
same evening. */}
|
||||||
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
|
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
|
||||||
strong={pass.max_el >= 30} />
|
strong valueClass={elClass(pass.max_el)} />
|
||||||
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{/* The sky, seen from underneath it: the centre is straight up, the
|
|
||||||
rim is the horizon, north is at the top. One glance says whether
|
|
||||||
the pass comes over the roof or along the treeline. */}
|
|
||||||
{skyShown && (
|
|
||||||
<div className="rounded-lg border border-border bg-card p-2">
|
|
||||||
<SkyPlot
|
|
||||||
track={sky}
|
|
||||||
az={tuning?.az ?? null}
|
|
||||||
el={tuning?.el ?? null}
|
|
||||||
name={bird?.name}
|
|
||||||
visible={!!tuning?.visible}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Where it is, right now. */}
|
|
||||||
<div className="rounded-lg border border-border bg-card p-2">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
|
|
||||||
sub={tuning ? compass(tuning.az) : ''} />
|
|
||||||
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
|
|
||||||
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
|
|
||||||
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
|
||||||
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
|
|
||||||
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
|
|
||||||
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Approaching or receding, which is the sign of the whole Doppler
|
|
||||||
correction and the one number that explains why the frequencies
|
|
||||||
are moving the way they are. */}
|
|
||||||
{!!tuning && !bird?.geostationary && (
|
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
|
|
||||||
{tuning.range_rate < 0
|
|
||||||
? <ArrowUp className="size-3 text-success" />
|
|
||||||
: <ArrowDown className="size-3 text-warning" />}
|
|
||||||
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
|
|
||||||
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Where the antenna is, beside where the satellite is. The two
|
|
||||||
differing is a rotator still slewing; the two differing for a
|
|
||||||
long time is a rotator that is stuck, and that is worth being
|
|
||||||
able to see without walking outside. */}
|
|
||||||
{tracking?.rot_on && (
|
|
||||||
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
|
|
||||||
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
|
|
||||||
{/* No elevation when none is being driven: an undriven zero
|
|
||||||
draws an antenna lying on the horizon, which is a bearing
|
|
||||||
and not the absence of one. */}
|
|
||||||
<span className="font-medium">
|
|
||||||
{tracking.rot_az_only
|
|
||||||
? fmtDeg(tracking.rot_az)
|
|
||||||
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
|
|
||||||
</span>
|
|
||||||
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
|
|
||||||
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* What to tune. */}
|
{/* What to tune. */}
|
||||||
<div className="rounded-lg border border-border bg-card p-2">
|
<Section
|
||||||
{/* The mode leads, because it decides everything below it. */}
|
title={t('sat.secTune')}
|
||||||
|
icon={SlidersHorizontal}
|
||||||
|
open={secOpen.tune}
|
||||||
|
onToggle={() => toggleSec('tune')}
|
||||||
|
right={<ModeBadge mode={tp?.mode} />}
|
||||||
|
>
|
||||||
|
{/* What KIND of transponder, as badges rather than a row of grey
|
||||||
|
words: inverting decides which sideband to answer on, and a
|
||||||
|
passband width decides whether there is room to move. */}
|
||||||
<div className="flex items-center gap-1.5 mb-1.5">
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
<ModeBadge mode={tp?.mode} />
|
|
||||||
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
|
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{tp?.inverting && (
|
{tp?.inverting && <Pill tone="warning" title={t('sat.invertingHint')}>{t('sat.inverting')}</Pill>}
|
||||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-warning">{t('sat.inverting')}</span>
|
{tp?.linear && <Pill tone="info">{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</Pill>}
|
||||||
)}
|
{bird?.geostationary && <Pill>{t('sat.geo')}</Pill>}
|
||||||
{tp?.linear && (
|
|
||||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
|
||||||
{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{bird?.geostationary && (
|
|
||||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">{t('sat.geo')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -990,14 +1125,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{/* What is coming. */}
|
{/* What is coming. */}
|
||||||
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
|
<Section
|
||||||
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0">
|
title={t('sat.nextPasses')}
|
||||||
{t('sat.nextPasses')}
|
icon={ListOrdered}
|
||||||
</div>
|
open={secOpen.passes}
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
onToggle={() => toggleSec('passes')}
|
||||||
|
grow
|
||||||
|
right={passes.length > 0 ? <Pill>{passes.length}</Pill> : undefined}
|
||||||
|
>
|
||||||
|
<div className="min-h-0">
|
||||||
{passes.length === 0 && idle.length === 0 && (
|
{passes.length === 0 && idle.length === 0 && (
|
||||||
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
||||||
)}
|
)}
|
||||||
@@ -1077,7 +1216,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1098,11 +1237,75 @@ function Readout({ label, value, colour, sub }: { label: string; value: string;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
|
// Pill is a small semantic badge. The tones are the app's own status tokens,
|
||||||
|
// so a warning here is the same colour as a warning everywhere else — the
|
||||||
|
// point of having them is that an operator learns one vocabulary, not one per
|
||||||
|
// panel.
|
||||||
|
const PILL_TONE: Record<string, string> = {
|
||||||
|
muted: 'text-muted-foreground border-border bg-muted/40',
|
||||||
|
success: 'text-success border-success/45 bg-success/10',
|
||||||
|
warning: 'text-warning border-warning/45 bg-warning/10',
|
||||||
|
caution: 'text-caution border-caution/45 bg-caution/10',
|
||||||
|
danger: 'text-danger border-danger/45 bg-danger/10',
|
||||||
|
info: 'text-info border-info/45 bg-info/10',
|
||||||
|
};
|
||||||
|
function Pill({ tone = 'muted', title, className, children }:
|
||||||
|
{ tone?: keyof typeof PILL_TONE | string; title?: string; className?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span title={title}
|
||||||
|
className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide border tabular-nums',
|
||||||
|
PILL_TONE[tone] ?? PILL_TONE.muted, className)}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section is one collapsible block of the readout column.
|
||||||
|
//
|
||||||
|
// The blocks had no headings at all, which cost twice: nothing said what a
|
||||||
|
// group of numbers was, and there was nowhere to put the control that shuts
|
||||||
|
// it. An operator working FM birds never looks at the linear passband and one
|
||||||
|
// watching a schedule never looks at the range rate, so each one shuts on its
|
||||||
|
// own and stays shut.
|
||||||
|
//
|
||||||
|
// `right` is for a badge that must stay readable with the block CLOSED — the
|
||||||
|
// state of a pass, the mode being tuned. A heading that still answers the
|
||||||
|
// question is why shutting a block is worth doing.
|
||||||
|
function Section({ title, icon: Icon, open, onToggle, right, grow, children }: {
|
||||||
|
title: string;
|
||||||
|
icon: any;
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
grow?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-lg border border-border bg-card flex flex-col overflow-hidden',
|
||||||
|
grow && open && 'flex-1 min-h-0')}>
|
||||||
|
<button type="button" onClick={onToggle}
|
||||||
|
className="shrink-0 flex items-center gap-1.5 px-2 py-1 text-left hover:bg-accent/40 transition-colors">
|
||||||
|
<Icon className="size-3 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{title}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{right}
|
||||||
|
<ChevronDown className={cn('size-3 shrink-0 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={cn('border-t border-border/60', grow ? 'flex-1 min-h-0 overflow-auto' : 'px-2 py-2')}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PassBit({ label, value, strong, valueClass }:
|
||||||
|
{ label: string; value: string; strong?: boolean; valueClass?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
|
||||||
<div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div>
|
<div className={cn('truncate', strong && 'font-semibold', valueClass ?? (strong ? 'text-foreground' : undefined))}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1110,14 +1313,47 @@ function PassBit({ label, value, strong }: { label: string; value: string; stron
|
|||||||
// The corrected frequency large, the nominal one small beside it. Showing only
|
// The corrected frequency large, the nominal one small beside it. Showing only
|
||||||
// one of them leaves an operator unable to tell a Doppler correction from a
|
// one of them leaves an operator unable to tell a Doppler correction from a
|
||||||
// mistuned transponder.
|
// mistuned transponder.
|
||||||
|
// satBand names the band a satellite frequency is in, for the badge.
|
||||||
|
//
|
||||||
|
// Only the bands satellites actually use, and by inspection rather than by
|
||||||
|
// asking the backend: it is a label beside a number that is already on
|
||||||
|
// screen, not a fact anything depends on.
|
||||||
|
function satBand(hz: number): string {
|
||||||
|
const mhz = hz / 1e6;
|
||||||
|
if (mhz >= 28 && mhz < 30) return '10m';
|
||||||
|
if (mhz >= 144 && mhz < 148) return '2m';
|
||||||
|
if (mhz >= 420 && mhz < 450) return '70cm';
|
||||||
|
if (mhz >= 1240 && mhz < 1300) return '23cm';
|
||||||
|
if (mhz >= 2300 && mhz < 2450) return '13cm';
|
||||||
|
if (mhz >= 10450 && mhz < 10500) return '3cm';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreqRow is the frequency to TUNE TO — the centre of the passband, not the
|
||||||
|
// Doppler-corrected one.
|
||||||
|
//
|
||||||
|
// It used to lead with the corrected figure, which is the wrong number to
|
||||||
|
// put in a reference panel: it moves every second, it is different for
|
||||||
|
// every operator, and it is not what the frequency plan, the AMSAT tables
|
||||||
|
// or anybody on the air calls the satellite's frequency. What the radio is
|
||||||
|
// actually on belongs beside Tracking, where the radio is, and that is
|
||||||
|
// where it now lives. The correction is still shown here, as the OFFSET
|
||||||
|
// that explains the difference between the two.
|
||||||
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
|
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const shift = hz && nominal ? hz - nominal : 0;
|
const shift = hz && nominal ? hz - nominal : 0;
|
||||||
|
const centre = nominal || hz;
|
||||||
|
const band = satBand(centre);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
|
||||||
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
|
<span className="text-base font-semibold tabular-nums">{fmtHz(centre)}</span>
|
||||||
|
{!!band && <Pill className="self-center">{band}</Pill>}
|
||||||
|
<div className="flex-1" />
|
||||||
{!!shift && (
|
{!!shift && (
|
||||||
<span className="text-[10px] tabular-nums text-muted-foreground">{fmtShift(shift)}</span>
|
<Pill tone={shift > 0 ? 'success' : 'caution'} className="self-center" title={t('sat.shiftHint')}>
|
||||||
|
{fmtShift(shift)}
|
||||||
|
</Pill>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -607,6 +607,9 @@ const en: Dict = {
|
|||||||
'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)',
|
'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)',
|
||||||
'sat.aos': 'Rises in', 'sat.los': 'Sets in', 'sat.rise': 'Rise', 'sat.peak': 'Peak', 'sat.set': 'Set',
|
'sat.aos': 'Rises in', 'sat.los': 'Sets in', 'sat.rise': 'Rise', 'sat.peak': 'Peak', 'sat.set': 'Set',
|
||||||
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint',
|
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint',
|
||||||
|
'sat.secWhere': 'Position', 'sat.secTune': 'Tune to', 'sat.skyWidthTip': 'Drag to resize the sky plot, double-click to reset it',
|
||||||
|
'sat.shiftHint': 'Doppler correction — the radio is on the centre frequency plus this. The corrected figure is beside Tracking.',
|
||||||
|
'sat.invertingHint': 'The passband is turned over: transmit on lower sideband to come back on upper.',
|
||||||
'sat.tipEl': 'Elevation', 'sat.tipAz': 'Azimuth', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
|
'sat.tipEl': 'Elevation', 'sat.tipAz': 'Azimuth', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
|
||||||
'sat.tone': 'Tone', 'sat.toneHint': 'CTCSS on the uplink', 'sat.toneNone': 'no tone needed',
|
'sat.tone': 'Tone', 'sat.toneHint': 'CTCSS on the uplink', 'sat.toneNone': 'no tone needed',
|
||||||
'sat.rotAzOnly': 'azimuth only',
|
'sat.rotAzOnly': 'azimuth only',
|
||||||
@@ -622,7 +625,7 @@ const en: Dict = {
|
|||||||
'sat.sideWidthTip': 'Drag to resize the readout. Double-click to reset it.',
|
'sat.sideWidthTip': 'Drag to resize the readout. Double-click to reset it.',
|
||||||
'sat.thSat': 'Satellite', 'sat.thAos': 'Rise', 'sat.thLos': 'Set', 'sat.thMaxEl': 'Max', 'sat.thIn': 'In',
|
'sat.thSat': 'Satellite', 'sat.thAos': 'Rise', 'sat.thLos': 'Set', 'sat.thMaxEl': 'Max', 'sat.thIn': 'In',
|
||||||
'sat.skyPlot': 'The pass across the sky — centre is straight up, the rim is the horizon, north is at the top',
|
'sat.skyPlot': 'The pass across the sky — centre is straight up, the rim is the horizon, north is at the top',
|
||||||
'sat.showSky': 'Show the sky plot', 'sat.hideSky': 'Hide the sky plot',
|
'sat.showSky': 'Show the sky and position', 'sat.hideSky': 'Hide the sky and position',
|
||||||
'sec.satellite': 'Satellites',
|
'sec.satellite': 'Satellites',
|
||||||
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
|
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
|
||||||
'satset.gridPlaceholder': 'e.g. JN18cx',
|
'satset.gridPlaceholder': 'e.g. JN18cx',
|
||||||
@@ -1230,6 +1233,9 @@ const fr: Dict = {
|
|||||||
'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)',
|
'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)',
|
||||||
'sat.aos': 'Lever dans', 'sat.los': 'Coucher dans', 'sat.rise': 'Lever', 'sat.peak': 'Culmination', 'sat.set': 'Coucher',
|
'sat.aos': 'Lever dans', 'sat.los': 'Coucher dans', 'sat.rise': 'Lever', 'sat.peak': 'Culmination', 'sat.set': 'Coucher',
|
||||||
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte',
|
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte',
|
||||||
|
'sat.secWhere': 'Position', 'sat.secTune': 'À accorder', 'sat.skyWidthTip': 'Glisser pour redimensionner le ciel, double-clic pour réinitialiser',
|
||||||
|
'sat.shiftHint': 'Correction Doppler — la radio est sur la fréquence centrale plus cette valeur. Le chiffre corrigé est à côté de Tracking.',
|
||||||
|
'sat.invertingHint': 'La bande passante est inversée : émettre en bande latérale inférieure pour revenir en supérieure.',
|
||||||
'sat.tipEl': 'Élévation', 'sat.tipAz': 'Azimut', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
|
'sat.tipEl': 'Élévation', 'sat.tipAz': 'Azimut', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
|
||||||
'sat.tone': 'Tonalité', 'sat.toneHint': 'CTCSS sur la montée', 'sat.toneNone': 'aucune tonalité requise',
|
'sat.tone': 'Tonalité', 'sat.toneHint': 'CTCSS sur la montée', 'sat.toneNone': 'aucune tonalité requise',
|
||||||
'sat.rotAzOnly': 'azimut seul',
|
'sat.rotAzOnly': 'azimut seul',
|
||||||
@@ -1245,7 +1251,7 @@ const fr: Dict = {
|
|||||||
'sat.sideWidthTip': 'Glisser pour redimensionner le panneau. Double-clic pour le remettre par défaut.',
|
'sat.sideWidthTip': 'Glisser pour redimensionner le panneau. Double-clic pour le remettre par défaut.',
|
||||||
'sat.thSat': 'Satellite', 'sat.thAos': 'Lever', 'sat.thLos': 'Coucher', 'sat.thMaxEl': 'Max', 'sat.thIn': 'Dans',
|
'sat.thSat': 'Satellite', 'sat.thAos': 'Lever', 'sat.thLos': 'Coucher', 'sat.thMaxEl': 'Max', 'sat.thIn': 'Dans',
|
||||||
'sat.skyPlot': 'Le passage dans le ciel — le centre est à la verticale, le bord est l’horizon, le nord en haut',
|
'sat.skyPlot': 'Le passage dans le ciel — le centre est à la verticale, le bord est l’horizon, le nord en haut',
|
||||||
'sat.showSky': 'Afficher la vue du ciel', 'sat.hideSky': 'Masquer la vue du ciel',
|
'sat.showSky': 'Afficher le ciel et la position', 'sat.hideSky': 'Masquer le ciel et la position',
|
||||||
'sec.satellite': 'Satellites',
|
'sec.satellite': 'Satellites',
|
||||||
'satset.hint': 'Où se trouve l’antenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans l’onglet Satellites.',
|
'satset.hint': 'Où se trouve l’antenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans l’onglet Satellites.',
|
||||||
'satset.gridPlaceholder': 'ex. JN18cx',
|
'satset.gridPlaceholder': 'ex. JN18cx',
|
||||||
|
|||||||
@@ -186,8 +186,38 @@ export function greatCirclePoints(
|
|||||||
// opposite edge, at the SAME latitude, so the line leaves one side of the map
|
// opposite edge, at the SAME latitude, so the line leaves one side of the map
|
||||||
// and re-enters the other at the height it left. Leaflet takes the result as a
|
// and re-enters the other at the height it left. Leaflet takes the result as a
|
||||||
// multi-polyline, so one path is still one layer.
|
// multi-polyline, so one path is still one layer.
|
||||||
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] {
|
// unwrapLon makes a longitude series continuous.
|
||||||
if (pts.length === 0) return [];
|
//
|
||||||
|
// splitAtAntimeridian works from which COPY of the world each longitude is in,
|
||||||
|
// which requires the series to run past ±180 rather than jumping back. A great
|
||||||
|
// circle built by greatCirclePoints already does; a satellite ground track does
|
||||||
|
// not — SGP4 reports every longitude inside (−180, 180], so a track leaving
|
||||||
|
// Kamchatka at +179.9 and arriving in Alaska at −179.9 looked to the splitter
|
||||||
|
// like one step of 359.8° inside a single world. No split was made and the
|
||||||
|
// polyline drew the chord: a straight dashed line clean across the map, from
|
||||||
|
// one side of the planet to the other, on every crossing.
|
||||||
|
//
|
||||||
|
// A jump of more than 180° between two consecutive points is that wrap and
|
||||||
|
// nothing else: no real path steps half the globe between samples. Idempotent
|
||||||
|
// on a series that was already continuous, so both callers can share it.
|
||||||
|
function unwrapLon(pts: [number, number][]): [number, number][] {
|
||||||
|
if (pts.length < 2) return pts;
|
||||||
|
const out: [number, number][] = [pts[0]];
|
||||||
|
let turns = 0;
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
const [lat, lon] = pts[i];
|
||||||
|
const prevRaw = pts[i - 1][1];
|
||||||
|
const d = lon - prevRaw;
|
||||||
|
if (d > 180) turns -= 1;
|
||||||
|
else if (d < -180) turns += 1;
|
||||||
|
out.push([lat, lon + 360 * turns]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitAtAntimeridian(input: [number, number][]): [number, number][][] {
|
||||||
|
if (input.length === 0) return [];
|
||||||
|
const pts = unwrapLon(input);
|
||||||
// Which copy of the world a longitude belongs to: 0 is the map's own.
|
// Which copy of the world a longitude belongs to: 0 is the map's own.
|
||||||
const world = (lon: number) => Math.floor((lon + 180) / 360);
|
const world = (lon: number) => Math.floor((lon + 180) / 360);
|
||||||
const norm = (lon: number) => lon - 360 * world(lon);
|
const norm = (lon: number) => lon - 360 * world(lon);
|
||||||
|
|||||||
@@ -1154,6 +1154,7 @@ export namespace cat {
|
|||||||
split?: boolean;
|
split?: boolean;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
band?: string;
|
band?: string;
|
||||||
|
rx_bands?: string[];
|
||||||
vfo?: string;
|
vfo?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
// Go type: time
|
// Go type: time
|
||||||
@@ -1175,6 +1176,7 @@ export namespace cat {
|
|||||||
this.split = source["split"];
|
this.split = source["split"];
|
||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
this.band = source["band"];
|
this.band = source["band"];
|
||||||
|
this.rx_bands = source["rx_bands"];
|
||||||
this.vfo = source["vfo"];
|
this.vfo = source["vfo"];
|
||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
this.updated_at = this.convertValues(source["updated_at"], null);
|
this.updated_at = this.convertValues(source["updated_at"], null);
|
||||||
|
|||||||
+60
-14
@@ -50,18 +50,28 @@ type interruptible interface {
|
|||||||
// and RxFreqHz is the active VFO (where they listen). When not split,
|
// and RxFreqHz is the active VFO (where they listen). When not split,
|
||||||
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
|
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
|
||||||
type RigState struct {
|
type RigState struct {
|
||||||
Enabled bool `json:"enabled"` // user toggled CAT on
|
Enabled bool `json:"enabled"` // user toggled CAT on
|
||||||
Connected bool `json:"connected"` // backend says rig is online
|
Connected bool `json:"connected"` // backend says rig is online
|
||||||
Backend string `json:"backend,omitempty"` // active backend name
|
Backend string `json:"backend,omitempty"` // active backend name
|
||||||
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
||||||
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
||||||
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
||||||
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
||||||
Split bool `json:"split,omitempty"` // rig is in split mode
|
Split bool `json:"split,omitempty"` // rig is in split mode
|
||||||
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
||||||
Band string `json:"band,omitempty"` // computed from FreqHz
|
Band string `json:"band,omitempty"` // computed from FreqHz
|
||||||
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
// RxBands is every band the radio currently has a receiver on, Band
|
||||||
Error string `json:"error,omitempty"` // last connect/poll error if any
|
// included. One entry on a rig with one VFO; one per slice on a Flex.
|
||||||
|
//
|
||||||
|
// It exists because Band alone is the TRANSMIT band, and a station running
|
||||||
|
// two slices on two bands with a decoder on each is not drifting when one
|
||||||
|
// of them announces the band it is legitimately on. Reported for W4TE:
|
||||||
|
// slice A on 20 m with its own WSJT-X, slice B on 40 m holding transmit
|
||||||
|
// focus, and OpsLog telling him his 20 m decoder disagreed with a radio
|
||||||
|
// that was on 20 m — on that slice.
|
||||||
|
RxBands []string `json:"rx_bands,omitempty"`
|
||||||
|
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
||||||
|
Error string `json:"error,omitempty"` // last connect/poll error if any
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,9 +594,9 @@ type FlexController interface {
|
|||||||
SetMute(bool) error
|
SetMute(bool) error
|
||||||
SetRXAntenna(string) error
|
SetRXAntenna(string) error
|
||||||
SetTXAntenna(string) error
|
SetTXAntenna(string) error
|
||||||
// SatAntennas sets the antenna on each SATELLITE slice — they are on two
|
// SatAntennas sets BOTH antennas on each SATELLITE slice — they are on two
|
||||||
// different bands and, with transverters, two different ports.
|
// different bands and, with transverters, two different ports.
|
||||||
SatAntennas(rxAnt, txAnt string) error
|
SatAntennas(downRX, downTX, upRX, upTX string) error
|
||||||
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
|
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
|
||||||
SatTone(hz float64) error
|
SatTone(hz float64) error
|
||||||
SetActiveSlice(int) error // focus slice idx so commands target it
|
SetActiveSlice(int) error // focus slice idx so commands target it
|
||||||
@@ -1002,11 +1012,47 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
if ns.FreqHz != 0 && ns.Band == "" {
|
if ns.FreqHz != 0 && ns.Band == "" {
|
||||||
ns.Band = BandFromHz(ns.FreqHz)
|
ns.Band = BandFromHz(ns.FreqHz)
|
||||||
}
|
}
|
||||||
|
ns.RxBands = m.rxBands(ns)
|
||||||
m.update(ns)
|
m.update(ns)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rxBands lists the bands the radio has a receiver on.
|
||||||
|
//
|
||||||
|
// Asked of the backend rather than derived from the state, because only the
|
||||||
|
// backend knows: a Flex reports its slices, and everything else has exactly
|
||||||
|
// one receiver whose band is already in the state. The transmit band is
|
||||||
|
// always included, even on a Flex, so this can never come back empty while
|
||||||
|
// the rig is on a frequency.
|
||||||
|
func (m *Manager) rxBands(st RigState) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
add := func(b string) {
|
||||||
|
if b = strings.ToLower(strings.TrimSpace(b)); b != "" && !seen[b] {
|
||||||
|
seen[b] = true
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(st.Band)
|
||||||
|
if st.RxFreqHz != 0 {
|
||||||
|
add(BandFromHz(st.RxFreqHz))
|
||||||
|
}
|
||||||
|
if fx, ok := m.FlexState(); ok {
|
||||||
|
for _, sl := range fx.Slices {
|
||||||
|
if sl.Band != "" {
|
||||||
|
add(sl.Band)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A slice the radio has reported a frequency but no band for.
|
||||||
|
if sl.FreqHz != 0 {
|
||||||
|
add(BandFromHz(sl.FreqHz))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) applyCommandDelay() {
|
func (m *Manager) applyCommandDelay() {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
d := m.cmdDelay
|
d := m.cmdDelay
|
||||||
|
|||||||
@@ -79,9 +79,13 @@ type Flex struct {
|
|||||||
// turns up LATE. Arming, the antennas, the tone and the mode all happen
|
// turns up LATE. Arming, the antennas, the tone and the mode all happen
|
||||||
// before the radio has necessarily reported the slice it was asked to
|
// before the radio has necessarily reported the slice it was asked to
|
||||||
// create; without this they were applied to an index of -1 and never again.
|
// create; without this they were applied to an index of -1 and never again.
|
||||||
satUpMode string
|
satUpMode string
|
||||||
satRXAnt string
|
// Both antennas of both slices: a slice has an rxant and a txant, and each
|
||||||
satTXAnt string
|
// pair belongs to that slice's own band.
|
||||||
|
satDownRX string
|
||||||
|
satDownTX string
|
||||||
|
satUpRX string
|
||||||
|
satUpTX string
|
||||||
satTone float64
|
satTone float64
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||||
|
|||||||
+32
-21
@@ -155,11 +155,13 @@ func (f *Flex) adoptSatSlice(role string, idx int) {
|
|||||||
// antenna and the tone are all sent ONCE — the step only re-sends
|
// antenna and the tone are all sent ONCE — the step only re-sends
|
||||||
// frequencies.
|
// frequencies.
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
mode, ant, tone := f.satUpMode, f.satTXAnt, f.satTone
|
mode, rxAnt, txAnt, tone := f.satUpMode, f.satUpRX, f.satUpTX, f.satTone
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if strings.TrimSpace(ant) != "" {
|
if strings.TrimSpace(rxAnt) != "" {
|
||||||
f.send(fmt.Sprintf("slice s %d txant=%s", idx, ant))
|
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
|
||||||
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, ant))
|
}
|
||||||
|
if strings.TrimSpace(txAnt) != "" {
|
||||||
|
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(mode) != "" {
|
if strings.TrimSpace(mode) != "" {
|
||||||
f.satMode(idx, mode, 0)
|
f.satMode(idx, mode, 0)
|
||||||
@@ -259,7 +261,7 @@ func (f *Flex) SatReceiveHz() (int64, error) {
|
|||||||
return s.freqHz, nil
|
return s.freqHz, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SatAntennas selects the antenna each satellite slice uses.
|
// SatAntennas selects both antennas of each satellite slice.
|
||||||
//
|
//
|
||||||
// The two slices are on two different bands — a V/U bird receives on 70 cm and
|
// The two slices are on two different bands — a V/U bird receives on 70 cm and
|
||||||
// transmits on 2 m, a U/V one does the reverse — so they cannot share one
|
// transmits on 2 m, a U/V one does the reverse — so they cannot share one
|
||||||
@@ -274,30 +276,39 @@ func (f *Flex) SatReceiveHz() (int64, error) {
|
|||||||
// Empty strings are left alone: an operator who has configured 2 m and not
|
// Empty strings are left alone: an operator who has configured 2 m and not
|
||||||
// 70 cm should keep whatever the radio already had on the other side rather
|
// 70 cm should keep whatever the radio already had on the other side rather
|
||||||
// than have it cleared.
|
// than have it cleared.
|
||||||
func (f *Flex) SatAntennas(rxAnt, txAnt string) error {
|
// A slice has an rxant AND a txant, and both belong to the slice's own band.
|
||||||
|
// Only two of the four were being set — the downlink's receive antenna and
|
||||||
|
// the uplink's transmit one — which left the downlink slice with an empty
|
||||||
|
// txant. It never keys, so nothing was wrong on the air, but the slice was
|
||||||
|
// half-configured: move transmit focus to it and the radio uses whatever
|
||||||
|
// antenna it was last left on.
|
||||||
|
func (f *Flex) SatAntennas(downRX, downTX, upRX, upTX string) error {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
rx, tx := f.satRX, f.satTX
|
rx, tx := f.satRX, f.satTX
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
// Remembered so a slice that is reported late still gets its antenna.
|
// Remembered so a slice that is reported late still gets its antennas.
|
||||||
f.satRXAnt, f.satTXAnt = rxAnt, txAnt
|
f.satDownRX, f.satDownTX = downRX, downTX
|
||||||
|
f.satUpRX, f.satUpTX = upRX, upTX
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
}
|
}
|
||||||
// The downlink slice is the one being listened to, so it takes the receive
|
set := func(idx int, which, rxAnt, txAnt string) {
|
||||||
// antenna; the uplink slice is the one keyed, so it takes the transmit one.
|
if idx < 0 {
|
||||||
if rx >= 0 && strings.TrimSpace(rxAnt) != "" {
|
return
|
||||||
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt))
|
}
|
||||||
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt)
|
if strings.TrimSpace(rxAnt) != "" {
|
||||||
}
|
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
|
||||||
if tx >= 0 && strings.TrimSpace(txAnt) != "" {
|
}
|
||||||
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt))
|
if strings.TrimSpace(txAnt) != "" {
|
||||||
// A transmit slice also has to HEAR its own band on some radios, and a
|
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
|
||||||
// transverter port is the only thing connected to it. Setting the
|
}
|
||||||
// receive antenna to match costs nothing when it is already right.
|
if strings.TrimSpace(rxAnt) != "" || strings.TrimSpace(txAnt) != "" {
|
||||||
f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt))
|
applog.Printf("flex: satellite %s slice %d rx=%s tx=%s", which, idx, rxAnt, txAnt)
|
||||||
applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt)
|
}
|
||||||
}
|
}
|
||||||
|
set(rx, "downlink", downRX, downTX)
|
||||||
|
set(tx, "uplink", upRX, upTX)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RxBands is what stops the decode panel's band-drift warning from lying to a
|
||||||
|
// station running two slices.
|
||||||
|
//
|
||||||
|
// Reported for W4TE: slice A on 20 m with its own WSJT-X, slice B on 40 m
|
||||||
|
// holding transmit focus. RigState.Band is the TRANSMIT band, so the 20 m
|
||||||
|
// decoder was told "the rig is on 40M" while the slice it listens to had been
|
||||||
|
// on 20 m the whole time.
|
||||||
|
func TestRxBandsAlwaysCarriesTheTransmitBand(t *testing.T) {
|
||||||
|
m := &Manager{}
|
||||||
|
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m"})
|
||||||
|
if len(got) != 1 || got[0] != "20m" {
|
||||||
|
t.Fatalf("got %v, want [20m]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A split rig receives on one band and transmits on another — cross-band split
|
||||||
|
// is unusual but legal, and the receive side is where a decoder listens.
|
||||||
|
func TestRxBandsIncludesTheSplitReceiveBand(t *testing.T) {
|
||||||
|
m := &Manager{}
|
||||||
|
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m", RxFreqHz: 7074000, Split: true})
|
||||||
|
if !has(got, "20m") || !has(got, "40m") {
|
||||||
|
t.Fatalf("got %v, want both 20m and 40m", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to report when the rig is on no frequency: an empty list means "there
|
||||||
|
// is nothing to compare with", and the panel treats it as such rather than as
|
||||||
|
// "the radio is on no band", which would warn about every decode.
|
||||||
|
func TestRxBandsIsEmptyWithNoFrequency(t *testing.T) {
|
||||||
|
m := &Manager{}
|
||||||
|
if got := m.rxBands(RigState{}); len(got) != 0 {
|
||||||
|
t.Fatalf("got %v, want nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicated and lower-cased, because the panel compares strings: two slices
|
||||||
|
// on the same band are one band, and "20M" from a backend must match "20m" from
|
||||||
|
// BandFromHz.
|
||||||
|
func TestRxBandsIsNormalised(t *testing.T) {
|
||||||
|
m := &Manager{}
|
||||||
|
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20M", RxFreqHz: 14080000})
|
||||||
|
if len(got) != 1 || got[0] != "20m" {
|
||||||
|
t.Fatalf("got %v, want [20m]", got)
|
||||||
|
}
|
||||||
|
for _, b := range got {
|
||||||
|
if b != strings.ToLower(b) {
|
||||||
|
t.Errorf("%q is not lower-cased", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func has(list []string, want string) bool {
|
||||||
|
for _, s := range list {
|
||||||
|
if s == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// relaunchCmd builds the command that starts OpsLog again — after an update, or
|
||||||
|
// after a database switch.
|
||||||
|
//
|
||||||
|
// It exists to hold one fact in one place: a relaunch of OPSLOG ITSELF must not
|
||||||
|
// suppress the new process's window.
|
||||||
|
//
|
||||||
|
// The auto-update relaunch used to go through hideConsole, which sets
|
||||||
|
// SysProcAttr{HideWindow: true}. On Windows that puts SW_HIDE into the
|
||||||
|
// STARTUPINFO handed to CreateProcess, and Windows applies it to the first
|
||||||
|
// top-level window the new process shows. So the updated OpsLog started
|
||||||
|
// perfectly, took the single-instance mutex, connected the rig — and never
|
||||||
|
// became visible. Reported by two operators on 0.27.23 as "it goes to reload
|
||||||
|
// and just fails to load": a process in the task manager, no window, killing it
|
||||||
|
// and starting it by hand working every time.
|
||||||
|
//
|
||||||
|
// It arrived with the removal of the PowerShell helper. PowerShell's
|
||||||
|
// Start-Process launched the exe with a normal show, and the direct
|
||||||
|
// exec.Command that replaced it borrowed hideConsole from the console tools
|
||||||
|
// beside it — where hiding a console window is exactly right, and where every
|
||||||
|
// other caller still belongs. Two self-relaunches then differed by that one
|
||||||
|
// line, and only the hidden one was ever reported broken.
|
||||||
|
//
|
||||||
|
// So: no SysProcAttr at all, which is what RestartApp already did and why the
|
||||||
|
// database-switch relaunch never showed the fault. There is no console to
|
||||||
|
// suppress either way — OpsLog is linked for the Windows GUI subsystem.
|
||||||
|
func relaunchCmd(exe string, args ...string) *exec.Cmd {
|
||||||
|
cmd := exec.Command(exe, args...)
|
||||||
|
cmd.Dir = filepath.Dir(exe)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A relaunch of OpsLog itself must not suppress the new process's window.
|
||||||
|
//
|
||||||
|
// SysProcAttr is where that damage was done: hideConsole sets HideWindow, which
|
||||||
|
// on Windows becomes SW_HIDE in the STARTUPINFO, and Windows applies it to the
|
||||||
|
// first top-level window the new process shows. The updated OpsLog started,
|
||||||
|
// took the single-instance mutex and connected the rig — invisibly. Two
|
||||||
|
// operators on 0.27.23 reported it as "it goes to reload and just fails to
|
||||||
|
// load": a process in the task manager, no window, and killing it then starting
|
||||||
|
// it by hand working every time.
|
||||||
|
//
|
||||||
|
// Nil, not "some specific value": there is nothing a self-relaunch needs from
|
||||||
|
// STARTUPINFO, and anything set there is a window flag waiting to be wrong.
|
||||||
|
func TestRelaunchCmdDoesNotTouchTheWindow(t *testing.T) {
|
||||||
|
cmd := relaunchCmd(filepath.Join("C:", "OpsLog", "OpsLog.exe"), "--post-update", "--wait-pid", "1234")
|
||||||
|
if cmd.SysProcAttr != nil {
|
||||||
|
t.Errorf("relaunchCmd set SysProcAttr = %+v; a self-relaunch must leave the window alone", cmd.SysProcAttr)
|
||||||
|
}
|
||||||
|
if len(cmd.Args) != 4 || cmd.Args[1] != "--post-update" || cmd.Args[3] != "1234" {
|
||||||
|
t.Errorf("args = %v, want the exe plus the three passed through", cmd.Args)
|
||||||
|
}
|
||||||
|
// The working directory matters: the new instance keeps its data folder
|
||||||
|
// beside the executable, and inheriting the old process's cwd would look for
|
||||||
|
// it somewhere else entirely.
|
||||||
|
if cmd.Dir != filepath.Join("C:", "OpsLog") {
|
||||||
|
t.Errorf("Dir = %q, want the executable's folder", cmd.Dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hideConsole is right for the console tools and wrong for OpsLog, and the two
|
||||||
|
// live a few lines apart. This is the guard that stops the update path
|
||||||
|
// borrowing it again — which is how it broke the first time, when the
|
||||||
|
// PowerShell helper was replaced by a direct exec.Command beside them.
|
||||||
|
func TestUpdateRelaunchDoesNotHideTheWindow(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("update.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read update.go: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(src), "hideConsole(") {
|
||||||
|
t.Error("update.go calls hideConsole — a relaunch of OpsLog must not hide its window (see relaunch.go)")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(src), "relaunchCmd(exe,") {
|
||||||
|
t.Error("update.go no longer relaunches through relaunchCmd, where that rule is written down")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -228,16 +227,21 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
|
|||||||
// side now. The new instance is told OUR pid and waits for this process to
|
// side now. The new instance is told OUR pid and waits for this process to
|
||||||
// end before taking the single-instance mutex.
|
// end before taking the single-instance mutex.
|
||||||
//
|
//
|
||||||
// Waiting on the mutex alone was not enough, and that is the bug this line
|
// Waiting on the mutex alone was not enough: shutting down is allowed thirty
|
||||||
// fixes: shutting down is allowed thirty seconds here (armExitWatchdog),
|
// seconds here (armExitWatchdog), because it closes a remote logbook, a CAT
|
||||||
// because it closes a remote logbook, a CAT session and sometimes a backup,
|
// session and sometimes a backup, while the new instance was only patient
|
||||||
// while the new instance was only patient for twenty. On a station where
|
// for twenty. On a station where that ran long, the new process gave up and
|
||||||
// that ran long, the new process gave up and exited — leaving the old one
|
// exited — leaving the old one still running and no new window, which is
|
||||||
// still running and no new window, which is precisely what the PowerShell
|
// precisely what the PowerShell helper never did: it waited for the pid,
|
||||||
// helper never did: it waited for the pid, however long it took.
|
// however long it took.
|
||||||
cmd := exec.Command(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
|
//
|
||||||
cmd.Dir = dir
|
// And relaunchCmd rather than a command built here, because the OTHER half
|
||||||
hideConsole(cmd)
|
// of the same report was this line calling hideConsole: SW_HIDE in the
|
||||||
|
// STARTUPINFO, which Windows applies to the new process's first window. The
|
||||||
|
// updated OpsLog started, took the mutex, and stayed invisible. See
|
||||||
|
// relaunch.go — the fact belongs in one place, since two self-relaunches
|
||||||
|
// differing by one line is how only one of them was broken.
|
||||||
|
cmd := relaunchCmd(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("schedule relaunch: %w", err)
|
return fmt.Errorf("schedule relaunch: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user