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