diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index 3603403c..6b54dc56 100644 --- a/docs/DEVICE_VALIDATION_LEDGER.md +++ b/docs/DEVICE_VALIDATION_LEDGER.md @@ -17,6 +17,7 @@ | # | Özellik | Nerede | Kabul ölçütü (cihazda ne gözlemlenmeli) | Eklendi | |---|---------|--------|------------------------------------------|---------| +| 56 | **🔴 Device Validation Pending — Source Health → HAL Fail-Closed Consumption (PR-2)** — PR-1'in taşıdığı `halStatusStore.sourceHealth` (worker 1 Hz watchdog: CAN 3s/OBD 5s/GPS 5s) artık HAL veri akışında TÜKETİLİYOR. Zincir: worker → SOURCE_HEALTH → halStatusStore → **UnifiedVehicleStoreProvider snapshot** → adapter → HAL → bridge → appEventBus. **Bu, SESSİZ ECU FRAME KAYBI blokajını kapatır:** kablo takılı/port açık olsa bile (native `canStatus` `connected:true` der) worker frame sessizliğini görür → CAN-özel sinyaller fail-closed olur. **MODEL:** provider snapshot'a bounded `sourceHealth {can,obd,gps,ts}` eklendi (yapısal DI — provider/adapter store'ları DOĞRUDAN import ETMEZ); **araç + sağlık store abonelikleri TEK unsub'a sarılır** → teşhisteki `activeSubscriptionCount=1` invaryantı KORUNUR. **UNKNOWN ≠ DEAD:** `null` (worker hiç bildirmedi) → SAHTE disconnect YOK, davranış W4B ile aynı; `false` → kaynak KESİN ölü. **SİNYAL-KAYNAK MATRİSİ (testle kilitli):** CAN-ÖZEL (`coolant_temp · oil_temp · throttle · battery_voltage · gear · ambient_temp · tpms · door_state · parking_brake`) → `can=false` ise `source:none`/`supported=false`/`value=null`/`quality=unknown`/`confidence=0`; FÜZYON (`speed · rpm · fuel_level · odometer · reverse`) → CAN ölse de OBD/GPS/worker beslerse SUPPORTED kalır (yalnız tüm kaynaklar gidince düşer). `rpm`'in CAN fallback'i (`canRpm`) CAN ölüyken **bayat sayılır ve KULLANILMAZ**. `ignition` hâlâ ÜRETİLMEZ. **DİRİLTME YASAĞI:** `can=true` TEK BAŞINA eski değeri supported yapmaz — ölüm anındaki değer saklanır; sinyal ancak **değer DEĞİŞİNCE** (= taze frame kanıtı) gerçek source/quality/confidence ile geri döner. **BATCH:** bir geçişte düşen/dönen tüm sinyaller TEK `hal.ingest(batch)` → TEK HAL emit (W4A korunur); aynı durum duplicate ingest/emit üretmez. **BRIDGE:** tüm destek kalkınca `vehicle.connection.changed=false`, kısmi kayıpta üretilmez, reconnect'te (gerçek veriyle) `true`. **TEŞHİS:** support_snapshot'a bounded `sourceHealth {can,obd,gps,lastChangeAt}` eklendi (null≠false ayrımı korunur; sinyal değeri/CAN frame/VIN/koordinat/payload YOK). **YENİ TIMER YOK.** ✅ **BACKGROUND SAHTE-ÖLÜ KAPISI (bu PR'da EKLENDİ — merge blokajı kaldırıldı):** kök neden — watchdog canlılığı monotonik saat farkıyla ölçer (`_alive()`, `VehicleCompute.worker.ts:409-411`); bu saat arka planda İLERLER ama WebView timer'ı kısılır ve adaptör frame'leri durur → foreground dönüşündeki ilk tikte `now - lastSeen ≫ timeout` → kaynak SAĞLAMKEN `false`. Düzeltme: yeni bounded `VISIBILITY {visible:boolean}` worker kontrol mesajı (ana thread `visibilitychange` → `VehicleSignalResolver`, **TEK dinleyici, stop()'ta sökülür, duplicate durum POSTLANMAZ**) + saf `sourceHealthGate` (timer/DOM/store YOK, zaman enjekte edilir): **hidden → sağlık GEÇİŞİ DONDURULUR** (`lastSeen` BOZULMAZ), **foreground → timeout SAATİ yeniden tabanlanır** (arka planda geçen süre timeout'a YAZILMAZ): dönüşten sonra taze frame → `true`; görünür zamanda gerçek timeout dolarsa → `false` (**gerçek foreground kaybı MASKELENMEZ**); henüz ikisi de değilse KARAR YOK → son bilinen değer korunur (yoksa unknown). Kapı YALNIZ SOURCE_HEALTH kararını etkiler — fusion/reverse/SAB ham `_alive()` kullanmaya devam eder; **timer sayısı DEĞİŞMEDİ (4)**; ölçümsüz "N ardışık tur" eşiği YOK. | `platform/vehicleDataLayer/sourceHealthGate.ts` (yeni, saf), `platform/vehicleDataLayer/VehicleCompute.worker.ts` (VISIBILITY + kapı), `platform/vehicleDataLayer/VehicleSignalResolver.ts` (visibilitychange kanalı), `__tests__/sourceHealthVisibilityGating.test.ts` (yeni, 27 test), `platform/vehicleHal/providers/unifiedVehicleStoreProvider.ts`, `platform/vehicleHal/vehicleHalProviderAdapter.ts`, `platform/system/platformCoreVehicleHalWiring.ts` (health store DI), `platform/vehicleHal/providers/index.ts` (tip export), `platform/diagnosticSections.ts` (bounded sourceHealth), `__tests__/vehicleHalSourceHealthConsumption.test.ts` (yeni, 25 test), `__tests__/platformRuntimeDiagnostics.test.ts` + `__tests__/workerSourceHealthTransport.test.ts` (kilitler güncellendi) | Gerçek cihazda/araçta: (1) worker sourceHealth adapter'a ULAŞIYOR (Tanı raporunda `sourceHealth.gps=true` gibi gerçek değer); (2) **CAN canlıyken health=true**; (3) **ECU/kontak kapatılıp kablo bağlı kalınca ≤3-5 sn içinde `can=false`** ve **yalnız CAN-özel sinyaller** unsupported; (4) GPS/OBD fallback sinyalleri SUPPORTED kalıyor; (5) tüm destek kalkarsa `connection.changed=false`; (6) frame dönünce `can=true` ve **yalnız yeni veriyle** supported/`connection=true`; (7) disconnect TEK batch/TEK emit, duplicate event yok, `halBridge.droppedCount=0`; (8) UNKNOWN'da sahte disconnect yok; (9) **background/foreground geçişinde yanlış-pozitif `can=false` ölçülür**; (10) Xiaomi'de boot/CPU/RAM/FPS regresyonu yok; (11) **en az bir low-tier cihazda (K24/Mali-400 veya T507)** teşhis raporundan health + bridge sayaçları doğrulanır. Araç ve low-tier doğrulanmadan 🟢 YAPMA. ⚠️ **KISMİ CİHAZ KANITI (2026-07-12, Xiaomi 23090RA98I — ARAÇ/OBD YOK; ölçüm mevcut "Tanı Gönder" akışıyla, uygulama koduna enstrümantasyon EKLENMEDEN):** 🟢 **Worker sağlığı adapter/teşhis zincirine ULAŞIYOR:** raporda `sourceHealth{can:false, obd:false, gps:true, lastChangeAt:3171}` (araç yok → CAN/OBD ölü, GPS canlı — doğru). 🟢 **Sahte disconnect YOK:** `halWiring.ingestedSignalCount=3` (DEĞİŞMEDİ) → `can=false` hiçbir sinyali düşürmedi (CAN-özel sinyaller zaten hiç aktarılmamıştı = unknown; fail-closed yalnız DAHA ÖNCE aktarılmışa uygulanır). 🟢 **TEK abonelik korunuyor:** `activeSubscriptionCount=1` (altta araç + sağlık store'u iki abonelik olmasına rağmen). 🟢 `halBridge{present:true, started:true, droppedCount:0}`, teşhis bölümü 698-700 bayt, **sızıntı izi YOK**. 🟢 Boot crash/ANR yok (3 cold start: 1361/1128/1011 ms), idle CPU 26-65% / RSS 565-585 MB → önceki build'lerle aynı bantta. 🟡 **BACKGROUND TESTİ (45 sn arka plan → geri dön):** dönüşte `gps:true` KALDI (kalıcı yanlış-pozitif YOK) ve HAL'e hiçbir sinyal düşüşü yansımadı; **ancak** `lastChangeAt` 3171 → 699171 ms'e taşındı → oturumda bir sağlık GEÇİŞİ oldu, yani **arka planda geçici bir `gps:false` DIŞLANAMADI** (rapor yalnız son değişimi taşır; geçmiş yok). Bu, araç bağlıyken `can` için de geçici yanlış-pozitif riskinin sürdüğü anlamına gelir. 🟢 **BACKGROUND KAPISI — CİHAZDA DOĞRULANDI (2026-07-12, Xiaomi 23090RA98I, düzeltme sonrası build; ölçüm gerçek "Tanı Gönder" rapor gövdesinden, uygulama koduna enstrümantasyon EKLENMEDEN):** ① **Premise cihazda teyit:** uygulama arka plandayken/kilit ekranındayken WebView JS yaşıyor ve `document.visibilityState === 'hidden'` dönüyor → görünürlük kanalı gerçek Android WebView'da tetikleniyor. ② **SAHTE DEAD YOK:** boot raporu `sourceHealth{can:false,obd:false,gps:true, lastChangeAt:1026.6}`; **~20 dk arka plan** (60 sn kasıtlı + ekran kapanması/doze) sonrasında arka plandan alınan rapor **AYNI `lastChangeAt:1026.6`** → arka planda **SIFIR sağlık geçişi** üretildi. (Düzeltme ÖNCESİ aynı cihazda 45 sn arka plan `lastChangeAt`'i 3171 → 699171'e taşıyordu.) `activeSubscriptionCount=1`, `halBridge.droppedCount=0`. 🟡 **FOREGROUND ARTIĞI (dürüst kayıt):** foreground dönüşünden hemen sonraki raporda `lastChangeAt` 1438027.8'e taşındı — dönüş civarında bir `gps:false→true` çifti yaşandı. Bu, **arka plan saatinden DEĞİL**, tasarım gereği GÖRÜNÜR zamandaki 5 sn GPS sessizliğinden kaynaklanıyor (OS konum akışı dönüşte geç başlıyor); ölçümsüz "grace/N-tur" eşiği EKLENMEDİ. Kalıcı hasar YOK: son durum `{can:false,obd:false,gps:true}` (arka plan öncesiyle AYNI), `ingestedSignalCount` 3'e geri döndü, `droppedCount=0`, sahte `connection.changed=false` YOK. 🟡 Arka plandayken `ingestedSignalCount` 3→1 düştü (W4B **değer kaybı** yolu — sağlık kapısı değil; health donmuş durumdaydı) → dönüşte 3'e döndü. **HÂLÂ DOĞRULANMADI (🔴):** (a) gerçek araçta CAN canlı→`can=true`, ECU/kontak kapatınca ≤3-5 sn `can=false`, yalnız CAN-özel sinyallerin düşmesi, fallback'in korunması, `connection.changed=false`, frame dönünce yalnız TAZE veriyle supported/`connection=true`; (b) **low-tier cihaz (K24/Mali-400 veya T507)**; (c) araç bağlıyken foreground dönüşündeki görünür-zaman timeout'unun CAN'de de geçici bir `false` üretip üretmediği (CAN frame akışı dönüşte ≤3 sn içinde başlıyor mu) ölçülmeli. | 2026-07-12 | | 55 | **🔴 Device Validation Pending — Worker Source Health Transport (PR-1)** — Worker'ın 1 Hz watchdog'unda **ZATEN hesapladığı** per-kaynak canlılık (`_alive(_canLastSeen, SRC_TIMEOUT_CAN_MS=3s)` · OBD 5s · GPS 5s — `VehicleCompute.worker.ts:92-96, 1010-1013`) ana thread'e taşınır: yeni `SOURCE_HEALTH` mesajı → `VehicleSignalResolver` → `halStatusStore.sourceHealth`. **NEDEN:** sessiz ECU frame kaybında native `canStatus` yalnız TRANSPORT durumunu bildiriyor (`CarLauncherPlugin.java:3748-3756` — port açıkken `connected:true`), dolayısıyla frame kaybını **yalnız worker** görüyor; bu bilgi bugüne dek ana thread'e HİÇ ulaşmıyordu. **DAVRANIŞ:** can/obd/gps **AYRI** tutulur (tek global `halConnected` yetersiz — CAN ölüp GPS yaşarken toptan kapatma yapılamaz); `null` = **BİLİNMİYOR** ve `false` (ölü) ile KARIŞTIRILMAZ; mesaj **YALNIZ DEĞİŞİMDE** postlanır (kenar-tetikli, duplicate durum mesaj üretmez; store de idempotent → gereksiz abone uyanışı yok); payload **yalnız 3 boolean + monotonik ts** (araç verisi/hız/RPM/CAN frame/VIN/PII YOK); pre-allocated envelope (hot-path allocation yok). **YENİ TIMER YOK** (worker'daki `setInterval` sayısı 4'te sabit: speed/fuel/coolant/watchdog — testle kilitli). **BU PR YALNIZ BİLGİ TAŞIR:** hiçbir sinyali unsupported YAPMAZ; HAL · adapter · Event Bus · bridge davranışı DEĞİŞMEZ (adapter hâlâ `halStatusStore` okumaz — kilitli). Fail-closed tüketim **AYRI PR (PR-2)**. ⚠️ **Background/visibility politikası EKLENMEDİ** (risk: uygulama arka plandayken worker timer'ı kısılır veya plugin event teslimi gecikirse yanlış-pozitif "CAN öldü" üretebilir → PR-2'de tüketimden önce ölçülmeli). | `platform/vehicleDataLayer/VehicleCompute.worker.ts` (WorkerOutMessage + kenar-tetikli post), `platform/vehicleDataLayer/VehicleSignalResolver.ts` (SOURCE_HEALTH kanalı), `platform/vehicleDataLayer/halStatusStore.ts` (bounded `sourceHealth` + setter), `__tests__/workerSourceHealthTransport.test.ts` (yeni, 20 test) | Gerçek cihazda/araçta: (1) **geçiş başına TEK mesaj** ve mesaj SPAM'i yok (store yazımı yalnız değişimde); (2) yeni timer/polling YOK; (3) **sahte disconnect YOK** — sinyaller hâlâ supported (Tanı raporu: `halWiring.ingestedSignalCount` düşmüyor, `halBridge.droppedCount=0`); (4) **gerçek araçta CAN frame kesilince `canAlive=false`** (≤3 sn) ve (5) **reconnect'te `canAlive=true`**; (6) OBD/GPS bağımsız kalıyor; (7) Xiaomi 23090RA98I'de boot/CPU/RAM regresyonu YOK; (8) **en az bir low-tier cihazda (K24/Mali-400 veya T507)** doğrulama; (9) background/foreground geçişinde yanlış-pozitif ölçülür. Araç ve low-tier doğrulanmadan 🟢 YAPMA. ⚠️ **KISMİ CİHAZ KANITI (2026-07-12, Xiaomi 23090RA98I — ARAÇ/OBD YOK):** 🟢 **Sahte disconnect YOK:** gerçek "Tanı Gönder" raporunda `halWiring{ingestedSignalCount:3 (değişmedi), activeSubscriptionCount:1, lastErrorCode:null}` ve `halBridge{present:true, started:true, droppedCount:0}` → PR-1 bilgi taşıması hiçbir sinyali düşürmedi, HAL/bridge davranışı DEĞİŞMEDİ. 🟢 Boot crash/ANR YOK (3 cold start: 1287/1027/1058 ms), idle CPU 13-60% / RSS 558-580 MB → önceki build'lerle aynı bantta (regresyon gözlenmedi). **HÂLÂ DOĞRULANMADI (bu yüzden 🔴):** (a) **`canAlive=false` / reconnect `true` — gerçek araç/OBD gerektirir** (CAN frame kesintisi üretilemiyor); (b) **low-tier cihaz (K24/T507) doğrulaması yok**; (c) background/foreground yanlış-pozitif ölçümü yapılmadı. ⚠️ **AÇIK BLOKAJ:** `sourceHealth` alanları TEŞHİS RAPORUNA henüz eklenmedi (diagnosticSections bu PR'ın izinli kapsamında değildi) → `canAlive`'ı cihazda ADB'siz okumak için PR-2'de (veya küçük bir teşhis PR'ında) bounded alan eklenmeli. | 2026-07-12 | | 54 | **🔴 Device Validation Pending — Vehicle HAL → Event Bus Bridge Runtime Wiring (W4C)** — Zincir TAMAMLANDI: `UnifiedVehicleStore → VehicleHalProviderAdapter → vehicleHal → VehicleHalEventBridge → appEventBus`. Bridge foundation'ı (PR #54) gerçek `vehicleHal` singleton'ına ve W3'ün TEK aktif bus'ına bağlandı. **SAHİPLİK:** bridge bu wiring'in → cleanup YALNIZ bridge'i dispose eder; **HAL ve Bus DISPOSE EDİLMEZ**. **TEK INSTANCE:** aktif bridge varken ikinci start YENİ HAL aboneliği AÇMAZ; bayat kayıt serbest; eski cleanup yeni bridge'i düşüremez. **SystemBoot:** Wave 2'de HAL wiring'den SONRA, SystemOrchestrator'dan ÖNCE `_reg` ile → LIFO shutdown: bridge → HAL wiring → VDL → … → Event Bus (en son) ⇒ bridge kapanırken HAL ve Bus HÂLÂ AYAKTA. Bus yoksa wiring sessizce no-op (fail-soft, boot sürer). **EVENT:** `vehicle.signal.changed` (transient, supported sinyal değişimi; W4A sayesinde tek HAL snapshot → değişen sinyal başına 1 event), `vehicle.connection.changed` (retained; W4B unsupported geçişi TÜM destek kalkınca false üretir, kısmi kayıpta üretmez, reconnect'te true), `vehicle.identity.changed` (retained, ham VIN YOK — yalnız fingerprint). **`vehicle.ignition.changed` ÜRETİLMEZ** (kaynak yok → supported=false). TPMS payload bounded (≤8). Duplicate değer → duplicate event YOK (bridge imza-dedupe). **TEŞHİS:** support_snapshot'a bounded `halBridge{present,started,disposed,publishedCount,droppedCount,lastPublishAt}` eklendi — "bridge yok" (present:false, null) ile "0 event" AYRI. **BİLİNÇLİ KARARLAR:** throttle/debounce/coalescing/async-queue **YOK** → publish hızı = gerçek sinyal değişim hızı; **W4D kararı bu PR'ın CİHAZ ÖLÇÜMÜNDEN sonra** verilecek. **RİSK:** `PlatformEventBus.publish()` SENKRON dispatch eder → ileride eklenecek consumer'lar HAL/store hot-path stack'inde çalışır (bu PR'da ABONE YOK → yalnız publish maliyeti). **TİP NOTU:** bridge'in publish sözleşmesi geniş (`string`) domain/source alır, Bus dar birlik tipleri bekler → foundation'a dokunmadan wiring'de daraltıcı publish hedefi kullanıldı (doğrulama zayıflamaz; Bus zaten beyaz liste uygular). **KAPSAM DIŞI:** consumer/abone · Capability bridge/Registry · Deep Scan · Kernel servis kaydı · yeni catalog girdisi · HAL stale sweeper · **native `canStatus` wiring / worker source-health** · throttle (W4D). | `platform/system/platformCoreVehicleHalBridgeWiring.ts` (yeni), `platform/system/SystemBoot.ts` (additive Wave 2), `platform/diagnosticSections.ts` (halBridge bölümü), `__tests__/platformCoreVehicleHalBridgeWiring.test.ts` (yeni, 35 test), `__tests__/platformRuntimeDiagnostics.test.ts` (kilit güncellendi) | Gerçek cihazda/araçta: (1) TEK aktif bridge ve DOĞRU app Event Bus (Tanı raporu: `halBridge.present=true`, `started=true`); (2) HAL sinyal değişimleri doğru topic'lere publish (araçta speed/rpm/coolant/gear/reverse); (3) **publish/sn ölçülür** (park + sürüş); (4) duplicate sinyal duplicate event ÜRETMİYOR; (5) cleanup sonrası publish YOK; (6) bridge HAL/Bus'ı dispose ETMİYOR (restart sonrası bus sayaçları sıfırlanmıyor/çift bus yok); (7) `vehicle.ignition.changed` HİÇ yok; (8) TPMS payload bounded; (9) raporda VIN/ham CAN/koordinat/secret YOK; (10) `droppedCount=0` (veya gerekçeli); (11) CAN/OBD disconnect sonrası mümkün olan sinyaller unsupported + `connection.changed=false` YALNIZ tüm destek kalkınca; (12) reconnect sonrası `connection.changed=true`; (13) Xiaomi'de boot/idle CPU/RSS/FPS regresyonu YOK; (14) **en az bir low-tier cihazda (K24/Mali-400 veya T507)** sayaçlar raporda görünüyor, ANR/donma yok, publish yükü CPU/FPS bütçesini bozmuyor. ⚠️ **AÇIK BLOKAJ (ayrı iş):** ECU sessizce frame kesip store değerlerini null'a çekmezse disconnect TESPİT EDİLEMEZ (native `canStatus` hiçbir store'a yazılmıyor) → `connection.changed=false` o senaryoda üretilemez. ⚠️ **throttle/coalescing W4D'ye bırakıldı** (ölçüm sonrası). Masaüstü/araçsız Xiaomi testleriyle 🟢 YAPMA. ⚠️ **KISMİ CİHAZ KANITI (2026-07-12, Xiaomi 23090RA98I — ARAÇ/OBD YOK; ölçüm PR #68 teşhis yüzeyinden, uygulama koduna HİÇ enstrümantasyon eklenmeden):** 🟢 Bridge CANLI: gerçek "Tanı Gönder" raporunda `halBridge{present:true, started:true, droppedCount:0}`. 🟢 **PUBLISH/SN ÖLÇÜLDÜ (park, yalnız GPS):** iki rapor arası 440 sn — bridge `publishedCount` 33→197 (Δ=164) ⇒ **≈0,37 publish/sn**; bus `publishedCount` 34→198 (aynı Δ ⇒ yalnız bridge yayınlıyor), `droppedCount=0`, **`deliveredCount=0` ve `activeListenerCount=0`** (tasarım gereği abone YOK). 🟢 `halWiring.refreshCount` 56→487 (≈1 store update/sn) ve **`activeSubscriptionCount=1`** (tek HAL aboneliği). 🟢 `historyCount` 6'da SABİT → `vehicle.signal.changed` transient olduğu için history/RAM ŞİŞMİYOR; `retainedEventCount=2` (runtime.started + connection.changed). 🟢 Teşhis bölümü 612-615 bayt, **sızıntı izi YOK**. 🟢 Boot crash/ANR YOK (3 cold start: 1474/1112/1077 ms), idle CPU 17-27% / RSS 545-576 MB (önceki build'lerle aynı bantta → regresyon gözlenmedi). **HÂLÂ DOĞRULANMADI (bu yüzden 🔴): gerçek araç/OBD** — speed/rpm/coolant/gear/reverse event'leri, **sürüşteki publish/sn** (CAN frame hızı kodda yok → W4D throttle kararının girdisi), CAN/OBD disconnect sonrası `connection.changed=false`, reconnect, CPU/FPS bütçesi; ve **low-tier cihaz (K24/Mali-400 veya T507) doğrulaması YAPILMADI.** | 2026-07-12 | | 53 | **🔴 Device Validation Pending — Vehicle HAL Source Disconnect / Fail-Closed (W4B)** — Kaynak kopunca HAL'in eski değerleri sonsuza kadar `supported=true`+`stale=false` tutması KAPATILDI. **KOD GERÇEĞİ:** `VehicleHal.refresh()` (tek stale süpürücü) üretimde HİÇ çağrılmıyor; `canStatus` native olayı hiçbir store'a yazılmıyor (yalnız `console.info` — CanAdapter.ts:57-59); `canPhase` yalnız İLK-FRAME timeout'unu temsil ediyor. **Gerçek kopma kanıtı:** worker kaynak-timeout'unda `null` yayar (SRC_TIMEOUT_CAN/OBD/GPS) ve `resetCanData()` CAN alanlarını null'lar. **SEÇİLEN MODEL (A-refined, timersız, yeni abonelik/DI YOK):** adapter, DAHA ÖNCE aktarılmış bir sinyalin değeri kaybolunca `source:'none'` ingest eder → HAL `supported=false` + `value=null` (eski değer taşınmaz; quality `unknown`, confidence 0 — uydurma YOK; timestamp = GEÇİŞ anı). **unknown ≠ disconnected:** hiç aktarılmamış sinyal için SAHTE disconnect üretilmez. **Toptan kapatma YOK:** yalnız kaynağı kaybolan sinyal düşer; füzyon `speed` GPS/OBD ile canlıysa supported KALIR. **CAN-özel boolean'lar** (door_state/parking_brake) artık CAN canlılığına bağlı → `resetCanData()`'nın `false` artığı "gerçek veri" sayılmaz (reverse füzyon olduğu için genel canlılıkta kalır). **Batch korunur (W4A):** bir geçişte düşen tüm sinyaller TEK batch → TEK HAL emit; aynı disconnected durum TEKRAR ingest edilmez (duplicate emit yok); kaynak dönünce değer normal yoldan yeniden ingest edilir (supported=true + gerçek source). **ignition:** kaynak yok → hâlâ ASLA üretilmez. **KAPSAM DIŞI:** zaman-tabanlı stale sweeper / yeni timer · Event Bus/bridge (W4C) · throttle (W4D) · native CAN state machine refactor. | `platform/vehicleHal/vehicleHalProviderAdapter.ts`, `__tests__/vehicleHalSourceDisconnect.test.ts` (yeni, 25 test) | Gerçek araçta: (1) CAN/OBD bağlıyken ilgili sinyaller supported; (2) adaptör/CAN kesilince **yalnız o kaynağın sinyalleri** `source=none`/`supported=false` oluyor; (3) diğer canlı kaynakların sinyalleri (GPS füzyon hızı) supported KALIYOR; (4) bir kopma geçişi TEK batch/TEK HAL emit üretiyor; (5) aynı disconnected durum duplicate emit ÜRETMİYOR; (6) yeniden bağlanınca supported/source/değer doğru geri dönüyor; (7) `vehicle.ignition` unsupported KALIYOR; (8) yeni timer/polling YOK ve tek subscription korunuyor (Tanı raporu: `halWiring.activeSubscriptionCount=1`, kopma/geri gelme sırasında `ingestedSignalCount` düşüp yükseliyor); (9) Xiaomi'de CPU/RAM/boot regresyonu YOK; (10) **en az bir low-tier cihazda (K24/Mali-400 veya T507)** doğrulama. ⚠️ **BİLİNEN SINIRLAMA (kapsam dışı):** CAN kablosu takılıyken ECU sessizce frame göndermeyi keserse store'daki CAN değerleri null'a DÖNMEZ (native `canStatus` hiçbir store'a yazılmıyor) → bu senaryoda kopma HÂLÂ tespit edilemez; ayrı iş (native canStatus wiring veya worker CAN-sağlık expose). Test edilmeden 🟢 yapma. ⚠️ **KISMİ CİHAZ KANITI (2026-07-12, Xiaomi 23090RA98I — ARAÇ/OBD YOK; ölçüm W4E teşhis yüzeyiyle, uygulama koduna HİÇ enstrümantasyon eklenmeden):** 🟢 Cihazın gerçek "Tanı Gönder" raporunda `halWiring{started:true, **activeSubscriptionCount:1**, refreshCount:169, ingestedSignalCount:3, lastErrorCode:null}` → tek abonelik korunuyor, hata yok. 🟢 **CAN-boolean kapısı cihazda GÖZLEMLENDİ:** aynı cihazda W4B ÖNCESİ build `ingestedSignalCount=5` veriyordu, W4B ile **3** → CAN yokken `door_state`+`parking_brake` artık HAL'e aktarılmıyor (resetCanData'nın `false` artığı "gerçek veri" sayılmıyor). 🟢 Boot/CPU/RAM regresyonu YOK (aynı cihaz durumunda A/B): baseline (main eşdeğeri) cold start 3026/3014/3014 ms · CPU 0-26% · RSS 360-365 MB → W4B 3017/3012/3010 ms · CPU 3-28% · RSS ~332 MB. 🟢 Crash/ANR yok (3 cold start + background/foreground + rapor gönderimi). **HÂLÂ DOĞRULANMADI (bu yüzden 🔴): gerçek CAN/OBD kopar-bağla senaryosu** (supported→unsupported geçişi, fallback, reconnect, tek-emit) — **araç/OBD gerektirir**; ve **low-tier cihaz (K24/Mali-400 veya T507) doğrulaması yapılmadı.** | 2026-07-12 | diff --git a/src/__tests__/platformRuntimeDiagnostics.test.ts b/src/__tests__/platformRuntimeDiagnostics.test.ts index 25763fc3..ccb61f26 100644 --- a/src/__tests__/platformRuntimeDiagnostics.test.ts +++ b/src/__tests__/platformRuntimeDiagnostics.test.ts @@ -70,9 +70,15 @@ describe('W4E — teşhis üretimi runtime wiring BAŞLATMAZ', () => { // KİLİT GÜNCELLENDİ (W4C): bridge artık üretimde bağlı → teşhis onun BOUNDED sayaçlarını // OKUR. Değişmeyen invaryant: teşhis kendisi bridge/consumer YARATMAZ ve publish/subscribe ETMEZ. const snap = buildPlatformRuntimeSnapshot(); - expect(Object.keys(snap)).toEqual(['eventBus', 'halWiring', 'halBridge']); + // KİLİT GÜNCELLENDİ (PR-2): bounded `sourceHealth` bölümü eklendi (worker watchdog → store). + expect(Object.keys(snap)).toEqual(['eventBus', 'halWiring', 'halBridge', 'sourceHealth']); expect(snap.halBridge.present).toBe(false); // wiring çalışmadı → "ölçülemiyor" (0 değil) expect(snap.halBridge.publishedCount).toBeNull(); + // sourceHealth: worker hiç bildirmediyse UNKNOWN (null) — `false` (ölü) ile KARIŞMAZ. + expect(snap.sourceHealth.can).toBeNull(); + expect(snap.sourceHealth.obd).toBeNull(); + expect(snap.sourceHealth.gps).toBeNull(); + expect(snap.sourceHealth.lastChangeAt).toBeNull(); const platformBlock = SECTIONS_SRC.slice(SECTIONS_SRC.indexOf('PLATFORM RUNTIME')); expect(platformBlock).not.toMatch(/createVehicleHalEventBridge|\.subscribe\(|\.publish\(/); }); @@ -190,6 +196,7 @@ describe('W4E — whitelist ve privacy sınırı', () => { eventBus: scrub(snap.eventBus as unknown as Record), halWiring: scrub(snap.halWiring as unknown as Record), halBridge: scrub(snap.halBridge as unknown as Record), + sourceHealth: scrub(snap.sourceHealth as unknown as Record), }); expect(json).not.toMatch(/137|91|14\.4/); // sinyal değerleri yok (yalnız sayaçlar) expect(json).not.toMatch(/speed|rpm|coolant|voltage|lat|lon/i); diff --git a/src/__tests__/sourceHealthVisibilityGating.test.ts b/src/__tests__/sourceHealthVisibilityGating.test.ts new file mode 100644 index 00000000..429851b6 --- /dev/null +++ b/src/__tests__/sourceHealthVisibilityGating.test.ts @@ -0,0 +1,330 @@ +/** + * sourceHealthVisibilityGating.test.ts — PR-2 düzeltmesi: BACKGROUND SAHTE "ÖLÜ" KAPISI. + * + * PROBLEM (cihazda gözlendi): uygulama arka plandayken WebView timer'ları kısılır ve + * CAN/OBD/GPS frame'leri durur; ama worker'ın `_alive()` ölçümü `performance.now()` + * kullanır ve bu saat arka planda İLERLEMEYE DEVAM EDER. Dönüşteki ilk 1 Hz watchdog + * tikinde `now - lastSeen` timeout'u kat kat aşar → kaynak SAĞLAMKEN "ölü" kararı. + * PR #72 ile bu karar HAL'e işlenir → sahte `supported=false` + sahte + * `connection.changed=false` + dönüşte true/false fırtınası. + * + * ÇÖZÜM: `sourceHealthGate` — hidden iken sağlık GEÇİŞİ üretilmez; foreground dönüşünde + * timeout SAATİ yeniden tabanlanır (arka planda geçen süre timeout'a YAZILMAZ). + * Gerçek foreground kaybı MASKELENMEZ: görünür zamanda timeout dolarsa `false` üretilir. + * + * Kapı SAF (timer/DOM/store yok, zaman enjekte edilir) → invaryantlar DAVRANIŞSAL + * doğrulanır. Worker/resolver Web Worker/DOM bağımlı olduğu için YAPISAL kilitlenir. + */ + +import { describe, it, expect } from 'vitest'; +import { createSourceHealthGate } from '../platform/vehicleDataLayer/sourceHealthGate'; +import gateSrc from '../platform/vehicleDataLayer/sourceHealthGate.ts?raw'; +import workerSrc from '../platform/vehicleDataLayer/VehicleCompute.worker.ts?raw'; +import resolverSrc from '../platform/vehicleDataLayer/VehicleSignalResolver.ts?raw'; + +/* ── Worker watchdog + sağlık postu simülasyonu (gerçek koda birebir uyar) ────── + * `_alive()` (worker:409) ve `_postSourceHealthIfChanged()` mantığı; tek fark: zaman + * ve frame'ler testten sürülür → arka plan/foreground senaryosu deterministik kurulur. */ + +const T_CAN = 3_000; // SRC_TIMEOUT_CAN_MS +const T_OBD = 5_000; // SRC_TIMEOUT_OBD_MS +const T_GPS = 5_000; // SRC_TIMEOUT_GPS_MS + +type Src = 'can' | 'obd' | 'gps'; +interface Post { can: boolean; obd: boolean; gps: boolean; ts: number } + +function makeWatchdog() { + const gate = createSourceHealthGate(); + const posts: Post[] = []; + const lastSeen: Record = { can: 0, obd: 0, gps: 0 }; + let prevCan: boolean | null = null; + let prevObd: boolean | null = null; + let prevGps: boolean | null = null; + + const alive = (ls: number, timeout: number, now: number) => ls > 0 && (now - ls) < timeout; + + return { + gate, + posts, + /** Adaptörden frame geldi (worker `_handleVehicleData` → `_xLastSeen = performance.now()`). */ + frame(src: Src, now: number) { lastSeen[src] = now; }, + setVisible(visible: boolean, now: number) { gate.setVisible(visible, now); }, + /** 1 Hz watchdog tiki (yeni timer YOK — mevcut tik). */ + tick(now: number) { + if (!gate.isVisible()) return; // hidden → geçiş DONDURULUR + const can = gate.decide(now, lastSeen.can, T_CAN, alive(lastSeen.can, T_CAN, now), prevCan); + const obd = gate.decide(now, lastSeen.obd, T_OBD, alive(lastSeen.obd, T_OBD, now), prevObd); + const gps = gate.decide(now, lastSeen.gps, T_GPS, alive(lastSeen.gps, T_GPS, now), prevGps); + if (can === null || obd === null || gps === null) return; // karar yok → unknown korunur + if (can === prevCan && obd === prevObd && gps === prevGps) return; + prevCan = can; prevObd = obd; prevGps = gps; + posts.push({ can, obd, gps, ts: now }); + }, + /** 1 Hz tik dizisi (from, to] — arka planda tikler KISILDIĞI için ayrıca kullanılır. */ + ticks(from: number, to: number, stepMs = 1_000) { + for (let t = from; t <= to; t += stepMs) this.tick(t); + }, + last(): Post | undefined { return posts[posts.length - 1]; }, + }; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * 1) GÖRÜNÜRKEN davranış DEĞİŞMEDİ (gerçek kayıp hâlâ yakalanır) + * ════════════════════════════════════════════════════════════════════════ */ + +describe('görünür (foreground) — mevcut davranış korunur', () => { + it('CAN alive → dead normal çalışır (gerçek kayıp MASKELENMEZ)', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); + w.tick(1_500); + expect(w.last()).toMatchObject({ can: true, obd: false, gps: false }); + + w.ticks(2_500, 6_000); // CAN frame yok → 3s sonra ölü + expect(w.last()!.can).toBe(false); + expect(w.last()!.ts).toBeLessThanOrEqual(4_500); // ~timeout içinde karar + }); + + it('dead → alive (yeni frame) geçişi çalışır', () => { + const w = makeWatchdog(); + w.tick(1_000); // hepsi ölü + expect(w.last()).toMatchObject({ can: false }); + w.frame('can', 2_000); + w.tick(2_100); + expect(w.last()!.can).toBe(true); + }); + + it('CAN / OBD / GPS BAĞIMSIZ (CAN düşer, GPS canlı kalır)', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); w.frame('gps', 1_000); + w.tick(1_100); + expect(w.last()).toMatchObject({ can: true, gps: true }); + + for (let t = 2_000; t <= 6_000; t += 1_000) { w.frame('gps', t); w.tick(t); } // yalnız GPS besleniyor + expect(w.last()).toMatchObject({ can: false, gps: true }); // CAN düştü, GPS AYAKTA + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 2) HIDDEN — sahte "ölü" ÜRETİLMEZ + * ════════════════════════════════════════════════════════════════════════ */ + +describe('arka plan (hidden) — sağlık geçişi dondurulur', () => { + it('hidden iken YENİ false ÜRETİLMEZ (timer kısılsa bile)', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); w.frame('gps', 1_000); + w.tick(1_100); + const n = w.posts.length; + + w.setVisible(false, 2_000); + w.ticks(3_000, 60_000); // arka planda tikler gelse bile + expect(w.posts.length).toBe(n); // HİÇ yeni mesaj yok + expect(w.last()).toMatchObject({ can: true, gps: true }); + }); + + it('hidden iken lastSeen BOZULMAZ — dönüşte taze frame anında "alive" der', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); + w.tick(1_100); + w.setVisible(false, 2_000); + w.setVisible(true, 50_000); // 48 sn arka plan + w.frame('can', 50_100); // adaptör frame'i döndü + w.tick(50_200); + expect(w.last()).toMatchObject({ can: true }); + expect(w.posts.length).toBe(1); // arada SAHTE false/true çifti YOK + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 3) FOREGROUND dönüşü — yeniden tabanlama + * ════════════════════════════════════════════════════════════════════════ */ + +describe('foreground dönüşü — timeout saati yeniden tabanlanır', () => { + it('ARKA PLAN SÜRESİ timeout hesabına YAZILMAZ (dönüşte anında false YOK)', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); w.frame('obd', 1_000); w.frame('gps', 1_000); + w.tick(1_100); + expect(w.last()).toMatchObject({ can: true, obd: true, gps: true }); + + w.setVisible(false, 2_000); + w.setVisible(true, 47_000); // 45 sn arka plan (cihaz senaryosu) + w.tick(47_001); // dönüşteki İLK tik — eski lastSeen=1000 + expect(w.posts.length).toBe(1); // 46 sn "sessizlik" ölü SAYILMAZ + expect(w.last()).toMatchObject({ can: true }); + }); + + it('dönüşten sonra YENİ FRAME gelirse → true KALIR (kayıp uydurulmaz)', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); + w.tick(1_100); + w.setVisible(false, 2_000); + w.setVisible(true, 47_000); + // Adaptör akışı döndü (gerçek araç sürekli frame yayar) + for (let t = 47_500; t <= 55_000; t += 1_000) { w.frame('can', t); w.tick(t); } + expect(w.last()).toMatchObject({ can: true }); + expect(w.posts.length).toBe(1); // dönüşte SAHTE false/true çifti YOK + }); + + it('dönüşten sonra FRAME GELMEZSE → GÖRÜNÜR zamanda gerçek timeout dolunca false', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); + w.tick(1_100); + w.setVisible(false, 2_000); + w.setVisible(true, 47_000); // baseline = 47_000 + + w.tick(48_000); // görünür süre 1s < 3s → KARAR YOK + expect(w.posts.length).toBe(1); + w.tick(49_500); // 2.5s < 3s → hâlâ karar yok + expect(w.posts.length).toBe(1); + + w.tick(50_100); // 3.1s ≥ 3s → GERÇEK timeout doldu + expect(w.posts.length).toBe(2); + expect(w.last()).toMatchObject({ can: false }); + }); + + it('gerçek foreground CAN kaybı SONSUZA KADAR maskelenmez (dead nihayetinde gelir)', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); + w.tick(1_100); + w.setVisible(false, 2_000); + w.setVisible(true, 100_000); + w.ticks(101_000, 130_000); + expect(w.last()).toMatchObject({ can: false }); + }); + + it('dönüşte CAN ölü + GPS canlı → BAĞIMSIZ (yalnız CAN düşer)', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); w.frame('gps', 1_000); + w.tick(1_100); + w.setVisible(false, 2_000); + w.setVisible(true, 47_000); + for (let t = 47_500; t <= 56_000; t += 1_000) { w.frame('gps', t); w.tick(t); } // yalnız GPS döndü + expect(w.last()).toMatchObject({ can: false, gps: true }); + }); + + it('DUPLICATE visibility mesajı pencereyi YENİDEN BAŞLATMAZ (spam/uzatma yok)', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); + w.tick(1_100); + w.setVisible(false, 2_000); + w.setVisible(true, 47_000); + w.setVisible(true, 49_000); // DUPLICATE — baseline 47_000 kalmalı + w.setVisible(true, 49_900); // DUPLICATE + w.tick(50_100); // 47_000 + 3s < 50_100 → false gelmeli + expect(w.last()).toMatchObject({ can: false }); + }); + + it('BACKGROUND → FOREGROUND: her şey sağlıklıyken EVENT FIRTINASI YOK (0 yeni mesaj)', () => { + const w = makeWatchdog(); + w.frame('can', 1_000); w.frame('obd', 1_000); w.frame('gps', 1_000); + w.tick(1_100); + const n = w.posts.length; + + w.setVisible(false, 2_000); + w.ticks(3_000, 47_000); // 45 sn arka plan + w.setVisible(true, 48_000); + for (let t = 48_500; t <= 56_000; t += 500) { w.frame('can', t); w.frame('obd', t); w.frame('gps', t); w.tick(t); } + + expect(w.posts.length).toBe(n); // false/true ÇİFTİ YOK → bridge sahte + // connection.changed üretmez + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 4) Kapı sözleşmesi + saflık + * ════════════════════════════════════════════════════════════════════════ */ + +describe('sourceHealthGate — sözleşme', () => { + it('ana thread bildirene kadar GÖRÜNÜR varsayılır (boot davranışı değişmez)', () => { + const g = createSourceHealthGate(); + expect(g.isVisible()).toBe(true); + // baseline yok → ham karar aynen döner + expect(g.decide(5_000, 1_000, 3_000, false, true)).toBe(false); + }); + + it('duplicate setVisible NO-OP döner (false), gerçek değişim true döner', () => { + const g = createSourceHealthGate(); + expect(g.setVisible(true, 10)).toBe(false); // zaten görünür + expect(g.setVisible(false, 20)).toBe(true); + expect(g.setVisible(false, 30)).toBe(false); // duplicate + expect(g.setVisible(true, 40)).toBe(true); + }); + + it('rebaseline penceresinde karar YOK → son BİLİNEN değer korunur (prev)', () => { + const g = createSourceHealthGate(); + g.setVisible(false, 1_000); + g.setVisible(true, 10_000); // baseline = 10_000 + expect(g.decide(10_500, 500, 3_000, false, true)).toBe(true); // prev=true korunur + expect(g.decide(10_500, 500, 3_000, false, null)).toBeNull(); // prev yok → UNKNOWN + }); + + it('SAF: timer/DOM/store/IO YOK — zaman dışarıdan enjekte edilir', () => { + expect(gateSrc).not.toMatch(/setInterval|setTimeout|requestAnimationFrame|performance\.now/); + expect(gateSrc).not.toMatch(/document|window|self\.|localStorage|fetch\(/); + expect(gateSrc).not.toMatch(/from\s+['"]/); // hiçbir import yok → yan etkisiz + }); + + it('kapı HAL/Event Bus/bridge/store KAVRAMI TAŞIMAZ (yalnız sağlık kararı)', () => { + expect(gateSrc).not.toMatch(/vehicleHal|eventBus|halStatusStore|supported|'none'/); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 5) Worker + resolver kilitleri (yapısal — Worker/DOM jsdom'da koşturulamaz) + * ════════════════════════════════════════════════════════════════════════ */ + +describe('worker — görünürlük kapısı bağlandı', () => { + it('VISIBILITY mesajı bounded: yalnız boolean (araç verisi/PII YOK)', () => { + expect(workerSrc).toMatch(/\|\s*\{\s*type:\s*'VISIBILITY';\s*visible:\s*boolean\s*\}/); + }); + + it('hidden iken sağlık postu ERKEN ÇIKAR (geçiş üretilmez)', () => { + expect(workerSrc).toMatch(/function _postSourceHealthIfChanged[\s\S]{0,200}if \(!_healthGate\.isVisible\(\)\) return;/); + }); + + it('karar verilemezse POSTLANMAZ (unknown korunur — sahte false yok)', () => { + expect(workerSrc).toMatch(/if \(can === null \|\| obd === null \|\| gps === null\) return;/); + }); + + it('VISIBILITY dispatcher\'a bağlandı ve YALNIZ kapıyı besler', () => { + expect(workerSrc).toMatch(/case 'VISIBILITY':\s*_handleVisibility\(msg\);/); + expect(workerSrc).toMatch(/function _handleVisibility[\s\S]{0,200}_healthGate\.setVisible\(msg\.visible === true, performance\.now\(\)\)/); + }); + + it('YENİ TIMER YOK — worker setInterval sayısı DEĞİŞMEDİ (4)', () => { + expect((workerSrc.match(/setInterval\(/g) ?? []).length).toBe(4); + }); + + it('kapı YALNIZ sağlık kararını etkiler — fusion/reverse/SAB HAM `_alive()` kullanır', () => { + // `_watchdog` ham değerleri hesaplar ve reverse/SAB kararlarını onlarla verir. + expect(workerSrc).toMatch(/const canAlive = _alive\(_canLastSeen, SRC_TIMEOUT_CAN_MS\);/); + expect(workerSrc).toMatch(/if \(!canAlive && !obdAlive\)/); // reverse reset — DEĞİŞMEDİ + }); +}); + +describe('resolver — görünürlük kanalı (zero-leak)', () => { + it('TEK visibilitychange dinleyicisi kurar', () => { + expect((resolverSrc.match(/addEventListener\('visibilitychange'/g) ?? []).length).toBe(1); + }); + + it('stop() dinleyiciyi SÖKER → dispose sonrası worker\'a VISIBILITY gitmez', () => { + const stopBlock = resolverSrc.slice(resolverSrc.indexOf('stop(): void {')); + expect(stopBlock).toMatch(/removeEventListener\('visibilitychange', this\._onVisibilityBound\)/); + expect(stopBlock).toMatch(/this\._onVisibilityBound = null;/); + // Sökme, worker terminate'ten ÖNCE olmalı + expect(stopBlock.indexOf('removeEventListener(\'visibilitychange\'')) + .toBeLessThan(stopBlock.indexOf('terminate()')); + }); + + it('DUPLICATE durum POSTLANMAZ (aynı görünürlük tekrar gönderilmez)', () => { + expect(resolverSrc).toMatch(/if \(visible === this\._lastVisibleSent\) return;/); + }); + + it('yeni timer/polling AÇMAZ (yalnız event listener)', () => { + const visBlock = resolverSrc.slice( + resolverSrc.indexOf('private _sendVisibility'), + resolverSrc.indexOf('stop(): void {'), + ); + expect(visBlock.length).toBeGreaterThan(0); + expect(visBlock).not.toMatch(/setInterval|setTimeout|requestAnimationFrame/); + }); +}); diff --git a/src/__tests__/vehicleHalSourceHealthConsumption.test.ts b/src/__tests__/vehicleHalSourceHealthConsumption.test.ts new file mode 100644 index 00000000..643d4a0e --- /dev/null +++ b/src/__tests__/vehicleHalSourceHealthConsumption.test.ts @@ -0,0 +1,355 @@ +/** + * vehicleHalSourceHealthConsumption.test.ts — PR-2: Source Health → HAL fail-closed tüketimi. + * + * AKIŞ: worker watchdog → SOURCE_HEALTH → halStatusStore → provider snapshot → adapter → HAL → bridge. + * + * ÇEKİRDEK KURALLAR: + * • `null` = BİLİNMİYOR → SAHTE disconnect YOK (davranış W4B ile aynı kalır). + * • `can=false` = CAN KESİN ölü → yalnız CAN-ÖZEL sinyaller `source:'none'` (fail-closed). + * • Füzyon sinyalleri (speed/rpm/fuel/odometer/reverse) başka kaynak canlıysa SUPPORTED kalır. + * • `can=true` TEK BAŞINA eski değeri DİRİLTMEZ — yalnız GERÇEKTEN YENİ veri supported yapar. + * • Tek batch / tek HAL emit (W4A korunur); aynı durum duplicate emit üretmez. + * • Yeni timer YOK. + */ + +import { describe, it, expect } from 'vitest'; +import { + createVehicleHalProviderAdapter, + createVehicleHal, + createUnifiedVehicleStoreProvider, + type VehicleHalSnapshot, + type VehicleSignalId, + type VehicleSignalInput, + type UnifiedVehicleStateReadable, + type UnifiedVehicleStoreLike, + type HalStatusStoreLike, +} from '../platform/vehicleHal'; +import { createPlatformEventBus, createVehicleHalEventBridge, type PlatformEvent } from '../platform/eventBus'; +import adapterSrc from '../platform/vehicleHal/vehicleHalProviderAdapter.ts?raw'; +import providerSrc from '../platform/vehicleHal/providers/unifiedVehicleStoreProvider.ts?raw'; + +const NOW = 11_000_000; + +type Health = { canAlive: boolean | null; obdAlive: boolean | null; gpsAlive: boolean | null; updatedAt: number | null }; +const UNKNOWN: Health = { canAlive: null, obdAlive: null, gpsAlive: null, updatedAt: null }; + +function fakeStores(initial: UnifiedVehicleStateReadable, health: Health = UNKNOWN) { + let state = initial; + let h = health; + const sl = new Set<() => void>(); + const hl = new Set<() => void>(); + const store: UnifiedVehicleStoreLike = { + getState: () => state, + subscribe: (l) => { const w = () => l(state); sl.add(w); return () => { sl.delete(w); }; }, + }; + const healthStore: HalStatusStoreLike = { + getState: () => ({ sourceHealth: h }), + subscribe: (l) => { hl.add(l); return () => { hl.delete(l); }; }, + }; + return { + store, healthStore, + setState: (next: UnifiedVehicleStateReadable) => { state = next; sl.forEach((f) => f()); }, + setHealth: (next: Partial) => { h = { ...h, ...next }; hl.forEach((f) => f()); }, + subCount: () => sl.size + hl.size, + }; +} + +/** Gerçek HAL + provider + adapter zinciri (emit sayacı ile). */ +function chain(initial: UnifiedVehicleStateReadable, health: Health = UNKNOWN) { + const s = fakeStores(initial, health); + const hal = createVehicleHal({ now: () => NOW }); + const emits: VehicleHalSnapshot[] = []; + hal.subscribe((snap) => emits.push(snap)); + const provider = createUnifiedVehicleStoreProvider({ store: s.store, healthStore: s.healthStore }); + const adapter = createVehicleHalProviderAdapter({ hal, source: provider, now: () => NOW }); + return { ...s, hal, emits, provider, adapter }; +} + +/** Batch'leri kaydeden fake HAL (batch/emit sayımı için). */ +function batchChain(initial: UnifiedVehicleStateReadable, health: Health = UNKNOWN) { + const s = fakeStores(initial, health); + const batches: Partial>[] = []; + const hal = { ingest: (b: Partial>) => { batches.push(b); } }; + const provider = createUnifiedVehicleStoreProvider({ store: s.store, healthStore: s.healthStore }); + const adapter = createVehicleHalProviderAdapter({ hal, source: provider, now: () => NOW }); + return { ...s, batches, adapter, provider, lastKeys: () => Object.keys(batches[batches.length - 1] ?? {}) as VehicleSignalId[] }; +} + +const CAN_SIGNALS: VehicleSignalId[] = [ + 'vehicle.coolant_temp', 'vehicle.oil_temp', 'vehicle.throttle', 'vehicle.battery_voltage', + 'vehicle.gear', 'vehicle.ambient_temp', 'vehicle.tpms', 'vehicle.door_state', 'vehicle.parking_brake', +]; + +/* ── UNKNOWN: sahte disconnect yok ────────────────────────────────────────── */ + +describe('PR-2 — sourceHealth UNKNOWN (null)', () => { + it('health bilinmiyorken SAHTE disconnect üretilmez (W4B davranışı korunur)', () => { + const c = chain({ speed: 50, canCoolantTemp: 90, canRpm: 1200 }); + c.adapter.start(); + expect(c.hal.hasSignal('vehicle.coolant_temp')).toBe(true); + expect(c.hal.getCoolantTemp()).toBe(90); + expect(c.hal.hasSignal('vehicle.speed')).toBe(true); + }); + + it('ilk boot\'ta unsupported batch ÜRETİLMEZ (health null + veri yok)', () => { + const c = batchChain({}); + c.adapter.start(); + expect(c.batches.length).toBe(0); + }); + + it('health null iken hiç aktarılmamış sinyal source:none YAPILMAZ', () => { + const c = batchChain({ speed: 40 }); + c.adapter.start(); + expect(c.lastKeys()).toEqual(['vehicle.speed']); // coolant vb. için "none" yok + }); + + it('healthStore VERİLMEZSE snapshot sağlığı UNKNOWN → davranış değişmez', () => { + const s = fakeStores({ speed: 30, canCoolantTemp: 88 }); + const provider = createUnifiedVehicleStoreProvider({ store: s.store }); // healthStore YOK + const snap = provider.getSnapshot()!; + expect(snap.sourceHealth?.can).toBeNull(); + expect(snap.canCoolantTemp).toBe(88); + }); +}); + +/* ── CAN DEAD: fail-closed, yalnız CAN-özel sinyaller ─────────────────────── */ + +describe('PR-2 — can=false (KESİN ölü)', () => { + it('yalnız CAN-ÖZEL sinyaller unsupported olur (füzyon speed SUPPORTED kalır)', () => { + const c = chain( + { speed: 55, canCoolantTemp: 90, canOilTemp: 100, canGearPos: 3, canDoorOpen: true }, + { ...UNKNOWN, canAlive: true }, + ); + c.adapter.start(); + expect(c.hal.hasSignal('vehicle.coolant_temp')).toBe(true); + + c.setHealth({ canAlive: false }); // ECU sustu, store değerleri DONMUŞ + expect(c.hal.hasSignal('vehicle.coolant_temp')).toBe(false); + expect(c.hal.getSignal('vehicle.coolant_temp')!.source).toBe('none'); + expect(c.hal.getSignal('vehicle.coolant_temp')!.value).toBeNull(); + expect(c.hal.hasSignal('vehicle.oil_temp')).toBe(false); + expect(c.hal.hasSignal('vehicle.gear')).toBe(false); + expect(c.hal.hasSignal('vehicle.door_state')).toBe(false); + // Füzyon korunur: + expect(c.hal.hasSignal('vehicle.speed')).toBe(true); + expect(c.hal.getSpeed()).toBe(55); + }); + + it('disconnect geçişi TEK batch / TEK HAL emit üretir', () => { + const c = chain( + { speed: 55, canCoolantTemp: 90, canOilTemp: 100, canBatteryVolt: 14, canGearPos: 3 }, + { ...UNKNOWN, canAlive: true }, + ); + c.adapter.start(); + const before = c.emits.length; + c.setHealth({ canAlive: false }); // 4 CAN sinyali birden düşer + expect(c.emits.length - before).toBe(1); // TEK emit + }); + + it('aynı false TEKRARINDA duplicate batch/emit YOK', () => { + const c = chain({ speed: 55, canCoolantTemp: 90 }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + c.setHealth({ canAlive: false }); + const after = c.emits.length; + c.setHealth({ canAlive: false }); // aynı durum + c.adapter.refresh(); + c.adapter.refresh(); + expect(c.emits.length).toBe(after); + }); + + it('CAN boolean `false` GERÇEK veri (can canlıyken) — "kaynak yok" ile karışmaz', () => { + const c = chain( + { canRpm: 900, canDoorOpen: false, canParkingBrake: false }, + { ...UNKNOWN, canAlive: true }, + ); + c.adapter.start(); + expect(c.hal.hasSignal('vehicle.door_state')).toBe(true); + expect(c.hal.getSignal('vehicle.door_state')!.value).toBe(false); + expect(c.hal.hasSignal('vehicle.parking_brake')).toBe(true); + }); + + it('CAN-özel sinyallerin TAMAMI düşer, füzyon hiç etkilenmez (matris kilidi)', () => { + const c = chain({ + speed: 20, rpm: 1500, fuel: 60, odometer: 1000, reverse: false, + canCoolantTemp: 90, canOilTemp: 100, canThrottle: 20, canBatteryVolt: 14, + canGearPos: 2, canAmbientTemp: 18, canTpmsKpa: [220, 221, 222, 223], + canDoorOpen: false, canParkingBrake: false, + }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + c.setHealth({ canAlive: false }); + for (const id of CAN_SIGNALS) expect(c.hal.hasSignal(id), id).toBe(false); + for (const id of ['vehicle.speed', 'vehicle.rpm', 'vehicle.fuel_level', 'vehicle.odometer', 'vehicle.reverse'] as VehicleSignalId[]) { + expect(c.hal.hasSignal(id), id).toBe(true); + } + }); + + it('ignition HİÇBİR durumda üretilmez (kaynak yok)', () => { + const c = chain({ speed: 10, canCoolantTemp: 90 }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + c.setHealth({ canAlive: false }); + expect(c.hal.hasSignal('vehicle.ignition')).toBe(false); + }); +}); + +/* ── Fallback ─────────────────────────────────────────────────────────────── */ + +describe('PR-2 — fallback', () => { + it('rpm: CAN ölünce BAYAT canRpm KULLANILMAZ; fused rpm varsa supported kalır', () => { + const c = chain({ rpm: 1800, canRpm: 900 }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + expect(c.hal.getRPM()).toBe(1800); + c.setHealth({ canAlive: false }); + expect(c.hal.hasSignal('vehicle.rpm')).toBe(true); // füzyon rpm canlı + expect(c.hal.getRPM()).toBe(1800); + }); + + it('rpm: CAN ölü + fused rpm YOK → unsupported (bayat canRpm diriltmez)', () => { + const c = chain({ canRpm: 900 }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + expect(c.hal.getRPM()).toBe(900); + c.setHealth({ canAlive: false }); + expect(c.hal.hasSignal('vehicle.rpm')).toBe(false); + }); + + it('speed/reverse GPS/worker füzyonuyla canlıysa CAN ölse de supported', () => { + const c = chain({ speed: 42, reverse: true, canCoolantTemp: 90 }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + c.setHealth({ canAlive: false }); + expect(c.hal.hasSignal('vehicle.speed')).toBe(true); + expect(c.hal.getSignal('vehicle.reverse')!.value).toBe(true); + expect(c.hal.getSignal('vehicle.speed')!.source).toBe('inferred'); // metadata bozulmadı + }); + + it('obd/gps health CAN sinyallerini ETKİLEMEZ (kaynaklar bağımsız)', () => { + const c = chain({ canCoolantTemp: 90, speed: 10 }, { ...UNKNOWN, canAlive: true, obdAlive: true, gpsAlive: true }); + c.adapter.start(); + c.setHealth({ obdAlive: false, gpsAlive: false }); // CAN hâlâ canlı + expect(c.hal.hasSignal('vehicle.coolant_temp')).toBe(true); + }); +}); + +/* ── Reconnect (sahte diriltme yasağı) ────────────────────────────────────── */ + +describe('PR-2 — reconnect', () => { + it('can=true TEK BAŞINA eski (bayat) değeri DİRİLTMEZ', () => { + const c = chain({ speed: 10, canCoolantTemp: 90 }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + c.setHealth({ canAlive: false }); + expect(c.hal.hasSignal('vehicle.coolant_temp')).toBe(false); + + c.setHealth({ canAlive: true }); // sağlık döndü, YENİ frame YOK + expect(c.hal.hasSignal('vehicle.coolant_temp')).toBe(false); // hâlâ unsupported + expect(c.hal.getCoolantTemp()).toBeNull(); + }); + + it('YENİ gerçek veri gelince supported=true + gerçek source/quality/confidence', () => { + const c = chain({ speed: 10, canCoolantTemp: 90 }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + c.setHealth({ canAlive: false }); + c.setHealth({ canAlive: true }); + c.setState({ speed: 10, canCoolantTemp: 95 }); // TAZE frame + const sig = c.hal.getSignal('vehicle.coolant_temp')!; + expect(sig.supported).toBe(true); + expect(sig.value).toBe(95); + expect(sig.source).toBe('can'); + expect(sig.quality).toBe('high'); + expect(sig.confidence).toBe(0.9); + }); + + it('reconnect TEK batch/TEK emit; tekrar eden aynı veri duplicate emit üretmez', () => { + const c = chain({ canCoolantTemp: 90, canOilTemp: 100 }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + c.setHealth({ canAlive: false }); + c.setHealth({ canAlive: true }); + const before = c.emits.length; + c.setState({ canCoolantTemp: 91, canOilTemp: 101 }); // ikisi birden taze + expect(c.emits.length - before).toBe(1); + c.setState({ canCoolantTemp: 91, canOilTemp: 101 }); // aynı veri + expect(c.emits.length - before).toBe(1); + }); +}); + +/* ── Bridge entegrasyonu (connection.changed) ─────────────────────────────── */ + +describe('PR-2 — bridge connection.changed', () => { + function withBridge(initial: UnifiedVehicleStateReadable, health: Health) { + const c = chain(initial, health); + const bus = createPlatformEventBus({ now: () => NOW }); + const events: PlatformEvent[] = []; + bus.subscribeDomain('vehicle', (e) => events.push(e)); + const bridge = createVehicleHalEventBridge({ hal: c.hal, bus }); + bridge.start(); + return { ...c, bus, events, bridge, conn: () => events.filter((e) => e.name === 'vehicle.connection.changed') + .map((e) => (e.payload as { connected: boolean }).connected) }; + } + + it('TÜM destek kalkınca connection=false yayınlanır', () => { + const b = withBridge({ canCoolantTemp: 90, canRpm: 1200 }, { ...UNKNOWN, canAlive: true }); + b.adapter.start(); + expect(b.conn()).toEqual([true]); + b.setHealth({ canAlive: false }); // CAN-özel + rpm(canRpm) düşer + expect(b.conn()).toEqual([true, false]); + expect(b.bridge.getStatus().droppedCount).toBe(0); + }); + + it('KISMİ kayıpta connection=false yayınlanmaz (speed hâlâ supported)', () => { + const b = withBridge({ speed: 44, canCoolantTemp: 90 }, { ...UNKNOWN, canAlive: true }); + b.adapter.start(); + b.setHealth({ canAlive: false }); + expect(b.conn()).toEqual([true]); // false YOK + }); + + it('reconnect sonrası connection=true YALNIZ gerçek veri gelince', () => { + const b = withBridge({ canCoolantTemp: 90 }, { ...UNKNOWN, canAlive: true }); + b.adapter.start(); + b.setHealth({ canAlive: false }); + b.setHealth({ canAlive: true }); // henüz frame yok + expect(b.conn()).toEqual([true, false]); + b.setState({ canCoolantTemp: 95 }); // taze frame + expect(b.conn()).toEqual([true, false, true]); + }); +}); + +/* ── Lifecycle & kapsam ───────────────────────────────────────────────────── */ + +describe('PR-2 — lifecycle & kapsam', () => { + it('provider TEK abonelik kaydeder (araç + sağlık store\'u tek unsub\'a sarılır)', () => { + const c = chain({ speed: 10 }, UNKNOWN); + c.adapter.start(); + expect(c.provider.activeSubscriptionCount).toBe(1); // teşhis invaryantı korunur + expect(c.subCount()).toBe(2); // altta 2 store aboneliği (araç+sağlık) + }); + + it('cleanup sonrası health değişimi HAL\'e AKMAZ; abonelikler bırakılır', () => { + const c = chain({ speed: 10, canCoolantTemp: 90 }, { ...UNKNOWN, canAlive: true }); + c.adapter.start(); + c.adapter.dispose(); + c.setHealth({ canAlive: false }); + expect(c.hal.hasSignal('vehicle.coolant_temp')).toBe(true); // akmadı + expect(c.provider.activeSubscriptionCount).toBe(0); + expect(c.subCount()).toBe(0); + }); + + it('boot → shutdown → boot abonelik ÇOĞALTMAZ', () => { + const c = chain({ speed: 10 }, UNKNOWN); + c.adapter.start(); + c.adapter.stop(); + c.adapter.start(); + expect(c.provider.activeSubscriptionCount).toBe(1); + }); + + it('yeni timer/polling YOK; import yan etkisiz', () => { + for (const src of [adapterSrc, providerSrc]) { + expect(src).not.toMatch(/setInterval|setTimeout|requestAnimationFrame/); + expect(src).not.toMatch(/window\.__|globalThis\./); + } + // Provider/adapter store'ları DOĞRUDAN import etmez (yapısal DI). + expect(providerSrc).not.toMatch(/from\s+['"][^'"]*halStatusStore/); + expect(adapterSrc).not.toMatch(/from\s+['"][^'"]*(halStatusStore|UnifiedVehicleStore)/); + }); + + it('worker/resolver/SystemBoot/bridge foundation DEĞİŞMEDİ (kapsam)', () => { + expect(adapterSrc).not.toMatch(/from\s+['"][^'"]*(eventBus|bridges?|kernel|capability|deepScan)/i); + expect(providerSrc).not.toMatch(/from\s+['"][^'"]*(eventBus|bridges?|kernel)/i); + }); +}); diff --git a/src/__tests__/workerSourceHealthTransport.test.ts b/src/__tests__/workerSourceHealthTransport.test.ts index 64a0039f..b41f973f 100644 --- a/src/__tests__/workerSourceHealthTransport.test.ts +++ b/src/__tests__/workerSourceHealthTransport.test.ts @@ -173,8 +173,12 @@ describe('PR-1 — kapsam sınırı', () => { } }); - it('adapter hâlâ halStatusStore OKUMAZ (fail-closed tüketim AYRI PR)', () => { - expect(adapterSrc).not.toMatch(/halStatusStore|sourceHealth/i); + it('adapter sağlığı YALNIZ snapshot üzerinden tüketir; halStatusStore DOĞRUDAN import EDİLMEZ', () => { + // KİLİT GÜNCELLENDİ (PR-2): fail-closed tüketim geldi → adapter artık `sourceHealth`'i + // provider snapshot'ından okur. DEĞİŞMEYEN invaryant: store'lar yapısal DI ile gelir, + // adapter/provider halStatusStore'u DOĞRUDAN import ETMEZ (import yan etkisiz kalır). + expect(adapterSrc).toMatch(/sourceHealth/); + expect(adapterSrc).not.toMatch(/from\s+['"][^'"]*halStatusStore/); }); it('sağlık bloğu hiçbir sinyali unsupported YAPMAZ (source:none üretmez)', () => { diff --git a/src/platform/diagnosticSections.ts b/src/platform/diagnosticSections.ts index 228350a5..4f3554b1 100644 --- a/src/platform/diagnosticSections.ts +++ b/src/platform/diagnosticSections.ts @@ -484,12 +484,30 @@ export interface PlatformHalBridgeDiag { lastPublishAt: number | null; } +/** + * Kaynak sağlığı (PR-1 worker watchdog → halStatusStore). `null` = **BİLİNMİYOR** + * (worker hiç bildirmedi) — `false` (kaynak ÖLÜ) ile ASLA karıştırılmaz. + * Yalnız 3 boolean|null + monotonik ts: sinyal DEĞERİ (hız/RPM), CAN frame, VIN, + * koordinat, ham payload GİRMEZ. + */ +export interface PlatformSourceHealthDiag { + can: boolean | null; + obd: boolean | null; + gps: boolean | null; + lastChangeAt: number | null; +} + export interface PlatformRuntimeSnapshot { eventBus: PlatformEventBusDiag; halWiring: PlatformHalWiringDiag; halBridge: PlatformHalBridgeDiag; + sourceHealth: PlatformSourceHealthDiag; } +const _SOURCE_HEALTH_UNKNOWN: PlatformSourceHealthDiag = { + can: null, obd: null, gps: null, lastChangeAt: null, +}; + const _EVENT_BUS_ABSENT: PlatformEventBusDiag = { present: false, disposed: false, publishedCount: null, deliveredCount: null, droppedCount: null, listenerErrorCount: null, @@ -571,7 +589,19 @@ export function buildPlatformRuntimeSnapshot(): PlatformRuntimeSnapshot { }; }, _HAL_BRIDGE_ABSENT); - return { eventBus: bus, halWiring, halBridge }; + const sourceHealth = _safe(() => { + const h = useHALStatusStore.getState().sourceHealth; + if (!h || typeof h !== 'object') return _SOURCE_HEALTH_UNKNOWN; + const b = (v: unknown) => (typeof v === 'boolean' ? v : null); // null = BİLİNMİYOR + return { + can: b(h.canAlive), + obd: b(h.obdAlive), + gps: b(h.gpsAlive), + lastChangeAt: _ts(h.updatedAt), + }; + }, _SOURCE_HEALTH_UNKNOWN); + + return { eventBus: bus, halWiring, halBridge, sourceHealth }; } /* ── util ────────────────────────────────────────────────────── */ diff --git a/src/platform/system/platformCoreVehicleHalWiring.ts b/src/platform/system/platformCoreVehicleHalWiring.ts index aeb80768..21defddb 100644 --- a/src/platform/system/platformCoreVehicleHalWiring.ts +++ b/src/platform/system/platformCoreVehicleHalWiring.ts @@ -43,7 +43,11 @@ import { createUnifiedVehicleStoreProvider, type UnifiedVehicleStoreLike, type UnifiedVehicleStoreProvider, + type HalStatusStoreLike, } from '../vehicleHal/providers'; +// Kaynak sağlığı (PR-1): worker watchdog → halStatusStore. Yalnız SALT-OKUNUR okunur; +// import yan etkisiz (zustand store yaratımı; abonelik yalnız provider `subscribe()`'ında). +import { useHALStatusStore } from '../vehicleDataLayer/halStatusStore'; /** Wiring bağımlılıkları — store DI zorunlu; hal opsiyonel (test), varsayılan singleton. */ export interface VehicleHalWiringDeps { @@ -51,6 +55,8 @@ export interface VehicleHalWiringDeps { readonly store: UnifiedVehicleStoreLike | null | undefined; /** Test için HAL enjeksiyonu; verilmezse üretim `vehicleHal` singleton'ı kullanılır. */ readonly hal?: VehicleHalIngestTarget; + /** Test için kaynak-sağlığı store'u; verilmezse üretim `useHALStatusStore`. */ + readonly healthStore?: HalStatusStoreLike | null; } /** Tek cleanup thunk — İDEMPOTENT + fail-soft. HAL'i dispose ETMEZ. */ @@ -106,7 +112,11 @@ export function startPlatformCoreVehicleHalWiring(deps: VehicleHalWiringDeps): V const store = deps && deps.store ? deps.store : null; const hal: VehicleHalIngestTarget = deps && deps.hal ? deps.hal : vehicleHal; - const provider = createUnifiedVehicleStoreProvider({ store }); + // Kaynak sağlığı: CAN KESİN ölünce (worker watchdog) CAN-özel sinyaller fail-closed olur. + // `undefined` → üretim store'u; test `null` geçerek sağlığı BİLİNMİYOR bırakabilir. + const healthStore: HalStatusStoreLike | null = + deps && 'healthStore' in deps ? (deps.healthStore ?? null) : useHALStatusStore; + const provider = createUnifiedVehicleStoreProvider({ store, healthStore }); const adapter = createVehicleHalProviderAdapter({ hal, source: provider }); // Store yoksa wiring inert kalır (abonelik açılmaz) → aktif kayıt TUTULMAZ ki diff --git a/src/platform/vehicleDataLayer/VehicleCompute.worker.ts b/src/platform/vehicleDataLayer/VehicleCompute.worker.ts index 865cb968..7278edae 100644 --- a/src/platform/vehicleDataLayer/VehicleCompute.worker.ts +++ b/src/platform/vehicleDataLayer/VehicleCompute.worker.ts @@ -20,6 +20,7 @@ import type { CanAdapterData, ObdAdapterData, GpsAdapterData, VehicleState, Work import type { VehicleEvent } from './VehicleEventHub'; import type { NormalizedVehicleData, SignalSource } from './valTypes'; import { OdometerGuard } from './OdometerGuard'; +import { createSourceHealthGate } from './sourceHealthGate'; // ── Mesaj protokolü ────────────────────────────────────────────────────── @@ -43,6 +44,12 @@ export type WorkerInMessage = | { type: 'RESTORE_ODO'; km: number } /** DEV-only kaos: _odoTMR'a bit-flip enjekte et (median recovery testi). */ | { type: 'CHAOS_BITFLIP' } + /** + * Uygulama görünürlüğü (ana thread `visibilitychange` → resolver). YALNIZ kaynak sağlığı + * (SOURCE_HEALTH) kararını etkiler: arka planda timer kısılması + frame sessizliği sahte + * "ölü" üretmesin. Fusion/reverse/SAB/odometre davranışına DOKUNMAZ, timer AÇMAZ/KAPATMAZ. + */ + | { type: 'VISIBILITY'; visible: boolean } | { type: 'STOP' }; export type WorkerOutMessage = @@ -423,12 +430,23 @@ let _prevCanAlive: boolean | null = null; let _prevObdAlive: boolean | null = null; let _prevGpsAlive: boolean | null = null; +/* Görünürlük kapısı: arka planda watchdog kısılması + frame sessizliği SAHTE "ölü" üretmesin + * (bkz. sourceHealthGate). Yalnız bu bloğu etkiler; fusion/reverse/SAB kararları HAM kalır. */ +const _healthGate = createSourceHealthGate(); + const _outSourceHealth: Extract = { type: 'SOURCE_HEALTH', can: false, obd: false, gps: false, ts: 0, }; /** Yalnız DEĞİŞİMDE postlar (duplicate durum mesaj üretmez). Yeni timer AÇMAZ. */ -function _postSourceHealthIfChanged(can: boolean, obd: boolean, gps: boolean): void { +function _postSourceHealthIfChanged(canRaw: boolean, obdRaw: boolean, gpsRaw: boolean): void { + if (!_healthGate.isVisible()) return; // arka plan → sağlık GEÇİŞİ DONDURULUR + const now = performance.now(); + const can = _healthGate.decide(now, _canLastSeen, SRC_TIMEOUT_CAN_MS, canRaw, _prevCanAlive); + const obd = _healthGate.decide(now, _obdLastSeen, SRC_TIMEOUT_OBD_MS, obdRaw, _prevObdAlive); + const gps = _healthGate.decide(now, _gpsLastSeen, SRC_TIMEOUT_GPS_MS, gpsRaw, _prevGpsAlive); + // Foreground yeniden-tabanlama penceresi: karar verilemiyor → POSTLAMA (unknown korunur) + if (can === null || obd === null || gps === null) return; if (can === _prevCanAlive && obd === _prevObdAlive && gps === _prevGpsAlive) return; _prevCanAlive = can; _prevObdAlive = obd; @@ -436,10 +454,15 @@ function _postSourceHealthIfChanged(can: boolean, obd: boolean, gps: boolean): v _outSourceHealth.can = can; _outSourceHealth.obd = obd; _outSourceHealth.gps = gps; - _outSourceHealth.ts = performance.now(); // monotonik; araç verisi/PII YOK + _outSourceHealth.ts = now; // monotonik; araç verisi/PII YOK self.postMessage(_outSourceHealth); } +/** Ana thread görünürlük bildirimi — YALNIZ sağlık kapısını besler (timer'lara DOKUNMAZ). */ +function _handleVisibility(msg: Extract): void { + _healthGate.setVisible(msg.visible === true, performance.now()); +} + function _postEvent(ev: VehicleEvent): void { _outEvent.event = ev; self.postMessage(_outEvent); @@ -1356,6 +1379,7 @@ self.onmessage = (e: MessageEvent): void => { case 'UPDATE_GEOFENCE': _handleUpdateGeofence(msg); break; case 'RESTORE_ODO': _handleRestoreOdo(msg); break; case 'CHAOS_BITFLIP': if (import.meta.env.DEV) _handleChaosBitflip(); break; + case 'VISIBILITY': _handleVisibility(msg); break; case 'STOP': _handleStop(); break; } }; diff --git a/src/platform/vehicleDataLayer/VehicleSignalResolver.ts b/src/platform/vehicleDataLayer/VehicleSignalResolver.ts index de5fa940..4c03429d 100644 --- a/src/platform/vehicleDataLayer/VehicleSignalResolver.ts +++ b/src/platform/vehicleDataLayer/VehicleSignalResolver.ts @@ -50,6 +50,9 @@ export class VehicleSignalResolver { private readonly _onCrash?: () => void; // Bound referans saklanır → removeEventListener doğru çalışsın private _onWorkerMessageBound: ((e: MessageEvent) => void) | null = null; + // Görünürlük kanalı (worker sağlık kapısı) — start()'ta bağlanır, stop()'ta SÖKÜLÜR + private _onVisibilityBound: (() => void) | null = null; + private _lastVisibleSent: boolean | null = null; // SAB zero-copy channel — null when fallback (old WebView / no COOP+COEP) private _sab: SharedArrayBuffer | null = null; @@ -162,15 +165,42 @@ export class VehicleSignalResolver { ); this.hal.start(); + // ── Görünürlük kanalı ──────────────────────────────────────────────── + // Arka planda WebView timer'ları kısılır ve adaptör frame'leri durur; worker'ın + // 1 Hz watchdog'u `performance.now() - lastSeen` ile ölçtüğü için dönüşte SAHTE + // "kaynak öldü" kararı üretebilir. Görünürlüğü worker'a bildiririz — worker yalnız + // SOURCE_HEALTH kararını dondurur/yeniden tabanlar (bkz. sourceHealthGate). + // YENİ TIMER YOK: tek `visibilitychange` dinleyicisi (mevcut proje deseni). + if (typeof document !== 'undefined') { + this._onVisibilityBound = () => this._sendVisibility(); + document.addEventListener('visibilitychange', this._onVisibilityBound); + this._sendVisibility(); // ilk durum (uygulama arka planda başlatılmış olabilir) + } + this.can.start(); this.obd.start(); this.gps.start(); } + /** Görünürlük DEĞİŞİMİNDE worker'a bildirir (aynı durum tekrar POSTLANMAZ — spam yok). */ + private _sendVisibility(): void { + if (typeof document === 'undefined') return; + const visible = document.visibilityState === 'visible'; + if (visible === this._lastVisibleSent) return; + this._lastVisibleSent = visible; + this._send({ type: 'VISIBILITY', visible }); + } + stop(): void { this._started = false; clearSABChannel(); this._stopSabPolling(); + // Görünürlük dinleyicisi SÖKÜLÜR (zero-leak) → dispose sonrası worker'a mesaj GİTMEZ + if (this._onVisibilityBound && typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', this._onVisibilityBound); + } + this._onVisibilityBound = null; + this._lastVisibleSent = null; this.can.stop(); this.obd.stop(); this.gps.stop(); diff --git a/src/platform/vehicleDataLayer/sourceHealthGate.ts b/src/platform/vehicleDataLayer/sourceHealthGate.ts new file mode 100644 index 00000000..869ac58f --- /dev/null +++ b/src/platform/vehicleDataLayer/sourceHealthGate.ts @@ -0,0 +1,73 @@ +/** + * sourceHealthGate — kaynak sağlığı (SOURCE_HEALTH) için GÖRÜNÜRLÜK KAPISI. + * + * PROBLEM (cihazda gözlendi): worker'ın 1 Hz watchdog'u kaynak canlılığını + * "monotonik saat − lastSeen < timeout" ile ölçer. O saat uygulama arka plandayken de + * İLERLER; ama (a) WebView timer'ları kısılır/durur ve (b) CAN/OBD/GPS frame'leri gelmez. + * Foreground dönüşündeki ilk tikte fark timeout'u KAT KAT aşar → kaynak sağlamken + * "ölü" kararı üretilir (sahte disconnect + dönüşte true/false fırtınası). + * + * ÇÖZÜM (bu modül): sağlık kararı görünürlüğe bağlanır. + * - `hidden` → sağlık GEÇİŞİ ÜRETİLMEZ (mevcut durum dondurulur; `lastSeen` BOZULMAZ). + * - `visible` dönüşü → sağlık timeout SAATİ yeniden tabanlanır: arka planda geçen süre + * timeout hesabına YAZILMAZ. Dönüşten sonra + * · taze frame gelirse → normal karar (alive) + * · görünür süre timeout'u dolarsa → normal karar (dead) — gerçek kayıp MASKELENMEZ + * · henüz ikisi de değilse → KARAR YOK (son bilinen değer korunur; yoksa unknown) + * + * SAF: timer/DOM/store/IO YOK — tüm zaman dışarıdan (`now`) verilir → deterministik test. + * Yalnız SOURCE_HEALTH kararını etkiler; fusion/reverse/SAB davranışına DOKUNMAZ. + */ + +export interface SourceHealthGate { + /** + * Görünürlük değişimi. Aynı durumun tekrarı NO-OP'tur (yeniden tabanlama YAPMAZ). + * @returns durum gerçekten değiştiyse `true` + */ + setVisible(visible: boolean, now: number): boolean; + /** Ana thread bildirene kadar GÖRÜNÜR varsayılır (boot'ta davranış değişmez). */ + isVisible(): boolean; + /** + * Tek kaynak için sağlık kararı. + * @param computed watchdog'un ham `_alive()` sonucu + * @param prev en son BİLDİRİLEN değer (`null` = hiç bildirilmedi) + * @returns `boolean` = karar · `null` = KARAR VERİLEMEZ (çağıran POSTLAMAZ → unknown korunur) + */ + decide( + now: number, + lastSeen: number, + timeoutMs: number, + computed: boolean, + prev: boolean | null, + ): boolean | null; +} + +export function createSourceHealthGate(): SourceHealthGate { + let _visible = true; // worker boot'ta görünür varsayar → mevcut davranış korunur + let _baselineAt = 0; // 0 = yeniden tabanlama penceresi YOK (normal çalışma) + + return { + setVisible(visible: boolean, now: number): boolean { + if (visible === _visible) return false; // duplicate → spam/rebaseline YOK + _visible = visible; + // YALNIZ foreground dönüşünde saat yeniden tabanlanır. Arka plana geçerken + // hiçbir şey sıfırlanmaz (lastSeen'ler ve son bildirilen durum korunur). + if (visible && Number.isFinite(now)) _baselineAt = now; + return true; + }, + + isVisible(): boolean { + return _visible; + }, + + decide(now, lastSeen, timeoutMs, computed, prev) { + if (_baselineAt === 0) return computed; // normal çalışma → ham karar + if (lastSeen >= _baselineAt) return computed; // dönüşten SONRA taze frame → karar geçerli + if (computed) return computed; // hâlâ canlı görünüyor → sorun yok + // "Ölü" kararı arka plandaki sessizliğe dayanıyor olabilir: görünür süre timeout'u + // doldurmadıysa KARAR VERME (son bilinen değer korunur; yoksa `null` = unknown). + if ((now - _baselineAt) < timeoutMs) return prev; + return false; // görünür zamanda GERÇEK timeout doldu + }, + }; +} diff --git a/src/platform/vehicleHal/providers/index.ts b/src/platform/vehicleHal/providers/index.ts index 13b053ba..dfa5c493 100644 --- a/src/platform/vehicleHal/providers/index.ts +++ b/src/platform/vehicleHal/providers/index.ts @@ -11,4 +11,6 @@ export { type UnifiedVehicleStoreLike, type UnifiedVehicleStoreProviderDeps, type UnifiedVehicleStoreProvider, + type HalStatusStoreLike, + type SourceHealthReadable, } from './unifiedVehicleStoreProvider'; diff --git a/src/platform/vehicleHal/providers/unifiedVehicleStoreProvider.ts b/src/platform/vehicleHal/providers/unifiedVehicleStoreProvider.ts index 91678833..c9a272e4 100644 --- a/src/platform/vehicleHal/providers/unifiedVehicleStoreProvider.ts +++ b/src/platform/vehicleHal/providers/unifiedVehicleStoreProvider.ts @@ -28,7 +28,7 @@ * `getState()` throw ederse `getSnapshot()` null döner; subscribe kurulamazsa no-op unsub. */ -import type { NormalizedVehicleSnapshot, VehicleStoreSource } from '../vehicleHalProviderAdapter'; +import type { NormalizedVehicleSnapshot, VehicleStoreSource, SourceHealth } from '../vehicleHalProviderAdapter'; /* ══════════════════════════════════════════════════════════════════════════ * DI hedefi — zustand `useUnifiedVehicleStore` yapısal olarak uyar (doğrudan import YOK) @@ -60,8 +60,26 @@ export interface UnifiedVehicleStoreLike { subscribe: (listener: (state: UnifiedVehicleStateReadable) => void) => (() => void); } +/** + * Kaynak sağlığı store'u (PR-1 `halStatusStore.sourceHealth`) — yapısal DI (doğrudan import YOK). + * `null` = BİLİNMİYOR (worker hiç bildirmedi); `false` = kaynak KESİN ölü; `true` = canlı. + */ +export interface SourceHealthReadable { + readonly canAlive: boolean | null; + readonly obdAlive: boolean | null; + readonly gpsAlive: boolean | null; + readonly updatedAt: number | null; +} + +export interface HalStatusStoreLike { + getState: () => { sourceHealth?: SourceHealthReadable }; + subscribe: (listener: () => void) => (() => void); +} + export interface UnifiedVehicleStoreProviderDeps { readonly store: UnifiedVehicleStoreLike | null | undefined; + /** Opsiyonel: verilmezse snapshot'ta `sourceHealth` UNKNOWN kalır (sahte disconnect YOK). */ + readonly healthStore?: HalStatusStoreLike | null; } /** VehicleStoreSource + açık yaşam döngüsü (adapter yalnız getSnapshot/subscribe kullanır). */ @@ -91,8 +109,26 @@ function _tpms(v: unknown): readonly [number, number, number, number] | null { return [v[0], v[1], v[2], v[3]]; } +/** Bilinmiyor (worker sağlık bildirmedi) — `false` (ölü) ile ASLA karıştırılmaz. */ +const _UNKNOWN_HEALTH: SourceHealth = Object.freeze({ can: null, obd: null, gps: null, ts: null }); + +/** Yalnız gerçek boolean geçer; her şey (undefined/NaN/metin) → null = BİLİNMİYOR. */ +function _alive(v: unknown): boolean | null { + return typeof v === 'boolean' ? v : null; +} + +function _mapHealth(h: SourceHealthReadable | undefined): SourceHealth { + if (!h || typeof h !== 'object') return _UNKNOWN_HEALTH; + return Object.freeze({ + can: _alive(h.canAlive), + obd: _alive(h.obdAlive), + gps: _alive(h.gpsAlive), + ts: typeof h.updatedAt === 'number' && Number.isFinite(h.updatedAt) ? h.updatedAt : null, + }); +} + /** Store state → HAL alt-kümesi (yeni dondurulmuş obje; ignition/metadata UYDURULMAZ). */ -function _mapSnapshot(s: UnifiedVehicleStateReadable): NormalizedVehicleSnapshot { +function _mapSnapshot(s: UnifiedVehicleStateReadable, health: SourceHealth): NormalizedVehicleSnapshot { return Object.freeze({ speed: _num(s.speed), rpm: _num(s.rpm), @@ -109,6 +145,7 @@ function _mapSnapshot(s: UnifiedVehicleStateReadable): NormalizedVehicleSnapshot canTpmsKpa: _tpms(s.canTpmsKpa), canDoorOpen: _bool(s.canDoorOpen), canParkingBrake: _bool(s.canParkingBrake), + sourceHealth: health, }); } @@ -125,31 +162,55 @@ export function createUnifiedVehicleStoreProvider( deps: UnifiedVehicleStoreProviderDeps, ): UnifiedVehicleStoreProvider { const store = deps && deps.store ? deps.store : null; + const healthStore = deps && deps.healthStore ? deps.healthStore : null; // Aktif abonelikleri izle (dispose zero-leak + duplicate koruması). + // NOT: sayaç ADAPTER aboneliği başına 1'dir — araç + sağlık store'u TEK unsub'a sarılır + // (teşhisteki `activeSubscriptionCount=1` invaryantı korunur). const unsubs = new Set<() => void>(); + /** Sağlık store'u yoksa/okunamazsa UNKNOWN (fail-soft) — sahte disconnect ÜRETİLMEZ. */ + function readHealth(): SourceHealth { + if (!healthStore) return _UNKNOWN_HEALTH; + try { return _mapHealth(healthStore.getState()?.sourceHealth); } catch { return _UNKNOWN_HEALTH; } + } + function getSnapshot(): NormalizedVehicleSnapshot | null { if (!store) return null; // store yok → fail-soft let raw: UnifiedVehicleStateReadable | null = null; try { raw = store.getState(); } catch { return null; } // getState throw → fail-soft if (!raw || typeof raw !== 'object') return null; - return _mapSnapshot(raw); // store state MUTATE EDİLMEZ + return _mapSnapshot(raw, readHealth()); // store state MUTATE EDİLMEZ } function subscribe(listener: () => void): () => void { if (!store) return () => { /* store yok → no-op unsub */ }; - let real: (() => void) | null = null; + const safe = () => { try { listener(); } catch { /* listener izole */ } }; + let realStore: (() => void) | null = null; + let realHealth: (() => void) | null = null; try { - real = store.subscribe(() => { try { listener(); } catch { /* listener izole */ } }); + realStore = store.subscribe(safe); } catch { - real = null; // abonelik kurulamadı → fail-soft + realStore = null; // abonelik kurulamadı → fail-soft } - if (real) unsubs.add(real); + if (healthStore) { + // Sağlık DEĞİŞİMİ de refresh tetiklemeli (CAN ölünce store yazımı GELMEZ). + try { realHealth = healthStore.subscribe(safe); } catch { realHealth = null; } + } + if (!realStore && !realHealth) return () => { /* hiçbiri kurulamadı → no-op */ }; + + // İki alt-abonelik TEK kayıt olarak tutulur → activeSubscriptionCount 1 kalır. + const combined = () => { + if (realStore) { try { realStore(); } catch { /* */ } realStore = null; } + if (realHealth) { try { realHealth(); } catch { /* */ } realHealth = null; } + }; + unsubs.add(combined); + let done = false; // İDEMPOTENT unsubscribe return () => { if (done) return; done = true; - if (real) { unsubs.delete(real); try { real(); } catch { /* */ } real = null; } + unsubs.delete(combined); + combined(); }; } diff --git a/src/platform/vehicleHal/vehicleHalProviderAdapter.ts b/src/platform/vehicleHal/vehicleHalProviderAdapter.ts index 4c30cf4a..9fb74999 100644 --- a/src/platform/vehicleHal/vehicleHalProviderAdapter.ts +++ b/src/platform/vehicleHal/vehicleHalProviderAdapter.ts @@ -83,6 +83,19 @@ export interface NormalizedVehicleSnapshot { readonly canTpmsKpa?: readonly [number, number, number, number] | null; readonly canDoorOpen?: boolean; readonly canParkingBrake?: boolean; + /** + * Kaynak sağlığı (PR-1 worker watchdog → halStatusStore → provider). `null` = BİLİNMİYOR, + * `false` = kaynak KESİN ölü, `true` = canlı. Alan yoksa/UNKNOWN ise SAHTE disconnect YOK. + */ + readonly sourceHealth?: SourceHealth; +} + +/** Bounded kaynak sağlığı — yalnız 3 boolean|null + monotonik ts (araç verisi/PII YOK). */ +export interface SourceHealth { + readonly can: boolean | null; + readonly obd: boolean | null; + readonly gps: boolean | null; + readonly ts: number | null; } /** Store bağlama (DI) — gerçek wiring PR'ı `useUnifiedVehicleStore.getState/subscribe` geçirir. */ @@ -122,8 +135,12 @@ interface PendingIngest { readonly value: number | boolean | number[]; } +/** Sinyalin veri kaynağı sınıfı: CAN-ÖZEL (yalnız CAN besler) vs FÜZYON (CAN/OBD/GPS/worker). */ +type SignalSourceKind = 'can_only' | 'fusion'; + interface SignalMapSpec { readonly id: VehicleSignalId; + readonly kind: SignalSourceKind; readonly source: VehicleSignalSource; readonly quality: SignalQuality; readonly confidence: number; @@ -152,15 +169,22 @@ function _isLive(s: NormalizedVehicleSnapshot): boolean { return _num(s.speed) !== null || _num(s.rpm) !== null || _isCanLive(s); } +/** CAN-ÖZEL sinyal: yalnız CAN besler → `can=false` (kesin ölü) ise KAYNAKSIZ sayılır. */ const CAN = (id: VehicleSignalId, extract: SignalMapSpec['extract']): SignalMapSpec => - ({ id, source: 'can', quality: CAN_QUALITY, confidence: CAN_CONFIDENCE, extract }); + ({ id, kind: 'can_only', source: 'can', quality: CAN_QUALITY, confidence: CAN_CONFIDENCE, extract }); +/** FÜZYON sinyali: CAN ölse de OBD/GPS/worker besleyebilir → sağlık TEK BAŞINA düşürmez. */ const INF = (id: VehicleSignalId, extract: SignalMapSpec['extract']): SignalMapSpec => - ({ id, source: 'inferred', quality: INFERRED_QUALITY, confidence: INFERRED_CONFIDENCE, extract }); + ({ id, kind: 'fusion', source: 'inferred', quality: INFERRED_QUALITY, confidence: INFERRED_CONFIDENCE, extract }); /** Sinyal eşleme tablosu. ignition YOK (kaynak yok → supported=false kalır). */ const SIGNAL_MAP: readonly SignalMapSpec[] = [ INF('vehicle.speed', (s) => { const v = _num(s.speed); return v === null ? null : { value: v }; }), - INF('vehicle.rpm', (s) => { const v = _num(s.rpm) ?? _num(s.canRpm); return v === null ? null : { value: v }; }), + // rpm FÜZYON: fused `rpm` yoksa CAN fallback — ama CAN KESİN ÖLÜYSE bayat `canRpm` KULLANILMAZ. + INF('vehicle.rpm', (s) => { + const canDead = s.sourceHealth?.can === false; + const v = _num(s.rpm) ?? (canDead ? null : _num(s.canRpm)); + return v === null ? null : { value: v }; + }), INF('vehicle.fuel_level', (s) => { const v = _num(s.fuel); return v === null ? null : { value: v }; }), INF('vehicle.odometer', (s) => { const v = _num(s.odometer); return v === null || v <= 0 ? null : { value: v }; }), CAN('vehicle.coolant_temp', (s) => { const v = _num(s.canCoolantTemp); return v === null ? null : { value: v }; }), @@ -208,6 +232,11 @@ export class VehicleHalProviderAdapter { private _refreshCount = 0; /** Sinyal başına son ingest edilen değer (duplicate ingest önleme, O(1)). */ private readonly _last = new Map(); + /** + * CAN ölümü anında donan değerler — kaynak geri gelince AYNI değerle sinyal DİRİLTİLMEZ + * (store frame gelmeyince eski değeri tutar; taze veri ancak değer DEĞİŞİNCE kanıtlanır). + */ + private readonly _canDeadValue = new Map(); constructor(deps: VehicleHalProviderDeps) { this._hal = deps.hal; @@ -258,10 +287,34 @@ export class VehicleHalProviderAdapter { let pending: PendingIngest[] | null = null; let lost: VehicleSignalId[] | null = null; // kaynağı kaybolan (fail-closed) sinyaller + // KAYNAK SAĞLIĞI (PR-1 worker watchdog): `false` = CAN KESİN ölü · `null` = BİLİNMİYOR. + // Bilinmiyorsa HİÇBİR ŞEY yapılmaz (sahte disconnect YOK) → davranış W4B ile aynı kalır. + const canDead = snap.sourceHealth?.can === false; + for (const spec of SIGNAL_MAP) { // SIGNAL_MAP sırası KORUNUR let res: ExtractResult = null; try { res = spec.extract(snap, live, canLive); } catch { res = null; } // tek sinyal bozuk → diğerleri sürer + if (spec.kind === 'can_only') { + if (canDead) { + // CAN KESİN ÖLÜ → CAN-ÖZEL sinyal KAYNAKSIZ sayılır. Store'daki DONMUŞ eski değer + // (frame gelmediği için null'a dönmez) "geçerli veri" gibi davranamaz. + if (res !== null) { + // Ölüm anındaki değeri sakla: kaynak geri gelince AYNI (bayat) değer sinyali + // DİRİLTMESİN — yalnız GERÇEKTEN YENİ veri supported yapar. + if (this._last.has(spec.id) && !this._canDeadValue.has(spec.id)) { + this._canDeadValue.set(spec.id, res.value); + } + } + res = null; // → aşağıdaki fail-closed yolu + } else if (res !== null && this._canDeadValue.has(spec.id)) { + // CAN yeniden canlı, AMA değer hâlâ ölüm anındaki değerle aynı → henüz TAZE frame yok. + // Sahte diriltme YASAK: değer değişene kadar unsupported KALIR (ingest yok). + if (_valueEqual(this._canDeadValue.get(spec.id), res.value)) continue; + this._canDeadValue.delete(spec.id); // gerçekten yeni veri geldi + } + } + if (res === null) { // KAYNAK YOK. İki durum AYRIDIR: // • unknown → sinyal hiç aktarılmadı (`_last`'ta yok) → HİÇBİR ŞEY yapma (sahte disconnect YOK). @@ -339,6 +392,7 @@ export class VehicleHalProviderAdapter { if (this._disposed) return; this.stop(); this._last.clear(); + this._canDeadValue.clear(); this._disposed = true; }