Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions handfunke/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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(); }
Expand Down
66 changes: 66 additions & 0 deletions handfunke/attrappe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;',
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions handfunke/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ <h1>QSO EINTRAGEN</h1>
<input id="qsoRuf" type="text" inputmode="text" autocomplete="off"
autocapitalize="characters" autocorrect="off" spellcheck="false"
placeholder="RUFZEICHEN">
<!-- Was im Logbuch ueber dieses Rufzeichen steht. Erscheint beim
Tippen (TciServer `log_dup:`) und ist LEER, solange nichts
bekannt ist — eine Zeile, die auf Verdacht "schon gearbeitet"
sagt, verhindert einen Kontakt, den es noch nicht gibt. -->
<div class="dupe" id="qsoDupe"></div>
<div class="rstreihe">
<div class="rstfeld">
<label for="qsoRstS">RST gesendet</label>
Expand All @@ -262,6 +267,19 @@ <h1>QSO EINTRAGEN</h1>
</div>
<div class="qsoMeldung" id="qsoMeldung"></div>
<button id="qsoOk">Eintragen</button>
<!-- ── Die letzten Kontakte, im SELBEN Blatt ───────────────────────
Kein eigener Schirm: nach dem Eintragen steht der eigene Kontakt
als erste Zeile da. Das ist der Beleg, den die Erfolgsmeldung
nur behaupten kann — und der Grund, warum die Liste hier und
nicht woanders steht. Gelesen wird vom DATEIENDE (TciServer
`log_last:`), nicht ueber das ganze Logbuch. -->
<div class="letzte">
<div class="letzteKopf">
<span>LETZTE KONTAKTE</span>
<button id="logFrisch" class="still klein">neu laden</button>
</div>
<div id="logListe" class="logListe"><div class="leer">—</div></div>
</div>
<button id="qsoAb" class="still">Abbrechen</button>
</div>
</div>
Expand Down
128 changes: 128 additions & 0 deletions handfunke/logbuch.js
Original file line number Diff line number Diff line change
@@ -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:<anzahl>`. 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' };
}
Loading
Loading