Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/DEVICE_VALIDATION_LEDGER.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion src/__tests__/platformRuntimeDiagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\(/);
});
Expand Down Expand Up @@ -190,6 +196,7 @@ describe('W4E — whitelist ve privacy sınırı', () => {
eventBus: scrub(snap.eventBus as unknown as Record<string, unknown>),
halWiring: scrub(snap.halWiring as unknown as Record<string, unknown>),
halBridge: scrub(snap.halBridge as unknown as Record<string, unknown>),
sourceHealth: scrub(snap.sourceHealth as unknown as Record<string, unknown>),
});
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);
Expand Down
330 changes: 330 additions & 0 deletions src/__tests__/sourceHealthVisibilityGating.test.ts

Large diffs are not rendered by default.

355 changes: 355 additions & 0 deletions src/__tests__/vehicleHalSourceHealthConsumption.test.ts

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions src/__tests__/workerSourceHealthTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
32 changes: 31 additions & 1 deletion src/platform/diagnosticSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -571,7 +589,19 @@ export function buildPlatformRuntimeSnapshot(): PlatformRuntimeSnapshot {
};
}, _HAL_BRIDGE_ABSENT);

return { eventBus: bus, halWiring, halBridge };
const sourceHealth = _safe<PlatformSourceHealthDiag>(() => {
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 ────────────────────────────────────────────────────── */
Expand Down
12 changes: 11 additions & 1 deletion src/platform/system/platformCoreVehicleHalWiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,20 @@ 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 {
/** `useUnifiedVehicleStore` (zustand — getState/subscribe). Yapısal DI; doğrudan import yok. */
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. */
Expand Down Expand Up @@ -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
Expand Down
28 changes: 26 additions & 2 deletions src/platform/vehicleDataLayer/VehicleCompute.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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ü ──────────────────────────────────────────────────────

Expand All @@ -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 =
Expand Down Expand Up @@ -423,23 +430,39 @@ 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<WorkerOutMessage, { type: 'SOURCE_HEALTH' }> = {
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;
_prevGpsAlive = gps;
_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<WorkerInMessage, { type: 'VISIBILITY' }>): void {
_healthGate.setVisible(msg.visible === true, performance.now());
}

function _postEvent(ev: VehicleEvent): void {
_outEvent.event = ev;
self.postMessage(_outEvent);
Expand Down Expand Up @@ -1356,6 +1379,7 @@ self.onmessage = (e: MessageEvent<WorkerInMessage>): 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;
}
};
30 changes: 30 additions & 0 deletions src/platform/vehicleDataLayer/VehicleSignalResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
73 changes: 73 additions & 0 deletions src/platform/vehicleDataLayer/sourceHealthGate.ts
Original file line number Diff line number Diff line change
@@ -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
},
};
}
2 changes: 2 additions & 0 deletions src/platform/vehicleHal/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ export {
type UnifiedVehicleStoreLike,
type UnifiedVehicleStoreProviderDeps,
type UnifiedVehicleStoreProvider,
type HalStatusStoreLike,
type SourceHealthReadable,
} from './unifiedVehicleStoreProvider';
Loading
Loading