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]*?) + + +