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]*?) + + +""" + + +def znajdz_chromium() -> str: + for kandydat in ( + os.environ.get("CHROME_BIN"), + shutil.which("chromium"), + shutil.which("chromium-browser"), + shutil.which("google-chrome"), + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ): + if kandydat and Path(kandydat).exists(): + return kandydat + sys.exit("Nie znaleziono Chromium/Chrome — ustaw CHROME_BIN.") + + +def main() -> int: + chrome = znajdz_chromium() + with tempfile.TemporaryDirectory() as tmp: + harness = Path(tmp) / "harness.html" + harness.write_text(zbuduj_harness(), encoding="utf-8") + wynik = subprocess.run( + [ + chrome, + "--headless", + "--disable-gpu", + "--no-sandbox", + "--virtual-time-budget=350000", + "--dump-dom", + f"file://{harness}", + ], + capture_output=True, + text=True, + timeout=180, + ) + m = re.search(r'
([\s\S]*?)
', wynik.stdout) + if not m: + print("BŁĄD: brak wyników w DOM — harness nie wystartował?") + print(wynik.stdout[:500]) + return 2 + linie = [l for l in m.group(1).strip().splitlines() if l] + for linia in linie: + print(linia) + if "KONIEC" not in linie: + print("BŁĄD: scenariusz nie dobiegł końca (za mały virtual-time-budget?)") + return 2 + return 1 if any(l.startswith("FAIL") for l in linie) else 0 + + +if __name__ == "__main__": + sys.exit(main())