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