From 88796c8c3bb1c6a0d4b29a06da95ede1b82ecd84 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 05:36:07 +0200 Subject: [PATCH] Ins Logbuch sehen, nicht nur hineinschreiben Seit #184 kann die App loggen, aber nicht nachsehen. Das ist die schlechtere Haelfte: ein Eintrag, der danebenging, faellt erst am Pult auf, und "hatte ich den schon?" ist die Frage, die in den zwei Sekunden zwischen Rufzeichen und Anruf beantwortet werden muss -- danach ist sie wertlos. Zwei Longpath-eigene TCI-Befehle, Gegenstueck zu `log_qso:`: log_last:; -> je Kontakt log_qso_zeile:, dann log_last_ok:; log_dup:; -> log_dup_ok:,,...,; ZUERST GEMESSEN, dann gebaut. An Martins echtem Logbuch (6,6 MB, 9271 Datensaetze): voller Durchlauf 4,1 ms Suche nach einem Rufzeichen 3,8 ms (3 Treffer) letzte 64 kB 0,04 ms (78 Datensaetze) Damit faellt der zweite Index weg, den ich bauen wollte. `WorkedBefore` gehoert ohnehin `RotorLogbookPanel` -- einem Fenster; der Netzdienst darf da nicht hineingreifen. Ein Index muesste gepflegt und bei jedem fremden Schreibzugriff verworfen werden; ein Durchlauf von 4 ms kann nicht veralten. LogbuchRueckschau liest darum vom DATEIENDE und siebt fuer die Dupe-Frage nach dem Rufzeichen vor. Genau daraus entsteht die Gefahr, gegen die der Prueftstand vor allem steht: ein Schnitt an einer willkuerlichen Bytestelle beginnt mitten in einem Datensatz, und der ADIF-Parser macht aus der hinteren Haelfte einen eigenen Kontakt. In einer von Longpath geschriebenen Datei steht CALL zuerst und `isValid()` faengt das zufaellig ab -- ADIF schreibt die Reihenfolge aber nicht vor, und Martins Datei enthaelt zusammengefuehrte Importe. Der Stand fuehrt denselben Schnitt ohne und mit Grenzschnitt vor; ohne entsteht der erfundene Kontakt wirklich. Auf der Seite stehen die letzten Kontakte im SELBEN Blatt wie die Eingabe: nach dem Eintragen steht der eigene Kontakt als erste Zeile da. Das ist der Beleg, den die Erfolgsmeldung nur behaupten kann. Darunter dem Rufzeichenfeld sagt eine Zeile, was das Logbuch weiss -- in Messing, weil es gemessen ist; kraeftiges Rot bleibt der Warnung. Band und Betriebsart fuer `log_dup:` kommen aus derselben Stelle wie beim Eintragen (bandUndModeDerScheibe). Sonst koennte die Antwort "schon gearbeitet" auf ein Band gehen, das das Geraet nicht eingestellt hat. 47 Pruefpunkte: 13 (LogbuchRueckschau) + 9 (TCI, ueber den echten WebSocket) + 25 (Seite, ohne Browser). Die Zahl in der Abschlusszeile MUSS mit den gesendeten Zeilen uebereinstimmen -- die Seite zeigt eine Liste nur dann, weil eine kuerzere Liste wie ein kuerzeres Logbuch aussieht und niemand es merkt. Am Telefonformat gefunden, nicht im Code: alle drei Dupe-Zustaende kamen in derselben Farbe heraus. Die Zustandsklasse hiess wie die Grundklasse, und `.dupe.dupe` trifft jedes Element mit class="dupe" und schlaegt per Spezifitaet die beiden anderen Regeln. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 1 + handfunke/app.js | 118 ++++++++++ handfunke/attrappe.py | 66 ++++++ handfunke/index.html | 18 ++ handfunke/logbuch.js | 128 +++++++++++ handfunke/pruefe-logbuch.mjs | 161 +++++++++++++ handfunke/pruefe.py | 80 ++++++- handfunke/stil.css | 43 ++++ handfunke/tci.js | 10 + src/core/LogbuchRueckschau.cpp | 176 ++++++++++++++ src/core/LogbuchRueckschau.h | 93 ++++++++ src/core/TciServer.cpp | 156 ++++++++++++- src/core/TciServer.h | 8 + tests/CMakeLists.txt | 26 +++ tests/tst_logbuch_rueckschau.cpp | 378 +++++++++++++++++++++++++++++++ tests/tst_tci_logbuch_lesen.cpp | 321 ++++++++++++++++++++++++++ 16 files changed, 1767 insertions(+), 16 deletions(-) create mode 100644 handfunke/logbuch.js create mode 100644 handfunke/pruefe-logbuch.mjs create mode 100644 src/core/LogbuchRueckschau.cpp create mode 100644 src/core/LogbuchRueckschau.h create mode 100644 tests/tst_logbuch_rueckschau.cpp create mode 100644 tests/tst_tci_logbuch_lesen.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 89382efd0..157497d5b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -518,6 +518,7 @@ set(CORE_SOURCES src/core/LogDatei.cpp src/core/audio/BusMitFrist.cpp src/core/LogbookDatei.cpp + src/core/LogbuchRueckschau.cpp src/core/SettingsBackup.cpp src/core/RxProfileManager.cpp src/core/CwDecoder.cpp diff --git a/handfunke/app.js b/handfunke/app.js index 6d54950ea..65bd3946e 100644 --- a/handfunke/app.js +++ b/handfunke/app.js @@ -8,6 +8,8 @@ import { TciLink, Fft } from './tci.js'; import { Mikrofon, mikrofonMoeglich } from './mikrofon.js'; +import { Sammelstelle, zeileLesen, befundLesen, zeitKurz, dupeSatz } + from './logbuch.js'; const $ = (id) => document.getElementById(id); const link = new TciLink(); @@ -1444,11 +1446,120 @@ function qsoAutomatikZeigen() { + `${zz(d.getUTCHours())}:${zz(d.getUTCMinutes())}`; } +// ── Ins Logbuch sehen (2026-10-04) ──────────────────────────────────────── +// +// Zwei Fragen, zwei Befehle: `log_last:` fuer die letzten Kontakte, +// `log_dup:` fuer "hatte ich den schon?". Gesammelt und gedeutet wird in +// logbuch.js — hier steht nur, wann gefragt wird und wo es landet. +// +// WARUM ENTPRELLT: `log_dup:` laeuft in Longpath ueber die ganze Datei +// (gemessen 3,8 ms bei 6,6 MB). Je Tastendruck waere das ein Durchlauf fuer +// jeden Zwischenstand eines Rufzeichens — acht Durchlaeufe fuer OE5SOS, und +// sieben Antworten, die niemand lesen will. 350 ms nach dem letzten Tipper +// reicht; wer fertig getippt hat, wartet keine halbe Sekunde. +const sammler = new Sammelstelle(); +let dupeFrist = null; +let dupeOffen = ''; // welches Rufzeichen gerade gefragt ist + +function logListeZeigen(liste) { + const el = $('logListe'); + el.textContent = ''; + if (!liste || !liste.length) { + const d = document.createElement('div'); + d.className = 'leer'; + d.textContent = '—'; + el.appendChild(d); + return; + } + for (const z of liste) { + const r = document.createElement('div'); + r.className = 'r'; + const w = document.createElement('span'); + w.className = 'wann'; + w.textContent = zeitKurz(z.datum, z.zeit) || '—'; + const ruf = document.createElement('span'); + ruf.className = 'ruf'; + ruf.textContent = z.ruf; + const wo = document.createElement('span'); + wo.className = 'wo'; + wo.textContent = [z.band, z.mode].filter(Boolean).join(' '); + r.append(w, ruf, wo); + el.appendChild(r); + } +} + +function logListeHolen() { + if (!link.ready) { return; } + sammler.beginnen(); + link.send('log_last:10'); +} + +function dupeZeigen(satz) { + const el = $('qsoDupe'); + el.textContent = satz.text; + el.className = `dupe${satz.art ? ' d-' + satz.art : ''}`; +} + +function dupeFragen() { + const ruf = ($('qsoRuf').value || '').trim().toUpperCase(); + clearTimeout(dupeFrist); + if (ruf.length < 3) { + // Unter drei Zeichen ist jedes Rufzeichen noch jedes andere. Eine + // Auskunft darueber waere beliebig. + dupeOffen = ''; + dupeZeigen({ text: '', art: '' }); + return; + } + dupeFrist = setTimeout(() => { + if (!link.ready) { return; } + dupeOffen = ruf; + link.send(`log_dup:${ruf}`); + }, 350); +} + +link.addEventListener('logzeile', (e) => { + sammler.zeile(zeileLesen((e.detail || {}).args || [])); +}); + +link.addEventListener('logende', (e) => { + const d = e.detail || {}; + if (d.anzahl === null || d.anzahl === undefined) { + // Abgelehnt (z. B. Logbuch aus dem Netz abgeschaltet). Die Liste bleibt + // leer und sagt, warum — statt still leer zu bleiben und wie ein leeres + // Logbuch zu wirken. + sammler.abschluss(-1); + const el = $('logListe'); + el.textContent = ''; + const x = document.createElement('div'); + x.className = 'leer'; + x.textContent = d.text || 'nicht freigegeben'; + el.appendChild(x); + return; + } + const liste = sammler.abschluss(d.anzahl); + // null heisst: es fehlen Zeilen. Dann NICHT zeigen — eine kuerzere Liste + // sieht aus wie ein kuerzeres Logbuch. + if (liste) { logListeZeigen(liste); } +}); + +link.addEventListener('logdupe', (e) => { + const d = e.detail || {}; + if (!d.args) { dupeZeigen({ text: d.text || '', art: '' }); return; } + const b = befundLesen(d.args); + // Eine Antwort auf ein Rufzeichen, das inzwischen weitergetippt wurde, + // gehoert nicht mehr unter das Feld. + if (!b || b.ruf !== dupeOffen) { return; } + dupeZeigen(dupeSatz(b)); +}); + function qsoOeffnen() { $('qsoRuf').value = ''; $('qsoMeldung').textContent = ''; $('qsoMeldung').className = ''; $('qsoOk').disabled = false; + dupeZeigen({ text: '', art: '' }); + dupeOffen = ''; + logListeHolen(); qsoAutomatikZeigen(); clearInterval(qsoUhr); qsoUhr = setInterval(qsoAutomatikZeigen, 5000); @@ -1499,6 +1610,11 @@ link.addEventListener('qso', (e) => { $('qsoMeldung').textContent = `${d.text} eingetragen`; $('qsoMeldung').className = 'ok'; $('qsoRuf').value = ''; + dupeZeigen({ text: '', art: '' }); + dupeOffen = ''; + // Der Beleg: der eigene Kontakt steht jetzt als erste Zeile in der + // Liste. Die Erfolgsmeldung kann das nur behaupten. + logListeHolen(); // Offen lassen: im Pile-up kommt der naechste sofort, und ein Blatt, // das nach jedem Kontakt zugeht, kostet zwei Tipper je QSO. setTimeout(() => $('qsoRuf').focus(), 30); @@ -1514,6 +1630,8 @@ $('qsoOk').addEventListener('click', qsoEintragen); $('qsoRuf').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); qsoEintragen(); } }); +$('qsoRuf').addEventListener('input', dupeFragen); +$('logFrisch').addEventListener('click', logListeHolen); // Tippen auf den Grund schliesst, wie beim Frequenz-Blatt. $('qsoBlatt').addEventListener('click', (e) => { if (e.target === $('qsoBlatt')) { qsoSchliessen(); } diff --git a/handfunke/attrappe.py b/handfunke/attrappe.py index 2bffc51b6..fc3ef3f5f 100644 --- a/handfunke/attrappe.py +++ b/handfunke/attrappe.py @@ -55,6 +55,26 @@ def _zahl_nach(flagge, standard): MAGIC = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + +def _jetzt_utc(): + """("20261004", "213000") — so wie Longpath es in log_qso_zeile schickt.""" + t = time.gmtime() + return (time.strftime('%Y%m%d', t), time.strftime('%H%M%S', t)) + + +# Ein kleiner Vorrat, damit die Liste nicht leer anfaengt, und damit sich +# ALLE vier Faelle des Dupe-Satzes am Telefon ansehen lassen: +# OE1AAA 20m CW -> echtes Dupe (Attrappe steht auf 20m CW) +# OE2BBB 20m SSB -> gleiches Band, andere Betriebsart +# OE3CCC 40m SSB -> bekannt, dieses Band noch nicht +# alles andere -> NEU +VORRAT = [ + (('20261001', '081500'), 'OE3CCC', '40m', 'SSB', '59', '59'), + (('20261002', '143000'), 'OE2BBB', '20m', 'SSB', '59', '57'), + (('20261002', '191200'), 'OE1AAA', '20m', 'CW', '599', '599'), +] +EINGETRAGEN = [] + INIT_BURST = [ 'protocol:Longpath-Attrappe,2.0;', 'device:Attrappe;', @@ -275,6 +295,52 @@ def befehl(self, zeile): re_ = args[2] if len(args) > 2 and args[2] else '59' print(f' {self.addr[1]}: log_qso {ruf} {rs}/{re_}') self.sende_text(f'log_qso_ok:{ruf};') + # Eingetragenes merken, damit `log_last` und `log_dup` + # etwas zu sagen haben. Nur im Speicher dieser Attrappe — + # Martins Logbuch wird hier nicht angefasst. + EINGETRAGEN.append((_jetzt_utc(), ruf, '20m', 'CW', rs, re_)) + elif name == 'log_last': + # Longpath-eigener Befehl (2026-10-04). Je Kontakt eine Zeile, + # dann der Abschluss mit der Anzahl. + # + # Die Attrappe deckelt wie der echte Server auf 50 — eine + # Attrappe, die mehr durchlaesst, verschiebt Fehler nach hinten. + try: + n = int(args[0]) if args and args[0].strip() else 10 + except ValueError: + n = 10 + if n <= 0: + n = 10 + n = min(n, 50) + liste = (VORRAT + EINGETRAGEN)[::-1][:n] + for i, (zeit, ruf, band, mode, rs, re_) in enumerate(liste): + self.sende_text( + f'log_qso_zeile:{i},{zeit[0]},{zeit[1]},{ruf},' + f'{band},{mode},{rs},{re_};') + print(f' {self.addr[1]}: log_last {n} -> {len(liste)} Zeilen') + self.sende_text(f'log_last_ok:{len(liste)};') + elif name == 'log_dup': + ruf = (args[0].strip().upper() if args else '') + if not ruf or len(ruf) > 20: + print(f' {self.addr[1]}: log_dup ABGELEHNT ({ruf!r})') + self.sende_text('log_dup_err:rufzeichen fehlt;') + else: + # Band und Betriebsart kommen beim echten Server aus der + # aktiven Scheibe. Die Attrappe steht auf 20m CW. + treffer = [e for e in (VORRAT + EINGETRAGEN) if e[1] == ruf] + anzahl = len(treffer) + letzter = treffer[-1] if treffer else None + gleiches_band = any(e[2] == '20m' for e in treffer) + dupe = any(e[2] == '20m' and e[3] == 'CW' for e in treffer) + dat = letzter[0][0] if letzter else '' + zei = letzter[0][1] if letzter else '' + band = letzter[2] if letzter else '' + mode = letzter[3] if letzter else '' + print(f' {self.addr[1]}: log_dup {ruf} -> {anzahl}× ' + f'(Band {int(gleiches_band)}, Dupe {int(dupe)})') + self.sende_text( + f'log_dup_ok:{ruf},{anzahl},{dat},{zei},{band},{mode},' + f'{int(gleiches_band)},{int(dupe)};') elif name in ('trx', 'tune'): # Sendesperre, wie der echte Server sie fuehrt (PR #189-Reihe). # Die Attrappe ist dabei absichtlich NICHT gutmuetiger: ohne diff --git a/handfunke/index.html b/handfunke/index.html index 4afc1b5e7..e280c721f 100644 --- a/handfunke/index.html +++ b/handfunke/index.html @@ -243,6 +243,11 @@

QSO EINTRAGEN

+ +
@@ -262,6 +267,19 @@

QSO EINTRAGEN

+ +
+
+ LETZTE KONTAKTE + +
+
—
+
diff --git a/handfunke/logbuch.js b/handfunke/logbuch.js new file mode 100644 index 000000000..47421ce28 --- /dev/null +++ b/handfunke/logbuch.js @@ -0,0 +1,128 @@ +// Logbuch in der App — die reinen Teile. +// +// Hier steht ABSICHTLICH kein DOM und kein WebSocket. Zwei Dinge gehen +// beim Logbuch leicht schief, und beide lassen sich nur dann pruefen, +// wenn sie ohne Telefon laufen: +// +// 1. Die Sammelstelle. Longpath schickt je Kontakt eine Zeile und erst +// danach `log_last_ok:`. Trifft eine zweite Anfrage ein, +// waehrend die erste noch laeuft, mischen sich zwei Listen. Die +// Liste sieht dann vollstaendig aus und ist es nicht — das ist +// schlimmer als eine leere. +// +// 2. Der Dupe-Satz. Wer "schon gearbeitet" sagt, obwohl Band oder +// Betriebsart unbekannt sind, verhindert einen Kontakt, den es noch +// nicht gibt — und niemand erfaehrt davon, weil nichts passiert. +// Unbekannt muss sich darum wie unbekannt lesen, nicht wie "nein". +// +// Gegenstueck in Longpath: `LogbuchRueckschau` und die beiden Befehle +// `log_last:` / `log_dup:` in TciServer.cpp. + +/** + * Sammelt die Zeilen EINER Abfrage und gibt die Liste erst heraus, wenn + * die Abschlusszeile dieselbe Anzahl nennt. + */ +export class Sammelstelle { + constructor() { this.zeilen = []; this.offen = false; } + + /** Eine Abfrage beginnt. Alles Vorige ist damit hinfaellig. */ + beginnen() { this.zeilen = []; this.offen = true; } + + /** + * Eine `log_qso_zeile:`-Zeile. Nummer 0 beginnt eine neue Liste — so + * uebersteht die Sammelstelle auch eine Antwort, deren Anfang sie nicht + * gesehen hat (Seite neu geladen, Abfrage noch unterwegs). + */ + zeile(satz) { + if (!satz) return; + if (satz.nr === 0) { this.zeilen = []; this.offen = true; } + if (!this.offen) return; + this.zeilen.push(satz); + } + + /** + * Die Abschlusszeile. Gibt die Liste zurueck, wenn die Anzahl stimmt, + * sonst `null` — dann fehlt etwas, und die Seite darf keine + * unvollstaendige Liste als vollstaendig zeigen. + */ + abschluss(anzahl) { + const fertig = this.offen && this.zeilen.length === anzahl; + const l = this.zeilen; + this.zeilen = []; + this.offen = false; + return fertig ? l : null; + } +} + +/** `log_qso_zeile:`-Argumente -> Satz. Gibt `null`, wenn nichts dasteht. */ +export function zeileLesen(args) { + const nr = parseInt(args[0], 10); + const ruf = (args[3] || '').trim().toUpperCase(); + if (isNaN(nr) || !ruf) return null; + return { + nr, + datum: (args[1] || '').trim(), // yyyymmdd, leer wenn ohne Zeit + zeit: (args[2] || '').trim(), // hhmmss + ruf, + band: (args[4] || '').trim(), + mode: (args[5] || '').trim(), + rstS: (args[6] || '').trim(), + rstE: (args[7] || '').trim(), + }; +} + +/** `log_dup_ok:`-Argumente -> Befund. */ +export function befundLesen(args) { + const ruf = (args[0] || '').trim().toUpperCase(); + if (!ruf) return null; + const anzahl = parseInt(args[1], 10); + return { + ruf, + anzahl: isNaN(anzahl) ? 0 : anzahl, + datum: (args[2] || '').trim(), + zeit: (args[3] || '').trim(), + band: (args[4] || '').trim(), + mode: (args[5] || '').trim(), + gleichesBand: (args[6] || '') === '1', + dupe: (args[7] || '') === '1', + }; +} + +/** "20261002" + "143000" -> "02.10. 14:30". Leeres Datum -> "". */ +export function zeitKurz(datum, zeit) { + if (!/^\d{8}$/.test(datum || '')) return ''; + const tag = datum.slice(6, 8), mon = datum.slice(4, 6); + if (!/^\d{4,6}$/.test(zeit || '')) return `${tag}.${mon}.`; + return `${tag}.${mon}. ${zeit.slice(0, 2)}:${zeit.slice(2, 4)}`; +} + +/** + * Was unter dem Rufzeichenfeld steht. + * + * `{text, art}` mit art 'neu' | 'bekannt' | 'dupe' | '' — die Seite + * entscheidet die Farbe, nicht dieser Satz. Messing fuer bekannt und + * dupe; kraeftiges Rot bleibt der Warnung (Hausregel). + */ +export function dupeSatz(b) { + if (!b || !b.ruf) return { text: '', art: '' }; + if (b.anzahl <= 0) return { text: 'NEU — noch nie gearbeitet', art: 'neu' }; + + const wann = zeitKurz(b.datum, b.zeit); + const wo = [b.band, b.mode].filter(Boolean).join(' '); + const mal = b.anzahl === 1 ? '1×' : `${b.anzahl}×`; + const hinten = [wann, wo].filter(Boolean).join(' · '); + + // Die Unterscheidung, auf die es ankommt: dasselbe Band UND dieselbe + // Betriebsart ist ein Dupe. Dasselbe Rufzeichen auf einem anderen Band + // ist ein neuer Kontakt — und oft genau der, den man haben will. + if (b.dupe) { + return { text: `DUPE · ${wo || 'dieses Band'} schon gearbeitet` + + (wann ? ` · ${wann}` : ''), art: 'dupe' }; + } + if (b.gleichesBand) { + return { text: `${mal} · dieses Band schon, andere Betriebsart` + + (hinten ? ` · ${hinten}` : ''), art: 'bekannt' }; + } + return { text: `${mal}${hinten ? ` · zuletzt ${hinten}` : ''}` + + ' · dieses Band noch nicht', art: 'bekannt' }; +} diff --git a/handfunke/pruefe-logbuch.mjs b/handfunke/pruefe-logbuch.mjs new file mode 100644 index 000000000..22b4ebc38 --- /dev/null +++ b/handfunke/pruefe-logbuch.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// Pruefstand fuer das Logbuch in der App — ohne Browser, ohne Funkgeraet. +// +// Geprueft wird, was still falsch sein kann: +// +// * Eine Liste, die unvollstaendig ist und vollstaendig aussieht. Longpath +// schickt je Kontakt eine Zeile und erst danach `log_last_ok:`. +// Ueberlappen zwei Abfragen, mischen sich zwei Listen — und niemand +// merkt es, weil zehn Zeilen wie zehn Zeilen aussehen. +// +// * Ein Dupe-Satz, der auf Verdacht warnt. "Schon gearbeitet" bei +// unbekanntem Band verhindert einen Kontakt, den es noch nicht gibt. +// Das faellt nie auf, weil nichts passiert. +// +// node pruefe-logbuch.mjs + +import { Sammelstelle, zeileLesen, befundLesen, zeitKurz, dupeSatz } + from './logbuch.js'; + +let fehler = 0; +const pruefe = (name, ok, text = '') => { + if (ok) { console.log(` ok ${name}`); } + else { console.log(` FEHLT ${name}${text ? ': ' + text : ''}`); fehler++; } +}; + +const z = (nr, ruf, band = '20m', mode = 'CW') => + zeileLesen([String(nr), '20261002', '143000', ruf, band, mode, '59', '59']); + +console.log('Sammelstelle\n'); + +// 1. Der gute Fall. +{ + const s = new Sammelstelle(); + s.beginnen(); + s.zeile(z(0, 'OE1AAA')); s.zeile(z(1, 'OE2BBB')); s.zeile(z(2, 'OE3CCC')); + const l = s.abschluss(3); + pruefe('drei Zeilen, drei angesagt -> Liste', Array.isArray(l) && l.length === 3); + pruefe('Reihenfolge bleibt', l && l[0].ruf === 'OE1AAA' && l[2].ruf === 'OE3CCC'); +} + +// 2. Der Fall, um den es geht: zu wenige Zeilen angekommen. +{ + const s = new Sammelstelle(); + s.beginnen(); + s.zeile(z(0, 'OE1AAA')); s.zeile(z(1, 'OE2BBB')); + pruefe('zwei Zeilen, drei angesagt -> nichts', s.abschluss(3) === null); +} + +// 3. Zwei Abfragen ueberlappen. Die zweite beginnt bei 0 — und was von der +// ersten noch kam, darf NICHT mitgezaehlt werden. +{ + const s = new Sammelstelle(); + s.beginnen(); + s.zeile(z(0, 'ALT1')); s.zeile(z(1, 'ALT2')); + // Zweite Abfrage: Nummer 0 wirft die alte Liste weg. + s.zeile(z(0, 'NEU1')); s.zeile(z(1, 'NEU2')); + const l = s.abschluss(2); + pruefe('neue Abfrage wirft die halbe alte weg', + l && l.length === 2 && l[0].ruf === 'NEU1', JSON.stringify(l)); +} + +// 4. Gegenprobe zu 3: ohne das Zuruecksetzen bei Nummer 0 waeren vier +// Zeilen in der Liste und `abschluss(2)` haette null geliefert — die +// Liste waere also verschwunden, nicht falsch. Beides belegen: +{ + const s = new Sammelstelle(); + s.beginnen(); + s.zeile(z(0, 'A')); s.zeile(z(1, 'B')); s.zeile(z(2, 'C')); s.zeile(z(3, 'D')); + pruefe('vier Zeilen, zwei angesagt -> nichts', s.abschluss(2) === null); +} + +// 5. Eine Antwort ohne gesehenen Anfang (Seite neu geladen). +{ + const s = new Sammelstelle(); + s.zeile(z(0, 'OE1AAA')); + pruefe('Nummer 0 oeffnet die Sammlung von selbst', + (s.abschluss(1) || []).length === 1); +} + +// 6. Eine Zeile mitten hinein, ohne Anfang: nicht sammeln. +{ + const s = new Sammelstelle(); + s.zeile(z(7, 'OE1AAA')); + pruefe('Zeile ohne Anfang wird nicht gesammelt', s.abschluss(1) === null); +} + +// 7. Muellzeilen. +{ + pruefe('Zeile ohne Rufzeichen -> null', + zeileLesen(['0', '20261002', '143000', '', '20m', 'CW']) === null); + pruefe('Zeile ohne Nummer -> null', + zeileLesen(['x', '20261002', '143000', 'OE1AAA']) === null); + pruefe('Rufzeichen wird gross', z(0, 'oe1aaa').ruf === 'OE1AAA'); +} + +console.log('\nZeit'); +{ + pruefe('20261002/143000 -> 02.10. 14:30', + zeitKurz('20261002', '143000') === '02.10. 14:30', + zeitKurz('20261002', '143000')); + pruefe('ohne Zeit nur das Datum', + zeitKurz('20261002', '') === '02.10.', zeitKurz('20261002', '')); + pruefe('ohne Datum nichts', zeitKurz('', '143000') === ''); + pruefe('Unfug ergibt nichts', zeitKurz('2026', '143000') === ''); +} + +console.log('\nDupe-Satz'); + +const bef = (o) => befundLesen([ + o.ruf ?? 'OE2BBB', String(o.anzahl ?? 0), o.datum ?? '', o.zeit ?? '', + o.band ?? '', o.mode ?? '', o.gleichesBand ? '1' : '0', o.dupe ? '1' : '0', +]); + +// 8. Noch nie gearbeitet — das muss sich wie eine Einladung lesen. +{ + const s = dupeSatz(bef({ anzahl: 0 })); + pruefe('nie gearbeitet -> NEU', s.art === 'neu' && /NEU/.test(s.text), s.text); +} + +// 9. DER Fall: bekannt, aber Band/Mode unbekannt -> KEIN Dupe. +{ + const s = dupeSatz(bef({ anzahl: 3, datum: '20261002', zeit: '143000', + band: '20m', mode: 'CW', + gleichesBand: false, dupe: false })); + pruefe('bekannt ohne Bandtreffer ist kein Dupe', s.art === 'bekannt', s.art); + pruefe('und sagt es auch so', !/DUPE/.test(s.text), s.text); + pruefe('nennt die Anzahl', /3×/.test(s.text), s.text); + pruefe('nennt, dass dieses Band fehlt', /noch nicht/.test(s.text), s.text); +} + +// 10. Echter Dupe. +{ + const s = dupeSatz(bef({ anzahl: 2, datum: '20261002', zeit: '143000', + band: '20m', mode: 'CW', + gleichesBand: true, dupe: true })); + pruefe('gleiches Band und Mode -> DUPE', + s.art === 'dupe' && /DUPE/.test(s.text), s.text); +} + +// 11. Gleiches Band, andere Betriebsart — kein Dupe, aber erwaehnenswert. +{ + const s = dupeSatz(bef({ anzahl: 1, band: '20m', mode: 'SSB', + gleichesBand: true, dupe: false })); + pruefe('gleiches Band, anderer Mode ist kein Dupe', + s.art === 'bekannt' && !/DUPE/.test(s.text), s.text); + pruefe('sagt, dass die Betriebsart die andere ist', + /Betriebsart/.test(s.text), s.text); +} + +// 12. Leeres oder fehlendes Rufzeichen -> gar kein Satz. Eine Zeile unter +// dem leeren Feld waere Laerm, und ein "NEU" unter einem leeren Feld +// eine Behauptung ueber nichts. +{ + pruefe('kein Befund -> kein Satz', dupeSatz(null).text === ''); + pruefe('leeres Rufzeichen -> kein Satz', + dupeSatz({ ruf: '', anzahl: 0 }).text === ''); + pruefe('befundLesen ohne Rufzeichen -> null', befundLesen(['']) === null); +} + +console.log(fehler === 0 ? '\nAlles gut.' : `\n${fehler} Punkt(e) offen.`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/handfunke/pruefe.py b/handfunke/pruefe.py index 4422f4ed4..a3fa312c8 100755 --- a/handfunke/pruefe.py +++ b/handfunke/pruefe.py @@ -423,6 +423,75 @@ def spanne(name, werte, grenze): print(" Unterschied nichts. Erst mit stehendem Traeger deuten.") +def miss_logbuch(v, sekunden): + """Was die App im Logbuch sieht — und wie lange Longpath dafuer braucht. + + LIEST NUR. `log_last:` und `log_dup:` haengen nichts an und aendern + nichts; der Schreibbefehl `log_qso:` wird hier bewusst nicht benutzt, + damit dieses Werkzeug an Martins echtem Logbuch laufen kann. + + Gemessen wird die ANTWORTZEIT, nicht nur das Zustandekommen. Die + Entwurfsentscheidung "kein zweiter Index, nur ein Durchlauf" steht und + faellt damit: bei 6,6 MB kostet ein Durchlauf gemessene 3,8 ms, und + wenn daraus im Betrieb einmal 300 ms werden, gehoert das gesehen und + nicht geraten. + """ + v.leeren() + t0 = time.time() + v.sende("log_last:10;") + v.sammle(min(sekunden, 3.0)) + ms = (time.time() - t0) * 1000.0 + zeilen = [z for z in v.zeilen() if z.startswith("log_qso_zeile:")] + ende = [z for z in v.zeilen() if z.startswith("log_last_")] + + if not ende: + print(" log_last: keine Abschlusszeile — der Server kennt den " + "Befehl nicht (vor 2026-10-04) oder schweigt.") + return + if ende[-1].startswith("log_last_err:"): + print(f" log_last abgelehnt: {ende[-1].split(':', 1)[1]}") + print(" Aus dem Netz verlangt Longpath TciAllowRemoteLog.") + return + + angesagt = ende[-1].split(":", 1)[1] + print(f" {len(zeilen)} Zeilen, angesagt {angesagt}, in {ms:.0f} ms") + # Die Zahl MUSS stimmen. Eine kuerzere Liste sieht aus wie ein + # kuerzeres Logbuch, und genau das darf nicht unbemerkt bleiben. + if angesagt.strip() != str(len(zeilen)): + print(" ACHTUNG: Anzahl und Zeilen stimmen nicht ueberein.") + for z in zeilen[:5]: + f = z.split(":", 1)[1].split(",") + while len(f) < 8: + f.append("") + print(f" {f[1]} {f[2]} {f[3]:<10} {f[4]:>4} {f[5]:<5} " + f"{f[6]}/{f[7]}") + + # Und die Dupe-Frage, mit dem jüngsten Rufzeichen aus der Liste: eines, + # das garantiert im Logbuch steht. Ein erfundenes Rufzeichen wuerde nur + # belegen, dass "nie gearbeitet" funktioniert. + if not zeilen: + return + ruf = zeilen[0].split(":", 1)[1].split(",")[3].strip() + if not ruf: + return + v.leeren() + t0 = time.time() + v.sende(f"log_dup:{ruf};") + v.sammle(min(sekunden, 3.0)) + ms = (time.time() - t0) * 1000.0 + antwort = [z for z in v.zeilen() if z.startswith("log_dup_")] + if not antwort: + print(f" log_dup:{ruf} — keine Antwort") + return + print(f" log_dup:{ruf} in {ms:.0f} ms -> {antwort[-1]}") + if antwort[-1].startswith("log_dup_ok:"): + f = antwort[-1].split(":", 1)[1].split(",") + if len(f) >= 2 and f[1].strip() in ("", "0"): + print(" ACHTUNG: ein Rufzeichen AUS der Liste gilt als nie " + "gearbeitet — das Vorsieb findet seinen eigenen Eintrag " + "nicht.") + + def eigene_adresse(): """Die Adresse, unter der dieser Rechner im Heimnetz steht.""" s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -439,7 +508,8 @@ def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("was", nargs="?", default="alles", - choices=["alles", "ton", "hf", "pegel", "skala"]) + choices=["alles", "ton", "hf", "pegel", "skala", + "logbuch"]) ap.add_argument("--host", default=None, help="Vorgabe: die eigene Netzadresse") ap.add_argument("--port", type=int, default=50001) ap.add_argument("--token", default=None, help="nur aus dem Netz noetig") @@ -466,15 +536,17 @@ def main(): vfo = [z for z in v.zeilen() if z.startswith("vfo:0,0")] print(f"Verbunden. {vfo[-1] if vfo else 'keine VFO-Meldung'}\n") - teile = ["hf", "pegel", "ton"] if args.was == "alles" else [args.was] + teile = (["hf", "pegel", "ton", "logbuch"] if args.was == "alles" + else [args.was]) for teil in teile: print({"ton": "── Ton ──", "hf": "── Signal im Bild ──", "pegel": "── Was der Server meldet ──", - "skala": "── Skala gegen Preamp/ATT ──"}[teil]) + "skala": "── Skala gegen Preamp/ATT ──", + "logbuch": "── Logbuch, wie die App es sieht ──"}[teil]) # Der Skala-Lauf braucht Zeit zum Durchschalten, nicht fuenf Sekunden. dauer = max(args.dauer, 40.0) if teil == "skala" else args.dauer {"ton": miss_ton, "hf": miss_hf, "pegel": miss_pegel, - "skala": miss_skala}[teil](v, dauer) + "skala": miss_skala, "logbuch": miss_logbuch}[teil](v, dauer) print() v.zu() diff --git a/handfunke/stil.css b/handfunke/stil.css index d1132ddd3..a1121817b 100644 --- a/handfunke/stil.css +++ b/handfunke/stil.css @@ -404,6 +404,49 @@ footer .warn{color:var(--warn)} font-size:var(--f-caption);letter-spacing:.1em;text-transform:uppercase} #qsoBlatt button:disabled{opacity:.45} +/* ── Was das Logbuch ueber dieses Rufzeichen weiss ──────────────────────── + Messing, weil es GEMESSEN ist — es kommt aus der Datei, nicht aus einer + Schaetzung. Kein kraeftiges Rot: Rot bleibt dem Fehler, und ein Dupe ist + kein Fehler, sondern eine Auskunft (Hausregel, 2026-10-01). + + Die Zeile haelt ihre Hoehe, auch leer. Ohne das wandern RST-Felder und + Knopf beim Tippen nach unten — und ein Knopf, der unter dem Finger + wegrutscht, traegt im Pile-up das falsche QSO ein. */ +#qsoBlatt .dupe{min-height:15px;font:500 var(--f-caption)/1.25 var(--mono); + letter-spacing:.04em;text-align:center;color:var(--text-inactive); + margin-top:-4px} +/* Die Zustandsklassen heissen ABSICHTLICH nicht wie die Grundklasse: + `.dupe.dupe` trifft jedes Element mit class="dupe" und schlaegt per + Spezifitaet die beiden anderen Regeln. Am 2026-10-04 kamen damit alle + drei Zustaende in Messing heraus — am Telefon gesehen, nicht im Code. */ +#qsoBlatt .dupe.d-neu{color:var(--text3)} +#qsoBlatt .dupe.d-bekannt{color:var(--amber-dim)} +#qsoBlatt .dupe.d-dupe{color:var(--measured);font-weight:600} + +/* ── Die letzten Kontakte ───────────────────────────────────────────────── + Dicktengleich und eng, damit acht Zeilen auf ein Telefon passen, ohne + dass der Eintragen-Knopf aus dem Bild rutscht. Hoehe begrenzt und + scrollbar: die Liste darf wachsen, das Blatt nicht. */ +#qsoBlatt .letzte{display:flex;flex-direction:column;gap:6px} +#qsoBlatt .letzteKopf{display:flex;align-items:center; + justify-content:space-between; + font:600 var(--f-caption)/1 inherit;letter-spacing:.13em; + color:var(--text3)} +#qsoBlatt button.klein{height:28px;padding:0 10px;font-size:10px; + letter-spacing:.09em} +#qsoBlatt .logListe{max-height:150px;overflow-y:auto;-webkit-overflow-scrolling:touch; + border-radius:10px;background:rgba(255,255,255,.02); + border:1px solid rgba(255,255,255,.05);padding:5px 7px; + display:flex;flex-direction:column;gap:2px} +#qsoBlatt .logListe .r{display:flex;gap:8px;align-items:baseline; + font:500 var(--f-caption)/1.5 var(--mono);white-space:nowrap} +#qsoBlatt .logListe .wann{color:var(--text-inactive);flex:none} +#qsoBlatt .logListe .ruf{color:var(--text);font-weight:600; + flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis} +#qsoBlatt .logListe .wo{color:var(--amber-dim);flex:none} +#qsoBlatt .logListe .leer{font:500 var(--f-caption)/1.5 var(--mono); + color:var(--text-inactive);text-align:center} + /* ── Die Halteleiste (Entwurf 1, gewaehlt 2026-10-04) ───────────────────── Gedrueckt halten nimmt auf, loslassen hoert auf. Der Finger ist die diff --git a/handfunke/tci.js b/handfunke/tci.js index e0c09587a..60ffa52a8 100644 --- a/handfunke/tci.js +++ b/handfunke/tci.js @@ -183,6 +183,16 @@ export class TciLink extends EventTarget { // Erfolg behaupten. case 'log_qso_ok': this._emit('qso', { ok: true, text: args[0] || '' }); break; case 'log_qso_err': this._emit('qso', { ok: false, text: args[0] || '' }); break; + // Ins Logbuch SEHEN (2026-10-04). Je Kontakt eine Zeile, dann der + // Abschluss mit der Anzahl — die Seite darf eine Liste erst zeigen, + // wenn beide uebereinstimmen (siehe Sammelstelle in logbuch.js). + // Durchgereicht, nicht gedeutet: hier wird nichts gezaehlt und + // nichts gesammelt, damit es EINE Stelle bleibt. + case 'log_qso_zeile': this._emit('logzeile', { args }); break; + case 'log_last_ok': this._emit('logende', { anzahl: int(0) }); break; + case 'log_last_err': this._emit('logende', { anzahl: null, text: args[0] || '' }); break; + case 'log_dup_ok': this._emit('logdupe', { args }); break; + case 'log_dup_err': this._emit('logdupe', { args: null, text: args[0] || '' }); break; // Ein abgelehnter Sendewunsch, mit Grund. Bis zum 2026-10-04 kam // hier gar nichts, und ein stummes Nein ist auf einer // Fernbedienung nicht von einem Defekt zu unterscheiden. diff --git a/src/core/LogbuchRueckschau.cpp b/src/core/LogbuchRueckschau.cpp new file mode 100644 index 000000000..091c7be23 --- /dev/null +++ b/src/core/LogbuchRueckschau.cpp @@ -0,0 +1,176 @@ +#include "core/LogbuchRueckschau.h" + +#include "core/AdifLog.h" +#include "core/LogbookDatei.h" +#include "models/LogEntry.h" + +#include + +#include + +namespace Longpath { +namespace LogbuchRueckschau { + +namespace { + +constexpr qint64 kErstesFenster = 64 * 1024; // gemessen: ~78 Datensaetze +constexpr int kFaktor = 4; // Fenster wachsen lassen + +const QByteArray& kEor() +{ + static const QByteArray e = QByteArrayLiteral(""); + return e; +} + +// Jüngster zuerst. Datensätze ohne Zeit nach hinten, statt sie als +// "vor allem anderen" zu behandeln -- eine fehlende Zeit ist keine Zeit +// im Jahr 0. +void juengsterZuerst(QVector& v) +{ + std::stable_sort(v.begin(), v.end(), + [](const LogEntry& a, const LogEntry& b) { + if (a.timeOn.isValid() != b.timeOn.isValid()) { + return a.timeOn.isValid(); + } + return a.timeOn > b.timeOn; + }); +} + +} // namespace + +QByteArray abDatensatzGrenze(const QByteArray& stueck) +{ + const QByteArray gross = stueck.toUpper(); + + // Der Dateikopf zuerst: enthält das Stück den Anfang der Datei, ist + // alles vor Kopf und kein Kontakt. + const qsizetype kopf = gross.indexOf(QByteArrayLiteral("")); + if (kopf >= 0) { return stueck.mid(kopf + 5); } + + const qsizetype ende = gross.indexOf(kEor()); + if (ende < 0) { + // Keine Grenze im ganzen Stück: das war die Mitte eines einzigen + // Datensatzes. Nichts davon ist verlässlich. + return {}; + } + return stueck.mid(ende + kEor().size()); +} + +QByteArray datensaetzeMit(const QByteArray& inhalt, const QByteArray& nadel) +{ + QByteArray aus; + if (inhalt.isEmpty() || nadel.isEmpty()) { return aus; } + + // Eine Großschreibkopie, EINMAL. Die Stellen darin gelten + // unverändert für das Original -- toUpper() ändert bei ASCII keine + // Länge, und ADIF-Feldnamen wie Rufzeichen sind ASCII. + const QByteArray gross = inhalt.toUpper(); + const QByteArray n = nadel.toUpper(); + + qsizetype hinterLetztem = 0; + qsizetype pos = 0; + while ((pos = gross.indexOf(n, pos)) >= 0) { + // Rückwärts zur vorigen Datensatzgrenze, vorwärts zur nächsten. + qsizetype anfang = gross.lastIndexOf(kEor(), pos); + anfang = (anfang < 0) ? 0 : anfang + kEor().size(); + qsizetype ende = gross.indexOf(kEor(), pos); + ende = (ende < 0) ? inhalt.size() : ende + kEor().size(); + + // Zwei Treffer im selben Datensatz: er steht schon drin. + if (anfang < hinterLetztem) { anfang = hinterLetztem; } + if (ende > anfang) { + aus.append(inhalt.mid(anfang, ende - anfang)); + if (!aus.endsWith('\n')) { aus.append('\n'); } + hinterLetztem = ende; + } + pos = ende; + } + return aus; +} + +Befund fasseZusammen(const QVector& treffer, const QString& rufzeichen, + const QString& band, const QString& mode) +{ + Befund b; + const QString ruf = rufzeichen.trimmed().toUpper(); + if (ruf.isEmpty()) { return b; } + + for (const LogEntry& e : treffer) { + // Das Vorsieb war großzügig (Bytesuche über die ganze Datei). + // Hier entscheidet das geparste Feld, nicht die Bytestelle. + if (e.call.trimmed().toUpper() != ruf) { continue; } + ++b.anzahl; + + if (e.timeOn.isValid() + && (!b.zuletzt.isValid() || e.timeOn > b.zuletzt)) { + b.zuletzt = e.timeOn; + b.letztesBand = e.band; + b.letzterMode = e.mode; + } + + // Leeres Band oder leere Betriebsart lassen die Flaggen falsch. + // "Ich weiß es nicht" darf nicht als "schon gearbeitet" ankommen -- + // das ist der eine Fehler, der einen Kontakt verhindert, den es + // noch nicht gibt. + if (!band.isEmpty() + && e.band.compare(band, Qt::CaseInsensitive) == 0) { + b.gleichesBand = true; + if (!mode.isEmpty() + && e.mode.compare(mode, Qt::CaseInsensitive) == 0) { + b.gleicherMode = true; + } + } + } + return b; +} + +QVector letzte(int n, const QString& pfad) +{ + QVector aus; + if (n <= 0) { return aus; } + + const QString p = pfad.isEmpty() ? LogbookDatei::pfad() : pfad; + QFile f(p); + // Eine fehlende Datei ist kein Fehler, sondern ein leeres Logbuch -- + // genau das hat eine frische Installation. + if (!f.open(QIODevice::ReadOnly)) { return aus; } + const qint64 groesse = f.size(); + if (groesse <= 0) { return aus; } + + // Die Datei bleibt chronologisch, ältester zuerst (LogbookWindow + // sortiert vor dem Schreiben ausdrücklich so). Das Dateiende hält + // also die jüngsten Kontakte. Sortiert wird hier trotzdem, weil ein + // angehängter Eintrag mit korrigierter Zeit sonst an falscher Stelle + // stünde. + qint64 fenster = kErstesFenster; + for (;;) { + const bool ganz = fenster >= groesse; + if (!f.seek(ganz ? 0 : groesse - fenster)) { return aus; } + QByteArray stueck = f.read(ganz ? groesse : fenster); + if (!ganz) { stueck = abDatensatzGrenze(stueck); } + aus = AdifLog::parse(stueck); + if (aus.size() >= n || ganz) { break; } + fenster *= kFaktor; + } + + juengsterZuerst(aus); + if (aus.size() > n) { aus.resize(n); } + return aus; +} + +Befund rueckschau(const QString& rufzeichen, const QString& band, + const QString& mode, const QString& pfad) +{ + const QString ruf = rufzeichen.trimmed().toUpper(); + if (ruf.isEmpty()) { return {}; } + + const QString p = pfad.isEmpty() ? LogbookDatei::pfad() : pfad; + QFile f(p); + if (!f.open(QIODevice::ReadOnly)) { return {}; } + + const QByteArray teil = datensaetzeMit(f.readAll(), ruf.toUtf8()); + return fasseZusammen(AdifLog::parse(teil), ruf, band, mode); +} + +} // namespace LogbuchRueckschau +} // namespace Longpath diff --git a/src/core/LogbuchRueckschau.h b/src/core/LogbuchRueckschau.h new file mode 100644 index 000000000..36ed29c72 --- /dev/null +++ b/src/core/LogbuchRueckschau.h @@ -0,0 +1,93 @@ +#pragma once + +// Rückschau ins Logbuch — die letzten Kontakte, und "hatte ich den schon?" +// +// Das Gegenstück zu `LogbookDatei` (dem einen Schreiber). Gelesen wird hier +// ABSICHTLICH anders als in `AdifLog::read`: der holt die ganze Datei in +// LogEntry-Objekte, samt Entfernung und Peilung je Kontakt. Das ist richtig +// für das Logbuchfenster, das alles zeigen und sortieren muss — und falsch +// für eine Fernbedienung, die zwei Fragen stellt: +// +// 1. "Zeig mir die letzten zehn." -> nur das Dateiende lesen +// 2. "Hatte ich OE5XYZ schon?" -> nur die Datensätze lesen, +// in denen das Rufzeichen steht +// +// GEMESSEN an Martins Logbuch (6,6 MB, 9271 Datensätze, 2026-10-04): +// +// voller Durchlauf 4,1 ms +// Suche nach einem Rufzeichen 3,8 ms (3 Treffer) +// letzte 64 kB 0,04 ms (78 Datensätze) +// +// Deshalb steht hier KEIN zweiter Index neben `WorkedBefore`. Ein Index +// müsste gepflegt und bei jedem fremden Schreibzugriff verworfen werden; +// ein Durchlauf von 4 ms kann nicht veralten. (`WorkedBefore` selbst +// gehört `RotorLogbookPanel` — einem Fenster. Der TCI-Server darf nicht +// hineingreifen, sonst hängt der Netzdienst an der Oberfläche.) +// +// Die Byte-Funktionen sind rein: sie bekommen Dateiinhalt, keinen Pfad. +// Nur `letzte()` und `rueckschau()` fassen die Datei an. + +#include +#include +#include +#include + +namespace Longpath { + +struct LogEntry; + +namespace LogbuchRueckschau { + +/// Was über ein Rufzeichen im Logbuch steht. +struct Befund { + int anzahl{0}; ///< Datensätze mit genau diesem Rufzeichen + QDateTime zuletzt; ///< UTC, ungültig wenn noch nie + QString letztesBand; ///< vom jüngsten Kontakt + QString letzterMode; ///< vom jüngsten Kontakt + bool gleichesBand{false}; ///< schon auf dem gefragten Band + bool gleicherMode{false}; ///< schon in der gefragten Betriebsart + ///< (auf dem gefragten Band) + bool kennt() const { return anzahl > 0; } +}; + +/// Schneidet ein Stück vom Dateiende auf eine Datensatzgrenze zurecht. +/// +/// WARUM NÖTIG: ein Schnitt an einer willkürlichen Byte-Stelle beginnt +/// mitten in einem Datensatz. Der ADIF-Parser sieht dann die hintere Hälfte +/// eines Kontakts und macht daraus einen eigenen — ein Rufzeichen, das +/// niemand gearbeitet hat, oder eine Zeit ohne Datum. Lieber einen echten +/// Datensatz zu wenig als einen erfundenen zu viel. +/// +/// Enthält das Stück den Dateikopf (``), beginnt die Ausgabe dahinter. +/// Sonst hinter dem ersten ``. Findet sich keine Grenze, ist die +/// Ausgabe leer — dann war das Stück ein einziger abgeschnittener Datensatz. +QByteArray abDatensatzGrenze(const QByteArray& stueck); + +/// Die Rohbytes aller Datensätze, in denen `nadel` vorkommt (Groß- und +/// Kleinschreibung gleich). Vorsieb: nur diese paar Datensätze müssen +/// durch den vollen Parser, nicht die ganze Datei. +/// +/// Siebt absichtlich zu großzügig — ein Rufzeichen in einer Bemerkung +/// kommt mit. Welcher Datensatz wirklich gilt, entscheidet danach das +/// geparste `CALL`-Feld, nicht diese Bytesuche. +QByteArray datensaetzeMit(const QByteArray& inhalt, const QByteArray& nadel); + +/// Fasst zusammen, was diese Datensätze über `rufzeichen` sagen. +/// `band` und `mode` dürfen leer sein — dann bleiben die beiden Flaggen +/// falsch, statt auf Verdacht wahr zu werden. +Befund fasseZusammen(const QVector& treffer, const QString& rufzeichen, + const QString& band, const QString& mode); + +/// Die letzten `n` Kontakte aus der Logbuchdatei, der jüngste zuerst. +/// +/// Liest vom Dateiende her und verdoppelt das Fenster, bis genug +/// Datensätze zusammen sind oder die ganze Datei gelesen ist. Eine fehlende +/// Datei ist kein Fehler, sondern ein leeres Logbuch. +QVector letzte(int n, const QString& pfad = QString()); + +/// Rückschau auf ein Rufzeichen, aus der Logbuchdatei. +Befund rueckschau(const QString& rufzeichen, const QString& band, + const QString& mode, const QString& pfad = QString()); + +} // namespace LogbuchRueckschau +} // namespace Longpath diff --git a/src/core/TciServer.cpp b/src/core/TciServer.cpp index cd93aceec..41c65a5d3 100644 --- a/src/core/TciServer.cpp +++ b/src/core/TciServer.cpp @@ -28,6 +28,7 @@ #include "TciProtocol.h" #include "TciSendQueue.h" #include "core/LogbookDatei.h" +#include "core/LogbuchRueckschau.h" #include "models/LogEntry.h" #include "TciBinaryFrame.h" #include "TciSensorManager.h" @@ -2520,6 +2521,31 @@ void TciServer::sendeAblehnung(const std::shared_ptr& session, QStringLiteral("tx_err:%1;").arg(grund)); } +void TciServer::bandUndModeDerScheibe(QString* band, QString* mode) const +{ + if (band) { band->clear(); } + if (mode) { mode->clear(); } + + SliceModel* sl = m_model ? m_model->activeSlice() : nullptr; + if (!sl) { return; } + + if (band) { *band = bandLabel(bandFromFrequency(sl->frequency())); } + if (!mode) { return; } + + // ADIF kennt LSB/USB nicht als Betriebsart -- das sind Unterarten von + // SSB, und ein Datensatz mit MODE=LSB wird abgelehnt oder + // stillschweigend umgeschrieben. CWL/CWU genauso. Gleiche Zuordnung + // wie am Pult. + const QString m = SliceModel::modeName(sl->dspMode()); + if (m == QLatin1String("LSB") || m == QLatin1String("USB")) { + *mode = QStringLiteral("SSB"); + } else if (m == QLatin1String("CWL") || m == QLatin1String("CWU")) { + *mode = QStringLiteral("CW"); + } else { + *mode = m; + } +} + void TciServer::startKeyedWatchdog() { if (!m_keyedWatchdog) { @@ -3003,20 +3029,15 @@ void TciServer::onTextMessageReceived(const QString& rohMsg) if (SliceModel* sl = m_model ? m_model->activeSlice() : nullptr) { e.freqMHz = sl->frequency() / 1e6; - e.band = bandLabel(bandFromFrequency(sl->frequency())); - // ADIF kennt LSB/USB nicht als Betriebsart -- das sind - // Unterarten von SSB, und ein Datensatz mit MODE=LSB wird - // abgelehnt oder stillschweigend umgeschrieben. Gleiche - // Zuordnung wie am Pult. + // Band und Betriebsart kommen aus bandUndModeDerScheibe() + // -- derselben Stelle, die `log_dup:` fragt. Siehe dort, + // warum das EINE Stelle sein muss. + bandUndModeDerScheibe(&e.band, &e.mode); const QString m = SliceModel::modeName(sl->dspMode()); - if (m == QLatin1String("LSB") || m == QLatin1String("USB")) { - e.mode = QStringLiteral("SSB"); + if (e.mode == QLatin1String("SSB") + && (m == QLatin1String("LSB") + || m == QLatin1String("USB"))) { e.submode = m; - } else if (m == QLatin1String("CWL") - || m == QLatin1String("CWU")) { - e.mode = QStringLiteral("CW"); - } else { - e.mode = m; } } @@ -3038,6 +3059,117 @@ void TciServer::onTextMessageReceived(const QString& rohMsg) } } + // ── Ins Logbuch SEHEN (2026-10-04) ────────────────────────────────── + // + // Seit #184 kann die App loggen, aber nicht nachsehen. Das ist die + // schlechtere Haelfte: ein Eintrag, der unbemerkt daneben ging, faellt + // erst am Pult auf, und "hatte ich den schon?" ist die Frage, die in + // den zwei Sekunden zwischen Rufzeichen und Anruf beantwortet werden + // muss -- danach ist sie wertlos. + // + // log_last:; -> log_qso_zeile:,,, + // ,,,,; + // ... je Kontakt, juengster zuerst + // log_last_ok:; + // log_dup:; -> log_dup_ok:,,, + // ,,, + // ,; + // + // WARUM DAS BAND NICHT MITGESCHICKT WIRD: `log_dup:` nimmt Band und + // Betriebsart aus der aktiven Scheibe -- genau wie `log_qso:` es beim + // Eintragen tut, und durch dieselbe Stelle. Sonst koennte die Antwort + // "schon gearbeitet" auf ein Band beziehen, das das Geraet gar nicht + // eingestellt hat. + // + // WARUM EINE OBERGRENZE: `log_last:100000` wuerde die Steuerleitung + // mit Zeilen fuellen, hinter denen jede Bedienung wartet. 50 ist mehr, + // als auf ein Telefon passt. + // + // Gelesen wird durch LogbuchRueckschau -- vom DATEIENDE, nicht ueber + // die ganze Datei. Gemessen an Martins Logbuch (6,6 MB / 9271 + // Datensaetze): letzte 64 kB 0,04 ms gegen 4,1 ms fuer alles. + { + QString t = trimmed; + const bool istLast = + t.startsWith(QStringLiteral("log_last:"), Qt::CaseInsensitive); + const bool istDup = + t.startsWith(QStringLiteral("log_dup:"), Qt::CaseInsensitive); + if (istLast || istDup) { + const QString art = istLast ? QStringLiteral("log_last") + : QStringLiteral("log_dup"); + auto antwort = [&](const QString& zeile) { + session->sendQueue.push(TciSendQueue::Priority::Control, + zeile); + }; + if (!session->authenticated) { return; } + // Dieselbe Sperre wie beim Eintragen. Wer im Logbuch LESEN + // darf, sieht jedes Rufzeichen, jede Zeit und jeden Standort + // darin -- das ist nicht weniger heikel als anhaengen. + if (!session->fromLoopback && !remoteLogAllowed()) { + qCWarning(lcTci) + << "TciServer: Logbuch-Abfrage von" << session->peer + << "abgelehnt — Logbuch aus dem Netz ist abgeschaltet" + << "(TciAllowRemoteLog)"; + antwort(QStringLiteral("%1_err:nicht freigegeben;").arg(art)); + return; + } + + if (istLast) { + constexpr int kMax = 50; + bool ok = false; + int n = t.mid(9).split(QLatin1Char(',')).value(0) + .trimmed().toInt(&ok); + if (!ok || n <= 0) { n = 10; } + n = qMin(n, kMax); + + const QVector l = LogbuchRueckschau::letzte(n); + for (int i = 0; i < l.size(); ++i) { + const LogEntry& e = l.at(i); + const QDateTime u = e.timeOn.toUTC(); + antwort(QStringLiteral("log_qso_zeile:%1,%2,%3,%4,%5,%6,%7,%8;") + .arg(i) + .arg(u.isValid() + ? u.toString(QStringLiteral("yyyyMMdd")) + : QString()) + .arg(u.isValid() + ? u.toString(QStringLiteral("hhmmss")) + : QString()) + .arg(e.call, e.band, e.mode, + e.rstSent, e.rstRcvd)); + } + antwort(QStringLiteral("log_last_ok:%1;").arg(l.size())); + return; + } + + const QString ruf = + t.mid(8).split(QLatin1Char(',')).value(0).trimmed().toUpper(); + if (ruf.isEmpty() || ruf.size() > 20) { + antwort(QStringLiteral("log_dup_err:rufzeichen fehlt;")); + return; + } + QString band, mode; + bandUndModeDerScheibe(&band, &mode); + const LogbuchRueckschau::Befund b = + LogbuchRueckschau::rueckschau(ruf, band, mode); + const QDateTime u = b.zuletzt.toUTC(); + antwort(QStringLiteral("log_dup_ok:%1,%2,%3,%4,%5,%6,%7,%8;") + .arg(ruf) + .arg(b.anzahl) + .arg(u.isValid() + ? u.toString(QStringLiteral("yyyyMMdd")) + : QString()) + .arg(u.isValid() + ? u.toString(QStringLiteral("hhmmss")) + : QString()) + .arg(b.letztesBand, b.letzterMode, + b.gleichesBand ? QStringLiteral("1") + : QStringLiteral("0"), + b.gleicherMode ? QStringLiteral("1") + : QStringLiteral("0"))); + return; + } + } + // ── Sendesperre für das Netz, Stelle 3 von 3 (2026-09-30) ─────────── // // `tune:N,true` startet den Abstimmträger — der geht auf die Antenne diff --git a/src/core/TciServer.h b/src/core/TciServer.h index aded6445c..a8a44e130 100644 --- a/src/core/TciServer.h +++ b/src/core/TciServer.h @@ -631,6 +631,14 @@ private slots: void sendeAblehnung(const std::shared_ptr& session, const QString& grund); + // Band und Betriebsart der aktiven Scheibe, in ADIF-Schreibweise. + // EINE Stelle: `log_qso:` traegt damit ein, `log_dup:` fragt damit + // nach. Wuerden beide das selbst entscheiden, koennte die Dupe-Antwort + // "schon gearbeitet" sagen und der Eintrag danach ein anderes Band + // nennen -- und ADIF-Eigenheiten wie "LSB/USB sind Unterarten von SSB" + // muessten an zwei Stellen stimmen. + void bandUndModeDerScheibe(QString* band, QString* mode) const; + void startKeyedWatchdog(); void stopKeyedWatchdog(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bdd57d616..ce24b26b4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1955,6 +1955,18 @@ longpath_add_test(tst_settings_hygiene) # genau einmal. longpath_add_test(tst_tci_qso_eintragen) +# ── Ins Logbuch SEHEN, vom Telefon aus (2026-10-04) ───────────────────────── +# tst_tci_logbuch_lesen: die Gegenstuecke zu log_qso: — log_last: und +# log_dup:. Geprueft wird vor allem die ANZAHL in der Abschlusszeile: die +# Seite zeigt eine Liste nur, wenn sie mit den gesendeten Zeilen +# uebereinstimmt, weil eine kuerzere Liste wie ein kuerzeres Logbuch +# aussieht und niemand es merkt. Dazu: juengster zuerst (auf einem Telefon +# sieht man drei Zeilen), Ablehnung MIT Grund statt stumm (ein stummes Nein +# ist auf einer Fernbedienung nicht von einem Defekt zu unterscheiden), und +# der Deckel auf log_last: — 100000 Zeilen wuerden die Steuerleitung +# fluten, hinter der jede Bedienung wartet. +longpath_add_test(tst_tci_logbuch_lesen) + # ── Ein Geraet, das nicht aufmacht, darf nicht alles mitnehmen (2026-10-03) ── # tst_bus_mit_frist: gibt einen Bus herein, dessen open() absichtlich # stehenbleibt, und prueft die zwei Zusagen der Behebung — nach der Frist @@ -1972,6 +1984,20 @@ longpath_add_test(tst_bus_mit_frist) # Schalter. longpath_add_test(tst_sendefehler_deutung) +# ── Vom Dateiende lesen darf keinen Kontakt ERFINDEN (2026-10-04) ─────────── +# tst_logbuch_rueckschau: die App kann seit #184 loggen, aber nicht nachsehen. +# Die letzten Kontakte kommen deshalb aus dem DATEIENDE (gemessen an Martins +# Logbuch: 6,6 MB / 9271 Datensaetze — voller Durchlauf 4,1 ms, letzte 64 kB +# 0,04 ms). Genau daraus entsteht die Gefahr: ein Schnitt an einer +# willkuerlichen Bytestelle beginnt mitten in einem Datensatz, und der +# ADIF-Parser macht aus der hinteren Haelfte einen eigenen Kontakt — ein +# Rufzeichen, das niemand gearbeitet hat, in einem Logbuch, das fuer Diplome +# eingereicht wird. Der Stand fuehrt denselben Schnitt ohne und mit +# Grenzschnitt vor; ohne entsteht der erfundene Datensatz wirklich. +# Zweitens: eine Dupe-Warnung, die bei unbekanntem Band auf Verdacht wahr +# wird, verhindert unbemerkt einen Kontakt, den es noch nicht gibt. +longpath_add_test(tst_logbuch_rueckschau) + # ── Logzeilen duerfen sich nicht zerschreiben (2026-10-03) ─────────────────── # tst_log_schreibt_atomar: acht Faeden schreiben gleichzeitig 250 Zeilen und # danach muss JEDE vollstaendig und genau einmal dastehen. Grund: in den diff --git a/tests/tst_logbuch_rueckschau.cpp b/tests/tst_logbuch_rueckschau.cpp new file mode 100644 index 000000000..94288eb21 --- /dev/null +++ b/tests/tst_logbuch_rueckschau.cpp @@ -0,0 +1,378 @@ +// Haelt fest, wie das Logbuch fuer die App gelesen wird — und vor allem, +// dass dabei kein Kontakt ERFUNDEN wird. +// +// Hintergrund (2026-10-04): vom Telefon laesst sich seit #184 loggen, aber +// nicht nachsehen. Die naechstliegende Loesung — die ganze Datei parsen und +// die letzten zehn nehmen — waere fuer Martins Logbuch (6,6 MB, 9271 +// Datensaetze) ein Vielfaches der Arbeit fuer ein Zehntel des Ergebnisses. +// Gemessen am echten Logbuch: +// +// voller Durchlauf 4,1 ms +// Suche nach einem Rufzeichen 3,8 ms (3 Treffer) +// letzte 64 kB 0,04 ms (78 Datensaetze) +// +// Also wird vom Dateiende gelesen. Genau daraus entsteht die GEFAHR, gegen +// die dieser Stand vor allem steht: ein Schnitt an einer willkuerlichen +// Byte-Stelle beginnt mitten in einem Datensatz, und der ADIF-Parser macht +// aus der hinteren Haelfte eines Kontakts einen eigenen. Das ist schlimmer +// als ein fehlender Kontakt — es ist ein Kontakt, den niemand hatte, in +// einem Logbuch, das Martin fuer Diplome einreicht. +// +// `rohesStueckErfindetEinenDatensatz` fuehrt genau das vor: derselbe +// Schnitt, einmal ohne und einmal mit `abDatensatzGrenze`. Ohne entsteht +// ein Datensatz mit abgeschnittenem Rufzeichen, mit nicht. Ein Pruefstand, +// der nur die Behebung gruen zeigt, belegt nichts. +// +// Die zweite Gefahr ist leiser: eine Dupe-Warnung, die auf Verdacht wahr +// wird. Wer "schon gearbeitet" meldet, obwohl Band oder Betriebsart +// unbekannt sind, verhindert einen Kontakt, den es noch nicht gibt — +// unbemerkt, weil nichts passiert. + +#include "core/AdifLog.h" +#include "core/LogbuchRueckschau.h" +#include "models/LogEntry.h" + +#include +#include + +using namespace Longpath; +using namespace Longpath::LogbuchRueckschau; + +namespace { + +QString datensatz(const QString& ruf, const QDateTime& zeit, + const QString& band, const QString& mode, + const QString& bemerkung = QString()) +{ + LogEntry e; + e.call = ruf; + e.timeOn = zeit; + e.band = band; + e.mode = mode; + e.rstSent = QStringLiteral("59"); + e.rstRcvd = QStringLiteral("59"); + e.comment = bemerkung; + return e.toAdifRecord(); +} + +// Eine Datei wie LogbookDatei::anhaengen sie anlegt: Kopf, dann Kontakte, +// aeltester zuerst. +QString schreibeLogbuch(const QString& pfad, const QStringList& datensaetze) +{ + QFile f(pfad); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { return {}; } + QTextStream aus(&f); + aus << "Longpath logbook\n" + << "3.1.4 Longpath \n"; + for (const QString& d : datensaetze) { aus << d << "\n"; } + aus.flush(); + return pfad; +} + +const QDateTime kStart{QDate(2026, 1, 1), QTime(0, 0), Qt::UTC}; + +} // namespace + +class TstLogbuchRueckschau : public QObject +{ + Q_OBJECT + +private slots: + void rohesStueckErfindetEinenDatensatz(); + void grenzeOhneJedeMarkeGibtNichtsZurueck(); + void letzteKommenJuengsterZuerst(); + void fensterWaechstBisGenugDaIst(); + void fehlendeDateiIstEinLeeresLogbuch(); + void vorsiebNimmtNurDieDatensaetzeMitDemRufzeichen(); + void zweiTrefferImSelbenDatensatzKommenEinmal(); + void nurDasGeparsteRufzeichenZaehlt(); + void leeresBandMeldetKeinenDupe(); + void gleicherModeGiltNurAufGleichemBand(); + void rueckschauFindetDenKontaktInDerDatei(); +}; + +// ── Die Gegenprobe: ohne Grenzschnitt entsteht ein Kontakt aus nichts ────── +void TstLogbuchRueckschau::rohesStueckErfindetEinenDatensatz() +{ + // WICHTIG fuer das Verstaendnis: in einer von Longpath geschriebenen + // Datei steht CALL als ERSTES Feld. Ein Schnitt mitten im Datensatz + // zerstoert dort immer das Rufzeichen, und `LogEntry::isValid()` wirft + // den Rest weg. Dieser Fall ist also zufaellig harmlos — zufaellig, + // nicht beabsichtigt. + // + // ADIF schreibt die Feldreihenfolge aber NICHT vor, und fremde Logger + // halten sich nicht an Longpaths. Martins Datei enthaelt zusammengefuehrte + // Importe. Steht das Datum vor dem Rufzeichen, behaelt ein Schnitt + // hinter dem Datum das Rufzeichen und verliert die Zeit: ein Kontakt + // mit richtigem Rufzeichen, ohne Zeit, den es nicht gibt. Er erscheint + // in "die letzten zehn" und zaehlt in der Dupe-Antwort mit. + const QByteArray fremderDatensatz = + QByteArrayLiteral("20260101 120000 " + "OE2BBB 20m CW " + "59 59 \n"); + + // Der Schnitt: hinter "2026" — genau so trifft ein Fenster + // vom Dateiende her irgendeinen Datensatz. + const qsizetype schnitt = fremderDatensatz.indexOf("0101"); + QVERIFY(schnitt > 0); + const QByteArray roh = fremderDatensatz.mid(schnitt); + + // ALTE Fassung (kein Grenzschnitt): ein vollwertig aussehender Kontakt. + const QVector ohne = AdifLog::parse(roh); + QCOMPARE(ohne.size(), 1); + QCOMPARE(ohne.at(0).call, QStringLiteral("OE2BBB")); + QCOMPARE(ohne.at(0).band, QStringLiteral("20m")); + QVERIFY2(!ohne.at(0).timeOn.isValid(), "Zeit muesste beim Schnitt weg sein"); + + // NEUE Fassung: derselbe Schnitt, erst zurechtgeschnitten. Nichts bleibt + // uebrig — lieber ein Kontakt zu wenig als einer erfunden. + QCOMPARE(AdifLog::parse(abDatensatzGrenze(roh)).size(), 0); + + // Und zur Vollstaendigkeit die Longpath-eigene Reihenfolge: dort faengt + // `isValid()` den halben Datensatz schon ab. Der Grenzschnitt ist die + // zweite Sicherung, nicht die einzige — festgehalten, damit niemand ihn + // spaeter als ueberfluessig wegnimmt. + const QByteArray eigen = + datensatz(QStringLiteral("OE2BBB"), kStart, QStringLiteral("20m"), + QStringLiteral("CW")).toUtf8(); + const qsizetype s2 = eigen.indexOf("OE2BBB"); + QVERIFY(s2 > 0); + QCOMPARE(AdifLog::parse(eigen.mid(s2 + 3)).size(), 0); + QCOMPARE(AdifLog::parse(abDatensatzGrenze(eigen.mid(s2 + 3))).size(), 0); +} + +void TstLogbuchRueckschau::grenzeOhneJedeMarkeGibtNichtsZurueck() +{ + // Ein Stueck ohne und ohne ist die Mitte eines einzigen + // Datensatzes. Nichts darin ist verlaesslich. + QVERIFY(abDatensatzGrenze(QByteArrayLiteral("20m CW ")).isEmpty()); + QVERIFY(abDatensatzGrenze(QByteArray()).isEmpty()); + + // Mit Kopf: alles davor ist Kopf, nicht Kontakt. + const QByteArray k = QByteArrayLiteral("Longpath logbook\n3.1.4 \nREST"); + QCOMPARE(abDatensatzGrenze(k).trimmed(), QByteArrayLiteral("REST")); + + // Kleinschreibung gilt genauso — ADIF ist dort gleichgueltig, und + // fremde Logger schreiben . + QCOMPARE(abDatensatzGrenze(QByteArrayLiteral("halb\nganz")).trimmed(), + QByteArrayLiteral("ganz")); +} + +// ── Die letzten n ────────────────────────────────────────────────────────── +void TstLogbuchRueckschau::letzteKommenJuengsterZuerst() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString p = dir.filePath(QStringLiteral("logbook.adi")); + + QStringList d; + for (int i = 0; i < 40; ++i) { + d << datensatz(QStringLiteral("OE%1TST").arg(i, 3, 10, QLatin1Char('0')), + kStart.addSecs(i * 600), QStringLiteral("40m"), + QStringLiteral("SSB")); + } + QVERIFY(!schreibeLogbuch(p, d).isEmpty()); + + const QVector l = letzte(5, p); + QCOMPARE(l.size(), 5); + QCOMPARE(l.at(0).call, QStringLiteral("OE039TST")); + QCOMPARE(l.at(4).call, QStringLiteral("OE035TST")); + // Absteigend, ohne Ausnahme. + for (int i = 1; i < l.size(); ++i) { + QVERIFY(l.at(i - 1).timeOn >= l.at(i).timeOn); + } + + // Mehr verlangt als vorhanden: alles, nicht leer. + QCOMPARE(letzte(500, p).size(), 40); + // Nichts verlangt: nichts bekommen, kein Dateizugriff. + QCOMPARE(letzte(0, p).size(), 0); +} + +void TstLogbuchRueckschau::fensterWaechstBisGenugDaIst() +{ + // Das erste Fenster ist 64 kB. Mit genug Bemerkungstext passen darin + // weniger Datensaetze als verlangt — dann MUSS das Fenster wachsen, + // sonst kaeme eine kurze Liste zurueck und niemand merkte es. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString p = dir.filePath(QStringLiteral("logbook.adi")); + + const QString fuellung(1500, QLatin1Char('x')); // ~1,5 kB je Datensatz + QStringList d; + for (int i = 0; i < 120; ++i) { + d << datensatz(QStringLiteral("OE%1FUL").arg(i, 3, 10, QLatin1Char('0')), + kStart.addSecs(i * 600), QStringLiteral("20m"), + QStringLiteral("CW"), fuellung); + } + QVERIFY(!schreibeLogbuch(p, d).isEmpty()); + QVERIFY(QFileInfo(p).size() > 64 * 1024); + + const QVector l = letzte(100, p); + QCOMPARE(l.size(), 100); + QCOMPARE(l.at(0).call, QStringLiteral("OE119FUL")); +} + +void TstLogbuchRueckschau::fehlendeDateiIstEinLeeresLogbuch() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString fehlt = dir.filePath(QStringLiteral("gibtesnicht.adi")); + QCOMPARE(letzte(10, fehlt).size(), 0); + QVERIFY(!rueckschau(QStringLiteral("OE5SOS"), QStringLiteral("40m"), + QStringLiteral("SSB"), fehlt).kennt()); +} + +// ── Das Vorsieb ─────────────────────────────────────────────────────────── +void TstLogbuchRueckschau::vorsiebNimmtNurDieDatensaetzeMitDemRufzeichen() +{ + const QByteArray inhalt = + QByteArrayLiteral("\n") + + datensatz(QStringLiteral("OE1AAA"), kStart, QStringLiteral("40m"), + QStringLiteral("SSB")).toUtf8() + "\n" + + datensatz(QStringLiteral("OE2BBB"), kStart.addSecs(60), + QStringLiteral("20m"), QStringLiteral("CW")).toUtf8() + "\n" + + datensatz(QStringLiteral("OE3CCC"), kStart.addSecs(120), + QStringLiteral("15m"), QStringLiteral("FT8")).toUtf8() + "\n"; + + const QByteArray teil = datensaetzeMit(inhalt, QByteArrayLiteral("OE2BBB")); + const QVector e = AdifLog::parse(teil); + QCOMPARE(e.size(), 1); + QCOMPARE(e.at(0).call, QStringLiteral("OE2BBB")); + + // Kleinschreibung findet genauso. + QCOMPARE(AdifLog::parse(datensaetzeMit(inhalt, QByteArrayLiteral("oe2bbb"))).size(), 1); + // Nichts Gesuchtes, nichts zurueck. + QVERIFY(datensaetzeMit(inhalt, QByteArrayLiteral("OE9ZZZ")).isEmpty()); + QVERIFY(datensaetzeMit(inhalt, QByteArray()).isEmpty()); +} + +void TstLogbuchRueckschau::zweiTrefferImSelbenDatensatzKommenEinmal() +{ + // Rufzeichen im CALL-Feld UND in der Bemerkung: ein Datensatz, nicht + // zwei. Sonst zaehlte ein Kontakt doppelt und die Dupe-Meldung + // behauptete mehr, als dasteht. + const QByteArray inhalt = + QByteArrayLiteral("\n") + + datensatz(QStringLiteral("OE2BBB"), kStart, QStringLiteral("20m"), + QStringLiteral("CW"), + QStringLiteral("Skedansage von OE2BBB")).toUtf8() + "\n"; + + const QVector e = + AdifLog::parse(datensaetzeMit(inhalt, QByteArrayLiteral("OE2BBB"))); + QCOMPARE(e.size(), 1); + QCOMPARE(fasseZusammen(e, QStringLiteral("OE2BBB"), QString(), QString()).anzahl, 1); +} + +void TstLogbuchRueckschau::nurDasGeparsteRufzeichenZaehlt() +{ + // Das Vorsieb siebt absichtlich grosszuegig: ein Rufzeichen in einer + // Bemerkung kommt mit. Zaehlen darf es nicht. + QVector treffer; + LogEntry fremd; + fremd.call = QStringLiteral("OE1AAA"); + fremd.timeOn = kStart; + fremd.band = QStringLiteral("40m"); + fremd.mode = QStringLiteral("SSB"); + fremd.comment = QStringLiteral("gehoert von OE2BBB"); + treffer << fremd; + + const Befund b = fasseZusammen(treffer, QStringLiteral("OE2BBB"), + QStringLiteral("40m"), QStringLiteral("SSB")); + QCOMPARE(b.anzahl, 0); + QVERIFY(!b.kennt()); + QVERIFY(!b.gleichesBand); +} + +// ── Die Dupe-Frage ──────────────────────────────────────────────────────── +void TstLogbuchRueckschau::leeresBandMeldetKeinenDupe() +{ + // Der eine Fehler, der einen Kontakt verhindert: "schon gearbeitet", + // obwohl das Band unbekannt ist. Dann steht die Warnung da, der Bediener + // ruft nicht, und niemand erfaehrt je davon. + QVector treffer; + LogEntry e; + e.call = QStringLiteral("OE2BBB"); + e.timeOn = kStart; + e.band = QStringLiteral("20m"); + e.mode = QStringLiteral("CW"); + treffer << e; + + const Befund ohneBand = + fasseZusammen(treffer, QStringLiteral("OE2BBB"), QString(), QString()); + QCOMPARE(ohneBand.anzahl, 1); // gekannt: ja + QVERIFY(!ohneBand.gleichesBand); // Dupe: nein + QVERIFY(!ohneBand.gleicherMode); + QCOMPARE(ohneBand.letztesBand, QStringLiteral("20m")); + + const Befund mitBand = fasseZusammen(treffer, QStringLiteral("OE2BBB"), + QStringLiteral("20m"), QStringLiteral("CW")); + QVERIFY(mitBand.gleichesBand); + QVERIFY(mitBand.gleicherMode); +} + +void TstLogbuchRueckschau::gleicherModeGiltNurAufGleichemBand() +{ + // Derselbe Mode auf einem ANDEREN Band ist kein Dupe. Wer das + // zusammenwirft, meldet 40 m SSB als gearbeitet, weil es 20 m SSB gab. + QVector treffer; + LogEntry e; + e.call = QStringLiteral("OE2BBB"); + e.timeOn = kStart; + e.band = QStringLiteral("20m"); + e.mode = QStringLiteral("SSB"); + treffer << e; + + const Befund b = fasseZusammen(treffer, QStringLiteral("OE2BBB"), + QStringLiteral("40m"), QStringLiteral("SSB")); + QCOMPARE(b.anzahl, 1); + QVERIFY(!b.gleichesBand); + QVERIFY(!b.gleicherMode); +} + +void TstLogbuchRueckschau::rueckschauFindetDenKontaktInDerDatei() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString p = dir.filePath(QStringLiteral("logbook.adi")); + + QStringList d; + for (int i = 0; i < 30; ++i) { + d << datensatz(QStringLiteral("OE%1TST").arg(i, 3, 10, QLatin1Char('0')), + kStart.addSecs(i * 600), QStringLiteral("40m"), + QStringLiteral("SSB")); + } + // Dreimal dasselbe Rufzeichen, zweimal auf 20 m CW. + d << datensatz(QStringLiteral("OE2BBB"), kStart.addSecs(100000), + QStringLiteral("20m"), QStringLiteral("CW")) + << datensatz(QStringLiteral("OE2BBB"), kStart.addSecs(200000), + QStringLiteral("20m"), QStringLiteral("CW")) + << datensatz(QStringLiteral("OE2BBB"), kStart.addSecs(300000), + QStringLiteral("15m"), QStringLiteral("SSB")); + QVERIFY(!schreibeLogbuch(p, d).isEmpty()); + + const Befund auf20 = rueckschau(QStringLiteral("OE2BBB"), + QStringLiteral("20m"), + QStringLiteral("CW"), p); + QCOMPARE(auf20.anzahl, 3); + QVERIFY(auf20.gleichesBand); + QVERIFY(auf20.gleicherMode); + QCOMPARE(auf20.zuletzt, kStart.addSecs(300000)); + QCOMPARE(auf20.letztesBand, QStringLiteral("15m")); + + // Dasselbe Rufzeichen auf einem noch nicht gearbeiteten Band. + const Befund auf10 = rueckschau(QStringLiteral("OE2BBB"), + QStringLiteral("10m"), + QStringLiteral("SSB"), p); + QCOMPARE(auf10.anzahl, 3); + QVERIFY(!auf10.gleichesBand); + + // Ein nie gearbeitetes Rufzeichen. + QVERIFY(!rueckschau(QStringLiteral("OE9ZZZ"), QStringLiteral("20m"), + QStringLiteral("CW"), p).kennt()); + // Leeres Rufzeichen fragt die Datei gar nicht. + QVERIFY(!rueckschau(QString(), QStringLiteral("20m"), + QStringLiteral("CW"), p).kennt()); +} + +QTEST_MAIN(TstLogbuchRueckschau) +#include "tst_logbuch_rueckschau.moc" diff --git a/tests/tst_tci_logbuch_lesen.cpp b/tests/tst_tci_logbuch_lesen.cpp new file mode 100644 index 000000000..92502a008 --- /dev/null +++ b/tests/tst_tci_logbuch_lesen.cpp @@ -0,0 +1,321 @@ +// Prüfstand: ins Logbuch SEHEN, vom Telefon aus. +// +// Zwei Longpath-eigene TCI-Befehle (2026-10-04), Gegenstück zu `log_qso:`: +// +// log_last:; -> log_qso_zeile:,,,,, +// ,,; je Kontakt +// log_last_ok:; +// log_dup:; -> log_dup_ok:,,,,, +// ,,; +// +// Geprüft werden die Zusagen, auf denen die Seite aufbaut: +// +// * Die ANZAHL in der Abschlusszeile stimmt mit den gesendeten Zeilen +// überein. Daran hängt alles: die Seite zeigt eine Liste nur, wenn +// beide gleich sind, weil eine kürzere Liste wie ein kürzeres Logbuch +// aussieht und niemand es merkt. +// * Der jüngste Kontakt steht ZUERST. Auf einem Telefon sieht man die +// ersten drei Zeilen; stünden dort die ältesten, wäre die Liste +// nutzlos und trotzdem nicht falsch. +// * Ohne Anmeldung kommt nichts — auch keine Antwort. +// * Abgeschaltet (TciAllowRemoteLog=False) wird abgelehnt, und zwar MIT +// Grund. Wer im Logbuch lesen darf, sieht jedes Rufzeichen und jede +// Zeit darin; das ist nicht weniger heikel als anhängen, und ein +// stummes Nein ist von einem Defekt nicht zu unterscheiden. +// * `log_last:` ist gedeckelt. `log_last:100000` würde die Steuerleitung +// mit Zeilen füllen, hinter denen jede Bedienung wartet. +// +// Die Datei liegt im Prüf-Sandkasten (QStandardPaths-Testmodus), nicht im +// echten Logbuch: der Stand schreibt Kontakte, um sie danach zu lesen. + +#ifdef HAVE_WEBSOCKETS + +#include +#include +#include +#include + +#include "core/AppSettings.h" +#include "core/LogbookDatei.h" +#include "core/TciServer.h" +#include "TciBurstHelfer.h" + +using Longpath::AppSettings; +using Longpath::TciServer; +namespace LogbookDatei = Longpath::LogbookDatei; + +namespace { +constexpr char kToken[] = "PRUEFTOKEN1234"; +} + +class TestTciLogbuchLesen : public QObject { + Q_OBJECT + +private: + QStringList m_antworten; + + bool aufbauen(TciServer& server, QWebSocket& client, bool loggenFrei, + bool anmelden = true) + { + AppSettings::instance().setValue(QStringLiteral("TciAllowRemoteLog"), + loggenFrei ? QStringLiteral("True") : QStringLiteral("False")); + if (!TciServer::setRemoteToken(QString::fromLatin1(kToken))) { return false; } + if (!server.start(0)) { return false; } + // Jede Verbindung als Netzverbindung behandeln — sonst greift die + // Freigabe im Testlauf nie, weil alles über Loopback läuft. + server.setTreatAllClientsAsRemoteForTest(true); + + connect(&client, &QWebSocket::textMessageReceived, this, + [this](const QString& m) { + if (m.startsWith(QStringLiteral("log_"))) { + m_antworten << m; + } + }); + + QSignalSpy verbunden(&client, &QWebSocket::connected); + client.open(QUrl(QStringLiteral("ws://127.0.0.1:%1").arg(server.port()))); + if (!verbunden.wait(2000)) { return false; } + if (!anmelden) { return true; } + + client.sendTextMessage(QStringLiteral("auth:%1;").arg(QLatin1String(kToken))); + return TciTest::warteAufReady(client); + } + + /// Wartet auf eine Zeile, die so anfängt — auf das EREIGNIS, nicht auf + /// Millisekunden. + bool warteAuf(const QString& anfang, int grenzeMs = 4000) + { + QElapsedTimer uhr; uhr.start(); + while (uhr.elapsed() < grenzeMs) { + for (const QString& a : m_antworten) { + if (a.startsWith(anfang)) { return true; } + } + QTest::qWait(25); + } + return false; + } + + QStringList zeilenMit(const QString& anfang) const + { + QStringList l; + for (const QString& a : m_antworten) { + if (a.startsWith(anfang)) { l << a; } + } + return l; + } + + /// Legt `n` Kontakte über `log_qso:` an — derselbe Weg, den das Telefon + /// nimmt. Kein zweiter Schreiber im Prüfstand. + bool eintragen(QWebSocket& client, const QStringList& rufe) + { + for (const QString& r : rufe) { + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("log_qso:%1;").arg(r)); + if (!warteAuf(QStringLiteral("log_qso_ok:"))) { return false; } + // Eine Sekunde Abstand, damit die Zeiten unterscheidbar sind: + // TIME_ON hat Sekundenauflösung, und bei gleicher Zeit könnte + // die Reihenfolge nicht belegt werden. + QTest::qWait(1100); + } + m_antworten.clear(); + return true; + } + +private slots: + + void init() + { + m_antworten.clear(); + QFile::remove(LogbookDatei::pfad()); + } + + void cleanup() + { + TciServer::setRemoteToken(QString()); + QFile::remove(LogbookDatei::pfad()); + } + + /// Der Normalfall: drei von fünf, jüngster zuerst, Anzahl stimmt. + void dieLetztenKommenJuengsterZuerst() + { + TciServer server(nullptr); + QWebSocket client; + QVERIFY2(aufbauen(server, client, /*loggenFrei=*/true), "Aufbau gescheitert"); + QVERIFY(eintragen(client, {QStringLiteral("OE1AAA"), + QStringLiteral("OE2BBB"), + QStringLiteral("OE3CCC")})); + + client.sendTextMessage(QStringLiteral("log_last:2;")); + QVERIFY2(warteAuf(QStringLiteral("log_last_ok:")), "Keine Abschlusszeile"); + + const QStringList zeilen = zeilenMit(QStringLiteral("log_qso_zeile:")); + const QStringList ende = zeilenMit(QStringLiteral("log_last_ok:")); + QCOMPARE(ende.size(), 1); + QCOMPARE(zeilen.size(), 2); + // Die Zusage, auf der die Seite aufbaut. + QCOMPARE(ende.first(), QStringLiteral("log_last_ok:2;")); + + // Jüngster zuerst, und die Nummern beginnen bei 0. + QVERIFY2(zeilen.at(0).contains(QStringLiteral("OE3CCC")), + qPrintable(zeilen.at(0))); + QVERIFY2(zeilen.at(1).contains(QStringLiteral("OE2BBB")), + qPrintable(zeilen.at(1))); + QVERIFY2(zeilen.at(0).startsWith(QStringLiteral("log_qso_zeile:0,")), + qPrintable(zeilen.at(0))); + + // Acht Felder, wie die Seite sie liest. + const QStringList f = + zeilen.at(0).mid(QStringLiteral("log_qso_zeile:").size()) + .chopped(1).split(QLatin1Char(',')); + QCOMPARE(f.size(), 8); + QCOMPARE(f.at(3), QStringLiteral("OE3CCC")); + QVERIFY2(f.at(1).size() == 8, qPrintable(f.at(1))); // yyyymmdd + QVERIFY2(f.at(2).size() == 6, qPrintable(f.at(2))); // hhmmss + } + + /// Ein leeres Logbuch ist eine Antwort, kein Schweigen. + void leeresLogbuchSagtNull() + { + TciServer server(nullptr); + QWebSocket client; + QVERIFY(aufbauen(server, client, /*loggenFrei=*/true)); + QVERIFY(!QFile::exists(LogbookDatei::pfad())); + + client.sendTextMessage(QStringLiteral("log_last:10;")); + QVERIFY2(warteAuf(QStringLiteral("log_last_ok:")), + "Ein leeres Logbuch wurde mit Schweigen beantwortet"); + QCOMPARE(zeilenMit(QStringLiteral("log_qso_zeile:")).size(), 0); + QCOMPARE(zeilenMit(QStringLiteral("log_last_ok:")).first(), + QStringLiteral("log_last_ok:0;")); + } + + /// Ohne Anmeldung passiert NICHTS — auch keine Antwort. + void ohneAnmeldungKeinBlickInsLogbuch() + { + TciServer server(nullptr); + QWebSocket client; + QVERIFY(aufbauen(server, client, /*loggenFrei=*/true, /*anmelden=*/false)); + + client.sendTextMessage(QStringLiteral("log_last:10;")); + client.sendTextMessage(QStringLiteral("log_dup:OE1AAA;")); + QTest::qWait(600); // "es darf nichts passieren" -> warten und sehen + QVERIFY2(m_antworten.isEmpty(), + qPrintable(m_antworten.join(QLatin1Char(' ')))); + } + + /// Abgeschaltet heisst abgelehnt — aber MIT Grund, für beide Befehle. + void abgeschaltetWirdAbgelehntUndGesagt() + { + TciServer server(nullptr); + QWebSocket client; + QVERIFY(aufbauen(server, client, /*loggenFrei=*/false)); + + client.sendTextMessage(QStringLiteral("log_last:10;")); + QVERIFY2(warteAuf(QStringLiteral("log_last_err:")), + "Eine abgelehnte Abfrage blieb stumm"); + QCOMPARE(zeilenMit(QStringLiteral("log_qso_zeile:")).size(), 0); + + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("log_dup:OE1AAA;")); + QVERIFY2(warteAuf(QStringLiteral("log_dup_err:")), + "Eine abgelehnte Dupe-Frage blieb stumm"); + } + + /// Die Obergrenze. `log_last:100000` darf die Steuerleitung nicht + /// fluten — und `log_last:0` muss trotzdem etwas liefern, statt mit + /// einer leeren Liste wie ein leeres Logbuch zu wirken. + void dieAnzahlIstGedeckelt() + { + TciServer server(nullptr); + QWebSocket client; + QVERIFY(aufbauen(server, client, /*loggenFrei=*/true)); + QVERIFY(eintragen(client, {QStringLiteral("OE1AAA"), + QStringLiteral("OE2BBB")})); + + client.sendTextMessage(QStringLiteral("log_last:100000;")); + QVERIFY(warteAuf(QStringLiteral("log_last_ok:"))); + // Zwei Kontakte, also zwei Zeilen — der Deckel greift erst über 50, + // belegt ist hier, dass die Zahl nicht durchgereicht wird. + QCOMPARE(zeilenMit(QStringLiteral("log_qso_zeile:")).size(), 2); + QCOMPARE(zeilenMit(QStringLiteral("log_last_ok:")).first(), + QStringLiteral("log_last_ok:2;")); + + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("log_last:0;")); + QVERIFY(warteAuf(QStringLiteral("log_last_ok:"))); + QCOMPARE(zeilenMit(QStringLiteral("log_qso_zeile:")).size(), 2); + + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("log_last:Unfug;")); + QVERIFY2(warteAuf(QStringLiteral("log_last_ok:")), + "Eine unlesbare Anzahl liess den Server schweigen"); + QCOMPARE(zeilenMit(QStringLiteral("log_qso_zeile:")).size(), 2); + } + + /// Die Dupe-Frage: ein eingetragenes Rufzeichen ist bekannt, ein + /// anderes nicht. Das ist der Satz, der einen Anruf entscheidet. + void dupeFrageKenntDasEigeneLogbuch() + { + TciServer server(nullptr); + QWebSocket client; + QVERIFY(aufbauen(server, client, /*loggenFrei=*/true)); + QVERIFY(eintragen(client, {QStringLiteral("OE1AAA"), + QStringLiteral("OE1AAA"), + QStringLiteral("OE2BBB")})); + + client.sendTextMessage(QStringLiteral("log_dup:OE1AAA;")); + QVERIFY(warteAuf(QStringLiteral("log_dup_ok:"))); + QStringList f = zeilenMit(QStringLiteral("log_dup_ok:")).first() + .mid(QStringLiteral("log_dup_ok:").size()) + .chopped(1).split(QLatin1Char(',')); + QCOMPARE(f.size(), 8); + QCOMPARE(f.at(0), QStringLiteral("OE1AAA")); + QCOMPARE(f.at(1), QStringLiteral("2")); // zweimal gearbeitet + QVERIFY2(f.at(2).size() == 8, qPrintable(f.at(2))); + + // Kleinschreibung fragt dasselbe. + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("log_dup:oe1aaa;")); + QVERIFY(warteAuf(QStringLiteral("log_dup_ok:"))); + QVERIFY(zeilenMit(QStringLiteral("log_dup_ok:")).first() + .startsWith(QStringLiteral("log_dup_ok:OE1AAA,2,"))); + + // Ein nie gearbeitetes Rufzeichen: bekannt als UNBEKANNT, mit 0 — + // nicht mit Schweigen, das sich wie ein Fehler liest. + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("log_dup:OE9ZZZ;")); + QVERIFY(warteAuf(QStringLiteral("log_dup_ok:"))); + QVERIFY2(zeilenMit(QStringLiteral("log_dup_ok:")).first() + .startsWith(QStringLiteral("log_dup_ok:OE9ZZZ,0,")), + qPrintable(zeilenMit(QStringLiteral("log_dup_ok:")).first())); + } + + /// Kein Rufzeichen, keine Auskunft — und ein verirrter Rahmen wird + /// nicht als Rufzeichen gesucht. + void unsinnWirdAbgelehnt() + { + TciServer server(nullptr); + QWebSocket client; + QVERIFY(aufbauen(server, client, /*loggenFrei=*/true)); + + client.sendTextMessage(QStringLiteral("log_dup:;")); + QVERIFY(warteAuf(QStringLiteral("log_dup_err:"))); + + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("log_dup:%1;") + .arg(QString(64, QLatin1Char('X')))); + QVERIFY(warteAuf(QStringLiteral("log_dup_err:"))); + } +}; + +QTEST_MAIN(TestTciLogbuchLesen) +#include "tst_tci_logbuch_lesen.moc" + +#else // HAVE_WEBSOCKETS + +#include +class TestTciLogbuchLesen : public QObject { Q_OBJECT }; +QTEST_MAIN(TestTciLogbuchLesen) +#include "tst_tci_logbuch_lesen.moc" + +#endif