From 909ed73eff09c2ada0b9030fcc7c841454f0a161 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 07:26:01 +0200 Subject: [PATCH 1/2] Zurueck aus dem Hintergrund: nicht warten, nicht schweigen, nicht luegen Die Seite behandelte das Entsperren bis hierher GAR NICHT. `visibilitychange` gab es genau einmal, und zwar um das Mikrofon loszulassen. Was dabei wirklich passiert: * Der WebSocket ist tot, aber `onclose` kommt erst, wenn das System die Seite auftaut -- und der Wiederholungsabstand ist inzwischen auf 10 s gewachsen. Man sieht sekundenlang nichts, obwohl das Funkgeraet bereit ist. LIVE GEMESSEN, gleiche Ausfalldauer (55 s): ohne Wecken: Server zurueck 05:23:37, Seite da 05:23:45 -> 8 s mit Wecken: Server zurueck 05:25:10, Seite da 05:25:13 -> 0 s (dieselbe Sekunde wie das Wecken) * Der AudioContext ist von iOS angehalten. Ein angehaltener Kontext ruft seinen Rueckruf nie wieder auf -- der Ton bliebe stumm, bis jemand TON AUS und wieder EIN drueckt. * Wasserfall und S-Meter zeigen den Stand von VOR dem Sperren, ohne das zu sagen. Dieselbe Gattung Luege wie ein stehender Wasserfall: er behauptet ein leeres Band, und danach wird nicht gerufen. Jetzt zieht das Aufwachen denselben Strich, den ein Bandwechsel zieht -- oben das Neue, unten das Alte. Entschieden wird in `aufwachen.js`, und NICHT am `readyState` allein: iOS friert die Seite ein, die Gegenseite raeumt auf, und der Socket meldet noch OFFEN. Wer darauf wartet, dass `onclose` kommt, wartet unter Umstaenden ewig. Darum zaehlt der Zustand UND die Stille -- kommt seit drei Sekunden nichts an, wird neu verbunden, ganz gleich was der Socket behauptet. Eine ueberfluessige Neuverbindung kostet eine halbe Sekunde, eine ausgelassene den Moment, in dem man das Telefon in die Hand nimmt. Die Grenze fuer "das Bild ist alt" liegt mit 1,5 s UNTER der fuer "der Socket ist tot" (3 s): lieber einmal zu frueh sagen, dass das Bild von vorhin ist, als einmal zu spaet. Ein falsch beschriftetes altes Bild ist harmlos, ein unbeschriftetes nicht. Die Abonnements muessen nicht erneuert werden -- sie haengen am `open`-Ereignis und kommen mit der neuen Verbindung von selbst. Die Spots schon, die laufen waehrend des Schlafens ab. Drei Wege ins Aufwachen, weil nicht jedes System `visibilitychange` zuverlaessig meldet: `visibilitychange`, `pageshow` mit `persisted`, `focus`. Mehrfaches Aufwachen schadet nicht -- die Entscheidung wirft eine lebendige Verbindung nicht weg, und genau das ist geprueft. 17 Pruefpunkte (pruefe-aufwachen.mjs), dazu die Live-Messung oben. Co-Authored-By: Claude Opus 5 --- handfunke/app.js | 61 ++++++++++++++++++++++++++++++++ handfunke/aufwachen.js | 59 +++++++++++++++++++++++++++++++ handfunke/pruefe-aufwachen.mjs | 63 ++++++++++++++++++++++++++++++++++ handfunke/tci.js | 26 ++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 handfunke/aufwachen.js create mode 100644 handfunke/pruefe-aufwachen.mjs diff --git a/handfunke/app.js b/handfunke/app.js index a73ab865b..df9e22fc4 100644 --- a/handfunke/app.js +++ b/handfunke/app.js @@ -12,6 +12,7 @@ import { Sammelstelle, zeileLesen, befundLesen, zeitKurz, dupeSatz } from './logbuch.js'; import { spotLesen, marken, trefferBei } from './spots.js'; import { Zeichenbremse } from './ton-vorrang.js'; +import { bildIstAlt } from './aufwachen.js'; const $ = (id) => document.getElementById(id); const link = new TciLink(); @@ -2016,6 +2017,66 @@ link.addEventListener('spectrum', (e) => { }); link.addEventListener('ready', () => zeichneBedienung()); link.addEventListener('ready', () => spotsBaldHolen(300)); + +// ── Zurueck aus dem Hintergrund (2026-10-05) ────────────────────────────── +// +// Bis hierher behandelte die Seite das Entsperren GAR NICHT. Was dabei +// wirklich passiert: +// +// * Der WebSocket ist tot, aber `onclose` kommt erst, wenn das System die +// Seite wieder auftaut -- und der Wiederholungsabstand ist inzwischen +// auf 10 s gewachsen. Man sieht sekundenlang nichts, obwohl das +// Funkgeraet bereit ist. +// * Der AudioContext ist von iOS angehalten; niemand weckt ihn. +// * Wasserfall und S-Meter zeigen den Stand von VOR dem Sperren, ohne das +// zu sagen. Das ist dieselbe Gattung Luege wie ein stehender +// Wasserfall: er behauptet ein leeres Band, und danach wird nicht +// gerufen. +// +// Die Abonnements selbst muessen hier nicht erneuert werden -- sie haengen +// am `open`-Ereignis und kommen mit der neuen Verbindung von selbst. +let warVerborgen = false; + +function aufwachen() { + const stille = performance.now() - letzteIq; + + // 1. Das Bild als alt kennzeichnen, BEVOR etwas anderes passiert. Der + // Strich ist derselbe, den ein Bandwechsel zieht: oben das Neue, unten + // das Alte. Ohne ihn sieht ein zehn Minuten alter Wasserfall aus wie + // ein lebendiger. + if (bildIstAlt(stille)) { + wasserfallSchnitt(); + state.hatSpektrumstrom = false; + } + + // 2. Sofort neu verbinden, wenn noetig (Entscheidung in aufwachen.js). + link.nachDemAufwachen(stille); + + // 3. Den Ton wecken. iOS haelt den AudioContext beim Sperren an, und ein + // angehaltener Kontext ruft seinen Rueckruf nie wieder auf -- der Ton + // bliebe stumm, bis jemand TON AUS und wieder EIN drueckt. + if (state.audio && state.audio.state === 'suspended') { + state.audio.resume().catch(() => { /* ohne Zutun des Bedieners nicht erlaubt */ }); + } + + // 4. Die Spots gehoeren ebenfalls aufgefrischt: waehrend des Schlafens + // sind welche abgelaufen und neue gekommen. + spotsBaldHolen(400); +} + +document.addEventListener('visibilitychange', () => { + if (document.hidden) { warVerborgen = true; return; } + if (!warVerborgen) { return; } + warVerborgen = false; + aufwachen(); +}); + +// Nicht jedes System meldet `visibilitychange` zuverlaessig, wenn die Seite +// aus dem Ruhezustand kommt -- `pageshow` mit `persisted` ist der zweite +// Weg, und `focus` der dritte. Mehrfaches Aufwachen schadet nicht: die +// Entscheidung in aufwachen.js wirft eine lebendige Verbindung nicht weg. +window.addEventListener('pageshow', (e) => { if (e.persisted) { aufwachen(); } }); +window.addEventListener('focus', () => { if (warVerborgen) { warVerborgen = false; aufwachen(); } }); link.addEventListener('state', () => { // Der Server hat das letzte Wort über die Rate. Weicht sie von unserer // Bitte ab, muss das Worklet es erfahren — sonst stimmt die Tonhöhe nicht. diff --git a/handfunke/aufwachen.js b/handfunke/aufwachen.js new file mode 100644 index 000000000..3c882a856 --- /dev/null +++ b/handfunke/aufwachen.js @@ -0,0 +1,59 @@ +// Zurück aus dem Hintergrund — die Entscheidung, rein. +// +// Was auf einem Telefon beim Entsperren wirklich passiert, und was die +// Seite bis heute davon unbemerkt lässt: +// +// * Der WebSocket ist tot, aber `onclose` kommt erst, wenn das System +// die Seite wieder auftaut. Bis dahin steht der Wiederholungsabstand +// womöglich schon bei 10 s — der Bediener sieht also sekundenlang +// nichts, obwohl das Funkgerät bereit ist. +// +// * Schlimmer: der Socket kann als OFFEN gemeldet werden und trotzdem +// tot sein. iOS friert die Seite ein, die Gegenseite räumt auf, und +// `readyState` hinkt hinterher. Wer nur auf den Zustand sieht, +// wartet auf ein `onclose`, das nie kommt. +// +// Darum entscheidet hier nicht der Zustand allein, sondern der Zustand +// UND die Stille: kommt seit Sekunden nichts mehr an, wird neu verbunden, +// ganz gleich was der Socket behauptet. Eine überflüssige Neuverbindung +// kostet eine halbe Sekunde; eine ausgelassene kostet den Moment, in dem +// man das Telefon in die Hand genommen hat. + +/** Ab dieser Stille gilt eine Verbindung als tot, auch wenn sie OFFEN sagt. */ +export const kTotStilleMs = 3000; + +/** WebSocket-Zustände, hier benannt statt als Zahl verstreut. */ +export const VERBINDET = 0, OFFEN = 1, SCHLIESST = 2, ZU = 3; + +/** + * Soll nach dem Aufwachen sofort neu verbunden werden? + * + * @param {{gewollt:boolean, zustand:(number|null), stilleMs:number}} lage + * `zustand` ist `WebSocket.readyState` oder null, wenn gar keiner da ist. + * `stilleMs` ist, wie lange schon nichts mehr ankam. + */ +export function sollNeuVerbinden(lage) { + const l = lage || {}; + // Wer gar nicht verbunden sein will, will es auch nach dem Aufwachen nicht. + if (!l.gewollt) { return false; } + const z = (l.zustand === null || l.zustand === undefined) ? ZU : l.zustand; + if (z === ZU || z === SCHLIESST) { return true; } + // VERBINDET: einen laufenden Versuch nicht abwürgen, SOLANGE er jung ist. + // Hängt er schon länger als die Stillegrenze, ist er mit dem Telefon + // eingeschlafen und wird von selbst nicht mehr wach. + if (z === VERBINDET) { return (l.stilleMs || 0) > kTotStilleMs; } + // OFFEN: nur bei Stille. Sonst würde jeder Blick auf den Bildschirm die + // laufende Verbindung wegwerfen. + return (l.stilleMs || 0) > kTotStilleMs; +} + +/** + * Ist das Bild, das gerade dasteht, noch von vor dem Schlafen? + * + * Darauf kommt es an: ein Wasserfall, der den Stand von vor zehn Minuten + * zeigt, ist von einem lebendigen nicht zu unterscheiden. Er behauptet ein + * leeres Band, und danach wird nicht gerufen. + */ +export function bildIstAlt(stilleMs, grenzeMs = 1500) { + return (stilleMs || 0) > grenzeMs; +} diff --git a/handfunke/pruefe-aufwachen.mjs b/handfunke/pruefe-aufwachen.mjs new file mode 100644 index 000000000..fee0ccf4c --- /dev/null +++ b/handfunke/pruefe-aufwachen.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Pruefstand fuer das Zurueckkommen aus dem Hintergrund. +// +// Der Fehler, gegen den das meiste hier steht, ist der LEISE: ein Socket, +// der OFFEN meldet und trotzdem tot ist. Wer nur auf `readyState` sieht, +// wartet auf ein `onclose`, das nie kommt -- und der Bediener steht vor +// einem Bild von vorhin, das wie ein leeres Band aussieht. +// +// node pruefe-aufwachen.mjs + +import { sollNeuVerbinden, bildIstAlt, kTotStilleMs, + VERBINDET, OFFEN, SCHLIESST, ZU } from './aufwachen.js'; + +let fehler = 0; +const pruefe = (name, ok, text = '') => { + if (ok) { console.log(` ok ${name}`); } + else { console.log(` FEHLT ${name}${text ? ': ' + text : ''}`); fehler++; } +}; + +console.log('Neu verbinden?\n'); +{ + const l = (zustand, stilleMs, gewollt = true) => ({ gewollt, zustand, stilleMs }); + pruefe('geschlossen -> ja', sollNeuVerbinden(l(ZU, 0)) === true); + pruefe('schliesst gerade -> ja', sollNeuVerbinden(l(SCHLIESST, 0)) === true); + pruefe('gar kein Socket -> ja', sollNeuVerbinden(l(null, 0)) === true); + + // DER Fall: offen, aber seit Sekunden still. + pruefe('offen und still -> ja (der tote Socket)', + sollNeuVerbinden(l(OFFEN, kTotStilleMs + 1)) === true); + pruefe('offen und lebendig -> nein', + sollNeuVerbinden(l(OFFEN, 200)) === false); + pruefe('offen, Stille genau an der Grenze -> nein', + sollNeuVerbinden(l(OFFEN, kTotStilleMs)) === false); + + // Ein laufender Versuch darf nicht bei jedem Blick abgewuergt werden. + pruefe('verbindet gerade, frisch -> nein', + sollNeuVerbinden(l(VERBINDET, 500)) === false); + pruefe('verbindet gerade, aber eingeschlafen -> ja', + sollNeuVerbinden(l(VERBINDET, kTotStilleMs + 1)) === true); + + // Wer getrennt sein WILL, bleibt getrennt. + pruefe('nicht gewollt -> nie', sollNeuVerbinden(l(ZU, 99999, false)) === false); + pruefe('nicht gewollt, auch nicht bei Stille', + sollNeuVerbinden(l(OFFEN, 99999, false)) === false); + pruefe('ohne Angaben wird nichts weggeworfen', sollNeuVerbinden(null) === false); +} + +console.log('\nIst das Bild alt?'); +{ + pruefe('frisch -> nein', bildIstAlt(100) === false); + pruefe('zwei Sekunden Stille -> ja', bildIstAlt(2000) === true); + pruefe('an der Grenze -> nein', bildIstAlt(1500) === false); + pruefe('nichts gemessen -> nein', bildIstAlt(undefined) === false); + // Die Grenze liegt UNTER der fuer die Neuverbindung: lieber einmal zu + // frueh sagen "das Bild ist von vorhin" als einmal zu spaet. Ein falsch + // beschriftetes altes Bild ist harmlos, ein unbeschriftetes nicht. + pruefe('Bild gilt frueher als alt als der Socket als tot', + bildIstAlt(2000) === true && sollNeuVerbinden( + { gewollt: true, zustand: OFFEN, stilleMs: 2000 }) === false); +} + +console.log(fehler === 0 ? '\nAlles gut.' : `\n${fehler} Punkt(e) offen.`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/handfunke/tci.js b/handfunke/tci.js index 0b1fae3cd..6fcc66147 100644 --- a/handfunke/tci.js +++ b/handfunke/tci.js @@ -16,6 +16,8 @@ export const HDR = 64; +import { sollNeuVerbinden } from './aufwachen.js'; + export class TciLink extends EventTarget { constructor() { super(); @@ -146,6 +148,30 @@ export class TciLink extends EventTarget { }; } + /** + * Zurueck aus dem Hintergrund: sofort wieder versuchen, ohne den + * gewachsenen Abstand abzuwarten. + * + * Entschieden wird in aufwachen.js -- und zwar NICHT am `readyState` + * allein: iOS friert die Seite ein, die Gegenseite raeumt auf, und der + * Socket meldet noch OFFEN. Wer darauf wartet, dass `onclose` kommt, + * wartet unter Umstaenden ewig. + * + * @param {number} stilleMs wie lange schon nichts mehr ankam + * @returns {boolean} true, wenn neu verbunden wurde + */ + nachDemAufwachen(stilleMs) { + const zustand = this.ws ? this.ws.readyState : null; + if (!sollNeuVerbinden({ gewollt: this.wanted, zustand, stilleMs })) { + return false; + } + // Den Abstand zuruecksetzen: er ist beim Schlafen gewachsen, und das + // Telefon ist gerade JETZT in der Hand. + this.retryMs = 500; + this._open(); + return true; + } + _retry() { if (!this.wanted) return; clearTimeout(this.retryTimer); From 99af2c92e951205dc51d5e23e875c06f2dedccbf Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 07:45:32 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Den=20Rotor=20vom=20Telefon=20drehen=20?= =?UTF-8?q?=E2=80=94=20mit=20der=20Sicherung=20davor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Punkt 2 der App-Zeitachse. Am anderen Ende der Leitung steht ein Mast mit einer Antenne darauf, der sich auf Zuruf minutenlang dreht, und niemand steht daneben. Danach richtet sich alles hier. ── DIE NAHTSTELLE ───────────────────────────────────────────────────── Der RotctldClient wird von `RotorLogbookPanel` angelegt -- einem FENSTER. Dasselbe Muster wie beim Logbuch, wo `WorkedBefore` am `RotorLogbookPanel` haengt und der TCI-Server ihn deshalb nicht anfassen durfte: ein Netzdienst, der an einem Fenster haengt, stirbt mit dem Fenster. `RadioModel` bekommt darum eine REGISTRIERUNG, keinen Besitz: das Panel meldet seinen Client an, das Modell haelt einen QPointer. Der sauberere Weg waere, den Besitz hierher zu holen (wie bei `m_spotModel`, das 2026-05-12 aus `SpotHubDialog` hierher kam) -- das ist eine Architekturaenderung an einem Geraet, das an Martins Mast haengt, und CLAUDE.md verlangt dafuer den Betreiber. Bis dahin der kleine, umkehrbare Schritt. ── DIE SPERREN ──────────────────────────────────────────────────────── rotor:; -> rotor_ist:,,; rotor_to:; -> rotor_ok:; oder rotor_err:; rotor_stop:; -> rotor_ok:stop; ABFRAGEN darf jeder angemeldete Client -- hinsehen bewegt nichts. DREHEN nur mit `TciAllowRemoteRotor=True`, ab Werk FALSE: dieselbe Strenge wie beim Senden, aus demselben Grund. Abgelehnt wird MIT Grund; ein stummes Nein ist von einem Defekt nicht zu unterscheiden, und beim Rotor sieht man zehn Sekunden lang ohnehin nichts. `frisch` ist kein Beiwerk: eine Nadel, die eine veraltete Stellung zeigt, ohne das zu sagen, ist schlimmer als eine, die nichts zeigt -- so steht es schon im Kopf von RotorController.h. ── WAS toDouble() ANGERICHTET HAETTE ────────────────────────────────── `RotorPeilung::lies()` nimmt NICHT einfach `toDouble()`: * aus "" und "abc" macht `toDouble()` eine 0 -- und NULL GRAD IST NORD. Eine leere Zeile haette die Antenne nach Norden gedreht. * NaN besteht jeden Bereichsvergleich, weil jeder Vergleich mit NaN falsch ist. Ein naiver Bereichstest laesst NaN durch. * 360 ist dieselbe Richtung wie 0 und gehoert angenommen; 361 ist ein Tippfehler. Ausserhalb wird ABGELEHNT statt umgerechnet: wer -90 schickt, hat sich vertan, und 270 Grad sind eine andere Antwort als "ich habe mich vertan". ── AUF DER SEITE ────────────────────────────────────────────────────── Das Feld BLEIBT VERSTECKT, bis Longpath einen Rotor meldet. An der QRP gibt es keinen, und ein leeres Feld, das nichts kann, ist auf einem Telefon reiner Platzverbrauch. Die Scheibe WAEHLT nur; gedreht wird mit dem Knopf darunter -- dieselbe Entscheidung wie bei der Sendetaste (Entwurf 1, 2026-10-04): ein Tipp, der sofort einen Mast dreht, ist in einer Hosentasche eine schlechte Idee. Eine Wahl verfaellt nach 15 s; sie gehoert sonst dem Finger von vorhin. Nord ist OBEN und gezaehlt wird im Uhrzeigersinn -- `atan2(x, -y)`, nicht `atan2(y, x)`. Letzteres waere Ost = 0 und gegen den Uhrzeigersinn und sieht auf einer runden Scheibe genauso plausibel aus; der Pruefstand haelt beide Verwechslungen ausdruecklich fest. Messing fuer die gemessene Stellung, Blau gestrichelt fuer das gewaehlte Ziel -- die beiden duerfen nie zu verwechseln sein. ── GEPRUEFT ─────────────────────────────────────────────────────────── 48 Pruefpunkte: 8 (RotorPeilung) + 9 (TCI, ueber den echten WebSocket) + 31 (Seite, ohne Browser). Live gegen die Attrappe: Osten angetippt -> "AUF 90° DREHEN", Knopf gedrueckt -> der Rotor nimmt an, meldet "DREHT …", und die Nadel wandert (143° -> 140° -> …). Danach steht der Knopf wieder auf "RICHTUNG WÄHLEN" -- die Sicherung ist zu. Co-Authored-By: Claude Opus 5 --- handfunke/app.js | 135 +++++++++++++ handfunke/attrappe.py | 63 ++++++ handfunke/index.html | 31 +++ handfunke/pruefe-rotor.mjs | 98 +++++++++ handfunke/rotor.js | 115 +++++++++++ handfunke/stil.css | 36 ++++ handfunke/tci.js | 4 + src/core/RotorPeilung.h | 53 +++++ src/core/TciServer.cpp | 103 ++++++++++ src/core/TciServer.h | 2 + src/gui/widgets/RotorLogbookPanel.cpp | 4 + src/models/RadioModel.h | 27 +++ tests/CMakeLists.txt | 23 +++ tests/tst_rotor_peilung.cpp | 94 +++++++++ tests/tst_tci_rotor.cpp | 273 ++++++++++++++++++++++++++ 15 files changed, 1061 insertions(+) create mode 100644 handfunke/pruefe-rotor.mjs create mode 100644 handfunke/rotor.js create mode 100644 src/core/RotorPeilung.h create mode 100644 tests/tst_rotor_peilung.cpp create mode 100644 tests/tst_tci_rotor.cpp diff --git a/handfunke/app.js b/handfunke/app.js index df9e22fc4..d4e14bceb 100644 --- a/handfunke/app.js +++ b/handfunke/app.js @@ -13,6 +13,8 @@ import { Sammelstelle, zeileLesen, befundLesen, zeitKurz, dupeSatz } import { spotLesen, marken, trefferBei } from './spots.js'; import { Zeichenbremse } from './ton-vorrang.js'; import { bildIstAlt } from './aufwachen.js'; +import { standLesen, zustandText, peilungAus, Sicherung as RotorSicherung, + DREHT } from './rotor.js'; const $ = (id) => document.getElementById(id); const link = new TciLink(); @@ -2018,6 +2020,139 @@ link.addEventListener('spectrum', (e) => { link.addEventListener('ready', () => zeichneBedienung()); link.addEventListener('ready', () => spotsBaldHolen(300)); +// ── Der Rotor (2026-10-05) ──────────────────────────────────────────────── +// +// Das Feld BLEIBT VERSTECKT, bis Longpath einen Rotor meldet. An der QRP +// gibt es keinen, und ein leeres Feld, das nichts kann, ist auf einem +// Telefon reiner Platzverbrauch. +// +// Die Scheibe WAEHLT nur. Gedreht wird erst mit dem Knopf -- dieselbe +// Entscheidung wie bei der Sendetaste: ein Tipp, der sofort einen Mast +// dreht, ist in einer Hosentasche eine schlechte Idee. Und Longpath +// verweigert es ohnehin, solange TciAllowRemoteRotor nicht steht; die +// Ablehnung kommt als `rotor_err:` hier an und wird gezeigt, statt +// verschluckt zu werden. +const rotorSicherung = new RotorSicherung(); +let rotorStand = null; +let rotorFeldAn = false; +let rotorTakt = null; + +function rotorStricheZeichnen() { + const g = $('rotorStriche'); + if (!g || g.childElementCount) { return; } + // Alle 10 Grad ein Strich, alle 30 ein laengerer. Das ist die Teilung + // jedes Kompasses; eine feinere kann ein Finger ohnehin nicht treffen. + for (let a = 0; a < 360; a += 10) { + const haupt = (a % 30) === 0; + const r1 = haupt ? 78 : 84, r2 = 92; + const rad = a * Math.PI / 180; + const l = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + l.setAttribute('x1', (Math.sin(rad) * r1).toFixed(2)); + l.setAttribute('y1', (-Math.cos(rad) * r1).toFixed(2)); + l.setAttribute('x2', (Math.sin(rad) * r2).toFixed(2)); + l.setAttribute('y2', (-Math.cos(rad) * r2).toFixed(2)); + if (haupt) { l.setAttribute('class', 'haupt'); } + g.appendChild(l); + } +} + +function rotorZeichnen() { + const nadel = $('rotorNadel'), ziel = $('rotorZiel'); + const alt = rotorStand && !rotorStand.frisch; + if (rotorStand) { + const rad = rotorStand.grad * Math.PI / 180; + nadel.setAttribute('x2', (Math.sin(rad) * 82).toFixed(2)); + nadel.setAttribute('y2', (-Math.cos(rad) * 82).toFixed(2)); + nadel.classList.toggle('alt', !!alt); + $('rotorGrad').textContent = `${Math.round(rotorStand.grad)}°`; + $('rotorGrad').classList.toggle('alt', !!alt); + } + const z = rotorSicherung.gilt(performance.now()) ? rotorSicherung.ziel : null; + if (z === null) { ziel.setAttribute('hidden', ''); } + else { + const rad = z * Math.PI / 180; + ziel.setAttribute('x2', (Math.sin(rad) * 92).toFixed(2)); + ziel.setAttribute('y2', (-Math.cos(rad) * 92).toFixed(2)); + ziel.removeAttribute('hidden'); + } + const knopf = $('rotorDreh'); + knopf.disabled = (z === null); + knopf.textContent = (z === null) ? 'RICHTUNG WÄHLEN' : `AUF ${z}° DREHEN`; + const t = zustandText(rotorStand); + if (!$('rotorMeldung').classList.contains('warn')) { + $('rotorMeldung').textContent = t; + } +} + +function rotorHolen() { if (link.ready) { link.send(`rotor:${state.trx}`); } } + +link.addEventListener('rotor', (e) => { + const st = standLesen((e.detail || {}).args || []); + if (!st) { return; } + rotorStand = st; + if (!rotorFeldAn) { + // Erst jetzt zeigen: davor wussten wir nicht, ob es ueberhaupt einen gibt. + rotorFeldAn = true; + rotorStricheZeichnen(); + $('rotorFeld').removeAttribute('hidden'); + } + rotorZeichnen(); +}); + +link.addEventListener('rotorok', () => { + $('rotorMeldung').classList.remove('warn'); + $('rotorMeldung').textContent = 'ANGENOMMEN'; + // Gleich nachfragen: der Rotor meldet den Zustand `dreht` erst, wenn er + // wirklich anlaeuft. + setTimeout(rotorHolen, 400); +}); + +link.addEventListener('rotorfehler', (e) => { + const t = ((e.detail || {}).text || '').trim(); + // "kein rotor" ist kein Fehler des Bedieners -- dann gibt es hier einfach + // keinen, und das Feld bleibt weg. + if (/kein rotor/i.test(t)) { return; } + $('rotorMeldung').textContent = t || 'abgelehnt'; + $('rotorMeldung').classList.add('warn'); + rotorSicherung.verwerfen(); + rotorZeichnen(); +}); + +$('rotorScheibe').addEventListener('pointerup', (e) => { + const r = $('rotorScheibe').getBoundingClientRect(); + if (!r.width) { return; } + // In die Koordinaten der Scheibe umrechnen: Mitte ist 0/0, y nach unten. + const x = (e.clientX - r.left) - r.width / 2; + const y = (e.clientY - r.top) - r.height / 2; + // Der Mindestradius ist in Bildpunkten der ANZEIGE, nicht des viewBox -- + // darum auf die tatsaechliche Breite bezogen. + const g = peilungAus(x, y, r.width * 0.12); + if (g === null) { return; } + rotorSicherung.waehle(g, performance.now()); + $('rotorMeldung').classList.remove('warn'); + $('rotorMeldung').textContent = ''; + rotorZeichnen(); +}); + +$('rotorDreh').addEventListener('click', () => { + const z = rotorSicherung.bestaetige(performance.now()); + if (z === null) { rotorZeichnen(); return; } + if (!link.ready) { + $('rotorMeldung').textContent = 'keine Verbindung — nicht gedreht'; + $('rotorMeldung').classList.add('warn'); + return; + } + link.send(`rotor_to:${z}`); + $('rotorMeldung').classList.remove('warn'); + $('rotorMeldung').textContent = 'wird gesendet …'; + rotorZeichnen(); +}); + +// Zwei Sekunden: ein Rotor braucht fuer ein Grad laenger als das, und +// haeufiger zu fragen kostet nur Leitung. +rotorTakt = setInterval(rotorHolen, 2000); +link.addEventListener('ready', () => setTimeout(rotorHolen, 500)); + // ── Zurueck aus dem Hintergrund (2026-10-05) ────────────────────────────── // // Bis hierher behandelte die Seite das Entsperren GAR NICHT. Was dabei diff --git a/handfunke/attrappe.py b/handfunke/attrappe.py index c6dfcf333..e93d7e83d 100644 --- a/handfunke/attrappe.py +++ b/handfunke/attrappe.py @@ -76,6 +76,32 @@ def _jetzt_utc(): EINGETRAGEN = [] +# ── Rotor aus Pappe (2026-10-05) ───────────────────────────────────────── +# --rotorfrei schaltet das Drehen frei; ohne das wird abgelehnt, genau wie +# TciAllowRemoteRotor ab Werk. +ROTOR_FREI = '--rotorfrei' in sys.argv +ROTOR = {'ist': 143.0, 'ziel': 143.0, 'zustand': 2, 'frisch': True} + + +def _rotor_nachziehen(): + """Zieht die gemeldete Stellung dem Ziel nach, 6 Grad je Sekunde -- + etwa so schnell wie ein echter Rotor.""" + while True: + time.sleep(0.25) + d = ROTOR['ziel'] - ROTOR['ist'] + if d > 180: + d -= 360 + elif d < -180: + d += 360 + if abs(d) < 0.3: + ROTOR['ist'] = ROTOR['ziel'] + if ROTOR['zustand'] == 3: + ROTOR['zustand'] = 2 + else: + schritt = 1.5 if d > 0 else -1.5 + ROTOR['ist'] = (ROTOR['ist'] + schritt) % 360 + + # ── Spots (2026-10-05) ─────────────────────────────────────────────────── # # Je Eintrag: (Versatz zur Mitte in Hz, Rufzeichen, Mode, Quelle, Alter s). @@ -341,6 +367,42 @@ def befehl(self, zeile): 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 in ('rotor', 'rotor_to', 'rotor_stop'): + # Ein Rotor aus Pappe. Er dreht nicht wirklich, aber er zieht + # seine gemeldete Stellung langsam nach -- sonst liesse sich am + # Telefon nicht ansehen, ob die Nadel ueberhaupt folgt. + # + # Die Attrappe ist dabei NICHT gutmuetiger als der echte Server: + # ohne --rotorfrei wird jedes Drehen abgelehnt, genau wie + # TciAllowRemoteRotor ab Werk. + if name == 'rotor': + self.sende_text( + f'rotor_ist:{ROTOR["ist"]:.1f},{ROTOR["zustand"]},' + f'{1 if ROTOR["frisch"] else 0};') + elif not ROTOR_FREI: + print(f' {self.addr[1]}: {name} ABGELEHNT (nicht freigegeben)') + self.sende_text('rotor_err:nicht freigegeben;') + elif name == 'rotor_stop': + ROTOR['ziel'] = ROTOR['ist'] + ROTOR['zustand'] = 2 + print(f' {self.addr[1]}: Rotor angehalten') + self.sende_text('rotor_ok:stop;') + else: + roh = (args[0].strip() if args else '') + try: + g = float(roh) + except ValueError: + g = None + if g is None or g != g or g < 0 or g > 360: + print(f' {self.addr[1]}: rotor_to ABGELEHNT ({roh!r})') + self.sende_text('rotor_err:peilung unbrauchbar;') + else: + if g == 360: + g = 0.0 + ROTOR['ziel'] = g + ROTOR['zustand'] = 3 # dreht + print(f' {self.addr[1]}: Rotor auf {g:.1f} Grad') + self.sende_text(f'rotor_ok:{g:.1f};') elif name == 'spots': # Longpath filtert auf den sichtbaren Ausschnitt. Die Attrappe # ist dabei absichtlich NICHT gutmuetiger: was ausserhalb der @@ -606,6 +668,7 @@ def run(self): def main(): + threading.Thread(target=_rotor_nachziehen, daemon=True).start() # Ab Werk nur auf dem eigenen Rechner. Mit `--alle` (oder HOST=0.0.0.0) # auch aus dem LAN erreichbar — das braucht man, sobald man die Handfunke # von einem echten Telefon aus prueft. diff --git a/handfunke/index.html b/handfunke/index.html index e280c721f..916a8413b 100644 --- a/handfunke/index.html +++ b/handfunke/index.html @@ -170,6 +170,37 @@
+ + +
SENDEN NUR IN DER APP
TUNE NUR IN DER APP
diff --git a/handfunke/pruefe-rotor.mjs b/handfunke/pruefe-rotor.mjs new file mode 100644 index 000000000..26277787a --- /dev/null +++ b/handfunke/pruefe-rotor.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +// Pruefstand fuer den Rotor auf der Seite. +// +// Am anderen Ende steht ein Mast. Zwei Fehler sind hier moeglich, und beide +// merkt man erst, wenn die Antenne falsch steht: +// +// * Der Winkel wird falsch herum oder mit falschem Nullpunkt gerechnet. +// Nord muss OBEN sein und im Uhrzeigersinn gezaehlt werden -- `atan2(y,x)` +// waere Ost = 0 und gegen den Uhrzeigersinn, und das sieht auf einer +// runden Scheibe genauso plausibel aus. +// * Ein Tipp dreht sofort. Auf einem Telefon in der Hosentasche ist das +// eine schlechte Idee. +// +// node pruefe-rotor.mjs + +import { standLesen, zustandText, peilungAus, Sicherung, + GETRENNT, VERBINDET, BEREIT, DREHT, FEHLER } from './rotor.js'; + +let fehler = 0; +const pruefe = (name, ok, text = '') => { + if (ok) { console.log(` ok ${name}`); } + else { console.log(` FEHLT ${name}${text ? ': ' + text : ''}`); fehler++; } +}; + +console.log('Stand lesen\n'); +{ + const s = standLesen(['143.5', '2', '1']); + pruefe('Grad, Zustand und Frische kommen an', + s.grad === 143.5 && s.zustand === BEREIT && s.frisch === true); + pruefe('nicht frisch wird nicht verschluckt', + standLesen(['10', '2', '0']).frisch === false); + pruefe('ueber 360 wird eingefangen', standLesen(['370', '2', '1']).grad === 10); + pruefe('negativ wird eingefangen', standLesen(['-10', '2', '1']).grad === 350); + pruefe('ohne Zahl -> null', standLesen(['', '2', '1']) === null); + pruefe('Unfug -> null', standLesen(['abc']) === null); +} + +console.log('\nWas darunter steht'); +{ + pruefe('bereit und frisch -> nichts', + zustandText({ grad: 0, zustand: BEREIT, frisch: true }) === ''); + pruefe('bereit, aber alt -> sagt es', + /VORHIN/.test(zustandText({ grad: 0, zustand: BEREIT, frisch: false }))); + pruefe('dreht -> sagt es', /DREHT/.test(zustandText({ zustand: DREHT }))); + pruefe('getrennt -> sagt es', /NICHT VERBUNDEN/.test(zustandText({ zustand: GETRENNT }))); + pruefe('Fehler -> sagt es', /FEHLER/.test(zustandText({ zustand: FEHLER }))); + pruefe('gar kein Rotor -> sagt es', /KEIN ROTOR/.test(zustandText(null))); +} + +console.log('\nDie Peilung unter dem Finger'); +{ + // Nord ist OBEN, gezaehlt im Uhrzeigersinn. y zeigt nach unten. + pruefe('oben = 0 (Nord)', peilungAus(0, -100) === 0); + pruefe('rechts = 90 (Ost)', peilungAus(100, 0) === 90); + pruefe('unten = 180 (Sued)', peilungAus(0, 100) === 180); + pruefe('links = 270 (West)', peilungAus(-100, 0) === 270); + pruefe('oben rechts = 45', peilungAus(70, -70) === 45); + pruefe('oben links = 315', peilungAus(-70, -70) === 315); + + // Gegenprobe: mit atan2(y,x) waere rechts = 0 und unten = 90. Genau das + // darf NICHT herauskommen -- auf einer runden Scheibe sieht es gleich aus. + pruefe('rechts ist NICHT 0 (der verdrehte Nullpunkt)', peilungAus(100, 0) !== 0); + pruefe('unten ist NICHT 90 (die verdrehte Zaehlrichtung)', peilungAus(0, 100) !== 90); + + pruefe('zu nah an der Mitte -> keine Peilung', peilungAus(2, -3) === null); + pruefe('genau in der Mitte -> keine Peilung', peilungAus(0, 0) === null); + pruefe('immer 0..359', [[1,-1],[-1,1],[0,-1],[-0.001,-100]] + .every(([x,y]) => { const g = peilungAus(x*100, y*100); return g >= 0 && g < 360; })); +} + +console.log('\nDie Sicherung'); +{ + const s = new Sicherung(); + pruefe('ohne Wahl dreht nichts', s.bestaetige(1000) === null); + + s.waehle(270, 1000); + pruefe('gewaehlt gilt', s.gilt(1500) === true); + pruefe('bestaetigen gibt das Ziel frei', s.bestaetige(1500) === 270); + pruefe('und danach ist wieder zu', s.bestaetige(1600) === null); + + // Eine Wahl von vorhin gehoert dem Finger von vorhin. + const s2 = new Sicherung({ haltbarMs: 15000 }); + s2.waehle(90, 0); + pruefe('nach 15 s gilt sie noch', s2.gilt(15000) === true); + pruefe('nach 16 s nicht mehr', s2.gilt(16000) === false); + pruefe('und bestaetigen dreht dann nicht', s2.bestaetige(16000) === null); + + const s3 = new Sicherung(); + pruefe('unsinnige Peilung wird nicht gewaehlt', s3.waehle(NaN, 0) === false); + pruefe('360 wird nicht gewaehlt (0 ist Nord)', s3.waehle(360, 0) === false); + pruefe('negativ wird nicht gewaehlt', s3.waehle(-1, 0) === false); + pruefe('und dann steht auch nichts bereit', s3.bestaetige(0) === null); + pruefe('0 Grad ist eine gueltige Wahl', s3.waehle(0, 0) === true); + pruefe('und wird auch so herausgegeben', s3.bestaetige(10) === 0); +} + +console.log(fehler === 0 ? '\nAlles gut.' : `\n${fehler} Punkt(e) offen.`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/handfunke/rotor.js b/handfunke/rotor.js new file mode 100644 index 000000000..9100dc0f3 --- /dev/null +++ b/handfunke/rotor.js @@ -0,0 +1,115 @@ +// Den Rotor von der Seite aus — die reinen Teile. +// +// Kein DOM, kein WebSocket. Hier steht, was auf einem Telefon still falsch +// sein kann, wenn am anderen Ende ein Mast steht: +// +// 1. **Die Peilung unter dem Finger.** Eine Scheibe, auf die man tippt, +// rechnet einen Winkel aus. Rechnet sie falsch herum oder mit dem +// falschen Nullpunkt, dreht die Antenne woanders hin als dorthin, wo +// der Finger war — und das merkt man erst, wenn nichts mehr zu hören +// ist. +// +// 2. **Der versehentliche Griff.** Ein Tipp, der sofort dreht, ist auf +// einem Telefon in der Hosentasche eine schlechte Idee. Darum wählt +// der erste Tipp nur, und erst ein zweiter, bewusster Griff dreht — +// dieselbe Entscheidung wie bei der Sendetaste (Entwurf 1, +// 2026-10-04). +// +// 3. **Die alte Stellung.** Ein Rotor ist langsame Mechanik am Ende eines +// Drahtes. Eine Nadel, die eine veraltete Richtung zeigt, ohne das zu +// sagen, ist schlimmer als eine, die nichts zeigt — so steht es schon +// im Kopf von RotorController.h, und die Seite hält sich daran. + +/** `rotor_ist:`-Argumente -> Stand. Gibt `null`, wenn nichts dasteht. */ +export function standLesen(args) { + const grad = parseFloat(args[0]); + if (isNaN(grad)) return null; + const zustand = parseInt(args[1], 10); + return { + grad: ((grad % 360) + 360) % 360, + zustand: isNaN(zustand) ? 0 : zustand, + frisch: (args[2] || '') === '1', + }; +} + +/** Zustandszahlen aus RotorController::State. */ +export const GETRENNT = 0, VERBINDET = 1, BEREIT = 2, DREHT = 3, FEHLER = 4; + +/** Was unter der Scheibe steht. Kein Rot: Rot bleibt der Warnung. */ +export function zustandText(stand) { + if (!stand) return 'KEIN ROTOR'; + switch (stand.zustand) { + case GETRENNT: return 'NICHT VERBUNDEN'; + case VERBINDET: return 'VERBINDET …'; + case DREHT: return 'DREHT …'; + case FEHLER: return 'ROTOR MELDET EINEN FEHLER'; + default: return stand.frisch ? '' : 'STELLUNG VON VORHIN'; + } +} + +/** + * Welche Peilung liegt unter dem Finger? + * + * `x`/`y` sind relativ zur Mitte der Scheibe, in Bildpunkten, mit y nach + * UNTEN (so wie jede Bildschirmkoordinate). Null Grad ist oben (Nord), und + * gezählt wird im Uhrzeigersinn — wie auf jedem Kompass. + * + * Gibt `null` zurück, wenn der Finger zu nah an der Mitte war: dort ist die + * Richtung nicht bestimmbar, und ein Tipp auf den Mittelpunkt darf keine + * willkürliche Peilung ergeben. + */ +export function peilungAus(x, y, mindestRadius = 12) { + const r = Math.sqrt(x * x + y * y); + if (!(r >= mindestRadius)) return null; + // atan2(x, -y): Nord oben, im Uhrzeigersinn. Nicht atan2(y, x) -- + // das waere Ost = 0 und gegen den Uhrzeigersinn. + let g = Math.atan2(x, -y) * 180 / Math.PI; + if (g < 0) g += 360; + // Auf ganze Grad: feiner kann ein Finger nicht zielen, und ein Rotor + // auch nicht. + g = Math.round(g) % 360; + return g; +} + +/** + * Die Sicherung: erst wählen, dann drehen. + * + * `waehle(grad)` merkt sich ein Ziel. `bestaetige()` gibt es frei — aber + * nur, wenn vorher wirklich gewählt wurde und die Wahl nicht zu alt ist. + * Eine Wahl, die eine Minute alt ist, gehört dem Finger von vorhin. + */ +export class Sicherung { + constructor({ haltbarMs = 15000 } = {}) { + this.haltbarMs = haltbarMs; + this.ziel = null; + this.gewaehltBei = 0; + } + + waehle(grad, jetztMs) { + if (typeof grad !== 'number' || !isFinite(grad) || grad < 0 || grad >= 360) { + return false; + } + this.ziel = grad; + this.gewaehltBei = jetztMs; + return true; + } + + /** Noch gültig? */ + gilt(jetztMs) { + return this.ziel !== null && (jetztMs - this.gewaehltBei) <= this.haltbarMs; + } + + /** + * Gibt das Ziel frei und vergisst es — oder `null`, wenn nichts gewählt + * war oder die Wahl zu alt ist. Danach ist die Sicherung wieder zu: ein + * zweiter Druck dreht nicht noch einmal. + */ + bestaetige(jetztMs) { + if (!this.gilt(jetztMs)) { this.verwerfen(); return null; } + const z = this.ziel; + this.verwerfen(); + return z; + } + + verwerfen() { this.ziel = null; this.gewaehltBei = 0; } +} diff --git a/handfunke/stil.css b/handfunke/stil.css index a1121817b..303e59717 100644 --- a/handfunke/stil.css +++ b/handfunke/stil.css @@ -491,3 +491,39 @@ footer .warn{color:var(--warn)} .mikmeldung{font:600 var(--f-caption)/1.3 var(--mono);letter-spacing:.08em; color:var(--text-inactive);text-align:center;min-height:13px} .mikmeldung.warn{color:var(--warn)} + + +/* ── Der Rotor (2026-10-05) ─────────────────────────────────────────────── + Die Scheibe ist ein Messinstrument, kein Zierrat: die Nadel zeigt, was + das Geraet MELDET, der zweite Strich, was gewaehlt ist. Messing fuer das + Gemessene, Blau fuer das Anfassbare -- dieselbe Rollenverteilung wie + ueberall sonst in dieser Seite. */ +.rotor .rotorreihe{display:flex;gap:14px;align-items:center} +.rotor .scheibe{width:132px;height:132px;flex:none} +.rotor .scheibe .ring{fill:rgba(255,255,255,.02);stroke:var(--border);stroke-width:1} +.rotor .scheibe .striche line{stroke:var(--text-inactive);stroke-width:1} +.rotor .scheibe .striche line.haupt{stroke:var(--text3);stroke-width:1.5} +.rotor .scheibe .himmel{fill:var(--text3);font:600 13px var(--mono); + text-anchor:middle;dominant-baseline:middle;letter-spacing:.04em} +/* Die Nadel ist GEMESSEN -> Messing. Steht die Meldung still, blasst sie ab + (Klasse `alt`), statt eine Richtung zu behaupten, die niemand bestaetigt. */ +.rotor .scheibe .nadel{stroke:var(--measured);stroke-width:2.5;stroke-linecap:round} +.rotor .scheibe .nadel.alt{stroke:var(--amber-dim)} +/* Das Ziel ist ANGEFASST -> Blau, gestrichelt, damit es nie mit der + gemessenen Stellung zu verwechseln ist. */ +.rotor .scheibe .ziel{stroke:var(--accent);stroke-width:2;stroke-dasharray:4 3; + stroke-linecap:round} +.rotor .scheibe .nabe{fill:var(--text3)} +.rotor .rotorwerte{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px} +.rotor .gradAn{font:600 28px/1 var(--mono);color:var(--measured); + letter-spacing:.02em} +.rotor .gradAn.alt{color:var(--amber-dim)} +.rotor .rotorMeldung{min-height:14px;font:500 var(--f-caption)/1.3 var(--mono); + color:var(--text3);letter-spacing:.04em} +.rotor .rotorMeldung.warn{color:var(--warn)} +.rotor .rotorDreh{height:42px;border-radius:11px;font:600 var(--f-caption)/1 inherit; + letter-spacing:.1em;border:1px solid #4886c0; + background:linear-gradient(#36699f,#21436a);color:#e2eefb; + box-shadow:inset 0 1px 0 rgba(255,255,255,.2),0 2px 6px rgba(0,0,0,.55)} +.rotor .rotorDreh:disabled{opacity:.4;border-color:var(--border); + background:rgba(18,19,23,.6);color:var(--text-inactive);box-shadow:none} diff --git a/handfunke/tci.js b/handfunke/tci.js index 6fcc66147..11174d6b8 100644 --- a/handfunke/tci.js +++ b/handfunke/tci.js @@ -225,6 +225,10 @@ export class TciLink extends EventTarget { case 'spot_zeile': this._emit('spotzeile', { args }); break; case 'spots_ok': this._emit('spotsende', { anzahl: int(0) }); break; case 'spots_err': this._emit('spotsende', { anzahl: null, text: args[0] || '' }); break; + // Der Rotor (2026-10-05). Durchgereicht, nicht gedeutet. + case 'rotor_ist': this._emit('rotor', { args }); break; + case 'rotor_ok': this._emit('rotorok', { text: args[0] || '' }); break; + case 'rotor_err': this._emit('rotorfehler', { 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/RotorPeilung.h b/src/core/RotorPeilung.h new file mode 100644 index 000000000..ad3663141 --- /dev/null +++ b/src/core/RotorPeilung.h @@ -0,0 +1,53 @@ +#pragma once + +// Eine Peilung vom Netz annehmen — oder ablehnen. +// +// Longpath-original, kopflastig fuer vier Zeilen Rechnung. Der Grund steht +// am anderen Ende der Leitung: ein Mast mit einer Antenne darauf, der sich +// auf Zuruf dreht. Was hier durchrutscht, dreht echtes Metall. +// +// Darum nimmt diese Stelle NICHT einfach `toDouble()`: +// +// * `toDouble()` macht aus "" eine 0 und aus "abc" eine 0. Null Grad ist +// Nord — eine leere Zeile wuerde die Antenne nach Norden drehen. +// * NaN und Unendlich rutschen durch jeden Bereichsvergleich: `nan < 0` +// ist falsch, `nan > 360` ist auch falsch. +// * 360 ist dieselbe Richtung wie 0 und muss angenommen werden; 361 ist +// ein Tippfehler und darf es nicht. +// +// Negative Werte und Werte ueber 360 werden ABGELEHNT statt umgerechnet. +// Ein Umrechnen waere bequem und falsch: wer -90 schickt, hat sich vertan, +// und 270 Grad sind eine andere Antwort als "ich habe mich vertan". + +#include + +#include + +namespace Longpath { +namespace RotorPeilung { + +/// Groesster erlaubter Wert. 360 gilt und bedeutet Nord. +constexpr double kHoechstens = 360.0; + +/// Liest eine Peilung aus dem Befehlstext. +/// +/// Gibt `false` zurueck, wenn nichts Brauchbares dasteht; `grad` bleibt dann +/// unberuehrt. 360 wird auf 0 gelegt, weil der Rotor dieselbe Richtung +/// meint und manche Steuerungen 360 nicht annehmen. +inline bool lies(const QString& text, double* grad) +{ + const QString t = text.trimmed(); + if (t.isEmpty()) { return false; } + bool ok = false; + const double w = t.toDouble(&ok); + if (!ok) { return false; } + // Reihenfolge wichtig: zuerst auf endlich pruefen. NaN besteht jeden + // Bereichsvergleich, weil jeder Vergleich mit NaN falsch ist. + if (!std::isfinite(w)) { return false; } + if (w < 0.0 || w > kHoechstens) { return false; } + if (grad) { *grad = (w == kHoechstens) ? 0.0 : w; } + return true; +} + +} // namespace RotorPeilung +} // namespace Longpath diff --git a/src/core/TciServer.cpp b/src/core/TciServer.cpp index 923828bb4..e6ede45e8 100644 --- a/src/core/TciServer.cpp +++ b/src/core/TciServer.cpp @@ -30,6 +30,8 @@ #include "core/LogbookDatei.h" #include "core/LogbuchRueckschau.h" #include "core/SpotAuswahl.h" +#include "core/RotorPeilung.h" +#include "core/RotorController.h" #include "models/SpotModel.h" #include "models/LogEntry.h" #include "TciBinaryFrame.h" @@ -2417,6 +2419,17 @@ bool TciServer::remoteLogAllowed() .toString() != QStringLiteral("False"); } +bool TciServer::remoteRotorAllowed() +{ + // Ab Werk NEIN -- wie beim Senden, und aus demselben Grund: am anderen + // Ende haengt echtes Metall. Ein Rotor strahlt zwar nicht, aber er + // dreht einen Mast, an dem Kabel haengen, und er tut es minutenlang + // ohne Zutun. Wer das aus dem Netz will, schaltet es bewusst frei. + return AppSettings::instance() + .value(QStringLiteral("TciAllowRemoteRotor"), QStringLiteral("False")) + .toString() == QStringLiteral("True"); +} + bool TciServer::remoteTxAllowed() { // Ab Werk NEIN. Wer aus dem Netz senden will, schaltet es bewusst frei — @@ -3248,6 +3261,96 @@ void TciServer::onTextMessageReceived(const QString& rohMsg) } } + // ── Der Rotor (2026-10-05) ────────────────────────────────────────── + // + // rotor:; -> rotor_ist:,,; + // rotor_to:; -> rotor_ok:; oder rotor_err:; + // rotor_stop:; -> rotor_ok:stop; oder rotor_err:; + // + // ABFRAGEN darf jeder angemeldete Client. DREHEN nur, wenn + // TciAllowRemoteRotor ausdruecklich auf True steht -- ab Werk nicht. + // Am anderen Ende haengt ein Mast mit Kabeln daran, und er dreht + // minutenlang ohne weiteres Zutun. + // + // `frisch` ist kein Beiwerk: der Rotor ist langsame Mechanik am Ende + // eines Drahtes. Eine Nadel, die eine alte Stellung zeigt, ohne das + // zu sagen, ist schlimmer als eine, die nichts zeigt (so steht es + // schon im Kopf von RotorController.h). Die Seite bekommt beides und + // kann es unterscheiden. + // + // Erreicht wird der Rotor ueber RadioModel::rotor() -- eine + // Registrierung, kein Besitz. Angelegt wird er weiter im Fenster; + // der Netzdienst greift nur nicht hinein. + { + QString t = trimmed; + const bool istAbfrage = + t.compare(QStringLiteral("rotor"), Qt::CaseInsensitive) == 0 + || t.startsWith(QStringLiteral("rotor:"), Qt::CaseInsensitive); + const bool istDrehen = + t.startsWith(QStringLiteral("rotor_to:"), Qt::CaseInsensitive); + const bool istHalt = + t.compare(QStringLiteral("rotor_stop"), Qt::CaseInsensitive) == 0 + || t.startsWith(QStringLiteral("rotor_stop:"), Qt::CaseInsensitive); + + if (istAbfrage || istDrehen || istHalt) { + auto antwort = [&](const QString& zeile) { + session->sendQueue.push(TciSendQueue::Priority::Control, + zeile); + }; + if (!session->authenticated) { return; } + + RotorController* rot = m_model ? m_model->rotor() : nullptr; + if (!rot) { + // "Es gibt hier keinen Rotor" ist eine andere Auskunft + // als "er steht auf 0 Grad". Nur eine davon heisst: + // such nicht weiter. + antwort(QStringLiteral("rotor_err:kein rotor;")); + return; + } + + if (istAbfrage) { + const int zustand = static_cast(rot->state()); + antwort(QStringLiteral("rotor_ist:%1,%2,%3;") + .arg(rot->azimuth(), 0, 'f', 1) + .arg(zustand) + .arg(rot->hasFreshPosition() ? 1 : 0)); + return; + } + + // Ab hier wird gedreht -- und dafuer gilt die Freigabe. + if (!session->fromLoopback && !remoteRotorAllowed()) { + qCWarning(lcTci) + << "TciServer: Rotorbefehl von" << session->peer + << "abgelehnt — Drehen aus dem Netz ist abgeschaltet" + << "(TciAllowRemoteRotor)"; + antwort(QStringLiteral("rotor_err:nicht freigegeben;")); + return; + } + + if (istHalt) { + rot->stop(); + qCInfo(lcTci) << "TciServer: Rotor angehalten von" + << session->peer; + antwort(QStringLiteral("rotor_ok:stop;")); + return; + } + + double grad = 0.0; + if (!RotorPeilung::lies(t.mid(9), &grad)) { + // Keine brauchbare Zahl. NICHT auf 0 zurueckfallen -- + // 0 Grad ist Nord, und die Antenne wuerde sich auf eine + // leere Zeile hin drehen. + antwort(QStringLiteral("rotor_err:peilung unbrauchbar;")); + return; + } + rot->moveTo(grad); + qCInfo(lcTci) << "TciServer: Rotor auf" << grad << "Grad von" + << session->peer; + antwort(QStringLiteral("rotor_ok:%1;").arg(grad, 0, 'f', 1)); + 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 a8a44e130..4bdddd0f7 100644 --- a/src/core/TciServer.h +++ b/src/core/TciServer.h @@ -176,6 +176,8 @@ class TciServer : public QObject { // wirkt an beiden Stellen, an denen gesendet werden kann: dem trx-Weg und // der Annahme von TX-Ton. Nur eine zu sperren liesse den Sendeweg offen. static bool remoteLogAllowed(); + /// Darf aus dem Netz der ROTOR gedreht werden? Ab Werk NEIN. + static bool remoteRotorAllowed(); static bool remoteTxAllowed(); // Test-only: bypass the RxChannel signal chain and inject audio directly diff --git a/src/gui/widgets/RotorLogbookPanel.cpp b/src/gui/widgets/RotorLogbookPanel.cpp index 44130644e..b65e7ae90 100644 --- a/src/gui/widgets/RotorLogbookPanel.cpp +++ b/src/gui/widgets/RotorLogbookPanel.cpp @@ -1133,6 +1133,10 @@ void RotorLogbookPanel::ensureRotor() { if (m_rotor) { return; } m_rotor = new RotctldClient(this); + // Beim Modell anmelden, damit der TCI-Server ihn erreicht, ohne in + // dieses Fenster zu greifen (RadioModel::rotor()). Angelegt und + // bedient wird er weiter hier; das Modell haelt nur einen QPointer. + if (m_radio) { m_radio->setRotor(m_rotor); } AppSettings& s = AppSettings::instance(); m_rotor->setTarget( diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index fbaa5ba99..936165cd0 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -108,6 +108,11 @@ #include "core/safety/BandPlanGuard.h" #include +#include + +// Vollstaendig noetig: QPointer kann T nicht vorwaerts deklariert +// benutzen (static_cast auf QObject*). Der Kopf ist winzig. +#include "core/RotorController.h" #include #include #include @@ -1066,6 +1071,26 @@ class RadioModel : public QObject { // Constructed in RadioModel ctor with identity / endpoint defaults from // AppSettings; startConnection() is NOT called at construction time. SpotModel* spotModel() const { return m_spotModel.get(); } + + // ── Der Rotor, ohne Fenster dazwischen (2026-10-05) ────────────────── + // + // Hier steht eine REGISTRIERUNG, kein Besitz: angelegt wird der + // RotctldClient weiterhin von `RotorLogbookPanel`, das ihn auch + // bedient. Diese Stelle gibt es nur, damit der TCI-Server ihn + // erreichen kann, ohne in ein Fenster zu greifen -- derselbe Grund, + // aus dem `WorkedBefore` beim Logbuch nicht angefasst wurde: ein + // Netzdienst, der an einem Fenster haengt, stirbt mit dem Fenster. + // + // DER SAUBERERE WEG waere, den Besitz hierher zu holen (wie bei + // `m_spotModel`, das 2026-05-12 aus `SpotHubDialog` hierher kam). + // Das ist eine Architekturaenderung an einem Geraet, das an Martins + // Mast haengt, und CLAUDE.md verlangt dafuer den Betreiber. Bis + // dahin ist dies der kleine, umkehrbare Schritt. + // + // QPointer, nicht roher Zeiger: wird das Fenster geschlossen, steht + // hier nachher nullptr statt einer Leiche. + RotorController* rotor() const { return m_rotor; } + void setRotor(RotorController* r) { m_rotor = r; } // 2026-05-12 bench fix: moved from SpotHubDialog ownership so the // table stays populated from app start regardless of whether the // dialog is open. Spots from auto-connected sources were @@ -3859,6 +3884,8 @@ private slots: // startConnection() / startListening() / startPolling() is the M3 // follow-up task. H2 only wires the in-process signal graph. std::unique_ptr m_spotModel; + // Registriert von RotorLogbookPanel, nicht besessen -- siehe rotor(). + QPointer m_rotor; std::unique_ptr m_spotTableModel; std::unique_ptr m_freeDvStationModel; std::unique_ptr m_rxDecodeModel; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 837ca0f56..192eff5e7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1984,6 +1984,29 @@ longpath_add_test(tst_bus_mit_frist) # Schalter. longpath_add_test(tst_sendefehler_deutung) +# ── Eine Peilung vom Netz dreht echtes Metall (2026-10-05) ────────────────── +# tst_rotor_peilung: am anderen Ende der Leitung steht ein Mast mit einer +# Antenne darauf. Drei Faelle, gegen die der Stand steht: `toDouble()` macht +# aus "" und "abc" eine 0, und NULL GRAD IST NORD -- eine leere Zeile wuerde +# die Antenne nach Norden drehen. NaN besteht jeden Bereichsvergleich, weil +# jeder Vergleich mit NaN falsch ist. Und 360 ist dieselbe Richtung wie 0 +# und gehoert angenommen, 361 ist ein Tippfehler. Ausserhalb wird ABGELEHNT +# statt umgerechnet: wer -90 schickt, hat sich vertan, und 270 Grad sind +# eine andere Antwort als "ich habe mich vertan". +longpath_add_test(tst_rotor_peilung) + +# ── Den Rotor vom Telefon drehen -- und wann NICHT (2026-10-05) ───────────── +# tst_tci_rotor: am anderen Ende steht ein Mast, der minutenlang ohne Zutun +# dreht, und niemand steht daneben. Darum dieselbe Strenge wie beim Senden: +# ABFRAGEN darf jeder angemeldete Client (hinsehen bewegt nichts), DREHEN +# nur mit TciAllowRemoteRotor=True -- ab Werk False, und wer die Vorgabe +# versehentlich umdreht, faellt hier auf. Dazu: Ablehnung MIT Grund (ein +# stummes Nein ist von einem Defekt nicht zu unterscheiden, und beim Rotor +# sieht man ohnehin zehn Sekunden nichts), "kein Rotor" ist eine andere +# Auskunft als "0 Grad", und eine unbrauchbare Peilung dreht NICHT nach +# Norden. +longpath_add_test(tst_tci_rotor) + # ── 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 diff --git a/tests/tst_rotor_peilung.cpp b/tests/tst_rotor_peilung.cpp new file mode 100644 index 000000000..4cb7eceea --- /dev/null +++ b/tests/tst_rotor_peilung.cpp @@ -0,0 +1,94 @@ +// Was vom Netz als Peilung angenommen wird — und was nicht. +// +// Am anderen Ende der Leitung steht ein Mast mit einer Antenne darauf, der +// sich auf Zuruf dreht. Was hier durchrutscht, dreht echtes Metall. Die +// drei Faelle, gegen die dieser Stand steht: +// +// * `toDouble()` macht aus "" und "abc" eine 0. NULL GRAD IST NORD — +// eine leere Zeile wuerde die Antenne nach Norden drehen, und niemand +// haette einen Befehl dazu gegeben. +// * NaN besteht jeden Bereichsvergleich: `nan < 0` ist falsch, `nan > 360` +// ist auch falsch. Ein naiver Bereichstest laesst NaN durch. +// * 360 ist dieselbe Richtung wie 0 und gehoert angenommen; 361 ist ein +// Tippfehler und darf es nicht. + +#include "core/RotorPeilung.h" + +#include + +using Longpath::RotorPeilung::lies; + +class TstRotorPeilung : public QObject +{ + Q_OBJECT + +private slots: + void gueltigeWerteKommenDurch(); + void leeresUndUnfugWerdenNichtZuNord(); + void nanUndUnendlichRutschenNichtDurch(); + void dreihundertsechzigIstNord(); + void ausserhalbWirdAbgelehntUndNichtUmgerechnet(); + void beiAblehnungBleibtDerAlteWertStehen(); +}; + +void TstRotorPeilung::gueltigeWerteKommenDurch() +{ + double g = -1; + QVERIFY(lies(QStringLiteral("0"), &g)); QCOMPARE(g, 0.0); + QVERIFY(lies(QStringLiteral("143"), &g)); QCOMPARE(g, 143.0); + QVERIFY(lies(QStringLiteral("359.9"), &g)); QCOMPARE(g, 359.9); + QVERIFY(lies(QStringLiteral(" 16 "), &g)); QCOMPARE(g, 16.0); +} + +void TstRotorPeilung::leeresUndUnfugWerdenNichtZuNord() +{ + // DER Fall: `toDouble()` liefert hier ueberall 0, und 0 ist Nord. + double g = 42; + for (const char* s : {"", " ", "abc", "N", "--", "1,5"}) { + QVERIFY2(!lies(QString::fromLatin1(s), &g), s); + } + QCOMPARE(g, 42.0); // unberuehrt +} + +void TstRotorPeilung::nanUndUnendlichRutschenNichtDurch() +{ + double g = 42; + for (const char* s : {"nan", "NaN", "inf", "-inf", "Infinity"}) { + QVERIFY2(!lies(QString::fromLatin1(s), &g), s); + } + QCOMPARE(g, 42.0); +} + +void TstRotorPeilung::dreihundertsechzigIstNord() +{ + // 360 gilt, wird aber auf 0 gelegt: der Rotor meint dieselbe Richtung, + // und manche Steuerungen nehmen 360 nicht an. + double g = -1; + QVERIFY(lies(QStringLiteral("360"), &g)); + QCOMPARE(g, 0.0); +} + +void TstRotorPeilung::ausserhalbWirdAbgelehntUndNichtUmgerechnet() +{ + // Umrechnen waere bequem und falsch: wer -90 schickt, hat sich vertan, + // und 270 Grad sind eine andere Antwort als "ich habe mich vertan". + double g = 42; + for (const char* s : {"-1", "-90", "361", "720", "1000"}) { + QVERIFY2(!lies(QString::fromLatin1(s), &g), s); + } + QCOMPARE(g, 42.0); +} + +void TstRotorPeilung::beiAblehnungBleibtDerAlteWertStehen() +{ + // Ein Aufrufer, der die Rueckgabe prueft, darf sich darauf verlassen, + // dass sein Wert unberuehrt bleibt -- sonst steht nach einer + // abgelehnten Zeile eine halb geschriebene Zahl im Ziel. + double g = 143.0; + QVERIFY(!lies(QStringLiteral("Unfug"), &g)); + QCOMPARE(g, 143.0); + QVERIFY(lies(QString(), nullptr) == false); // nullptr vertraegt sich +} + +QTEST_MAIN(TstRotorPeilung) +#include "tst_rotor_peilung.moc" diff --git a/tests/tst_tci_rotor.cpp b/tests/tst_tci_rotor.cpp new file mode 100644 index 000000000..7ed9d888a --- /dev/null +++ b/tests/tst_tci_rotor.cpp @@ -0,0 +1,273 @@ +// Prüfstand: den Rotor vom Telefon drehen — und vor allem, wann NICHT. +// +// Am anderen Ende der Leitung steht ein Mast mit einer Antenne darauf. Er +// dreht minutenlang ohne weiteres Zutun, und niemand steht daneben. Darum +// gilt hier dieselbe Strenge wie beim Senden und nicht die beim Loggen: +// +// rotor:; -> rotor_ist:,,; +// rotor_to:; -> rotor_ok:; oder rotor_err:; +// rotor_stop:; -> rotor_ok:stop; oder rotor_err:; +// +// Die Zusagen, die hier festgehalten sind: +// +// * ABFRAGEN darf jeder angemeldete Client. Hinsehen bewegt nichts. +// * DREHEN nur mit TciAllowRemoteRotor=True. Ab Werk steht das auf False, +// und genau das ist der Punkt: wer die Vorgabe versehentlich umdreht, +// fällt hier auf. +// * Eine Ablehnung kommt MIT Grund. Ein stummes Nein ist auf einer +// Fernbedienung nicht von einem Defekt zu unterscheiden — und bei einem +// Rotor sieht man zehn Sekunden lang ohnehin nichts. +// * "Es gibt hier keinen Rotor" ist eine andere Auskunft als "er steht auf +// 0 Grad". Nur eine davon heißt: such nicht weiter. +// * Eine unbrauchbare Peilung dreht NICHT nach Norden (siehe +// tst_rotor_peilung — `toDouble()` macht aus "" eine 0, und 0 ist Nord). + +#ifdef HAVE_WEBSOCKETS + +#include +#include +#include + +#include "core/AppSettings.h" +#include "core/RotorController.h" +#include "core/TciServer.h" +#include "models/RadioModel.h" +#include "TciBurstHelfer.h" + +using namespace Longpath; + +namespace { +constexpr char kToken[] = "PRUEFTOKEN1234"; + +/// Ein Rotor aus Pappe: merkt sich, was ihm gesagt wurde, und dreht nichts. +class RotorAttrappe : public RotorController { + Q_OBJECT +public: + using RotorController::RotorController; + QString description() const override { return QStringLiteral("Attrappe"); } + State state() const override { return m_state; } + double azimuth() const override { return m_az; } + bool hasFreshPosition() const override { return m_frisch; } + void connectToRotor() override {} + void disconnectFromRotor() override {} + void moveTo(double az) override { m_ziele.append(az); } + void stop() override { m_haltRufe++; } + + State m_state{State::Idle}; + double m_az{143.0}; + bool m_frisch{true}; + QVector m_ziele; + int m_haltRufe{0}; +}; +} // namespace + +class TestTciRotor : public QObject { + Q_OBJECT + +private: + QStringList m_antworten; + + bool aufbauen(TciServer& server, QWebSocket& client, bool drehenFrei, + bool anmelden = true) + { + AppSettings::instance().setValue(QStringLiteral("TciAllowRemoteRotor"), + drehenFrei ? QStringLiteral("True") : QStringLiteral("False")); + if (!TciServer::setRemoteToken(QString::fromLatin1(kToken))) { return false; } + if (!server.start(0)) { return false; } + server.setTreatAllClientsAsRemoteForTest(true); + + connect(&client, &QWebSocket::textMessageReceived, this, + [this](const QString& m) { + if (m.startsWith(QStringLiteral("rotor"))) { 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); + } + + 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; + } + + QString ersteMit(const QString& anfang) const + { + for (const QString& a : m_antworten) { + if (a.startsWith(anfang)) { return a; } + } + return {}; + } + +private slots: + void init() { m_antworten.clear(); } + void cleanup() { TciServer::setRemoteToken(QString()); } + + /// Hinsehen bewegt nichts — und darf darum immer. + void abfragenGehtAuchOhneFreigabe() + { + RadioModel model; + RotorAttrappe rot; + rot.m_az = 143.5; rot.m_frisch = true; + model.setRotor(&rot); + + TciServer server(&model); + QWebSocket client; + QVERIFY2(aufbauen(server, client, /*drehenFrei=*/false), "Aufbau gescheitert"); + + client.sendTextMessage(QStringLiteral("rotor:;")); + QVERIFY2(warteAuf(QStringLiteral("rotor_ist:")), "Keine Antwort auf rotor:"); + const QStringList f = ersteMit(QStringLiteral("rotor_ist:")) + .mid(QStringLiteral("rotor_ist:").size()) + .chopped(1).split(QLatin1Char(',')); + QCOMPARE(f.size(), 3); + QCOMPARE(f.at(0), QStringLiteral("143.5")); + QCOMPARE(f.at(2), QStringLiteral("1")); // frisch + QVERIFY(rot.m_ziele.isEmpty()); // nichts gedreht + } + + /// Eine alte Stellung wird als alt gemeldet. Eine Nadel, die eine + /// veraltete Richtung zeigt, ohne das zu sagen, ist schlimmer als eine, + /// die nichts zeigt (so steht es im Kopf von RotorController.h). + void alteStellungWirdAlsAltGemeldet() + { + RadioModel model; + RotorAttrappe rot; + rot.m_frisch = false; + model.setRotor(&rot); + + TciServer server(&model); + QWebSocket client; + QVERIFY(aufbauen(server, client, false)); + client.sendTextMessage(QStringLiteral("rotor:;")); + QVERIFY(warteAuf(QStringLiteral("rotor_ist:"))); + QVERIFY2(ersteMit(QStringLiteral("rotor_ist:")).endsWith(QStringLiteral(",0;")), + qPrintable(ersteMit(QStringLiteral("rotor_ist:")))); + } + + /// DER Punkt: ab Werk wird aus dem Netz NICHT gedreht. + void drehenIstAbWerkGesperrtUndSagtEs() + { + RadioModel model; + RotorAttrappe rot; + model.setRotor(&rot); + + TciServer server(&model); + QWebSocket client; + QVERIFY(aufbauen(server, client, /*drehenFrei=*/false)); + + client.sendTextMessage(QStringLiteral("rotor_to:270;")); + QVERIFY2(warteAuf(QStringLiteral("rotor_err:")), + "Ein abgelehnter Rotorbefehl blieb stumm"); + QVERIFY2(rot.m_ziele.isEmpty(), "Trotz Ablehnung wurde gedreht"); + + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("rotor_stop:;")); + QVERIFY(warteAuf(QStringLiteral("rotor_err:"))); + QCOMPARE(rot.m_haltRufe, 0); + } + + /// Freigegeben dreht er — und zwar dorthin, wo er hinsoll. + void freigegebenDrehtErWirklich() + { + RadioModel model; + RotorAttrappe rot; + model.setRotor(&rot); + + TciServer server(&model); + QWebSocket client; + QVERIFY(aufbauen(server, client, /*drehenFrei=*/true)); + + client.sendTextMessage(QStringLiteral("rotor_to:270;")); + QVERIFY(warteAuf(QStringLiteral("rotor_ok:"))); + QCOMPARE(rot.m_ziele.size(), 1); + QCOMPARE(rot.m_ziele.first(), 270.0); + + // 360 ist Nord, nicht ein Fehler. + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("rotor_to:360;")); + QVERIFY(warteAuf(QStringLiteral("rotor_ok:"))); + QCOMPARE(rot.m_ziele.size(), 2); + QCOMPARE(rot.m_ziele.last(), 0.0); + + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("rotor_stop:;")); + QVERIFY(warteAuf(QStringLiteral("rotor_ok:"))); + QCOMPARE(rot.m_haltRufe, 1); + } + + /// Eine unbrauchbare Peilung dreht NICHT nach Norden. + void unbrauchbarePeilungDrehtNichtNachNorden() + { + RadioModel model; + RotorAttrappe rot; + model.setRotor(&rot); + + TciServer server(&model); + QWebSocket client; + QVERIFY(aufbauen(server, client, /*drehenFrei=*/true)); + + for (const QString& unfug : {QStringLiteral(""), QStringLiteral("abc"), + QStringLiteral("nan"), QStringLiteral("-5"), + QStringLiteral("361")}) { + m_antworten.clear(); + client.sendTextMessage(QStringLiteral("rotor_to:%1;").arg(unfug)); + QVERIFY2(warteAuf(QStringLiteral("rotor_err:")), qPrintable(unfug)); + } + QVERIFY2(rot.m_ziele.isEmpty(), + "Eine unbrauchbare Peilung hat den Rotor bewegt"); + } + + /// Ohne Anmeldung passiert NICHTS — auch keine Antwort. + void ohneAnmeldungKeinRotor() + { + RadioModel model; + RotorAttrappe rot; + model.setRotor(&rot); + + TciServer server(&model); + QWebSocket client; + QVERIFY(aufbauen(server, client, true, /*anmelden=*/false)); + + client.sendTextMessage(QStringLiteral("rotor:;")); + client.sendTextMessage(QStringLiteral("rotor_to:90;")); + QTest::qWait(600); + QVERIFY2(m_antworten.isEmpty(), qPrintable(m_antworten.join(QLatin1Char(' ')))); + QVERIFY(rot.m_ziele.isEmpty()); + } + + /// "Kein Rotor da" ist eine andere Auskunft als "er steht auf 0 Grad". + void ohneRotorKommtEinFehlerKeineNull() + { + RadioModel model; // kein Rotor angemeldet + TciServer server(&model); + QWebSocket client; + QVERIFY(aufbauen(server, client, true)); + + client.sendTextMessage(QStringLiteral("rotor:;")); + QVERIFY(warteAuf(QStringLiteral("rotor_err:"))); + QVERIFY(ersteMit(QStringLiteral("rotor_ist:")).isEmpty()); + } +}; + +QTEST_MAIN(TestTciRotor) +#include "tst_tci_rotor.moc" + +#else // HAVE_WEBSOCKETS + +#include +class TestTciRotor : public QObject { Q_OBJECT }; +QTEST_MAIN(TestTciRotor) +#include "tst_tci_rotor.moc" + +#endif