diff --git a/handfunke/app.js b/handfunke/app.js index a73ab865b..df9e22fc4 100644 --- a/handfunke/app.js +++ b/handfunke/app.js @@ -12,6 +12,7 @@ import { Sammelstelle, zeileLesen, befundLesen, zeitKurz, dupeSatz } from './logbuch.js'; import { spotLesen, marken, trefferBei } from './spots.js'; import { Zeichenbremse } from './ton-vorrang.js'; +import { bildIstAlt } from './aufwachen.js'; const $ = (id) => document.getElementById(id); const link = new TciLink(); @@ -2016,6 +2017,66 @@ link.addEventListener('spectrum', (e) => { }); link.addEventListener('ready', () => zeichneBedienung()); link.addEventListener('ready', () => spotsBaldHolen(300)); + +// ── Zurueck aus dem Hintergrund (2026-10-05) ────────────────────────────── +// +// Bis hierher behandelte die Seite das Entsperren GAR NICHT. Was dabei +// wirklich passiert: +// +// * Der WebSocket ist tot, aber `onclose` kommt erst, wenn das System die +// Seite wieder auftaut -- und der Wiederholungsabstand ist inzwischen +// auf 10 s gewachsen. Man sieht sekundenlang nichts, obwohl das +// Funkgeraet bereit ist. +// * Der AudioContext ist von iOS angehalten; niemand weckt ihn. +// * Wasserfall und S-Meter zeigen den Stand von VOR dem Sperren, ohne das +// zu sagen. Das ist dieselbe Gattung Luege wie ein stehender +// Wasserfall: er behauptet ein leeres Band, und danach wird nicht +// gerufen. +// +// Die Abonnements selbst muessen hier nicht erneuert werden -- sie haengen +// am `open`-Ereignis und kommen mit der neuen Verbindung von selbst. +let warVerborgen = false; + +function aufwachen() { + const stille = performance.now() - letzteIq; + + // 1. Das Bild als alt kennzeichnen, BEVOR etwas anderes passiert. Der + // Strich ist derselbe, den ein Bandwechsel zieht: oben das Neue, unten + // das Alte. Ohne ihn sieht ein zehn Minuten alter Wasserfall aus wie + // ein lebendiger. + if (bildIstAlt(stille)) { + wasserfallSchnitt(); + state.hatSpektrumstrom = false; + } + + // 2. Sofort neu verbinden, wenn noetig (Entscheidung in aufwachen.js). + link.nachDemAufwachen(stille); + + // 3. Den Ton wecken. iOS haelt den AudioContext beim Sperren an, und ein + // angehaltener Kontext ruft seinen Rueckruf nie wieder auf -- der Ton + // bliebe stumm, bis jemand TON AUS und wieder EIN drueckt. + if (state.audio && state.audio.state === 'suspended') { + state.audio.resume().catch(() => { /* ohne Zutun des Bedieners nicht erlaubt */ }); + } + + // 4. Die Spots gehoeren ebenfalls aufgefrischt: waehrend des Schlafens + // sind welche abgelaufen und neue gekommen. + spotsBaldHolen(400); +} + +document.addEventListener('visibilitychange', () => { + if (document.hidden) { warVerborgen = true; return; } + if (!warVerborgen) { return; } + warVerborgen = false; + aufwachen(); +}); + +// Nicht jedes System meldet `visibilitychange` zuverlaessig, wenn die Seite +// aus dem Ruhezustand kommt -- `pageshow` mit `persisted` ist der zweite +// Weg, und `focus` der dritte. Mehrfaches Aufwachen schadet nicht: die +// Entscheidung in aufwachen.js wirft eine lebendige Verbindung nicht weg. +window.addEventListener('pageshow', (e) => { if (e.persisted) { aufwachen(); } }); +window.addEventListener('focus', () => { if (warVerborgen) { warVerborgen = false; aufwachen(); } }); link.addEventListener('state', () => { // Der Server hat das letzte Wort über die Rate. Weicht sie von unserer // Bitte ab, muss das Worklet es erfahren — sonst stimmt die Tonhöhe nicht. diff --git a/handfunke/aufwachen.js b/handfunke/aufwachen.js new file mode 100644 index 000000000..3c882a856 --- /dev/null +++ b/handfunke/aufwachen.js @@ -0,0 +1,59 @@ +// Zurück aus dem Hintergrund — die Entscheidung, rein. +// +// Was auf einem Telefon beim Entsperren wirklich passiert, und was die +// Seite bis heute davon unbemerkt lässt: +// +// * Der WebSocket ist tot, aber `onclose` kommt erst, wenn das System +// die Seite wieder auftaut. Bis dahin steht der Wiederholungsabstand +// womöglich schon bei 10 s — der Bediener sieht also sekundenlang +// nichts, obwohl das Funkgerät bereit ist. +// +// * Schlimmer: der Socket kann als OFFEN gemeldet werden und trotzdem +// tot sein. iOS friert die Seite ein, die Gegenseite räumt auf, und +// `readyState` hinkt hinterher. Wer nur auf den Zustand sieht, +// wartet auf ein `onclose`, das nie kommt. +// +// Darum entscheidet hier nicht der Zustand allein, sondern der Zustand +// UND die Stille: kommt seit Sekunden nichts mehr an, wird neu verbunden, +// ganz gleich was der Socket behauptet. Eine überflüssige Neuverbindung +// kostet eine halbe Sekunde; eine ausgelassene kostet den Moment, in dem +// man das Telefon in die Hand genommen hat. + +/** Ab dieser Stille gilt eine Verbindung als tot, auch wenn sie OFFEN sagt. */ +export const kTotStilleMs = 3000; + +/** WebSocket-Zustände, hier benannt statt als Zahl verstreut. */ +export const VERBINDET = 0, OFFEN = 1, SCHLIESST = 2, ZU = 3; + +/** + * Soll nach dem Aufwachen sofort neu verbunden werden? + * + * @param {{gewollt:boolean, zustand:(number|null), stilleMs:number}} lage + * `zustand` ist `WebSocket.readyState` oder null, wenn gar keiner da ist. + * `stilleMs` ist, wie lange schon nichts mehr ankam. + */ +export function sollNeuVerbinden(lage) { + const l = lage || {}; + // Wer gar nicht verbunden sein will, will es auch nach dem Aufwachen nicht. + if (!l.gewollt) { return false; } + const z = (l.zustand === null || l.zustand === undefined) ? ZU : l.zustand; + if (z === ZU || z === SCHLIESST) { return true; } + // VERBINDET: einen laufenden Versuch nicht abwürgen, SOLANGE er jung ist. + // Hängt er schon länger als die Stillegrenze, ist er mit dem Telefon + // eingeschlafen und wird von selbst nicht mehr wach. + if (z === VERBINDET) { return (l.stilleMs || 0) > kTotStilleMs; } + // OFFEN: nur bei Stille. Sonst würde jeder Blick auf den Bildschirm die + // laufende Verbindung wegwerfen. + return (l.stilleMs || 0) > kTotStilleMs; +} + +/** + * Ist das Bild, das gerade dasteht, noch von vor dem Schlafen? + * + * Darauf kommt es an: ein Wasserfall, der den Stand von vor zehn Minuten + * zeigt, ist von einem lebendigen nicht zu unterscheiden. Er behauptet ein + * leeres Band, und danach wird nicht gerufen. + */ +export function bildIstAlt(stilleMs, grenzeMs = 1500) { + return (stilleMs || 0) > grenzeMs; +} diff --git a/handfunke/pruefe-aufwachen.mjs b/handfunke/pruefe-aufwachen.mjs new file mode 100644 index 000000000..fee0ccf4c --- /dev/null +++ b/handfunke/pruefe-aufwachen.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Pruefstand fuer das Zurueckkommen aus dem Hintergrund. +// +// Der Fehler, gegen den das meiste hier steht, ist der LEISE: ein Socket, +// der OFFEN meldet und trotzdem tot ist. Wer nur auf `readyState` sieht, +// wartet auf ein `onclose`, das nie kommt -- und der Bediener steht vor +// einem Bild von vorhin, das wie ein leeres Band aussieht. +// +// node pruefe-aufwachen.mjs + +import { sollNeuVerbinden, bildIstAlt, kTotStilleMs, + VERBINDET, OFFEN, SCHLIESST, ZU } from './aufwachen.js'; + +let fehler = 0; +const pruefe = (name, ok, text = '') => { + if (ok) { console.log(` ok ${name}`); } + else { console.log(` FEHLT ${name}${text ? ': ' + text : ''}`); fehler++; } +}; + +console.log('Neu verbinden?\n'); +{ + const l = (zustand, stilleMs, gewollt = true) => ({ gewollt, zustand, stilleMs }); + pruefe('geschlossen -> ja', sollNeuVerbinden(l(ZU, 0)) === true); + pruefe('schliesst gerade -> ja', sollNeuVerbinden(l(SCHLIESST, 0)) === true); + pruefe('gar kein Socket -> ja', sollNeuVerbinden(l(null, 0)) === true); + + // DER Fall: offen, aber seit Sekunden still. + pruefe('offen und still -> ja (der tote Socket)', + sollNeuVerbinden(l(OFFEN, kTotStilleMs + 1)) === true); + pruefe('offen und lebendig -> nein', + sollNeuVerbinden(l(OFFEN, 200)) === false); + pruefe('offen, Stille genau an der Grenze -> nein', + sollNeuVerbinden(l(OFFEN, kTotStilleMs)) === false); + + // Ein laufender Versuch darf nicht bei jedem Blick abgewuergt werden. + pruefe('verbindet gerade, frisch -> nein', + sollNeuVerbinden(l(VERBINDET, 500)) === false); + pruefe('verbindet gerade, aber eingeschlafen -> ja', + sollNeuVerbinden(l(VERBINDET, kTotStilleMs + 1)) === true); + + // Wer getrennt sein WILL, bleibt getrennt. + pruefe('nicht gewollt -> nie', sollNeuVerbinden(l(ZU, 99999, false)) === false); + pruefe('nicht gewollt, auch nicht bei Stille', + sollNeuVerbinden(l(OFFEN, 99999, false)) === false); + pruefe('ohne Angaben wird nichts weggeworfen', sollNeuVerbinden(null) === false); +} + +console.log('\nIst das Bild alt?'); +{ + pruefe('frisch -> nein', bildIstAlt(100) === false); + pruefe('zwei Sekunden Stille -> ja', bildIstAlt(2000) === true); + pruefe('an der Grenze -> nein', bildIstAlt(1500) === false); + pruefe('nichts gemessen -> nein', bildIstAlt(undefined) === false); + // Die Grenze liegt UNTER der fuer die Neuverbindung: lieber einmal zu + // frueh sagen "das Bild ist von vorhin" als einmal zu spaet. Ein falsch + // beschriftetes altes Bild ist harmlos, ein unbeschriftetes nicht. + pruefe('Bild gilt frueher als alt als der Socket als tot', + bildIstAlt(2000) === true && sollNeuVerbinden( + { gewollt: true, zustand: OFFEN, stilleMs: 2000 }) === false); +} + +console.log(fehler === 0 ? '\nAlles gut.' : `\n${fehler} Punkt(e) offen.`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/handfunke/tci.js b/handfunke/tci.js index 0b1fae3cd..6fcc66147 100644 --- a/handfunke/tci.js +++ b/handfunke/tci.js @@ -16,6 +16,8 @@ export const HDR = 64; +import { sollNeuVerbinden } from './aufwachen.js'; + export class TciLink extends EventTarget { constructor() { super(); @@ -146,6 +148,30 @@ export class TciLink extends EventTarget { }; } + /** + * Zurueck aus dem Hintergrund: sofort wieder versuchen, ohne den + * gewachsenen Abstand abzuwarten. + * + * Entschieden wird in aufwachen.js -- und zwar NICHT am `readyState` + * allein: iOS friert die Seite ein, die Gegenseite raeumt auf, und der + * Socket meldet noch OFFEN. Wer darauf wartet, dass `onclose` kommt, + * wartet unter Umstaenden ewig. + * + * @param {number} stilleMs wie lange schon nichts mehr ankam + * @returns {boolean} true, wenn neu verbunden wurde + */ + nachDemAufwachen(stilleMs) { + const zustand = this.ws ? this.ws.readyState : null; + if (!sollNeuVerbinden({ gewollt: this.wanted, zustand, stilleMs })) { + return false; + } + // Den Abstand zuruecksetzen: er ist beim Schlafen gewachsen, und das + // Telefon ist gerade JETZT in der Hand. + this.retryMs = 500; + this._open(); + return true; + } + _retry() { if (!this.wanted) return; clearTimeout(this.retryTimer);