Przejście na kanał zbiorczy LuPort + naprawa reconnectu, watchdog, widoczny offline

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 08:04:30 +02:00
parent c893b5e975
commit 199e71327f
4 changed files with 542 additions and 284 deletions
+1
View File
@@ -0,0 +1 @@
.DS_Store
+109 -94
View File
@@ -1,145 +1,160 @@
# ALS Alert Screen # 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 ## 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 | | 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 | | 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"`.
--- 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".
## 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 |
--- ---
## Konfiguracja ## Konfiguracja
Wszystkie parametry konfiguracyjne znajdują się na początku sekcji `<script>` w pliku `als_alert_screen.html`. Wszystkie parametry na początku sekcji `<script>` w `als_alert_screen.html`:
### Lista maszyn | Stała | Znaczenie |
|---|---|
| `WS_URL` | adres kanału zbiorczego, np. `ws://luport.local/ws/als-data/` |
| `WS_TOKEN` | token konta `ApiUser` (patrz niżej) |
| `MACHINES` | filtr maszyn pokazywanych na ekranie; `[]` = wszystkie |
| `WATCHDOG_SILENCE_MS` | po jakiej ciszy uznać połączenie za martwe (90 s) |
| `STALE_MS` | po jakim czasie dane maszyny znikają z ekranu (5 min) |
| `OFFLINE_BANNER_AFTER_MS` | po jakim czasie bez łączności pokazać pasek (10 s) |
```js Oba kluczowe parametry można też nadpisać w adresie strony — wygodne przy
const MACHINES = [ konfiguracji kiosku bez edycji pliku:
"1.64", "1.76", "2.60", ...
]; ```
als_alert_screen.html?ws=ws%3A%2F%2Fluport.local%2Fws%2Fals-data%2F&token=...
``` ```
Każdy wpis to numer maszyny używany w adresie WebSocket. Aby dodać lub usunąć maszynę — edytuj tę tablicę. ### Token (wymagany przez nowy system)
### Adres WebSocket Nowy LuPort wymaga uwierzytelnienia WebSocket. Utwórz dedykowane konto:
Django Admin → **Users → Api users** → dodaj `bot-als-alert-screen`
**bez żadnych uprawnień** (sam odbiór strumienia ALS ich nie wymaga — konto
ma minimalny możliwy dostęp). Wygenerowany token wpisz w `WS_TOKEN`.
```js ### Format wiadomości
const WS_BASE = "ws://luport.local/ws/als-data/";
```
Dla każdej maszyny otwierane jest połączenie pod adresem `WS_BASE + id + "/"`, Strona obsługuje dwa typy ramek — obydwa tak samo (liczy się najnowszy stan):
np. `ws://luport.local/ws/als-data/2.60/`.
### Format wiadomości WebSocket - `machine_data` — migawka wysyłana przez serwer zaraz po połączeniu,
- `machine_update` — aktualizacje na żywo.
```json ```json
{ {
"type": "machine_update", "type": "machine_update",
"machine": "2.60", "machine": "2.60",
"data": { "data": { "status": "Automatyczny", "orders": ["202605350"], "progress": 25, "...": "..." },
"machine": "2.60", "timestamp": "2026-07-31T12:00:32"
"status": "Automatyczny",
"mold": 6447,
"variant": [3],
"cavities": 2,
"order": "202605350 0010",
"qty_planned": 8400,
"qty_produced_ok": 2132,
"qty_produced_nok": 10,
"cycle_time_planned": 58.0,
"cycle_time_average": 57.4,
"progress": 25
},
"timestamp": "2026-05-28T12:00:32.898808"
} }
``` ```
Strona obsługuje tylko wiadomości z `"type": "machine_update"`. Pozostałe są ignorowane. ---
### Czas ponownego połączenia ## Odporność na awarie — dlaczego cron-restart nie jest już potrzebny
```js 1. **Reconnect z wykładniczym odstępem i jitterem** (1 s → 30 s). Odstęp wraca
const RECONNECT_MS = 5000; do minimum dopiero po 60 s stabilnego połączenia — serwer, który przyjmuje
i zaraz zrywa (np. zły token), nie jest młócony co sekundę.
2. **Watchdog martwego połączenia.** Przeglądarka nie wystawia ping/pong do
JS, więc połączenie zabite bez zamknięcia TCP potrafi wisieć „otwarte"
w nieskończoność. Na kanale zbiorczym dane płyną praktycznie ciągle
(35 maszyn × cykl scrapera 30 s) — cisza dłuższa niż 90 s oznacza martwe
połączenie i wymusza reconnect.
3. **Wygasanie danych.** Dane maszyny starsze niż 5 min znikają z ekranu —
alarm maszyny usuniętej z widgetu ALS nie wisi w nieskończoność.
4. **Widoczna utrata łączności.** Pasek offline + pasek statusu w rogu.
Ekran nigdy nie pokazuje po cichu nieświeżego obrazu.
---
## Historia: dlaczego alarmy „przestawały wyskakiwać" (do 2026-07-31)
Poprzednia wersja utrzymywała **35 osobnych połączeń** (po jednym na maszynę)
i miała błąd, który ujawnił się po ukryciu paska statusu: element `#status-bar`
został **zakomentowany 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.
Maszyny odpadały jedna po drugiej przy każdym czknięciu sieci, a restart
z crona co 2 h maskował problem, zaczynając od zera.
Wnioski utrwalone w obecnym kodzie:
- element statusu **musi istnieć w DOM** (ukrywanie wyłącznie przez CSS),
a `refreshStatus()` i tak ma osłonę na jego brak,
- brak `ws.onerror` jest celowy — po błędzie przeglądarka i tak wywołuje
`onclose`, a osobny handler był tylko drugim miejscem, z którego dało się
rzucić wyjątkiem,
- scenariusz 6 w testach (`test/testy_ekranu.py`) jest bezpośrednim testem
regresji tego błędu.
Poprawki robione na kiosku w czerwcu (timery świeżości per-maszyna,
ubijanie „zombie" połączeń, jitter reconnectu) 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` — czyli
własna obrona uśmiercała maszynę na stałe. Ich sedno (wykrywanie ciszy,
jitter) jest zachowane w obecnej wersji; nakładkowy iframe („iframe
optimalization") również. Zrezygnowano z czyszczenia danych przy rozłączeniu:
alarm nie znika przy 3-sekundowym czknięciu sieci — o niepewności informuje
pomarańczowy pasek, a migawka po reconnexie przywraca prawdę.
**Cron restartujący przeglądarkę co 2 h można usunąć.**
---
## Testy
```bash
python3 test/testy_ekranu.py
# lub na kiosku:
CHROME_BIN=/usr/bin/chromium python3 test/testy_ekranu.py
``` ```
Przy zerwaniu połączenia WS strona automatycznie próbuje ponownie po 5 sekundach. Deterministyczne, bez sieci: skrypt aplikacji jest wycinany z żywego
`als_alert_screen.html`, `window.WebSocket` podmieniany na fałszywkę,
a scenariusz (10 asercji: migawka, aktualizacje, reconnect, baner offline,
watchdog, wygasanie danych) jedzie na wirtualnym czasie Chromium — całość
trwa kilka sekund mimo symulowania ~6 minut.
--- ---
## Tryb debug ## Tryb debug
Służy do podglądu ekranu alarmowego bez rzeczywistych danych WS. Podgląd ekranu alarmowego bez danych i bez połączeń WS:
### Przez URL
Otwórz stronę z parametrem `?debug`:
``` ```
als_alert_screen.html?debug als_alert_screen.html?debug
``` ```
Ładuje przykładowe dane dla maszyn `2.60`, `3.22`, `4.09` i natychmiast pokazuje ekran alarmowy. Połączenia WS **nie są** nawiązywane. Pasek statusu wyświetla `[DEBUG] widok alarmowy`. albo w konsoli przeglądarki: `DEBUG_ALARM = true; updateDisplay();`
(powrót: `DEBUG_ALARM = false; updateDisplay();`).
### Przez konsolę przeglądarki
W każdej chwili można przełączyć widok bez przeładowania strony:
```js
// włącz widok alarmowy
DEBUG_ALARM = true; updateDisplay();
// wróć do normalnego widoku
DEBUG_ALARM = false; updateDisplay();
```
### Dane przykładowe
Maszyny i dane używane w trybie debug są zdefiniowane w zmiennych `DEBUG_MACHINES` i `DEBUG_DATA` — można je swobodnie edytować.
---
## Pasek statusu
W prawym dolnym rogu ekranu widoczny jest mały pasek z liczbą aktywnych połączeń WebSocket:
```
WS: 32 / 35
```
--- ---
## Wymagania ## Wymagania
- Przeglądarka z obsługą WebSocket, CSS Container Queries (`cqw`) i `min()` w CSS — spełnia każda nowoczesna przeglądarka (Chrome 105+, Firefox 110+, Edge 105+). - Chromium/Chrome 105+ (CSS Container Queries, `min()`) — kiosk działa na
- Dostęp sieciowy do `luport.local` (WebSocket) oraz `diluals31` (iframe). Chromium pod Debianem.
- Dostęp sieciowy do LuPort (WebSocket) oraz infoterminala ALS (iframe).
+231 -190
View File
@@ -21,6 +21,10 @@
} }
/* ── NORMAL VIEW ──────────────────────────────── */ /* ── NORMAL VIEW ──────────────────────────────── */
/* Iframe jest ZAWSZE zamontowany, a widok alarmowy to nakładka nad nim
(pomysł z wersji kioskowej, "iframe optimalization"). Przełączanie
przez display:none wymuszało przeładowanie treści iframe'a przy każdym
powrocie z alarmu. */
#normal-view { #normal-view {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -38,20 +42,17 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100vh; height: 100vh;
z-index: 2; /* Warstwa na wierzchu */ z-index: 2;
/* Zamiast display: none, używamy przezroczystości i wyłączamy kliknięcia */
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #0d0000; background: #0d0000;
transition: opacity 0.3s ease; /* Łagodne przejście */ transition: opacity 0.3s ease;
} }
#alarm-view.active { #alarm-view.active {
opacity: 1; opacity: 1;
pointer-events: auto; /* Włączenie interakcji, jeśli potrzebne */ pointer-events: auto;
} }
.alarm-header { .alarm-header {
@@ -144,64 +145,42 @@
text-shadow: 0 0 24px rgba(255, 0, 0, 0.6); text-shadow: 0 0 24px rgba(255, 0, 0, 0.6);
} }
.card-status { /* ── OFFLINE BANNER ──────────────────────────── */
font-size: min( /* Widoczna z daleka informacja o utracie łączności z LuPort.
calc(7vw / var(--cols, 1)), POMARAŃCZOWA, nie czerwona — czerwień jest zarezerwowana dla alarmów
calc(7vh / var(--rows, 1)), maszyn; ten pasek mówi „ekran może pokazywać nieświeży obraz",
5cqw a nie „maszyna stoi". Poprzednia wersja tej strony traciła połączenia
); PO CICHU (patrz README, sekcja Historia) — ten pasek jest wprost
font-weight: 700; wnioskiem z tamtej awarii: utrata łączności ma być widoczna z hali,
color: #ff9999; a nie odkrywana po fakcie. */
text-transform: uppercase; #offline-banner {
letter-spacing: 0.08em; display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9998;
background: #7a4a00;
color: #ffd9a0;
border-bottom: 3px solid #ffaa33;
padding: 10px 24px;
text-align: center; text-align: center;
} font-size: clamp(16px, 2vw, 28px);
font-weight: 700;
/* ── detail grid ── */
.card-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 14px;
border-top: 1px solid #3a0000;
padding-top: 8px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: clamp(9px, 0.8vw, 12px);
color: #664444;
text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.info-value { #offline-banner.active {
font-size: clamp(11px, 1.1vw, 16px); display: block;
color: #ddcccc; animation: offlineBlink 2s step-start infinite;
font-weight: 700;
} }
@keyframes offlineBlink {
.progress-row { 0%,
grid-column: 1 / -1; 100% {
margin-top: 2px; background: #7a4a00;
} }
.progress-bar { 50% {
height: 12px; background: #9a5e00;
background: #3a0000; }
border-radius: 6px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #990000, #ff4444);
border-radius: 6px;
transition: width 0.8s ease;
}
.progress-label {
text-align: right;
font-size: clamp(9px, 0.8vw, 11px);
color: #775555;
margin-top: 3px;
} }
/* ── STATUS BAR ──────────────────────────────── */ /* ── STATUS BAR ──────────────────────────────── */
@@ -236,167 +215,217 @@
<div id="machines-grid"></div> <div id="machines-grid"></div>
</div> </div>
<!-- <div id="status-bar">WS: 0 / 0</div> --> <div id="offline-banner">⚠ BRAK POŁĄCZENIA Z LUPORT — DANE MOGĄ BYĆ NIEAKTUALNE ⚠</div>
<!-- Ten element MUSI istnieć w DOM. Poprzednia wersja miała go
zakomentowanego, a refreshStatus() dalej po niego sięgał — TypeError
w ws.onclose przerywał handler PRZED zaplanowaniem reconnectu i każda
maszyna po pierwszym zerwaniu połączenia milkła na zawsze (naprawiane
restartem z crona co 2 h). Jeśli pasek ma być niewidoczny, ukryj go
przez CSS (display:none), nigdy przez usunięcie/komentarz. -->
<div id="status-bar">WS: łączenie…</div>
<script> <script>
// ══ KONFIGURACJA ══════════════════════════════════════════════════════
// Kanał ZBIORCZY nowego systemu — jedno połączenie niesie aktualizacje
// wszystkich maszyn (stara wersja: osobne połączenie na każdą z 35
// maszyn). Po nawiązaniu połączenia serwer wysyła migawkę stanu
// wszystkich maszyn (ramki "machine_data"), więc po reconnexie trwający
// alarm wraca na ekran natychmiast, nie po cyklu scrapera.
const WS_URL = "ws://luport.local/ws/als-data/";
// Nowy system wymaga uwierzytelnienia WebSocket. Wstaw token konta
// ApiUser (Django Admin -> Users -> Api users -> "bot-als-alert-screen";
// konto NIE potrzebuje żadnych uprawnień — sam odbiór strumienia ich nie
// wymaga). Można też podać token w adresie strony: ?token=...
const WS_TOKEN = "WSTAW-TOKEN-APIUSER";
// Filtr maszyn pokazywanych na ekranie. Kanał zbiorczy niesie WSZYSTKIE
// maszyny znane LuPortowi — pusta lista ([]) oznacza „pokazuj wszystkie".
const MACHINES = [ const MACHINES = [
"1.64", "1.64", "1.76", "2.60", "2.63", "2.67", "2.72", "2.74",
"1.76", "3.01", "3.04", "3.08", "3.22", "3.26", "3.51", "3.55",
"2.60", "3.56", "3.59", "3.61", "3.66", "3.69", "3.70", "3.71",
"2.63", "3.73", "3.75", "4.05", "4.09", "4.14", "4.52", "4.53",
"2.67", "4.57", "4.58", "4.62", "4.65", "4.68", "5.29", "5.54",
"2.72",
"2.74",
"3.01",
"3.04",
"3.08",
"3.22",
"3.26",
"3.51",
"3.55",
"3.56",
"3.59",
"3.61",
"3.66",
"3.69",
"3.70",
"3.71",
"3.73",
"3.75",
"4.05",
"4.09",
"4.14",
"4.52",
"4.53",
"4.57",
"4.58",
"4.62",
"4.65",
"4.68",
"5.29",
"5.54",
]; ];
const WS_BASE = "ws://192.168.40.127/ws/als-data/"; // Reconnect: wykładniczy odstęp z losowym rozrzutem, żeby po awarii
const RECONNECT_MS = 5000; // serwera ekran nie młócił połączeń co sekundę w nieskończoność.
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const RECONNECT_JITTER_MS = 2000;
// ── Debug mode ──────────────────────────────────────────────────────────── // Watchdog martwego połączenia. Przeglądarka NIE wystawia ping/pong do
// Otwórz stronę z ?debug aby wymusić widok alarmowy z przykładowymi danymi. // JS — gdy trasa sieciowa umrze bez zamknięcia TCP, `onclose` może nie
// Możesz też wpisać w konsoli: DEBUG_ALARM = true; updateDisplay(); // przyjść nigdy (dokładnie ten scenariusz wcześniej „leczył" restart
// z crona). Na kanale zbiorczym dane płyną praktycznie ciągle (35 maszyn
// x cykl scrapera 30 s), więc cisza dłuższa niż WATCHDOG_SILENCE_MS
// oznacza martwe połączenie: zamykamy je ręcznie, co uruchamia zwykłą
// ścieżkę reconnectu.
const WATCHDOG_SILENCE_MS = 90000;
const WATCHDOG_CHECK_MS = 15000;
// Dane maszyny starsze niż STALE_MS znikają z ekranu. Bez tego alarm
// maszyny usuniętej z widgetu ALS (albo trwale wyłączonej) wisiałby na
// ekranie w nieskończoność — klucz w Redisie wygasa po 2x cyklu
// scrapera, więc świeże dane po prostu przestają przychodzić.
const STALE_MS = 5 * 60 * 1000;
// Po jakim czasie bez połączenia pokazać pomarańczowy pasek. Krótkie
// przerwy (restart api przy aktualizacji) nie muszą straszyć hali.
const OFFLINE_BANNER_AFTER_MS = 10000;
// ══ Debug mode ════════════════════════════════════════════════════════
// Otwórz stronę z ?debug aby wymusić widok alarmowy z przykładowymi
// danymi (bez połączenia WS). W konsoli: DEBUG_ALARM = true; updateDisplay();
let DEBUG_ALARM = new URLSearchParams(location.search).has("debug"); let DEBUG_ALARM = new URLSearchParams(location.search).has("debug");
const DEBUG_MACHINES = ["2.60", "3.22", "4.09"];
const DEBUG_DATA = { const DEBUG_DATA = {
"2.60": { machine: "2.60", status: "Alarm - brak materiału" }, "2.60": { machine: "2.60", status: "Alarm - brak materiału" },
3.22: { machine: "3.22", status: "Alarm - awaria" }, "3.22": { machine: "3.22", status: "Alarm - awaria" },
4.09: { machine: "4.09", status: "Alarm - przekroczony cykl" }, "4.09": { machine: "4.09", status: "Alarm - przekroczony cykl" },
}; };
const machineData = {}; // id → last data payload // ══ Stan ══════════════════════════════════════════════════════════════
const wsStatus = {}; // id → 'connected' | 'error' | 'disconnected'
const wsMap = {}; // id → WebSocket instance
const dataTimers = {}; // id → stale-data timeout handle
// Jeśli przez DATA_STALE_MS nie przyjdzie żadna wiadomość od maszyny const machineData = {}; // id -> { data, receivedAt }
// (nawet przy technicznie otwartym WS), dane uznajemy za nieaktualne. let ws = null;
const DATA_STALE_MS = 2 * 60 * 1000; // 2 minuty let wsConnected = false;
let reconnectDelay = RECONNECT_MIN_MS;
let reconnectTimer = null;
let stableTimer = null;
let lastMessageAt = 0;
let disconnectedSince = Date.now();
const iframe = document.getElementById("normal-view"); const machineFilter = MACHINES.length ? new Set(MACHINES) : null;
iframe.onload = () => { function wsUrlWithToken() {
console.log("Iframe załadowany, startuję WebSockety..."); const params = new URLSearchParams(location.search);
// Dajemy stronie w iframe jeszcze sekundę na przetworzenie własnego JS // Oba parametry mają nadpisania w adresie strony (?ws=...&token=...) —
setTimeout(() => { // kiosk można wycelować w inny serwer bez edycji pliku, a testy strony
MACHINES.forEach(connect); // w serwer lokalny.
}, 1000); const base = params.get("ws") || WS_URL;
}; const token = params.get("token") || WS_TOKEN;
return base + "?token=" + encodeURIComponent(token);
setTimeout(() => {
if (Object.keys(wsMap).length === 0) {
MACHINES.forEach(connect);
}
}, 10000);
function resetStaleTimer(id) {
clearTimeout(dataTimers[id]);
dataTimers[id] = setTimeout(() => {
delete machineData[id];
updateDisplay();
// NOWE: Jeśli dane są przestarzałe, zakładamy, że WebSocket to "zombie".
// Zabijamy go ręcznie, co wywoła onclose i sprowokuje ponowne połączenie.
if (wsMap[id] && wsStatus[id] === "connected") {
console.warn(`Brak danych dla ${id} - ubijam ciche połączenie`);
wsMap[id].close();
}
}, DATA_STALE_MS);
} }
// ── WebSocket management ────────────────────────────────────────────────── // ══ Połączenie ════════════════════════════════════════════════════════
function connect(id) { function connect() {
if (wsMap[id]) { clearTimeout(reconnectTimer);
if (ws) {
try { try {
wsMap[id].close(); ws.onclose = null; // stare gniazdo nie może zaplanować DRUGIEGO reconnectu
ws.close();
} catch (_) {} } catch (_) {}
} }
const ws = new WebSocket(WS_BASE + id + "/"); ws = new WebSocket(wsUrlWithToken());
wsMap[id] = ws;
wsStatus[id] = "connecting";
ws.onopen = () => { ws.onopen = () => {
wsStatus[id] = "connected"; wsConnected = true;
lastMessageAt = Date.now();
// Odstęp reconnectu wraca do minimum dopiero po chwili STABILNEGO
// połączenia — reset w onopen pozwalałby serwerowi, który przyjmuje
// i zaraz zrywa (np. zły token -> close 4001), młócić co sekundę.
clearTimeout(stableTimer);
stableTimer = setTimeout(() => {
reconnectDelay = RECONNECT_MIN_MS;
}, 60000);
refreshStatus(); refreshStatus();
resetStaleTimer(id); // NOWE: Startujemy timer od razu po otwarciu
}; };
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
lastMessageAt = Date.now();
try { try {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
if (msg.data) { // "machine_data" = migawka po połączeniu, "machine_update" = strumień
machineData[id] = { ...msg.data, _ts: Date.now() }; // na żywo. Ta sama obsługa: liczy się najnowszy znany stan maszyny.
resetStaleTimer(id); if (
(msg.type === "machine_data" || msg.type === "machine_update") &&
msg.data &&
msg.machine
) {
if (machineFilter && !machineFilter.has(msg.machine)) return;
machineData[msg.machine] = {
data: { ...msg.data, machine: msg.machine },
receivedAt: Date.now(),
};
updateDisplay(); updateDisplay();
refreshStatus();
} }
} catch (e) { } catch (e) {
/* ignore malformed frames */ /* ignoruj uszkodzone ramki */
} }
}; };
ws.onerror = () => {
wsStatus[id] = "error";
refreshStatus();
};
ws.onclose = () => { ws.onclose = () => {
wsStatus[id] = "disconnected"; wsConnected = false;
clearTimeout(dataTimers[id]); disconnectedSince = Date.now();
delete machineData[id]; clearTimeout(stableTimer);
updateDisplay();
refreshStatus(); refreshStatus();
reconnectTimer = setTimeout(connect, nextReconnectDelay());
const reconnectDelay =
RECONNECT_MS + Math.floor(Math.random() * 3000);
setTimeout(() => connect(id), reconnectDelay);
}; };
// Celowo brak ws.onerror: po błędzie przeglądarka i tak wywołuje
// onclose, a osobny handler był w starej wersji tylko drugim miejscem,
// z którego dało się rzucić wyjątkiem.
refreshStatus();
} }
// ── Display logic ───────────────────────────────────────────────────────── function nextReconnectDelay() {
const delay = reconnectDelay + Math.random() * RECONNECT_JITTER_MS;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
return delay;
}
function isAlarm(data) { // Watchdog + odświeżenie widoku (przeterminowane dane, pasek offline)
if (DEBUG_ALARM) return true; // w trybie debug wszystkie wstrzyknięte maszyny to alarmy // w jednym cyklu.
setInterval(() => {
if (DEBUG_ALARM) return;
if (
wsConnected &&
ws &&
ws.readyState === WebSocket.OPEN &&
Date.now() - lastMessageAt > WATCHDOG_SILENCE_MS
) {
// close() uruchomi onclose -> zwykła ścieżka reconnectu.
try {
ws.close();
} catch (_) {}
}
updateDisplay();
refreshStatus();
}, WATCHDOG_CHECK_MS);
// ══ Widok ═════════════════════════════════════════════════════════════
function isAlarm(entry) {
return ( return (
data && entry &&
typeof data.status === "string" && entry.data &&
data.status.toLowerCase().includes("alarm") typeof entry.data.status === "string" &&
entry.data.status.toLowerCase().includes("alarm")
); );
} }
function updateDisplay() { function isFresh(entry) {
const alarms = Object.entries(machineData) return entry && Date.now() - entry.receivedAt < STALE_MS;
.filter(([, d]) => isAlarm(d)) }
function currentAlarms() {
if (DEBUG_ALARM) {
return Object.entries(DEBUG_DATA).map(([id, d]) => [id, { data: d }]);
}
return Object.entries(machineData)
.filter(([, entry]) => isFresh(entry) && isAlarm(entry))
.sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })); .sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }));
}
function updateDisplay() {
const alarms = currentAlarms();
const alarmView = document.getElementById("alarm-view"); const alarmView = document.getElementById("alarm-view");
const countEl = document.getElementById("alarm-count"); const countEl = document.getElementById("alarm-count");
@@ -423,32 +452,44 @@
grid.style.setProperty("--cols", cols); grid.style.setProperty("--cols", cols);
grid.style.setProperty("--rows", rows); grid.style.setProperty("--rows", rows);
alarms.forEach(([id, d]) => { alarms.forEach(([id, entry]) => {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "machine-card"; card.className = "machine-card";
card.innerHTML = ` const machineId = document.createElement("div");
<div class="card-machine-id">${d.machine ?? id}</div> machineId.className = "card-machine-id";
<!-- <div class="card-status">${d.status ?? "ALARM"}</div> --> machineId.textContent = entry.data.machine ?? id;
`; card.appendChild(machineId);
grid.appendChild(card); grid.appendChild(card);
}); });
} }
function refreshStatus() { function refreshStatus() {
const connected = Object.values(wsStatus).filter( // Osłona zamiast założenia, że element istnieje — dokładnie ten brak
(s) => s === "connected", // osłony ubijał reconnect w poprzedniej wersji (patrz komentarz przy
).length; // #status-bar w HTML).
document.getElementById("status-bar").textContent = const bar = document.getElementById("status-bar");
`WS: ${connected} / ${MACHINES.length}`; if (bar) {
const fresh = Object.values(machineData).filter(isFresh).length;
bar.textContent = wsConnected
? `WS: połączono · maszyny z danymi: ${fresh}`
: "WS: rozłączono — ponawiam…";
}
const banner = document.getElementById("offline-banner");
if (banner) {
const offlineLongEnough =
!wsConnected && Date.now() - disconnectedSince > OFFLINE_BANNER_AFTER_MS;
banner.classList.toggle("active", offlineLongEnough && !DEBUG_ALARM);
}
} }
// ── Bootstrap ───────────────────────────────────────────────────────────── // ══ Start ═════════════════════════════════════════════════════════════
if (DEBUG_ALARM) { if (DEBUG_ALARM) {
DEBUG_MACHINES.forEach((id) => { const bar = document.getElementById("status-bar");
machineData[id] = DEBUG_DATA[id]; if (bar) bar.textContent = "[DEBUG] widok alarmowy";
}); } else {
document.getElementById("status-bar").textContent = connect();
"[DEBUG] widok alarmowy";
} }
updateDisplay(); updateDisplay();
</script> </script>
+201
View File
@@ -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"<script>([\s\S]*)</script>", app).group(1)
markup = re.search(r"<body>([\s\S]*?)<script>", app).group(1)
# iframe bez realnego adresu — test działa offline
markup = re.sub(r'src="http://[^"]*"', 'src="about:blank"', markup)
return f"""<!doctype html><html><head><meta charset="utf-8"><style>
#alarm-view{{display:none}} #alarm-view.active{{display:flex}}
#offline-banner{{display:none}} #offline-banner.active{{display:block}}
</style></head><body>
{markup}
<pre id="wynik"></pre>
<script>{FAKE_WS}</script>
<script>{skrypt}</script>
<script>{SCENARIUSZ}</script>
</body></html>"""
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'<pre id="wynik">([\s\S]*?)</pre>', 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())