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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions handfunke/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions handfunke/attrappe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions handfunke/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,37 @@
<div class="chips" id="dsp"></div>
</div>

<!-- ── Der Rotor (2026-10-05) ────────────────────────────────────────────
BLEIBT VERSTECKT, bis Longpath einen Rotor meldet. An der QRP gibt es
keinen; ein leeres Feld, das nichts kann, ist auf einem Telefon reiner
Platzverbrauch.

Die Scheibe WAEHLT nur. Gedreht wird erst mit dem Knopf darunter —
dieselbe Entscheidung wie bei der Sendetaste: ein Tipp, der sofort
einen Mast dreht, ist in einer Hosentasche eine schlechte Idee. -->
<div class="feld schicht rotor" id="rotorFeld" hidden>
<div class="caps">Rotor</div>
<div class="rotorreihe">
<svg id="rotorScheibe" class="scheibe" viewBox="-110 -110 220 220"
aria-label="Rotorscheibe, antippen waehlt eine Richtung">
<circle class="ring" cx="0" cy="0" r="92"/>
<g class="striche" id="rotorStriche"></g>
<text class="himmel" x="0" y="-70">N</text>
<text class="himmel" x="74" y="7">O</text>
<text class="himmel" x="0" y="82">S</text>
<text class="himmel" x="-74" y="7">W</text>
<line class="nadel" id="rotorNadel" x1="0" y1="0" x2="0" y2="-82"/>
<line class="ziel" id="rotorZiel" x1="0" y1="0" x2="0" y2="-92" hidden/>
<circle class="nabe" cx="0" cy="0" r="5"/>
</svg>
<div class="rotorwerte">
<div class="gradAn" id="rotorGrad">—</div>
<div class="rotorMeldung" id="rotorMeldung"></div>
<button id="rotorDreh" class="rotorDreh" disabled>RICHTUNG WÄHLEN</button>
</div>
</div>
</div>

<div class="txrow">
<div class="tx locked" id="tx">SENDEN <small>NUR IN DER APP</small></div>
<div class="tune locked" id="tune">TUNE <small>NUR IN DER APP</small></div>
Expand Down
98 changes: 98 additions & 0 deletions handfunke/pruefe-rotor.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading