Compare commits

..
7 Commits
Author SHA1 Message Date
rouggy 2808bead97 fix(uploads): Club Log is configured — it never needed an API key
The guard I added for services with no credentials demanded one, and
nobody has ever set it: OpsLog carries its own Club Log APPLICATION key
(clublogAppAPIKey), so the account is an email, a password and the
logbook callsign. An operator whose live upload had been working for
months was told the service was not configured the moment he sent QSOs by
hand after an import.

Written in app.go, the rules drifted from the uploaders on the first try.
They now live in internal/extsvc beside the Upload* functions that
enforce them, each case mirroring that function's own guard — which also
caught Cloudlog, where the station profile is required and the check did
not ask for it. The message names the fields actually missing rather than
listing everything the service takes.
2026-09-06 18:06:06 +02:00
rouggy e8f9e68759 diag(icom net): name the last CI-V commands before the silence
Audio off, and the IC-7760 still answers at connect and then never again:
twenty-eight commands sent, not one reply, packets still arriving on the
stream. A count says how much went unanswered and never which command
went out last — which is the one thing that can identify a frame this rig
does not tolerate, on a radio nobody here has.

The transport now keeps the command headers of the last eight frames and
prints them with the silence report.
2026-09-06 17:49:32 +02:00
rouggy f2168d339b diag(icom net): name the audio stream when CI-V goes quiet
The RX audio stream is experimental and shares the rig's session with
CI-V. The shape in an operator's log is unmistakable: audio packets
arriving by the hundred while not one CI-V reply comes back, the watchdog
tearing the session down, twenty seconds' pause, and the whole thing
again — read from outside as "the Icom keeps disconnecting", with
nothing pointing at the switch that would end it.

The log now says it, and names the setting. icomAudio counts what it has
delivered so the line distinguishes "audio is enabled" from "audio is
arriving", which is the half that makes it a suspect.
2026-09-06 17:42:37 +02:00
rouggy 507d1f0882 fix(icom net): a sleeping rig keeps its session, and the console is reachable
From an operator's log: an IC-7760 in standby, and OpsLog dialling and
dropping every forty seconds for as long as it was left there — control
link up, login OK, token renewed, and not one CI-V answer.

lastGoodAt bounds "the link answers but no CI-V comes back". It belongs
to a session and was never cleared when a new one opened, so a rig that
went to standby half an hour ago handed every fresh session a
half-hour-old last good read: past the grace before the first command was
even sent. Torn down at once, redialled twenty seconds later, torn down
again. Cleared on connect, the rule reads as it was written — silent
since connect is a rig in standby, and the session is kept so it can be
woken.

The console it is woken from was missing too. It appeared only once the
live CAT state said "icom", which a sleeping rig never says, so the ON
button was absent at the one moment it exists for. It now follows the
CONFIGURED radio — which also had to start following a radio switched
from the status bar, instead of waiting for a trip through Settings and a
Save that changed nothing.
2026-09-06 17:35:53 +02:00
rouggy 381a7fdc40 fix(adif): one record, one line
An exported file was full of blank gaps: a record ran down a dozen lines
and the next appeared to start in the middle of the page. ADDRESS is a
multi-line field by the standard and callbooks and other loggers fill it
that way — "Kabul", four blank lines, "Afghanistan" — and the writer
copied the value out as it stood. The files were always valid, since ADIF
counts bytes; they were unreadable, and so was anything that quoted them.

Line breaks inside a value are now joined with a comma and tabs become
spaces, which is how an address reads on one line anyway. The length
prefix is computed after the flattening, so the record stays exact, and
every path through the writer gets it: the file exports, the uploads and
the record forwarded over UDP.

The changelog's 0.27.15 block also takes back the FT-map hover fix, which
landed after the 0.27.14 release commit and was sitting in that block.
2026-09-06 17:09:18 +02:00
rouggy c8e2e3a29f fix(ft map): the hover label came back
The invisible circle that catches the clicks sits ON TOP of the dot, so
it takes the hover as well — and the tooltip was bound only to the dot
underneath. From the moment the stations became clickable, pointing at
one said nothing: the callsign, square and report an operator reads off
the map had simply gone.
2026-09-06 16:56:58 +02:00
rouggy 92a3189784 chore(changelog): 0.27.14 reads novelties first
The block is read top to bottom: what is new leads, each fix follows the
thing it belongs to, and the five genuinely new features carry [NEW].
2026-09-06 14:58:54 +02:00
12 changed files with 400 additions and 74 deletions
+9 -33
View File
@@ -11834,40 +11834,16 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
return nil return nil
} }
// uploadConfigured reports whether a service has what it needs to be uploaded // uploadConfigured reports whether a service can be uploaded to at all.
// to at all — the credentials it cannot work without, not a guarantee they are //
// correct. The service says whether they are; this says whether to ask. // The rules live in internal/extsvc, beside the uploaders that enforce them.
// Written here instead they drifted at once: Club Log was refused for a missing
// API key that nobody has ever set — OpsLog carries its own application key —
// and an operator whose live upload had worked for months was told his service
// was not configured.
func uploadConfigured(svc extsvc.Service, cfg extsvc.ExternalServices) error { func uploadConfigured(svc extsvc.Service, cfg extsvc.ExternalServices) error {
has := func(v string) bool { return strings.TrimSpace(v) != "" } if err := extsvc.Configured(svc, cfg); err != nil {
switch svc { return fmt.Errorf("%w (Settings → External services)", err)
case extsvc.ServiceCloudlog:
if !has(cfg.Cloudlog.URL) || !has(cfg.Cloudlog.APIKey) {
return fmt.Errorf("Cloudlog / Wavelog is not configured — set its URL and API key in Settings → External services")
}
case extsvc.ServiceQRZ:
if !has(cfg.QRZ.APIKey) {
return fmt.Errorf("QRZ.com is not configured — set the logbook API key in Settings → External services")
}
case extsvc.ServiceClublog:
if !has(cfg.Clublog.Email) || !has(cfg.Clublog.Password) || !has(cfg.Clublog.APIKey) {
return fmt.Errorf("Club Log is not configured — set the account email, password and API key in Settings → External services")
}
case extsvc.ServiceHRDLog:
if !has(cfg.HRDLog.Callsign) || !has(cfg.HRDLog.Code) {
return fmt.Errorf("HRDLog.net is not configured — set the callsign and upload code in Settings → External services")
}
case extsvc.ServiceEQSL:
if !has(cfg.EQSL.Username) || !has(cfg.EQSL.Password) {
return fmt.Errorf("eQSL.cc is not configured — set the username and password in Settings → External services")
}
case extsvc.ServiceHamQTH:
if !has(cfg.HamQTH.Username) || !has(cfg.HamQTH.Password) {
return fmt.Errorf("HamQTH is not configured — set the username and password in Settings → External services")
}
case extsvc.ServiceLoTW:
if !has(cfg.LoTW.StationLocation) {
return fmt.Errorf("LoTW is not configured — set the TQSL station location in Settings → External services")
}
} }
return nil return nil
} }
+46 -26
View File
@@ -1,42 +1,62 @@
[ [
{
"version": "0.27.15",
"date": "",
"en": [
"FT map: the callsign, square and report show on hover again. The invisible circle that catches the clicks sits on top of the dot, so it takes the hover too — and the label was bound only to the dot underneath, which left the map silent from the moment the stations became clickable.",
"ADIF export: a record is written on one line again. ADDRESS is a multi-line field by the standard and callbooks and other loggers fill it that way — “Kabul”, four blank lines, “Afghanistan” — and OpsLog wrote it out as it was, so a record ran down a dozen lines with the next apparently starting in the middle of the page. Line breaks inside a value are now joined with a comma, which is how an address reads on one line anyway. The files were always valid (ADIF counts bytes); they were unreadable.",
"Icom over the network: a rig left in standby no longer sits in a dial-and-drop loop. The clock that bounds “the control link answers but no CI-V comes back” belongs to a session and was never cleared when a new one opened, so every fresh session started already past its grace — torn down at once, redialled twenty seconds later, and torn down again for as long as the radio was asleep. Silent since connect is now read as what it is: a rig in standby, with the session kept so it can be woken.",
"The Icom console appears whenever the configured radio is an Icom, not only once the rig is talking — the console is where the power-ON button lives, so it used to be missing at the one moment it was needed. The consoles configured backend also follows a radio switched from the status bar, instead of waiting for a trip through Settings and a Save that changed nothing.",
"Icom over the network: when CI-V goes quiet while the experimental RX audio stream is still delivering, the log now says so and names the switch to try. The two share the rigs session, and the shape in the field is exactly that — hundreds of audio packets arriving, not one CI-V reply, the watchdog tearing the session down, and the whole thing starting again. The silence report also lists the last eight CI-V commands sent: a rig that answers at connect and then never again has usually been sent something it does not like, and a count of unanswered commands never said which one.",
"Club Log uploads are no longer refused as “not configured”. The check added for services with no credentials demanded a Club Log API key, which nobody has ever set — OpsLog carries its own application key — so an operator whose live upload had worked for months was turned away when sending QSOs by hand. Each services requirements now live beside the uploader that enforces them, and the message names the fields that are actually missing."
],
"fr": [
"Carte FTx : lindicatif, le locator et le report réapparaissent au survol. Le cercle invisible qui capte les clics est au-dessus du point, donc il capte aussi le survol — et l’étiquette n’était liée quau point du dessous, ce qui rendait la carte muette dès que les stations sont devenues cliquables.",
"Export ADIF : un enregistrement tient de nouveau sur une ligne. ADDRESS est un champ multiligne selon la norme, et les callbooks comme les autres logiciels le remplissent ainsi — « Kabul », quatre lignes vides, « Afghanistan » — quOpsLog recopiait tel quel : un enregistrement s’étalait sur une douzaine de lignes, le suivant semblant commencer au milieu de la page. Les retours à la ligne dans une valeur sont désormais réunis par une virgule, ce qui est de toute façon la façon de lire une adresse sur une ligne. Les fichiers étaient valides (lADIF compte les octets) ; ils étaient illisibles.",
"Icom en réseau : un poste laissé en veille ne tourne plus en boucle connexion/déconnexion. Lhorloge qui borne « la liaison de contrôle répond mais aucun CI-V ne revient » appartient à une session et n’était jamais remise à zéro à louverture de la suivante : chaque nouvelle session démarrait déjà au-delà de son délai de grâce — coupée aussitôt, rappelée vingt secondes plus tard, recoupée, aussi longtemps que la radio dormait. « Silencieux depuis la connexion » se lit désormais pour ce que cest : un poste en veille, dont on garde la session pour pouvoir le réveiller.",
"La console Icom saffiche dès que la radio configurée est un Icom, et pas seulement quand le poste parle — cest là que se trouve le bouton dallumage, il manquait donc au seul moment où il servait. Le backend configuré suit aussi un changement de radio fait depuis la barre d’état, au lieu dattendre un passage dans les réglages et un « Enregistrer » qui ne changeait rien.",
"Icom en réseau : quand le CI-V devient muet alors que le flux audio expérimental continue darriver, le journal le dit et nomme loption à essayer. Les deux partagent la session du poste, et cest exactement la forme observée en vrai — des centaines de paquets audio, pas une réponse CI-V, le chien de garde qui coupe la session, et tout qui recommence. Le rapport de silence liste aussi les huit dernières commandes CI-V envoyées : un poste qui répond à la connexion puis plus jamais sest en général vu envoyer quelque chose quil naime pas, et un compteur de commandes sans réponse na jamais dit laquelle.",
"Les envois vers Club Log ne sont plus refusés comme « non configuré ». Le contrôle ajouté pour les services sans identifiants réclamait une clé API Club Log que personne na jamais saisie — OpsLog embarque la sienne — et un opérateur dont lenvoi automatique fonctionnait depuis des mois se voyait éconduit au moment denvoyer des QSO à la main. Les exigences de chaque service vivent désormais à côté du code qui les applique, et le message nomme les champs réellement manquants."
]
},
{ {
"version": "0.27.14", "version": "0.27.14",
"date": "", "date": "",
"en": [ "en": [
"Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.",
"Icom CI-V: address 00 can be set, and “Other (custom address)” stays chosen. Zero was treated as “not configured” and every save put the rig back to the IC-7610s 98 — the model list following it, since it is derived from the address rather than stored.",
"Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).",
"Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA.",
"Auto-call never parks a watched callsign. After a few series of unanswered calls a station is set aside for the session — the right answer for one the LOG picked out, the wrong one for a station YOU named: a DXpedition running a pileup takes more than two series to get through to, which is exactly why it is on the list. The rest between series still applies.",
"Right-click → Send to: an upload to a service with no credentials is refused, and says which ones are missing and where. It used to run on its own and report into the QSL Managers console, which is not open when the command came from the QSO list — so it looked exactly like an upload that worked. Cloudlog / Wavelog and HamQTH also name themselves properly in the toast.",
"[NEW] The mouse wheel steps the RST fields, in the entry strip and in the QSO editor. It counts the way an operator does — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — one S-unit up to nine and then five decibels at a time, and one decibel on a digital report. R and T do not move. The dropdown beside them lists the reports worth having to hand, not every legal one, so the wheel works on the value rather than walking the list.", "[NEW] The mouse wheel steps the RST fields, in the entry strip and in the QSO editor. It counts the way an operator does — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — one S-unit up to nine and then five decibels at a time, and one decibel on a digital report. R and T do not move. The dropdown beside them lists the reports worth having to hand, not every legal one, so the wheel works on the value rather than walking the list.",
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.",
"The world map now waits for the stations square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
"[NEW] The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world maps own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.",
"[NEW] FT map: the station dots answer the same two gestures as the decodes list — one click takes the callsign into the entry, two answer it. The hit area is wider than the dot, and a double click no longer zooms the map on its way through.",
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period.",
"Auto-call: the rest between two series is counted in the stations own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
"Auto-call sees a decode that arrives after the others. A decoder sends a period in a burst and stragglers follow — a deep decode a second behind the rest — and the straggler was judged on its own, with the thirty stations of its own period nowhere in sight. The period now stays open until the next one starts, and a late arrival is weighed against all of it.",
"Auto-call: a period the station was decoded in is never counted as a miss. A period is judged more than once — the decodes arrive in a burst and stragglers follow — and a later judgement holds a partial view of it, not evidence of absence: a station answering in that very period showed “1/3 missed” against it.",
"Auto-call never parks a watched callsign. After a few series of unanswered calls a station is set aside for the session — the right answer for one the LOG picked out, the wrong one for a station YOU named: a DXpedition running a pileup takes more than two series to get through to, which is exactly why it is on the list. The rest between series still applies.",
"PSK Reporter panel: with the whole-band scope, clicking a decode no longer resets the report count to zero. The window there belongs to the BAND — every FTx report on it, filtered by target only when the analysis is drawn — and it was being emptied on every target change, throwing away an hour of evidence at the exact moment it was worth something. The narrow scope still clears it, because there the window is one stations.", "PSK Reporter panel: with the whole-band scope, clicking a decode no longer resets the report count to zero. The window there belongs to the BAND — every FTx report on it, filtered by target only when the analysis is drawn — and it was being emptied on every target change, throwing away an hour of evidence at the exact moment it was worth something. The narrow scope still clears it, because there the window is one stations.",
"Changing mode with a callsign in the field now fixes the report. The “the operator chose this report” flag was holding across a change of mode, where it means nothing — “+00” is not a weak SSB report, it is not a report at all — and anything that fills the field from the rig (the S-meter readouts in the rig consoles) sets that flag too, so it could stay in the wrong notation for the whole QSO. A judgement that can be carried across is carried (57 → 579, 599 → 59); otherwise the modes preset answers.", "Changing mode with a callsign in the field now fixes the report. The “the operator chose this report” flag was holding across a change of mode, where it means nothing — “+00” is not a weak SSB report, it is not a report at all — and anything that fills the field from the rig (the S-meter readouts in the rig consoles) sets that flag too, so it could stay in the wrong notation for the whole QSO. A judgement that can be carried across is carried (57 → 579, 599 → 59); otherwise the modes preset answers.",
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.", "Icom CI-V: address 00 can be set, and “Other (custom address)” stays chosen. Zero was treated as “not configured” and every save put the rig back to the IC-7610s 98 — the model list following it, since it is derived from the address rather than stored.",
"The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world maps own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.", "Cloudlog / Wavelog upload: a simplex contact is no longer uploaded as split. Every QSO carried a receive band and frequency equal to the transmit side, and Wavelog draws both — an ordinary FT8 contact read “17m/17m”. In ADIF an absent BAND_RX means “same as transmit”, so they are now written only when they differ. The record forwarded to another logger on the UDP link still carries them in full (Log4OM reads BAND_RX).",
"The world map now waits for the stations square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.", "Right-click → Send to: an upload to a service with no credentials is refused, and says which ones are missing and where. It used to run on its own and report into the QSL Managers console, which is not open when the command came from the QSO list — so it looked exactly like an upload that worked. Cloudlog / Wavelog and HamQTH also name themselves properly in the toast.",
"Auto-call: the rest between two series is counted in the stations own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).", "Cluster: “S/F” in a spot comment is read as FT8, alongside “superfox”, “sfox” and “F/H”. They are all the same DXpedition transmit mode, and the comment was falling through to the band plan and coming out DATA."
"Auto-call: a period the station was decoded in is never counted as a miss. A period is judged more than once — the decodes arrive in a burst and stragglers follow — and a later judgement holds a partial view of it, not evidence of absence: a station answering in that very period showed “1/3 missed” against it.",
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period.",
"FT map: the station dots answer the same two gestures as the decodes list — one click takes the callsign into the entry, two answer it. The hit area is wider than the dot, and a double click no longer zooms the map on its way through."
], ],
"fr": [ "fr": [
"Lauto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusquau début de la suivante, et un arrivant tardif est pesé face à lensemble.",
"Icom CI-V : ladresse 00 peut être saisie, et « Other (custom address) » reste sélectionné. Le zéro était pris pour « non configuré » et chaque enregistrement remettait le poste sur le 98 de lIC-7610 — la liste des modèles suivant, puisquelle est déduite de ladresse et non enregistrée.",
"Upload Cloudlog / Wavelog : un contact simplex nest plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que sils diffèrent. Lenregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).",
"Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». Cest le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA.",
"Lauto-call ne met jamais de côté un indicatif de la watchlist. Après quelques séries dappels sans réponse, une station est écartée pour la session — la bonne réponse pour une station choisie par le CARNET, la mauvaise pour une station que VOUS avez nommée : un DX en pile-up demande plus de deux séries pour passer, et cest précisément pour ça quil est sur la liste. Le repos entre séries sapplique toujours.",
"Clic droit → Envoyer vers : un envoi vers un service non configuré est refusé, en disant ce qui manque et où. Il partait tout seul et rendait compte dans la console du gestionnaire QSL, qui nest pas ouverte quand la commande vient de la liste des QSO — ça ressemblait donc exactement à un envoi réussi. Cloudlog / Wavelog et HamQTH sannoncent aussi sous leur nom dans le message.",
"[NEW] La molette fait défiler les champs RST, dans la barre de saisie comme dans l’éditeur de QSO. Elle compte comme un opérateur — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — un point S jusqu’à neuf puis cinq décibels à la fois, et un décibel sur un report numérique. R et T ne bougent pas. La liste déroulante à côté contient les reports quon veut sous la main, pas tous les reports légaux : la molette agit donc sur la valeur plutôt que de parcourir la liste.", "[NEW] La molette fait défiler les champs RST, dans la barre de saisie comme dans l’éditeur de QSO. Elle compte comme un opérateur — 57, 58, 59, 59+5, 59+10, 59+15, 59+20 — un point S jusqu’à neuf puis cinq décibels à la fois, et un décibel sur un report numérique. R et T ne bougent pas. La liste déroulante à côté contient les reports quon veut sous la main, pas tous les reports légaux : la molette agit donc sur la valeur plutôt que de parcourir la liste.",
"[NEW] La carte du monde souvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à lest, lEurope et lAfrique à louest. La latitude penche vers votre hémisphère sans vous suivre jusquau pôle, et une vue que vous avez déplacée vous-même reste prioritaire.",
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
"[NEW] La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, cest dire quelle partie du monde on travaille, et c’était jeté à chaque changement donglet.",
"[NEW] Carte FTx : les points des stations répondent aux mêmes deux gestes que la liste des décodages — un clic met lindicatif dans la saisie, deux lappellent. La zone cliquable est plus large que le point, et un double clic ne zoome plus la carte au passage.",
"Laffichage de lauto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue saffiche avec un sablier. Le bouton changeait de largeur à chaque période.",
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, cest quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
"Lauto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusquau début de la suivante, et un arrivant tardif est pesé face à lensemble.",
"Auto-call : une période où la station a été décodée nest plus comptée comme un raté. Une période est jugée plusieurs fois — les décodages arrivent en rafale puis les retardataires — et un jugement tardif nen donne quune vue partielle, pas la preuve dune absence : une station qui répondait dans cette période exacte se voyait compter « 1/3 raté ».",
"Lauto-call ne met jamais de côté un indicatif de la watchlist. Après quelques séries dappels sans réponse, une station est écartée pour la session — la bonne réponse pour une station choisie par le CARNET, la mauvaise pour une station que VOUS avez nommée : un DX en pile-up demande plus de deux séries pour passer, et cest précisément pour ça quil est sur la liste. Le repos entre séries sapplique toujours.",
"Panneau PSK Reporter : en portée « toute la bande », cliquer sur un décodage ne remet plus le nombre de reports à zéro. La fenêtre appartient là à la BANDE — tous les reports FTx qui y circulent, filtrés par cible seulement à laffichage — et elle était vidée à chaque changement de cible, jetant une heure dobservations au moment précis où elles servent. La portée étroite continue de la vider : là, la fenêtre est celle dune seule station.", "Panneau PSK Reporter : en portée « toute la bande », cliquer sur un décodage ne remet plus le nombre de reports à zéro. La fenêtre appartient là à la BANDE — tous les reports FTx qui y circulent, filtrés par cible seulement à laffichage — et elle était vidée à chaque changement de cible, jetant une heure dobservations au moment précis où elles servent. La portée étroite continue de la vider : là, la fenêtre est celle dune seule station.",
"Changer de mode avec un indicatif dans le champ corrige désormais le report. Le drapeau « lopérateur a choisi ce report » tenait au travers dun changement de mode, où il ne veut rien dire — « +00 » nest pas un report SSB faible, ce nest pas un report du tout — et tout ce qui remplit le champ depuis le poste (les lectures S-mètre des consoles) lève ce drapeau aussi : la notation pouvait rester fausse pour tout le QSO. Un jugement transposable lest (57 → 579, 599 → 59) ; sinon le préréglage du mode répond.", "Changer de mode avec un indicatif dans le champ corrige désormais le report. Le drapeau « lopérateur a choisi ce report » tenait au travers dun changement de mode, où il ne veut rien dire — « +00 » nest pas un report SSB faible, ce nest pas un report du tout — et tout ce qui remplit le champ depuis le poste (les lectures S-mètre des consoles) lève ce drapeau aussi : la notation pouvait rester fausse pour tout le QSO. Un jugement transposable lest (57 → 579, 599 → 59) ; sinon le préréglage du mode répond.",
"[NEW] La carte du monde souvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à lest, lEurope et lAfrique à louest. La latitude penche vers votre hémisphère sans vous suivre jusquau pôle, et une vue que vous avez déplacée vous-même reste prioritaire.", "Icom CI-V : ladresse 00 peut être saisie, et « Other (custom address) » reste sélectionné. Le zéro était pris pour « non configuré » et chaque enregistrement remettait le poste sur le 98 de lIC-7610 — la liste des modèles suivant, puisquelle est déduite de ladresse et non enregistrée.",
"La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, cest dire quelle partie du monde on travaille, et c’était jeté à chaque changement donglet.", "Upload Cloudlog / Wavelog : un contact simplex nest plus envoyé comme un split. Chaque QSO portait une bande et une fréquence de réception égales à l’émission, et Wavelog affiche les deux — un FT8 ordinaire se lisait « 17m/17m ». En ADIF, un BAND_RX absent signifie « identique à l’émission » : ils ne sont donc écrits que sils diffèrent. Lenregistrement transmis à un autre logiciel par UDP les porte toujours en entier (Log4OM lit BAND_RX).",
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.", "Clic droit → Envoyer vers : un envoi vers un service non configuré est refusé, en disant ce qui manque et où. Il partait tout seul et rendait compte dans la console du gestionnaire QSL, qui nest pas ouverte quand la commande vient de la liste des QSO — ça ressemblait donc exactement à un envoi réussi. Cloudlog / Wavelog et HamQTH sannoncent aussi sous leur nom dans le message.",
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, cest quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).", "Cluster : « S/F » dans un commentaire de spot est lu comme du FT8, au même titre que « superfox », « sfox » et « F/H ». Cest le même mode d’émission DXpédition, et le commentaire retombait sur le plan de bande pour ressortir en DATA."
"Auto-call : une période où la station a été décodée nest plus comptée comme un raté. Une période est jugée plusieurs fois — les décodages arrivent en rafale puis les retardataires — et un jugement tardif nen donne quune vue partielle, pas la preuve dune absence : une station qui répondait dans cette période exacte se voyait compter « 1/3 raté ».",
"Laffichage de lauto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue saffiche avec un sablier. Le bouton changeait de largeur à chaque période.",
"Carte FTx : les points des stations répondent aux mêmes deux gestes que la liste des décodages — un clic met lindicatif dans la saisie, deux lappellent. La zone cliquable est plus large que le point, et un double clic ne zoome plus la carte au passage."
] ]
}, },
{ {
+28 -5
View File
@@ -305,8 +305,12 @@ function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.——
// pill. The full message stays in the tooltip. Recognises the common cases // pill. The full message stays in the tooltip. Recognises the common cases
// (OmniRig not installed, not registered) and otherwise truncates. // (OmniRig not installed, not registered) and otherwise truncates.
// RadioChip — the CAT status chip, and the radio picker behind it. // RadioChip — the CAT status chip, and the radio picker behind it.
function RadioChip({ catUp, catState, onOpenSettings }: { function RadioChip({ catUp, catState, onOpenSettings, onRadioSwitched }: {
catUp: boolean; catState: any; onOpenSettings: () => void; catUp: boolean; catState: any; onOpenSettings: () => void;
// Switching radio here IS a settings change — the chosen entry becomes the CAT
// settings — so whatever reads those has to be told. Two Icoms swapped for one
// another never change the live backend name, and nothing else would notice.
onRadioSwitched?: () => void;
}) { }) {
const [radios, setRadios] = useState<any[]>([]); const [radios, setRadios] = useState<any[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -371,7 +375,7 @@ function RadioChip({ catUp, catState, onOpenSettings }: {
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
if (r.active) return; if (r.active) return;
SetActiveRadio(r.id).then(load).catch(() => {}); SetActiveRadio(r.id).then(() => { load(); onRadioSwitched?.(); }).catch(() => {});
}} }}
className={cn('flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs hover:bg-muted', className={cn('flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs hover:bg-muted',
r.active && 'font-semibold text-primary')} r.active && 'font-semibold text-primary')}
@@ -717,6 +721,15 @@ export default function App() {
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the // hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
// rig is off so power-ON can't work). // rig is off so power-ON can't work).
const [catBackend, setCatBackend] = useState(''); const [catBackend, setCatBackend] = useState('');
// icomConfigured is "this station's radio IS an Icom", from the settings
// rather than from the link.
//
// The console used to appear only once the rig was talking. Switching to an
// Icom that was switched OFF therefore showed no console at all — and the
// console is where the ON button lives, so the one moment the button exists
// for was the one moment it could not be reached.
const icomConfigured = catBackend === 'icom' || catBackend === 'icom-net';
const icomShown = catState.backend === 'icom' || icomConfigured;
// Live space-weather (solar flux / sunspots / A / K) for the header strip. // Live space-weather (solar flux / sunspots / A / K) for the header strip.
// Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow // Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow
// fallback poll. These same numbers are stamped onto each logged QSO. // fallback poll. These same numbers are stamped onto each logged QSO.
@@ -3265,6 +3278,15 @@ export default function App() {
setCatBackend(c.backend ?? ''); setCatBackend(c.backend ?? '');
} catch {} } catch {}
}, []); }, []);
// The configured backend follows every way the radio can change: the CAT
// panel's Save, a switch from the status bar's radio list, and the link
// itself reporting a different backend. It was read once at launch and after
// a Settings save only — so switching radio from the status bar left the
// console configured for the previous rig, and the Icom power buttons stayed
// hidden until the operator went into Settings and pressed Save for no
// reason.
useEffect(() => { loadCATCfg(); }, [catState.backend, loadCATCfg]);
const loadLists = useCallback(async () => { const loadLists = useCallback(async () => {
try { try {
const l: ListsSettings = await GetListsSettings(); const l: ListsSettings = await GetListsSettings();
@@ -8020,7 +8042,7 @@ export default function App() {
</TabsTrigger> </TabsTrigger>
)} )}
{catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>} {catState.backend === 'flex' && <TabsTrigger value="flex">Flex Console</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom Console</TabsTrigger>} {icomShown && <TabsTrigger value="icom">Icom Console</TabsTrigger>}
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>} {catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu Console</TabsTrigger>}
{(catState.backend === 'elecraft' || catState.backend === 'kenwood') && <TabsTrigger value="elecraft">{t('k3.console')}</TabsTrigger>} {(catState.backend === 'elecraft' || catState.backend === 'kenwood') && <TabsTrigger value="elecraft">{t('k3.console')}</TabsTrigger>}
{catState.backend === 'tci' && <TabsTrigger value="tci">{t('tcip.console')}</TabsTrigger>} {catState.backend === 'tci' && <TabsTrigger value="tci">{t('tcip.console')}</TabsTrigger>}
@@ -8796,7 +8818,7 @@ export default function App() {
</TabsContent> </TabsContent>
)} )}
{catState.backend === 'icom' && ( {icomShown && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0"> <TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> <IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</TabsContent> </TabsContent>
@@ -8967,6 +8989,7 @@ export default function App() {
catUp={catUp} catUp={catUp}
catState={catState} catState={catState}
onOpenSettings={() => { setSettingsSection('cat'); setShowSettings(true); }} onOpenSettings={() => { setSettingsSection('cat'); setShowSettings(true); }}
onRadioSwitched={loadCATCfg}
/> />
<Chip <Chip
on={rotatorHeading.enabled && rotatorHeading.ok} on={rotatorHeading.enabled && rotatorHeading.ok}
@@ -9212,7 +9235,7 @@ export default function App() {
onSaved={onSettingsSaved} onSaved={onSettingsSaved}
onMainPaneChanged={onSettingsPaneChanged} onMainPaneChanged={onSettingsPaneChanged}
flexAvailable={catState.backend === 'flex'} flexAvailable={catState.backend === 'flex'}
icomAvailable={catState.backend === 'icom'} icomAvailable={icomShown}
yaesuAvailable={catState.backend === 'yaesu'} yaesuAvailable={catState.backend === 'yaesu'}
elecraftAvailable={catState.backend === 'elecraft' || catState.backend === 'kenwood'} elecraftAvailable={catState.backend === 'elecraft' || catState.backend === 'kenwood'}
tciAvailable={catState.backend === 'tci'} tciAvailable={catState.backend === 'tci'}
+10 -4
View File
@@ -164,15 +164,21 @@ export function FTMapPanel({ decodes, myGrid, onSelect, onCall }: {
L.polyline(pts as L.LatLngExpression[][], { L.polyline(pts as L.LatLngExpression[][], {
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0, color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
}).addTo(layer); }).addTo(layer);
const label = `${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`;
const mk = L.circleMarker([to.lat, to.lon], { const mk = L.circleMarker([to.lat, to.lon], {
// A three-pixel dot is a fine mark and a poor target, so the visible // A three-pixel dot is a fine mark and a poor target, so the visible
// radius stays and an invisible one twice the size takes the clicks. // radius stays and an invisible one three times the size takes the
// clicks.
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade, radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' }) }).bindTooltip(label, { direction: 'top' }).addTo(layer);
.addTo(layer); // The tooltip goes on the HIT circle too, and it is the one that matters:
// being on top, it takes the hover as well as the click, and binding it
// only to the dot underneath left the map silent from the moment the dots
// became clickable — the callsign and report an operator reads by pointing
// at a station had simply gone.
const hit = L.circleMarker([to.lat, to.lon], { const hit = L.circleMarker([to.lat, to.lon], {
radius: 9, opacity: 0, fillOpacity: 0, interactive: true, radius: 9, opacity: 0, fillOpacity: 0, interactive: true,
}).addTo(layer); }).bindTooltip(label, { direction: 'top' }).addTo(layer);
for (const target of [mk, hit]) { for (const target of [mk, hit]) {
target.on('click', (e) => { target.on('click', (e) => {
// Not to the map: a click on a station is not a click on the water. // Not to the map: a click on a station is not a click on the water.
+29
View File
@@ -407,12 +407,41 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
// length is the byte count (ADIF spec), which matches len(v) in Go since v is // length is the byte count (ADIF spec), which matches len(v) in Go since v is
// already a UTF-8 byte string. // already a UTF-8 byte string.
func writeField(bw *bufio.Writer, tag, v string) { func writeField(bw *bufio.Writer, tag, v string) {
v = oneLine(v)
if v == "" { if v == "" {
return return
} }
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v) fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
} }
// oneLine flattens a value onto a single line.
//
// ADIF counts bytes, so a value carrying line breaks is still read correctly —
// and it turns the file into something nobody can read. ADDRESS is a multi-line
// field by the standard, and callbooks and other loggers fill it that way: a
// value of "Kabul" followed by four blank lines and "Afghanistan" came out of
// OpsLog as one record spread down a dozen lines, with the next record
// apparently starting in the middle of the page.
//
// The breaks are dropped rather than escaped: the parts are trimmed and joined
// with a comma, which is how an address reads on one line anyway, and empty
// fragments go. The length prefix is computed after this, so the record stays
// exact.
func oneLine(v string) string {
if !strings.ContainsAny(v, "\r\n\t") {
return v
}
parts := strings.FieldsFunc(v, func(r rune) bool { return r == '\r' || r == '\n' })
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(strings.ReplaceAll(part, "\t", " "))
if part != "" {
out = append(out, part)
}
}
return strings.Join(out, ", ")
}
func writeIntPtr(bw *bufio.Writer, tag string, p *int) { func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
if p == nil { if p == nil {
return return
+46
View File
@@ -0,0 +1,46 @@
package adif
import (
"bufio"
"strings"
"testing"
"hamlog/internal/qso"
)
// An exported record has to fit on its own line. ADDRESS is a multi-line field
// by the standard and callbooks fill it that way, so an OpsLog export was one
// record spread down a dozen lines with the next apparently starting in the
// middle of the page.
func TestAMultiLineValueIsWrittenOnOneLine(t *testing.T) {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeField(bw, "ADDRESS", "Kabul\r\n\r\n\r\n\r\nAfghanistan\r\n")
bw.Flush()
got := b.String()
if strings.ContainsAny(got, "\r\n") {
t.Fatalf("the record still breaks across lines: %q", got)
}
if want := "<ADDRESS:18>Kabul, Afghanistan "; got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// And the whole record, the way an operator reads the file.
func TestARecordIsOneLine(t *testing.T) {
hz := int64(28555000)
rec := SingleRecordADIF(qso.QSO{
Callsign: "T6T", Band: "10m", Mode: "SSB", FreqHz: &hz,
Address: "Kabul\n\n\nAfghanistan", Name: "Shuravi\t(Vyacheslav)",
})
if n := strings.Count(strings.TrimRight(rec, "\r\n"), "\n"); n != 0 {
t.Errorf("the record spans %d extra lines:\n%s", n, rec)
}
if !strings.Contains(rec, "Kabul, Afghanistan") {
t.Errorf("the address lost its parts:\n%s", rec)
}
if !strings.Contains(rec, "Shuravi (Vyacheslav)") {
t.Errorf("a tab was left in the value:\n%s", rec)
}
}
+30
View File
@@ -60,3 +60,33 @@ func TestIcomSilenceBackoff(t *testing.T) {
t.Errorf("backoff overshot the ceiling: %s", g) t.Errorf("backoff overshot the ceiling: %s", g)
} }
} }
// A new session must not be judged on the previous one's silence.
//
// From an operator's log: a rig switched to standby, then a dial-and-drop loop
// every forty seconds for as long as it was left there. lastGoodAt bounds "the
// link answers but no CI-V comes back"; it belongs to a session, and it was
// never cleared when a new one opened, so every fresh session started already
// past the grace — and the Icom console, where the power-ON button lives,
// blinked away on every pass.
func TestAFreshSessionStartsWithACleanSilenceClock(t *testing.T) {
b := &IcomSerial{
lastGoodAt: time.Now().Add(-30 * time.Minute), // a session from before standby
readFails: 9,
silentGrace: icomSilentGraceMax,
}
// What Connect does once the transport is open, before anything is sent.
b.lastGoodAt = time.Time{}
b.readFails = 0
b.silentGrace = icomSilentGrace
if !b.lastGoodAt.IsZero() {
t.Fatal("the previous session's last good read survived into this one")
}
tolerate := func(lastGood time.Time, silentFor, grace time.Duration) bool {
return lastGood.IsZero() || silentFor < grace
}
if !tolerate(b.lastGoodAt, time.Hour, b.silentGrace) {
t.Error("a session silent since connect was torn down — that is a rig in standby, and where the ON button has to work")
}
}
+15 -1
View File
@@ -67,12 +67,26 @@ type icomAudio struct {
txOuter uint16 txOuter uint16
txSend uint16 txSend uint16
lastRx atomic.Int64 // UnixNano of last packet (liveness) lastRx atomic.Int64 // UnixNano of last packet (liveness)
rxCount atomic.Int64 // packets delivered on this stream
done chan struct{} done chan struct{}
closeOnce sync.Once closeOnce sync.Once
} }
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) } func (a *icomAudio) markRx() {
a.lastRx.Store(time.Now().UnixNano())
a.rxCount.Add(1)
}
// packets reports whether the stream is actually delivering — the difference
// between "audio is on" and "audio is arriving", which is what makes it worth
// naming as a suspect when CI-V has gone quiet on the same session.
func (a *icomAudio) packets() int64 {
if a == nil {
return 0
}
return a.rxCount.Load()
}
// Close tears the audio stream down (disconnect a few times; UDP is lossy). // Close tears the audio stream down (disconnect a few times; UDP is lossy).
func (a *icomAudio) Close() { func (a *icomAudio) Close() {
+49
View File
@@ -132,6 +132,13 @@ type icomNet struct {
// loop, not the rig — and RS-BA1 showing no such dropouts points that way. // loop, not the rig — and RS-BA1 showing no such dropouts points that way.
txCiv atomic.Uint32 txCiv atomic.Uint32
txAtData atomic.Uint32 txAtData atomic.Uint32
// WHAT was asked, not just how much. A rig that answers at connect and then
// never again has usually been sent something it does not like, and counting
// the unanswered commands says nothing about which one that was. The last few
// command headers are kept so the silence report can name them — the only way
// to find a poison command on a radio nobody here has.
cmdMu sync.Mutex
lastCmd []string
rx chan []byte // CI-V byte chunks from civPump → Read (control replies) rx chan []byte // CI-V byte chunks from civPump → Read (control replies)
scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter
@@ -268,6 +275,7 @@ func (n *icomNet) Write(p []byte) (int, error) {
n.vCivSeq++ n.vCivSeq++
n.seqMu.Unlock() n.seqMu.Unlock()
n.txCiv.Add(1) n.txCiv.Add(1)
n.noteCmd(p)
pkt := icnCivData(seq, n.vID, n.vRemote, civSeq, p) pkt := icnCivData(seq, n.vID, n.vRemote, civSeq, p)
n.sentMu.Lock() n.sentMu.Lock()
n.sentBuf[seq] = pkt n.sentBuf[seq] = pkt
@@ -280,6 +288,35 @@ func (n *icomNet) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
// noteCmd remembers the command bytes of a CI-V frame — everything after the
// preamble and the two addresses, up to four bytes, which is command,
// sub-command and the first of the data.
func (n *icomNet) noteCmd(p []byte) {
if len(p) < 5 {
return
}
body := p[4:]
if len(body) > 4 {
body = body[:4]
}
n.cmdMu.Lock()
n.lastCmd = append(n.lastCmd, fmt.Sprintf("% X", body))
if len(n.lastCmd) > 8 {
n.lastCmd = n.lastCmd[len(n.lastCmd)-8:]
}
n.cmdMu.Unlock()
}
// recentCmds is what noteCmd collected, oldest first.
func (n *icomNet) recentCmds() string {
n.cmdMu.Lock()
defer n.cmdMu.Unlock()
if len(n.lastCmd) == 0 {
return "none"
}
return strings.Join(n.lastCmd, " | ")
}
// icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing // icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing
// the network transport. Off by default (the connect-step logs stay); flip to // the network transport. Off by default (the connect-step logs stay); flip to
// true to trace every TX/RX again. // true to trace every TX/RX again.
@@ -496,6 +533,18 @@ func (n *icomNet) civPump() {
} }
debugLog.Printf("icom net: no CI-V DATA for 10 s (transport last heard %s ago; last scope frame %s ago; last socket error: %v; missing-seq backlog: %d; CI-V commands SENT since the last answer: %d)", debugLog.Printf("icom net: no CI-V DATA for 10 s (transport last heard %s ago; last scope frame %s ago; last socket error: %v; missing-seq backlog: %d; CI-V commands SENT since the last answer: %d)",
time.Since(lastPkt).Round(time.Second), scopeAge, lastErr, len(n.rxMissing), n.txCiv.Load()-n.txAtData.Load()) time.Since(lastPkt).Round(time.Second), scopeAge, lastErr, len(n.rxMissing), n.txCiv.Load()-n.txAtData.Load())
debugLog.Printf("icom net: the last CI-V commands sent, oldest first: %s", n.recentCmds())
// THE AUDIO STREAM IS THE FIRST SUSPECT, AND ONLY THE LOG CAN SAY SO.
//
// The RX audio stream is experimental and shares the rig's session with
// CI-V. The shape seen in the field is exactly this one: audio packets
// arriving by the hundred while not one CI-V reply comes back, the
// watchdog tearing the session down, and the whole thing beginning
// again — a loop an operator reads as "the Icom keeps disconnecting",
// with nothing pointing at the switch that would end it.
if n.audio != nil && n.audio.packets() > 0 {
debugLog.Printf("icom net: the RX audio stream is running and still delivering while CI-V has gone quiet — that option is experimental and shares this session. If the drops continue, turn OFF \"Stream RX audio over the network\" in Settings → CAT and see whether control steadies.")
}
// And try the gentle repair before the 30 s watchdog tears the whole // And try the gentle repair before the 30 s watchdog tears the whole
// session down: if the rig quietly closed the CI-V data flow (the // session down: if the rig quietly closed the CI-V data flow (the
// transport is still chatting, so the session itself stands), saying // transport is still chatting, so the session itself stands), saying
+15
View File
@@ -232,6 +232,21 @@ func (b *IcomSerial) Connect() error {
_ = port.SetRTS(false) _ = port.SetRTS(false)
b.port = port b.port = port
b.model = civ.ModelName(b.rigAddr) b.model = civ.ModelName(b.rigAddr)
// A NEW SESSION IS NOT JUDGED ON THE OLD ONE'S SILENCE.
//
// lastGoodAt bounds "the control link answers but no CI-V comes back". It
// belongs to a session, and it was never cleared when a new one opened —
// so a rig that went to standby half an hour ago handed every fresh session
// a half-hour-old "last good read", which is past the grace before the first
// command is even sent. The session was torn down at once, redialled twenty
// seconds later, and torn down again: a loop with no way out, and the Icom
// console (with its power-ON button) blinking away on every pass.
//
// Cleared, the rule reads as it was written: silent since connect is a rig in
// standby, and the session is kept so the operator can wake it.
b.lastGoodAt = time.Time{}
b.readFails = 0
b.silentGrace = icomSilentGrace
// Start the reader before any request: recv() now waits on respCh, which only // Start the reader before any request: recv() now waits on respCh, which only
// the reader feeds. respCh is buffered so a burst (or the scope stream) never // the reader feeds. respCh is buffered so a burst (or the scope stream) never
+79
View File
@@ -0,0 +1,79 @@
package extsvc
import (
"fmt"
"strings"
)
// Configured reports what a service still needs before it can be uploaded to.
//
// It exists so the answer lives NEXT TO THE UPLOADERS that enforce it. Written
// once in the app instead, it drifted immediately: Club Log was refused for a
// missing API key, which nobody has ever set — OpsLog carries its own
// application key (see clublogAppAPIKey) and the account is an email, a password
// and the logbook callsign. An operator whose live upload had been working for
// months was told his service was not configured.
//
// Each case mirrors the guard at the top of the matching Upload* function. It
// answers "can this be attempted", not "are these credentials right": only the
// service can say that, and it says it by refusing the upload.
func Configured(svc Service, cfg ExternalServices) error {
missing := func(service string, fields ...string) error {
return fmt.Errorf("%s is not configured — %s", service, strings.Join(fields, ", "))
}
set := func(v string) bool { return strings.TrimSpace(v) != "" }
var need []string
add := func(ok bool, what string) {
if !ok {
need = append(need, what)
}
}
switch svc {
case ServiceQRZ:
add(set(cfg.QRZ.APIKey), "the logbook API key")
if len(need) > 0 {
return missing("QRZ.com", need...)
}
case ServiceClublog:
// No API key: OpsLog's own application key is embedded.
add(set(cfg.Clublog.Email), "the account email")
add(set(cfg.Clublog.Password), "the password")
add(set(cfg.Clublog.Callsign), "the logbook callsign")
if len(need) > 0 {
return missing("Club Log", need...)
}
case ServiceHRDLog:
add(set(cfg.HRDLog.Callsign), "the station callsign")
add(set(cfg.HRDLog.Code), "the upload code")
if len(need) > 0 {
return missing("HRDLog.net", need...)
}
case ServiceEQSL:
add(set(cfg.EQSL.Username), "the username (callsign)")
add(set(cfg.EQSL.Password), "the password")
if len(need) > 0 {
return missing("eQSL.cc", need...)
}
case ServiceHamQTH:
add(set(cfg.HamQTH.Username), "the username")
add(set(cfg.HamQTH.Password), "the password")
if len(need) > 0 {
return missing("HamQTH", need...)
}
case ServiceCloudlog:
add(set(cfg.Cloudlog.URL), "the instance URL")
add(set(cfg.Cloudlog.APIKey), "the API key")
add(set(cfg.Cloudlog.StationID), "the station profile")
if len(need) > 0 {
return missing("Cloudlog / Wavelog", need...)
}
case ServiceLoTW:
add(set(cfg.LoTW.TQSLPath), "the path to tqsl.exe")
add(set(cfg.LoTW.StationLocation), "the TQSL station location")
if len(need) > 0 {
return missing("LoTW", need...)
}
}
return nil
}
+44 -5
View File
@@ -25,11 +25,50 @@ func TestUploadRefusesAnUnconfiguredService(t *testing.T) {
t.Errorf("%s: %q does not say where to fix it", svc, err) t.Errorf("%s: %q does not say where to fix it", svc, err)
} }
} }
}
// Configured: nothing in the way. // And a CONFIGURED service is not turned away. Club Log is the one that was:
cfg := extsvc.ExternalServices{} // its API key is OpsLog's own application key, embedded and never entered, so
cfg.Cloudlog.URL, cfg.Cloudlog.APIKey = "https://log.f4bpo.fr", "cl123" // demanding it refused every operator who had the service working.
if err := uploadConfigured(extsvc.ServiceCloudlog, cfg); err != nil { func TestAConfiguredServiceIsAccepted(t *testing.T) {
t.Errorf("a configured Cloudlog was refused: %v", err) var cfg extsvc.ExternalServices
cfg.Clublog.Email, cfg.Clublog.Password, cfg.Clublog.Callsign = "[email protected]", "secret", "F4BPO"
cfg.QRZ.APIKey = "1234-5678"
cfg.Cloudlog.URL, cfg.Cloudlog.APIKey, cfg.Cloudlog.StationID = "https://log.example.com", "cl-key", "3"
cfg.EQSL.Username, cfg.EQSL.Password = "F4BPO", "secret"
cfg.HamQTH.Username, cfg.HamQTH.Password = "f4bpo", "secret"
cfg.HRDLog.Callsign, cfg.HRDLog.Code = "F4BPO", "12345"
cfg.LoTW.TQSLPath, cfg.LoTW.StationLocation = `C:\Program Files (x86)\TrustedQSL\tqsl.exe`, "Home"
for _, svc := range []extsvc.Service{
extsvc.ServiceCloudlog, extsvc.ServiceQRZ, extsvc.ServiceClublog,
extsvc.ServiceHRDLog, extsvc.ServiceEQSL, extsvc.ServiceHamQTH, extsvc.ServiceLoTW,
} {
if err := uploadConfigured(svc, cfg); err != nil {
t.Errorf("%s: a configured service was refused: %v", svc, err)
}
}
}
// The message names what is actually missing, so the operator opens the right
// field rather than checking three that were already filled in.
func TestTheRefusalNamesTheMissingFields(t *testing.T) {
var cfg extsvc.ExternalServices
cfg.Clublog.Email = "[email protected]"
err := uploadConfigured(extsvc.ServiceClublog, cfg)
if err == nil {
t.Fatal("a half-configured Club Log was accepted")
}
msg := err.Error()
if strings.Contains(msg, "email") {
t.Errorf("%q asks for the one field that IS set", msg)
}
for _, want := range []string{"password", "logbook callsign"} {
if !strings.Contains(msg, want) {
t.Errorf("%q does not mention the missing %s", msg, want)
}
}
if strings.Contains(strings.ToLower(msg), "api key") {
t.Errorf("%q asks for the API key — it is OpsLog's own, embedded", msg)
} }
} }