UNPKG

max485-raspberry-nodejs

Version:

Node.js library for Modbus RTU communication over RS485 using MAX485 transceivers on Raspberry Pi

293 lines (255 loc) 14.9 kB
# TODO — max485-raspberry-nodejs Lista planowanych zmian w bibliotece w celu wyeliminowania chronicznych błędów komunikacji Modbus obserwowanych na wiacie produkcyjnej ChargeGo. Hardware kontekst tych zmian (z `RaspberryPi_Power_Shield_v2`): - ISL43485IBZ RS485 transceiver z 10kΩ failsafe biasing (R9 A→3V3, R11 B→GND) - 120Ω termination (R10) na masterze - 10kΩ pull-up na RO/RE/DE/DI - 3 slave'y zasilane z tej samej płytki (common GND przez power wire) - 9600 baud, 8N1 - Środowisko z EMI (Victron MultiPlus II inwerter na tym samym GND railu) ## Workflow zmian — JEDNA ZMIANA NA RAZ **Złota zasada:** każdy fix jest osobnym commitem na osobnym branchu i osobno weryfikowany na wiacie produkcyjnej. Nie merge'ujemy kolejnego dopóki poprzedni nie został potwierdzony jako stabilny. ### Cykl per zmiana 1. **Branch** `fix/<short-name>` z `main` 2. **Implementacja** — minimalna zmiana, najlepiej <20 linii diffu 3. **Build na wiacie**`~/max485-fix` z patched binding/main: ```bash cd ~/max485-fix make so GO=/home/elineshed2002/.local/share/go/bin/go npx node-gyp rebuild ``` 4. **Smoke test** — odczyt z 3 sterowników (slave 21 LED + 31/35 outlets): ```bash sudo systemctl stop touchapp touchapp-control node /home/elineshed2002/test-min.js # patrz repo: README "manual smoke" ``` Wymagane: wszystkie 3 sterowniki odpowiadają poprawnymi danymi (jak przed zmianą). 5. **Deploy do node_modules** — zastąp pliki w `/home/elineshed2002/elinetouch/node_modules/max485-raspberry-nodejs/` lub przez `npm install` z github tag (po push'u brancha). 6. **Restart serwisów**: ```bash sudo systemctl reset-failed touchapp touchapp-watchdog sudo systemctl start touchapp-control touchapp ``` 7. **Monitoring 30 min** — porównaj wskaźniki PRZED vs PO: - Liczba "invalid response length" w `journalctl -u touchapp-control` - Liczba "invalid slave ID" tamże - Liczba MODBUS_TIMEOUT w `journalctl -u touchapp` - Czy wiata trzyma się jako klient w `emqx ctl clients list` - **Brak nowych typów błędów** w logu (regression check) 8. **Decyzja**: - ✅ liczba błędów spadła + brak regresji → merge na `main`, tag `v3.0.x`, `npm publish` (gdy token), update `elinetouch/package.json` - ❌ regresja lub brak poprawy → revert, analiza, pivot ### Quick rollback Każda zmiana jest publikowana jako osobny tag, więc rollback to: ```bash cd ~/elinetouch # Cofnij package.json do poprzedniego tagu max485: sed -i 's|max485-raspberry-nodejs.*|max485-raspberry-nodejs": "github:chargego-pl/max485-raspberry-nodejs#v3.0.X",|' package.json npm install sudo systemctl restart touchapp-control touchapp ``` ## Lista zmian ### Priorytet ⭐⭐⭐ (krytyczne) - [x] ~~**A. `port.Flush()` przed każdym `sendModbusRequest`**~~ — **REVERTED** - **Plik:** `go/main.go:131` (początek `sendModbusRequest`) - **Zmiana:** dodać `d.port.Flush()` jako pierwszą linię metody - **Motywacja:** zalegające bajty w RX bufferze z poprzedniego cyklu (np. response który nadszedł po timeout) mieszają się z aktualną response → "invalid slave ID: got X, expected Y" lub "invalid response length" - **Wynik testu (2026-05-21, branch `fix/flush-before-send`):** | Metryka | Baseline (v3.0.2, 22 min) | Po fix A (21 min) | Zmiana | |---|---|---|---| | touch-control errors | 17 | 93 | **+447%** | | invalid response length | 12 | 74 | +517% | | invalid slave ID | 5 | 18 | +260% | | touchapp errors | 33 | 120 | +264% | - **Hipoteza dlaczego pogorszyło:** `tarm/serial.Flush()` wywołuje `tcflush(TCIOFLUSH)` — flushuje OBA kierunki. To prawdopodobnie obcina in-flight transmisję (kernel UART tx buffer ma 64-byte FIFO który shift-uje przez ~7ms przy 9600 baud). Jeśli następne wywołanie Flush dzieje się gdy poprzednia response ledwo wpadła do RX FIFO, tcflush(TCIFLUSH) ją wymiata zanim Read zdąży skopiować. Efekt netto: WIĘCEJ partial responses zamiast mniej. - **Implication:** ten fix wymaga **selektywnego flush'a tylko input buffera** (`tcflush(TCIFLUSH)` zamiast `TCIOFLUSH`). `tarm/serial` nie wystawia tej granularności — wymaga przejścia na `go.bug.st/serial` która ma `ResetInputBuffer()` (tylko RX). - **Re-plan:** Fix A musi być powiązany z migracją na `go.bug.st/serial` (razem z fix B/E). Pojedynczy fix Flush jest niemożliwy do zaimplementowania bezpiecznie z aktualnym tarm/serial. - **Status:** REVERTED — wymaga przepisania na inną bibliotekę serial - [ ] **E. `flock()` na otwartym serial port** - **Plik:** `go/main.go:50` (po `serial.OpenPort`) - **Zmiana:** wymusić `LOCK_EX|LOCK_NB` na fd — drugi proces dostaje explicit error zamiast cichej walki o port - **Motywacja:** obecny incydent — `services/pro.js` w touchapp ma własną instancję `ModbusRTU('/dev/serial0', ...)` równolegle z touch-control → dwa drivery na bus → gwarantowany chaos - **Blocker:** `tarm/serial.Port` nie wystawia fd → wymaga przejścia na `go.bug.st/serial` (też daje `.Drain()` dla zmiany B) - **Spodziewany efekt:** twardy fail przy próbie drugiego openu zamiast obecnego niewidocznego corruption - **Acceptance:** smoke test pokazuje że pojedynczy proces działa; próba uruchomienia drugiego z tej samej lib daje czysty error - **Wersja docelowa:** v3.1.0 (większy refaktor — switch lib) - **Status:** PENDING — czeka na pierwszą walidację `tarm/serial` → `go.bug.st/serial` ### Priorytet ⭐⭐ (znaczące) - [ ] **B. Drain UART po Write, proporcjonalne do długości packet'u** - **Plik:** `go/main.go:152-156` (po byte-by-byte write loop, przed `enableRX()`) - **Zmiana opcja 1 (bez zmiany lib):** zastąpić `postSendDelay = 3ms` fixed na `(len(request) * 10 / baudRate) seconds + safety margin` - **Zmiana opcja 2 (recommended):** switch `tarm/serial` → `go.bug.st/serial`, użyć `port.Drain()` (proper `tcdrain()` syscall) - **Motywacja:** kernel UART tx buffer absorbuje cały packet PRZED transmisją. `Write` zwraca po copy do bufora, nie po shift-out. Przy długich packetach (writeMultipleCoils 30+ bajtów) `enableRX()` przełącza DE LOW zanim ostatni bajt wyjdzie z UART → ucięcie końca transmisji → slave ignoruje całość → timeout - **Spodziewany efekt:** eliminuje rzadkie ale powtarzające się "no response" dla long writes - **Acceptance:** brak MODBUS_TIMEOUT przy writeMultipleCoils w 30 min workloadzie - **Wersja docelowa:** v3.0.4 (opcja 1) lub v3.1.0 (opcja 2, razem z E) - **Status:** PENDING ### Priorytet ⭐ (poprawa diagnostyki) - [x] ~~**C. Rozróżnić timeout od corrupted response**~~ — **REVERTED** - **Plik:** `go/main.go:181-183` - **Zmiana:** jeśli `totalRead == 0` → `"MODBUS_TIMEOUT: no response from slave N"` zamiast generic "invalid response length" - **Wynik testu (2026-05-21, branch `fix/timeout-error-message`):** | Metryka | Baseline (9.5 min v3.0.2) | Po Fix C (10.5 min) | Zmiana | |---|---|---|---| | touch-control errors | 11 | 121 | **+1000%** | | invalid response length | 8 | 88 | +1000% | | invalid slave ID | 3 | 24 | +700% | | MODBUS_TIMEOUT (nowy) | 0 | 8 | nowy | | touchapp errors | 15 | 152 | +913% | | touchapp.service | active | **FAILED** (start-limit) | ALARM | - **Diagnoza dlaczego pogorszyło (mimo że to "tylko zmiana stringa"):** Po zmianie komunikatu, `modbus-server.js _withReconnect` regex `/file already closed|EBADF|ENOENT|timeout/i` zaczął matchować `MODBUS_TIMEOUT` → kick'nął retry logic (3 attempts × 300ms sleep). Każdy timeout to teraz **3× więcej calls na busie**: - przed: 1 call → "invalid response length: got 0" → throw, idziemy dalej - po: 1 call → 5s wait → "MODBUS_TIMEOUT" → matchuje retry regex → sleep 300ms → call 2 (timeout 5s) → sleep 300ms → call 3 (5s) → throw - **15.6s + 3 calls** zamiast 5s + 1 call Skutek: bus saturated, każdy timeout blokuje queue na ~15s, w międzyczasie inne slave'y nie są odpytywane → ich own pollery (touchapp pro.js, metrics.js) też timeoutują → kaskada, touchapp wpada w restart-loop. - **Implication:** **fix biblioteki SAM nie wystarczy**. Musi być skoordynowany z modbus-server.js: - Wariant A: `MODBUS_TIMEOUT` to "give up, throw immediately, no retry" (slave realnie milczał 5s — dodatkowe próby tylko marnują czas) - Wariant B: dla MODBUS_TIMEOUT retry max 1× zamiast 3, bez sleep - Najlepiej: typed error class zamiast regex matching w error message - **Status:** REVERTED — wymaga skoordynowanej zmiany w `elinetouch/src/control/modbus-server.js` i `services/pro.js` - [ ] **D. Parse Modbus exception responses** - **Plik:** `go/main.go:131-203` - **Zmiana:** po przeczytaniu pierwszych 2 bajtów, jeśli `response[1] == request[1]|0x80` → exception frame, czytaj jeszcze 3 bajty (errCode + CRC), zwróć typed error z exception code - **Motywacja:** slave odpowiada exception (Illegal Function/Address/Data Value) jako 5-bajtowy frame. Obecnie lib oczekuje pełnego `expectedLength` → break, "invalid response length", diagnostyka stracona - **Spodziewany efekt:** lepsza diagnostyka *poza* obecnym zestawem błędów. Sam liczbę errorów nie zmieni (większość to corruption/timeout) - **Wersja docelowa:** v3.0.6 - **Status:** PENDING - [ ] **F. `errors.Is(err, io.EOF)` zamiast string compare** - **Plik:** `go/main.go:169` - **Zmiana:** `err.Error() == "EOF"``errors.Is(err, io.EOF)` - **Motywacja:** string compare łamie się jak `tarm/serial` zmieni error message. Robustness. - **Wersja docelowa:** v3.0.7 lub piggyback na innym fixie - **Status:** PENDING ### Niska priorytetu / nice-to-have - [ ] Configurable timing constants (`postSendDelay`, `byteSendDelay` jako params konstruktora) — żeby user mógł tunować bez fork'a libki - [ ] Bulk write zamiast byte-by-byte (jeśli switch na go.bug.st/serial z proper Drain) — performance, ~50% szybszy `sendModbusRequest` - [ ] Brak unit testów dla `calculateCRC`, packet encoding — łatwe do dodania (no hardware required) - [ ] `rpio.Close()` w `(*ModbusDevice).Close()` zamyka globalne GPIO mapping → bug jeśli kiedyś chcielibyśmy >1 instancji w procesie (obecnie N/A) - [ ] Configurable read timeout (obecnie hardcoded 5s w `NewModbusDevice`) — pozwala szybciej fail dla niewspieranych slave'ów ## Historia (zrobione) - [x] **v3.0.2** (commit `4779094`, 2026-05-21) — `cgo.Handle` dla `*ModbusDevice` w napi external. Eliminuje SIGSEGV po ~9h pracy gdy Go GC przeniósł obiekt. Plus unifikacja error format w `ReadCoilsJS` (był napi error object, teraz string "Error: ..." jak inne metody). ## Meta-lessons learned Po **trzech** revertach (A, C, oraz pro.js→IPC w elinetouch) z dramatycznymi regresjami w produkcji: 1. **Każdy "tylko mały fix" w hot path Modbus może mieć non-obvious konsekwencje.** Fix C był zmianą stringa — i wywołał 10× regresję przez interakcję z regex w modbus-server.js. Lib + caller są silnie sprzężone, separation of concerns iluzoryczna. 2. **Hot path obecnie operuje na granicy stabilności.** Każde dodatkowe obciążenie (więcej retries, dodatkowy syscall flush, więcej delays) wpycha system w spiralę: więcej calls → więcej timeouts → więcej retries → kaskada → touchapp restart-loop. 3. **Pojedyncze fixy w libie wymagają skoordynowanej zmiany u caller'a.** Następne iteracje muszą obejmować JEDNOCZEŚNIE: - lib change (np. typed errors) - modbus-server.js change (retry policy per error type) - PR ze zmianą obu repos, deploy razem, rollback razem 4. **Workflow "1 fix → test → decide" jest pomocny ale nie wystarcza gdy zmiana jest cross-repo.** Trzeba przejść na "feature flag + ramp + rollback per slave" lub po prostu większe atomy zmian (cały coordinated fix razem). 5. **Magistrala jest bardziej fragile niż zakładaliśmy.** Te ~70/h errorów to nie "bug do naprawy" — to **wskaźnik nasycenia bus'a**. Każda dodatkowa transmisja zwiększa szansę kolizji. Pierwszą rzeczą do zrobienia powinno być **zmniejszenie liczby transmisji**, nie ich naprawa. 6. **Wszystkie fixy są PO TYM JAK coś już poszło źle** (timeout, corruption, exception). Większy zysk byłby z prevention. 7. **(NOWE po 3-cim revercie)** Fix pro.js→IPC był koncepcyjnie poprawny — eliminował podwójnego ownera serial port (lsof potwierdził). ALE: spowodował 9× wzrost timeoutów. Diagnoza: pro.js wcześniej miał **własną równoległą queue** (chaotic parallel throughput), teraz wszystko serializuje się przez jedną queue touch-control (SLEEP_MS=500 + Modbus call) → metrics scrape + mqtt report + monitor + IPC callers stoją w kolejce → 5s read timeouts strzelają. **Wniosek:** nie istnieje "prosty" fix. Każda strukturalna zmiana ujawnia inny bottleneck. System jest w stanie nasycenia magistrali — wszelkie modyfikacje timing/serialization wpychają go głębiej. 8. **Najpilniejsza prawdziwa potrzeba:** redukcja LICZBY operacji na busie, nie ich naprawa. Plan na osobną sesję: - **Shared cache w touch-control:** monitor poll co X, cached state, IPC clients dostają cached state bez nowego Modbus call dopóki świeży (np. TTL 2s dla statuses, 10s dla power, 30s dla LED). - **Tuning polling intervals:** metrics scrape rzadziej (co 30s zamiast 5s), mqtt report rzadziej (co 60s zamiast 15s). - **DOPIERO POTEM** spadek baseline → nowe pole do prób fix lib. 9. **70/h errorów na v3.0.2 to nowy normal.** System operacyjnie działa (intent ACK lecą, MQTT trzyma się, UI responsywny). Retry logic maskuje większość. Te errors są **wskaźnikiem nasycenia bus**, nie service degradation z perspektywy użytkownika. ## Wnioski na dalszą pracę **Nie ruszać biblioteki przed redukcją base load na magistrali.** Lib jest napięta — każda zmiana destabilizuje. Najpierw: 1. Shared cache w touch-control (elinetouch, osobny task) 2. Tune polling intervals (elinetouch) 3. **Dopiero potem** wracać do fixów lib (A z `go.bug.st/serial`, B drain, E flock — wszystkie razem jako v3.1.0) ## Pliki referencyjne - `docs/edge-process-architecture.md` w `elinetouch` repo — IPC contract, why touch-control owns /dev/serial0 exclusively (relevant dla fix E) - `docs/wiata-spec-produktu.md` — gdzie są slave'y w bus topology - Schemat HW: `~/Desktop/pliki projektowe elines/RaspberryPi_Power_Shield_v2/Schematic.pdf`