diff --git a/handfunke/app.js b/handfunke/app.js index 8d05e39ca..b721fb014 100644 --- a/handfunke/app.js +++ b/handfunke/app.js @@ -13,6 +13,8 @@ import { Sammelstelle, zeileLesen, befundLesen, zeitKurz, dupeSatz } import { spotLesen, marken, trefferBei, trefferAufSchrift } 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(); @@ -2068,6 +2070,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 0b43e1f1c..57b8d8a32 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1984,6 +1984,28 @@ 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) # ── Uhrendrift zwischen Funkgeraet und Tonkarte (#131, 2026-10-05) ────────── # tst_audio_rate_matcher: das Regelgesetz aus Thetis' rmatch, auf die # Erzeugerseite geholt (CLAUDE.md verbietet eine Sperre im Tonrueckruf; 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