From 199e71327f3a004b05862a6f184fc29ba2cdccb9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Przemys=C5=82aw=20Kie=C5=82kowski?=
Date: Fri, 31 Jul 2026 08:04:30 +0200
Subject: [PATCH] =?UTF-8?q?Przej=C5=9Bcie=20na=20kana=C5=82=20zbiorczy=20L?=
=?UTF-8?q?uPort=20+=20naprawa=20reconnectu,=20watchdog,=20widoczny=20offl?=
=?UTF-8?q?ine?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PRZYCZYNA ZNIKAJĄCYCH ALARMÓW ZNALEZIONA — i nie była nią liczba połączeń.
Commit 7cf0634 ("hidden status-bar & machine.status") zakomentował element
#status-bar w HTML, ale refreshStatus() nadal po niego sięgał. TypeError na
null przerywał handler ws.onclose PRZED zaplanowaniem reconnectu — każda
maszyna po pierwszym zerwaniu połączenia milkła NA ZAWSZE. Restart z crona
co 2 h maskował problem, zaczynając od zera.
Czerwcowe poprawki na kiosku (timery świeżości per-maszyna, ubijanie "zombie"
połączeń, jitter) szły w dobrą stronę, ale nieświadomie POGORSZYŁY objaw:
watchdog celowo zamykał ciche połączenie, a każde zamknięcie przechodziło
przez ten sam wadliwy onclose — własna obrona uśmiercała maszynę na stałe.
Ich sedno jest zachowane w tej wersji (wykrywanie ciszy, jitter, nakładkowy
iframe z "iframe optimalization").
NOWA ARCHITEKTURA (pod nowy system LuPort):
- JEDNO połączenie na kanał zbiorczy /ws/als-data/ zamiast 35 per-maszyna;
serwer wysyła migawkę stanu wszystkich maszyn zaraz po połączeniu (dodane
w LuPort równolegle), więc trwający alarm wraca na ekran natychmiast po
reconnexie
- token ApiUser w adresie (nowy system wymaga uwierzytelnienia WS); konto
bot-als-alert-screen bez żadnych uprawnień
- reconnect z wykładniczym odstępem (1->30 s) i jitterem; odstęp wraca do
minimum dopiero po 60 s stabilnego połączenia
- watchdog ciszy > 90 s (przeglądarka nie wystawia ping/pong do JS — martwe
półotwarte połączenie inaczej wisi w nieskończoność)
- dane maszyny starsze niż 5 min znikają z ekranu; przy rozłączeniu dane NIE
są czyszczone od razu (alarm nie znika przy 3-sekundowym czknięciu sieci —
o niepewności mówi pomarańczowy pasek, migawka po reconnexie przywraca
prawdę)
- utrata łączności WIDOCZNA: pomarańczowy pasek po 10 s + przywrócony pasek
statusu; element MUSI istnieć w DOM, refreshStatus() ma mimo to osłonę
- MACHINES stało się filtrem wyświetlania; ?ws= i ?token= nadpisują
konfigurację z adresu strony
- karty przez textContent; celowo BRAK ws.onerror (po błędzie przeglądarka
i tak wywołuje onclose)
TESTY (test/testy_ekranu.py): 10 deterministycznych scenariuszy bez sieci —
skrypt wycinany z żywego HTML, WebSocket podmieniony fałszywką, wirtualny
czas Chromium. Scenariusz 6 to bezpośredni test regresji błędu reconnectu;
mutacja (usunięcie planowania reconnectu) wywraca scenariusze 6, 8 i 9.
CRON RESTARTUJĄCY CO 2 H MOŻNA USUNĄĆ — README opisuje czemu.
Co-Authored-By: Claude Fable 5
---
.gitignore | 1 +
README.md | 203 ++++++++++----------
als_alert_screen.html | 421 +++++++++++++++++++++++-------------------
test/testy_ekranu.py | 201 ++++++++++++++++++++
4 files changed, 542 insertions(+), 284 deletions(-)
create mode 100644 .gitignore
create mode 100644 test/testy_ekranu.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e43b0f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.DS_Store
diff --git a/README.md b/README.md
index ee38533..85bfd50 100644
--- a/README.md
+++ b/README.md
@@ -1,145 +1,160 @@
# ALS Alert Screen
-Jednostronicowa aplikacja HTML/JS wyświetlająca alarmy maszyn na podstawie danych z WebSocket. Przeznaczona do montażu na monitorze halowym.
+Jednostronicowa aplikacja HTML/JS wyświetlająca alarmy maszyn na podstawie
+danych WebSocket z LuPort. Przeznaczona do montażu na monitorze halowym
+(Chromium na Debianie).
---
## Działanie
-Strona nasłuchuje danych z WebSocket dla każdej skonfigurowanej maszyny. W zależności od statusów maszyn wyświetla jeden z dwóch widoków:
+Strona utrzymuje **jedno** połączenie WebSocket ze zbiorczym kanałem ALS
+nowego systemu (`/ws/als-data/` bez maszyny w adresie) — kanał niesie
+aktualizacje wszystkich maszyn, a zaraz po połączeniu serwer wysyła migawkę
+ich bieżącego stanu, więc trwający alarm pojawia się na ekranie natychmiast,
+także po każdym wznowieniu połączenia.
| Sytuacja | Widok |
|----------|-------|
-| Brak maszyny w alarmie | Iframe z `http://diluals31/terminal/ift/5/PL/index` |
+| Brak maszyny w alarmie | Iframe z infoterminalem ALS |
| Co najmniej jedna maszyna w alarmie | Ekran alarmowy z kartami maszyn |
+| Brak łączności z LuPort > 10 s | Pomarańczowy pasek u góry (na obu widokach) |
-Alarm jest wykrywany gdy pole `status` w danych maszyny zawiera słowo **`alarm`** (bez rozróżnienia wielkości liter), np. `"Alarm - awaria"`, `"ALARM"`.
+Alarm jest wykrywany, gdy pole `status` maszyny zawiera słowo **`alarm`**
+(bez rozróżnienia wielkości liter), np. `"Alarm - awaria"`, `"ALARM"`.
----
-
-## Ekran alarmowy
-
-- Karty maszyn wypełniają cały dostępny ekran w układzie **siatki N×M** (możliwie kwadratowej).
-- Numer maszyny jest wyświetlany bardzo dużą czcionką, widoczną z daleka — rozmiar skaluje się automatycznie zarówno do wymiarów ekranu (`vw`/`vh`), jak i szerokości karty (`cqw`), więc tekst nigdy nie jest ucięty.
-- Pod numerem wyświetlany jest pełny tekst statusu.
-- Pasek u góry pokazuje liczbę maszyn w alarmie i miga.
-
-### Układ siatki w zależności od liczby alarmów
-
-| Alarmy | Kolumny | Wiersze |
-|--------|---------|---------|
-| 1 | 1 | 1 |
-| 2 | 2 | 1 |
-| 3 | 2 | 2 |
-| 4 | 2 | 2 |
-| 5 | 3 | 2 |
-| 6 | 3 | 2 |
-| 9 | 3 | 3 |
+Pomarańczowy pasek to celowo INNY kolor niż czerwień alarmów: mówi „ekran może
+pokazywać nieświeży obraz", a nie „maszyna stoi".
---
## Konfiguracja
-Wszystkie parametry konfiguracyjne znajdują się na początku sekcji `
diff --git a/test/testy_ekranu.py b/test/testy_ekranu.py
new file mode 100644
index 0000000..15e0957
--- /dev/null
+++ b/test/testy_ekranu.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+"""Testy ekranu alarmowego — deterministyczne, bez sieci i bez serwera.
+
+Skrypt aplikacji jest wycinany z ŻYWEGO als_alert_screen.html (nie z kopii,
+więc testy nie mogą się rozjechać z kodem), a `window.WebSocket` podmieniany
+na fałszywkę sterowaną scenariuszem. Cały przebieg — łącznie z 90-sekundowym
+watchdogiem i 5-minutowym wygasaniem danych — jedzie na wirtualnym czasie
+Chromium (`--virtual-time-budget`), więc trwa sekundy.
+
+Uruchomienie:
+ python3 test/testy_ekranu.py
+ CHROME_BIN=/usr/bin/chromium python3 test/testy_ekranu.py
+
+Kod wyjścia 0 = wszystkie scenariusze PASS.
+
+Dlaczego to istnieje: poprzednia wersja strony miała błąd, przez który KAŻDA
+maszyna po pierwszym zerwaniu połączenia milkła na zawsze (wyjątek w onclose
+przerywał handler przed zaplanowaniem reconnectu — patrz README, Historia).
+Objaw „alarmy przestały wyskakiwać" był leczony restartem z crona co 2 h.
+Scenariusz 6 jest bezpośrednim testem regresji tego błędu.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+APP = Path(__file__).resolve().parent.parent / "als_alert_screen.html"
+
+SCENARIUSZ = """
+const wyniki = [];
+function assert(nazwa, warunek) {
+ wyniki.push((warunek ? "PASS " : "FAIL ") + nazwa);
+ document.getElementById("wynik").textContent = wyniki.join("\\n");
+}
+const alarmActive = () => document.getElementById("alarm-view").classList.contains("active");
+const cards = () => [...document.querySelectorAll(".card-machine-id")].map(e => e.textContent);
+const bannerActive = () => document.getElementById("offline-banner").classList.contains("active");
+const inst = () => FakeWebSocket.instances;
+
+setTimeout(() => {
+ assert("1. polaczenie nawiazane", inst().length === 1 && inst()[0].readyState === 1);
+ inst()[0]._message({type:"machine_data", machine:"2.60", data:{status:"Alarm - awaria"}});
+ inst()[0]._message({type:"machine_data", machine:"3.22", data:{status:"Automatyczny"}});
+}, 50);
+
+setTimeout(() => {
+ assert("2. migawka: widok alarmowy z 2.60", alarmActive() && cards().join()==="2.60");
+ inst()[0]._message({type:"machine_update", machine:"3.22", data:{status:"Alarm - przekroczony cykl"}});
+}, 100);
+
+setTimeout(() => {
+ assert("3. update na zywo: dwie karty", alarmActive() && cards().join()==="2.60,3.22");
+ inst()[0]._message({type:"machine_update", machine:"2.60", data:{status:"Automatyczny"}});
+ inst()[0]._message({type:"machine_update", machine:"3.22", data:{status:"Automatyczny"}});
+}, 150);
+
+setTimeout(() => {
+ assert("4. koniec alarmow: powrot do iframe", !alarmActive());
+ inst()[0]._message({type:"machine_update", machine:"9.99", data:{status:"Alarm - obca"}});
+}, 200);
+
+setTimeout(() => {
+ assert("5. maszyna spoza filtra ignorowana", !alarmActive());
+ FakeWebSocket.autoOpen = false;
+ inst()[0]._serverClose();
+}, 250);
+
+// KLUCZOWY test regresji: po zamknięciu MUSI powstać nowe połączenie.
+setTimeout(() => {
+ assert("6. reconnect zaplanowany i wykonany", inst().length >= 2);
+}, 5000);
+
+setTimeout(() => {
+ assert("7. baner offline widoczny po 10 s", bannerActive());
+ FakeWebSocket.autoOpen = true;
+ inst()[inst().length-1]._open();
+ inst()[inst().length-1]._message({type:"machine_data", machine:"4.09", data:{status:"Alarm - test"}});
+}, 16000);
+
+setTimeout(() => {
+ assert("8. po powrocie: baner znika, alarm 4.09", !bannerActive() && cards().join()==="4.09");
+}, 17000);
+
+setTimeout(() => { window.__przedWatchdog = inst().length; }, 18000);
+setTimeout(() => {
+ assert("9. watchdog: cisza 100s wymusza nowe polaczenie", inst().length > window.__przedWatchdog);
+}, 125000);
+
+setTimeout(() => {
+ assert("10. przeterminowany alarm znika z ekranu", !alarmActive());
+ document.getElementById("wynik").textContent += "\\nKONIEC";
+}, 340000);
+"""
+
+FAKE_WS = """
+class FakeWebSocket {
+ static instances = [];
+ static autoOpen = true;
+ constructor(url) {
+ this.url = url;
+ this.readyState = 0;
+ FakeWebSocket.instances.push(this);
+ if (FakeWebSocket.autoOpen) setTimeout(() => this._open(), 5);
+ }
+ _open() {
+ if (this.readyState !== 0) return;
+ this.readyState = 1;
+ this.onopen && this.onopen();
+ }
+ _message(obj) {
+ this.onmessage && this.onmessage({ data: JSON.stringify(obj) });
+ }
+ _serverClose() {
+ if (this.readyState === 3) return;
+ this.readyState = 3;
+ this.onclose && this.onclose();
+ }
+ close() {
+ if (this.readyState === 3) return;
+ this.readyState = 3;
+ setTimeout(() => this.onclose && this.onclose(), 1);
+ }
+}
+FakeWebSocket.OPEN = 1;
+window.WebSocket = FakeWebSocket;
+window.WebSocket.OPEN = 1;
+"""
+
+
+def zbuduj_harness() -> str:
+ app = APP.read_text(encoding="utf-8")
+ skrypt = re.search(r"", app).group(1)
+ markup = re.search(r"([\s\S]*?)
+
+
+