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