diff --git a/src/__tests__/discoveryCapture.test.ts b/src/__tests__/discoveryCapture.test.ts new file mode 100644 index 00000000..1a5c22e7 --- /dev/null +++ b/src/__tests__/discoveryCapture.test.ts @@ -0,0 +1,256 @@ +/** + * discoveryCapture.test.ts — Otomatik PID/DID keşif yakalama boru hattı (PR-DISC-1). + * + * Kapsananlar (görev tanımı): + * - Duplicate discovery oluşmuyor (aynı ECU+mode+PID/DID tek kayıt). + * - Registry'deki PID tekrar kaydedilmiyor (bilinen PID atlanır). + * - Yeni DID yakalanıyor. + * - Hash deduplikasyon çalışıyor (FNV-1a kimlik + bounded eviction). + * - Queue davranışı doğru (enqueue/peekAll/drain/bounded/kalıcılık). + * - Export JSON doğru (sürümlü zarf). + * - Yeni keşifte tanı event'i düşüyor (item 6). + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + DiscoveryCache, + DiscoveryQueue, + DiscoveryCaptureService, + exportDiscoveryJson, + buildDiscoveryEnvelope, + DISCOVERY_EXPORT_SCHEMA, + createDiscoveryRecord, + discoveryHash, + fnv1a, + type DiscoveryRecord, + type DiscoveryCaptureOptions, +} from '../platform/obd/discovery'; +import { clear as clearDiag, getEvents } from '../platform/obdDiagnosticRecorder'; + +/** Her testte benzersiz storage anahtarı (kalıcı kuyruk çapraz-kirlenme yok). */ +let _k = 0; +const freshQueue = () => new DiscoveryQueue(`test-discovery-${_k++}`, 500); + +/** Enjekte edilebilir servis kur (tanı emitörü spy; registry gerçek). */ +function makeService(overrides: DiscoveryCaptureOptions = {}) { + const emit = vi.fn(); + const svc = new DiscoveryCaptureService({ + cache: new DiscoveryCache(), + queue: freshQueue(), + emitDiagnostic: emit, + ...overrides, + }); + return { svc, emit }; +} + +beforeEach(() => { + try { localStorage.clear(); } catch { /* jsdom */ } +}); + +/* ── Model / hash ─────────────────────────────────────────────────────────── */ + +describe('discoveryModel — kimlik & hash', () => { + it('fnv1a deterministik + 8 hane hex', () => { + expect(fnv1a('abc')).toBe(fnv1a('abc')); + expect(fnv1a('abc')).toMatch(/^[0-9a-f]{8}$/); + expect(fnv1a('abc')).not.toBe(fnv1a('abd')); + }); + + it('dedup hash zaman/yanıttan BAĞIMSIZ, kimlikten (ECU+mode+PID/DID+kaynak) türer', () => { + const base = { discoverySource: 'DID' as const, mode: '22', ecuAddress: '7E8', pidOrDid: 'F190' }; + const a = createDiscoveryRecord({ ...base, timestamp: 1, rawResponse: 'AAA' }); + const b = createDiscoveryRecord({ ...base, timestamp: 999, rawResponse: 'BBB' }); + expect(discoveryHash(a)).toBe(discoveryHash(b)); // aynı kimlik → aynı hash + // farklı ECU → farklı hash + const c = createDiscoveryRecord({ ...base, ecuAddress: '7E9' }); + expect(discoveryHash(c)).not.toBe(discoveryHash(a)); + }); + + it('createDiscoveryRecord tam şekil + hex normalize (küçük harf/boşluk)', () => { + const r = createDiscoveryRecord({ pidOrDid: 'f1 90', discoverySource: 'DID', ecuAddress: '7e8', mode: '22' }); + expect(r.pidOrDid).toBe('F190'); + expect(r.ecuAddress).toBe('7E8'); + expect(r.supported).toBe(false); + expect(r.vehicleProfile).toBe(''); + }); +}); + +/* ── DiscoveryCache — hash dedup ──────────────────────────────────────────── */ + +describe('DiscoveryCache — hash-tabanlı dedup', () => { + it('aynı kimlik ikinci eklemede false (dedup), farklı kimlik true', () => { + const c = new DiscoveryCache(); + const id = { discoverySource: 'PID' as const, mode: '01', ecuAddress: '7E8', pidOrDid: 'A5' }; + expect(c.add(id)).toBe(true); + expect(c.add(id)).toBe(false); + expect(c.has(id)).toBe(true); + expect(c.add({ ...id, pidOrDid: 'A6' })).toBe(true); + expect(c.size).toBe(2); + }); + + it('bounded: tavan aşılınca en eski hash düşer (zero-leak)', () => { + const c = new DiscoveryCache(2); + c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A1' }); + c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A2' }); + c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A3' }); // A1 düşer + expect(c.size).toBe(2); + // A1 düştüğü için yeniden "yeni" sayılır + expect(c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A1' })).toBe(true); + }); + + it('clear tümünü sıfırlar', () => { + const c = new DiscoveryCache(); + c.add({ discoverySource: 'PID', mode: '01', ecuAddress: '7E8', pidOrDid: 'A5' }); + c.clear(); + expect(c.size).toBe(0); + }); +}); + +/* ── DiscoveryQueue — offline-first FIFO ──────────────────────────────────── */ + +describe('DiscoveryQueue — offline-first kuyruk', () => { + const rec = (pid: string): DiscoveryRecord => + createDiscoveryRecord({ pidOrDid: pid, discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); + + it('enqueue + peekAll kopya döner (dış mutasyon sızmaz)', () => { + const q = freshQueue(); + q.enqueue(rec('A1')); + q.enqueue(rec('A2')); + const all = q.peekAll(); + expect(all.map((r) => r.pidOrDid)).toEqual(['A1', 'A2']); + all.pop(); // kopya — iç durum etkilenmez + expect(q.size).toBe(2); + }); + + it('drain kuyruğu boşaltır ve içeriği döndürür', () => { + const q = freshQueue(); + q.enqueue(rec('A1')); + const drained = q.drain(); + expect(drained).toHaveLength(1); + expect(q.size).toBe(0); + }); + + it('bounded: tavan aşılınca en eski düşer (FIFO, zero-leak)', () => { + const q = new DiscoveryQueue(`test-bound-${_k++}`, 2); + q.enqueue(rec('A1')); + q.enqueue(rec('A2')); + q.enqueue(rec('A3')); // A1 düşer + expect(q.peekAll().map((r) => r.pidOrDid)).toEqual(['A2', 'A3']); + }); + + it('kalıcılık: aynı anahtarla yeni örnek diskten yükler (offline-first)', () => { + const key = `test-persist-${_k++}`; + const q1 = new DiscoveryQueue(key, 500); + q1.enqueue(rec('F1')); + const q2 = new DiscoveryQueue(key, 500); + expect(q2.peekAll().map((r) => r.pidOrDid)).toEqual(['F1']); + }); +}); + +/* ── Export JSON ──────────────────────────────────────────────────────────── */ + +describe('discoveryExport — yerel JSON', () => { + it('sürümlü zarf: schema/count/records tutarlı ve geri parse edilebilir', () => { + const records = [createDiscoveryRecord({ pidOrDid: 'F190', discoverySource: 'DID', mode: '22' })]; + const env = buildDiscoveryEnvelope(records); + expect(env.schema).toBe(DISCOVERY_EXPORT_SCHEMA); + expect(env.count).toBe(1); + + const json = exportDiscoveryJson(records); + const parsed = JSON.parse(json); + expect(parsed.schema).toBe(DISCOVERY_EXPORT_SCHEMA); + expect(parsed.count).toBe(1); + expect(parsed.records[0].pidOrDid).toBe('F190'); + expect(typeof json).toBe('string'); + }); + + it('boş liste → count 0, records []', () => { + const parsed = JSON.parse(exportDiscoveryJson([])); + expect(parsed.count).toBe(0); + expect(parsed.records).toEqual([]); + }); +}); + +/* ── DiscoveryCaptureService — orkestrasyon ───────────────────────────────── */ + +describe('DiscoveryCaptureService — yakalama kuralları', () => { + it('registry\'deki PID (0x0C RPM) YAKALANMAZ (reason: known)', () => { + const { svc, emit } = makeService(); + const res = svc.capture({ pidOrDid: '0C', discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); + expect(res).toEqual({ captured: false, reason: 'known' }); + expect(svc.getCaptured()).toHaveLength(0); + expect(emit).not.toHaveBeenCalled(); + }); + + it('registry\'de OLMAYAN PID yakalanır + tanı event\'i düşer', () => { + const { svc, emit } = makeService(); + const res = svc.capture({ pidOrDid: 'A5', discoverySource: 'PID', mode: '01', ecuAddress: '7E8', supported: true }); + expect(res.captured).toBe(true); + expect(svc.getCaptured().map((r) => r.pidOrDid)).toEqual(['A5']); + expect(emit).toHaveBeenCalledTimes(1); // yeni keşif → tanı event + }); + + it('yeni DID (F190) yakalanır (bilinen DID kümesi boşken)', () => { + const { svc } = makeService(); + const res = svc.capture({ pidOrDid: 'F190', discoverySource: 'DID', mode: '22', ecuAddress: '7E8', decodedValue: 'VF1...' }); + expect(res.captured).toBe(true); + expect(svc.getCaptured()[0]?.discoverySource).toBe('DID'); + }); + + it('setKnownDids ile profildeki DID artık YAKALANMAZ (reason: known)', () => { + const { svc, emit } = makeService(); + svc.setKnownDids(['f190']); // küçük harf → normalize + const res = svc.capture({ pidOrDid: 'F190', discoverySource: 'DID', mode: '22', ecuAddress: '7E8' }); + expect(res).toEqual({ captured: false, reason: 'known' }); + expect(emit).not.toHaveBeenCalled(); + }); + + it('DUPLICATE: aynı ECU+mode+PID/DID ikinci gözlemde yakalanmaz (reason: duplicate)', () => { + const { svc, emit } = makeService(); + const input = { pidOrDid: 'A7', discoverySource: 'PID' as const, mode: '01', ecuAddress: '7E8' }; + expect(svc.capture(input).captured).toBe(true); + const second = svc.capture({ ...input, rawResponse: 'farklı-yanit', timestamp: 12345 }); + expect(second).toEqual({ captured: false, reason: 'duplicate' }); + expect(svc.getCaptured()).toHaveLength(1); // tek kayıt + expect(emit).toHaveBeenCalledTimes(1); // event yalnız ilk keşifte + }); + + it('farklı ECU aynı PID → AYRI keşif (kimlik ECU\'yu içerir)', () => { + const { svc } = makeService(); + svc.capture({ pidOrDid: 'A8', discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); + svc.capture({ pidOrDid: 'A8', discoverySource: 'PID', mode: '01', ecuAddress: '7E9' }); + expect(svc.getCaptured()).toHaveLength(2); + }); + + it('exportJson yakalananları sürümlü zarfta verir', () => { + const { svc } = makeService(); + svc.capture({ pidOrDid: 'A9', discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); + const parsed = JSON.parse(svc.exportJson()); + expect(parsed.count).toBe(1); + expect(parsed.records[0].pidOrDid).toBe('A9'); + }); + + it('reset cache + kuyruğu temizler', () => { + const { svc } = makeService(); + svc.capture({ pidOrDid: 'AA', discoverySource: 'PID', mode: '01', ecuAddress: '7E8' }); + svc.reset(); + expect(svc.getCaptured()).toHaveLength(0); + expect(svc.capturedCount).toBe(0); + }); +}); + +/* ── Tanı log entegrasyonu (varsayılan emitör → recordDiag) ───────────────── */ + +describe('DiscoveryCaptureService — varsayılan tanı event wiring', () => { + beforeEach(() => clearDiag()); + + it('varsayılan emitör yeni keşfi tanı timeline\'ına \'ecuQuery\' event\'i olarak yazar', () => { + // emitDiagnostic enjekte EDİLMEZ → gerçek recordDiag yolu kullanılır. + const svc = new DiscoveryCaptureService({ cache: new DiscoveryCache(), queue: freshQueue() }); + svc.capture({ pidOrDid: 'B5', discoverySource: 'PID', mode: '01', ecuAddress: '7E8', request: '01B5', rawResponse: '41B500' }); + const evt = getEvents().find((e) => e.technicalMessage.includes('B5')); + expect(evt).toBeDefined(); + expect(evt?.stage).toBe('ecuQuery'); + expect(evt?.status).toBe('info'); + expect(evt?.command).toBe('01B5'); + }); +}); diff --git a/src/platform/obd/discovery/DiscoveryCache.ts b/src/platform/obd/discovery/DiscoveryCache.ts new file mode 100644 index 00000000..55e8c146 --- /dev/null +++ b/src/platform/obd/discovery/DiscoveryCache.ts @@ -0,0 +1,61 @@ +/** + * DiscoveryCache — keşif kayıtları için HASH-tabanlı, sınırlı (bounded) deduplikasyon. + * + * NEDEN: aynı ECU + mode + PID/DID tekrar tekrar gözlemlenir (poll turları); bunları + * her seferinde kaydetmek kuyruğu/logu şişirir. Cache, bir keşfin FNV-1a kimlik hash'ini + * tutar → ikinci gözlemi O(1) eler. + * + * ZERO-LEAK (CLAUDE.md — sınırlı bellek): hash kümesi SABİT tavanla (maxEntries) sınırlıdır; + * tavan aşılınca EN ESKİ hash düşürülür (insertion-order, FIFO). Böylece uzun oturumlarda + * bile bellek sabit kalır. (Düşen hash ileride yeniden yakalanabilir — sınırlı bellek, + * mükemmel dedup'tan önceliklidir; tavan geniş tutulur.) + * + * SAF: React/Capacitor/native importu yok — kök vitest paketinden test edilebilir. + */ + +import { discoveryHash, type DiscoveryRecord } from './discoveryModel'; + +/** Kimlik alt kümesi — hash için yeterli (tam kayıt gerekmez). */ +type Identity = Pick; + +export class DiscoveryCache { + /** Ekleme sırası korunan hash kümesi (Map anahtar sırası = FIFO eviction). */ + private readonly _seen = new Map(); + private readonly maxEntries: number; + + constructor(maxEntries = 1024) { + if (maxEntries < 1) throw new Error('DiscoveryCache maxEntries ≥ 1 olmalı'); + this.maxEntries = maxEntries; + } + + /** Bu kimlik daha önce görüldü mü. */ + has(id: Identity): boolean { + return this._seen.has(discoveryHash(id)); + } + + /** + * Kimliği kaydeder. @returns true = YENİ (ilk kez), false = zaten görülmüştü. + * Tavan aşılırsa en eski hash düşürülür (bounded). + */ + add(id: Identity): boolean { + const h = discoveryHash(id); + if (this._seen.has(h)) return false; + this._seen.set(h, true); + if (this._seen.size > this.maxEntries) { + // En eski anahtar (ilk eklenen) — Map iterasyonu ekleme sırasındadır. + const oldest = this._seen.keys().next().value; + if (oldest !== undefined) this._seen.delete(oldest); + } + return true; + } + + /** Kayıtlı benzersiz kimlik sayısı. */ + get size(): number { + return this._seen.size; + } + + /** Tümünü temizler (oturum sıfırlama / araç değişimi). */ + clear(): void { + this._seen.clear(); + } +} diff --git a/src/platform/obd/discovery/DiscoveryCaptureService.ts b/src/platform/obd/discovery/DiscoveryCaptureService.ts new file mode 100644 index 00000000..e7de13b1 --- /dev/null +++ b/src/platform/obd/discovery/DiscoveryCaptureService.ts @@ -0,0 +1,136 @@ +/** + * DiscoveryCaptureService — keşif yakalama boru hattının MERKEZÎ orkestratörü (PR-DISC-1). + * + * SORUMLULUK (Single Responsibility): araçtan gözlemlenen bir PID/DID sonucunu alır, + * (1) katalogda VAR MI diye bakar → varsa YOK SAYAR ("registry'de olanları tekrar kaydetme"), + * (2) YOKSA hash-dedup'tan geçirir (DiscoveryCache) → tekrarsa yok sayar, + * (3) yeni + benzersizse: offline kuyruğa yazar (DiscoveryQueue) + tanı log'una + * discovery event'i düşer (mevcut 'ecuQuery' stage'i — tanı tipleri DEĞİŞMEZ). + * + * BAĞIMLILIK TERSİNE ÇEVİRME (DIP/SOLID): registry "biliniyor mu" denetimleri ve tanı + * emitörü DIŞARIDAN enjekte edilebilir (test + gevşek bağlaşım). Varsayılanlar: + * - isPidKnown → StandardPidRegistry.STANDARD_PID_MAP (SALT-OKUNUR; registry'ye DOKUNULMAZ) + * - isDidKnown → setKnownDids ile beslenen küme (araç DID profili anahtarları) + * - emitDiagnostic → obdDiagnosticRecorder.recordDiag (stage 'ecuQuery', status 'info') + * + * NATIVE'e / hot-poll'a / PID registry'ye / DTC kataloğuna DOKUNMAZ. Ağ çağrısı YOK. + */ + +import { STANDARD_PID_MAP } from '../StandardPidRegistry'; +import { recordDiag } from '../../obdDiagnosticRecorder'; +import { DiscoveryCache } from './DiscoveryCache'; +import { DiscoveryQueue } from './DiscoveryQueue'; +import { exportDiscoveryJson } from './discoveryExport'; +import { + createDiscoveryRecord, + normalizeHex, + type DiscoveryRecord, + type DiscoverySource, +} from './discoveryModel'; + +/** captureDiscovery girişi — bilinenler geçilir, gerisi güvenli varsayılana düşer. */ +export type DiscoveryInput = Partial & { + pidOrDid: string; + discoverySource: DiscoverySource; +}; + +/** Yakalama sonucu — dürüst ayrım: yeni mi, biliniyor mu, tekrar mı. */ +export type CaptureResult = + | { captured: true; record: DiscoveryRecord } + | { captured: false; reason: 'known' | 'duplicate' }; + +export interface DiscoveryCaptureOptions { + /** PID katalogda var mı (varsayılan: standart registry — salt-okunur). */ + isPidKnown?: (pid: string) => boolean; + /** DID katalogda var mı (varsayılan: setKnownDids ile beslenen küme). */ + isDidKnown?: (did: string) => boolean; + /** Yeni keşifte tanı log'una event düşürücü (varsayılan: recordDiag → 'ecuQuery'). */ + emitDiagnostic?: (record: DiscoveryRecord) => void; + /** Test/özelleştirme için enjekte edilebilir cache/queue. */ + cache?: DiscoveryCache; + queue?: DiscoveryQueue; +} + +/** Varsayılan tanı emitörü — yeni keşfi mevcut 'ecuQuery' stage'iyle timeline'a yazar. */ +function defaultDiagnosticEmitter(r: DiscoveryRecord): void { + recordDiag({ + stage: 'ecuQuery', + status: 'info', + protocol: r.protocol || null, + command: r.request || null, + response: r.rawResponse || null, + technicalMessage: `Keşif: katalogda olmayan ${r.discoverySource} ${r.pidOrDid}` + + (r.ecuAddress ? ` (ECU ${r.ecuAddress})` : '') + + (r.supported ? ' — desteklen/yanıtladı' : ' — yanıt yok/negatif'), + userMessage: 'Yeni araç sinyali keşfedildi', + }); +} + +export class DiscoveryCaptureService { + private readonly _cache: DiscoveryCache; + private readonly _queue: DiscoveryQueue; + private readonly _isPidKnown: (pid: string) => boolean; + private readonly _isDidKnown: (did: string) => boolean; + private readonly _emitDiagnostic: (record: DiscoveryRecord) => void; + /** setKnownDids ile beslenen bilinen DID kümesi (varsayılan isDidKnown bunu okur). */ + private _knownDids = new Set(); + + constructor(opts: DiscoveryCaptureOptions = {}) { + this._cache = opts.cache ?? new DiscoveryCache(); + this._queue = opts.queue ?? new DiscoveryQueue(); + this._isPidKnown = opts.isPidKnown ?? ((pid) => STANDARD_PID_MAP.has(normalizeHex(pid))); + this._isDidKnown = opts.isDidKnown ?? ((did) => this._knownDids.has(normalizeHex(did))); + this._emitDiagnostic = opts.emitDiagnostic ?? defaultDiagnosticEmitter; + } + + /** + * Bilinen DID kümesini (yüklü araç DID profili anahtarları) ayarlar — böylece profildeki + * DID'ler "keşif" sayılmaz. Registry'ye DOKUNMADAN dışarıdan beslenir (DIP). + */ + setKnownDids(dids: Iterable): void { + this._knownDids = new Set([...dids].map(normalizeHex)); + } + + /** Bir PID/DID gözlemini işler. Katalogda varsa/tekrarsa yakalamaz (dürüst reason). */ + capture(input: DiscoveryInput): CaptureResult { + const record = createDiscoveryRecord(input); + + const known = record.discoverySource === 'PID' + ? this._isPidKnown(record.pidOrDid) + : this._isDidKnown(record.pidOrDid); + if (known) return { captured: false, reason: 'known' }; + + // Katalogda YOK → yeni aday; hash-dedup (aynı ECU+mode+PID/DID ikinci gözlemi elenir). + const isNew = this._cache.add(record); + if (!isNew) return { captured: false, reason: 'duplicate' }; + + this._queue.enqueue(record); + try { this._emitDiagnostic(record); } catch { /* tanı log hatası yakalamayı engellemez */ } + return { captured: true, record }; + } + + /** Yakalanmış (kuyruktaki) tüm keşiflerin kopyası. */ + getCaptured(): DiscoveryRecord[] { + return this._queue.peekAll(); + } + + /** Yakalanan keşifleri sürümlü JSON zarfına serileştirir (yerel export). */ + exportJson(pretty = true): string { + return exportDiscoveryJson(this._queue.peekAll(), pretty); + } + + /** Benzersiz yakalanan keşif sayısı (dedup sonrası). */ + get capturedCount(): number { + return this._cache.size; + } + + /** Cache + kuyruğu sıfırlar (oturum kapanışı / araç değişimi). */ + reset(): void { + this._cache.clear(); + this._queue.clear(); + this._knownDids.clear(); + } +} + +/** Uygulama geneli tekil örnek (servis wiring). Testler kendi örneğini kurar. */ +export const discoveryCaptureService = new DiscoveryCaptureService(); diff --git a/src/platform/obd/discovery/DiscoveryQueue.ts b/src/platform/obd/discovery/DiscoveryQueue.ts new file mode 100644 index 00000000..ea4a1651 --- /dev/null +++ b/src/platform/obd/discovery/DiscoveryQueue.ts @@ -0,0 +1,100 @@ +/** + * DiscoveryQueue — keşif kayıtları için OFFLINE-FIRST, kalıcı, sınırlı FIFO kuyruğu. + * + * TASARIM (görev: offline-first, ileride Supabase'e gönderilebilir, ŞİMDİLİK ağ YOK): + * - Kayıtlar safeStorage ile atomik/kota-güvenli yazılır (CLAUDE.md — safeStorage zorunlu). + * - HİÇBİR ağ çağrısı YOK: bu katman yalnız birikim + kalıcılık. Gelecekte bir "uploader" + * drain()/peekAll() ile okuyup Supabase'e gönderecek (bu PR kapsamı dışında, sözleşme hazır). + * - Bounded: MAX_QUEUE tavanı; taşınca EN ESKİ kayıt düşürülür (zero-leak, disk sınırı). + * - Yazma throttling: safeStorage debounce (yüksek frekanslı diske yazma yasağı — CLAUDE.md). + * + * SAF sınıf: yalnız safeStorage util'ine bağımlı; native/React yok. + */ + +import { safeSetRaw, safeGetRaw, safeRemoveRaw } from '../../../utils/safeStorage'; +import type { DiscoveryRecord } from './discoveryModel'; + +const STORAGE_KEY = 'obd-discovery-queue'; +/** Kalıcı kuyruk tavanı — disk/bellek sınırı (bounded); aşınca en eski düşer. */ +const MAX_QUEUE = 500; + +export class DiscoveryQueue { + private _items: DiscoveryRecord[] = []; + private _loaded = false; + private readonly storageKey: string; + private readonly maxQueue: number; + + constructor(storageKey = STORAGE_KEY, maxQueue = MAX_QUEUE) { + this.storageKey = storageKey; + this.maxQueue = maxQueue; + } + + /** İlk erişimde diskten yükler (lazy) — fail-soft: bozuk veri → boş kuyruk. */ + private _ensureLoaded(): void { + if (this._loaded) return; + this._loaded = true; + try { + const raw = safeGetRaw(this.storageKey); + if (raw) { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) this._items = parsed as DiscoveryRecord[]; + } + } catch { + this._items = []; // bozuk JSON → dürüstçe boş başla + } + } + + /** Diske yazar (safeStorage debounce — yüksek frekanslı yazma önlenir). */ + private _persist(): void { + try { + safeSetRaw(this.storageKey, JSON.stringify(this._items)); + } catch { + /* kota/serileştirme hatası — bellek kuyruğu korunur, fail-soft */ + } + } + + /** + * Kayıt ekler (kuyruğun SONUNA). Tavan aşılırsa en eski (baş) düşürülür. + * @returns kuyruğun güncel uzunluğu. + */ + enqueue(record: DiscoveryRecord): number { + this._ensureLoaded(); + this._items.push(record); + if (this._items.length > this.maxQueue) { + this._items.splice(0, this._items.length - this.maxQueue); + } + this._persist(); + return this._items.length; + } + + /** Kuyruktaki tüm kayıtların KOPYASI (dış mutasyona kapalı) — uploader okuma yüzeyi. */ + peekAll(): DiscoveryRecord[] { + this._ensureLoaded(); + return this._items.slice(); + } + + /** Bekleyen kayıt sayısı. */ + get size(): number { + this._ensureLoaded(); + return this._items.length; + } + + /** + * Kuyruğu boşaltır ve boşaltılan kayıtları döndürür (gelecekteki uploader başarı + * sonrası çağırır). Şimdilik yalnız yerel tüketiciler kullanır — ağ YOK. + */ + drain(): DiscoveryRecord[] { + this._ensureLoaded(); + const out = this._items; + this._items = []; + this._persist(); + return out; + } + + /** Kuyruğu ve kalıcı kaydı tamamen temizler. */ + clear(): void { + this._items = []; + this._loaded = true; + try { safeRemoveRaw(this.storageKey); } catch { /* yoksay */ } + } +} diff --git a/src/platform/obd/discovery/discoveryExport.ts b/src/platform/obd/discovery/discoveryExport.ts new file mode 100644 index 00000000..668ae523 --- /dev/null +++ b/src/platform/obd/discovery/discoveryExport.ts @@ -0,0 +1,38 @@ +/** + * discoveryExport — keşif kayıtlarının YEREL JSON export altyapısı (PR-DISC-1). + * + * KAPSAM (görev): yalnız yerel export; Supabase entegrasyonu YOK, ağ çağrısı YOK. + * SAF fonksiyonlar — deterministik string üretir; I/O yapmaz (çağıran dosyaya/panoya + * yazar). Gelecekteki Supabase gönderimi bu zarfı aynen kullanabilir (şema sürümlü). + */ + +import type { DiscoveryRecord } from './discoveryModel'; + +/** Export şeması sürümü — ileride alan eklenirse tüketici uyum sağlayabilsin. */ +export const DISCOVERY_EXPORT_SCHEMA = 1; + +export interface DiscoveryExportEnvelope { + schema: number; + exportedAt: number; + count: number; + records: DiscoveryRecord[]; +} + +/** Kayıtları sürümlü zarfa sarar (saf; export + gelecekteki upload ortak yapı). */ +export function buildDiscoveryEnvelope(records: readonly DiscoveryRecord[]): DiscoveryExportEnvelope { + return { + schema: DISCOVERY_EXPORT_SCHEMA, + exportedAt: Date.now(), + count: records.length, + records: records.slice(), + }; +} + +/** + * Keşif kayıtlarını okunabilir (girintili) JSON string'e serileştirir. + * @param pretty true (varsayılan) → 2 boşluk girinti; false → tek satır (kompakt upload). + */ +export function exportDiscoveryJson(records: readonly DiscoveryRecord[], pretty = true): string { + const envelope = buildDiscoveryEnvelope(records); + return JSON.stringify(envelope, null, pretty ? 2 : 0); +} diff --git a/src/platform/obd/discovery/discoveryModel.ts b/src/platform/obd/discovery/discoveryModel.ts new file mode 100644 index 00000000..f01266a7 --- /dev/null +++ b/src/platform/obd/discovery/discoveryModel.ts @@ -0,0 +1,105 @@ +/** + * discoveryModel — Otomatik PID/DID keşif yakalama boru hattının SAF veri modeli (PR-DISC-1). + * + * AMAÇ: araçtan gözlemlenen ancak katalogda (StandardPidRegistry / araç DID profili) + * bulunmayan PID/DID'leri yapılandırılmış tek bir kayıt tipiyle temsil etmek. Bu dosya + * SAF'tır — React/Capacitor/native importu YOK; kök vitest paketinden test edilebilir + * (Clean Architecture: model katmanı dış bağımlılık taşımaz). + * + * DEDUPLİKASYON: bir keşif kimliği ECU adresi + mode + PID/DID + kaynak (PID/DID) ile + * tanımlanır; zaman/yanıt kimliğe DAHİL DEĞİL (aynı sinyalin tekrar gözlemi = tek kayıt). + * Kimlik üzerinden FNV-1a hash üretilir (DiscoveryCache hash-tabanlı dedup için kullanır). + */ + +/** Keşfin kaynağı: standart Mode 01 PID mi, üretici UDS DID mi. */ +export type DiscoverySource = 'PID' | 'DID'; + +/** + * Tek bir keşif kaydı. Alanlar görev tanımıyla birebir; opsiyoneller gerçekten + * bilinmeyebilen alanlar (decodedValue/firmwareVersion) — uydurma yok, yoksa undefined. + */ +export interface DiscoveryRecord { + /** Kaydın alındığı an (Date.now() — sıralama/gösterim; süre hesabı için DEĞİL). */ + timestamp: number; + /** Araç/profil kimliği (profil adı veya VIN türevi). Bilinmiyorsa ''. */ + vehicleProfile: string; + /** Yanıt veren ECU adresi ('7E8', '18DAF110'…). Bilinmiyorsa ''. */ + ecuAddress: string; + /** Taşıma protokolü ('ISO15765-4', '6'…). Bilinmiyorsa ''. */ + protocol: string; + /** OBD servisi/modu ('01' PID, '22' DID, '09' araç bilgisi…). */ + mode: string; + /** PID veya DID (büyük-harf hex, ör. '78' / 'F190'). */ + pidOrDid: string; + /** ECU'ya gönderilen ham istek ('22F190'). */ + request: string; + /** ECU ham yanıtı (hex). Negatif yanıt/NO DATA da olabilir. */ + rawResponse: string; + /** Çözülmüş fiziksel/metin değer — YALNIZ güvenilir çözülebildiyse. */ + decodedValue?: number | string; + /** ECU bu PID/DID'i pozitif (veri) yanıtladı mı. */ + supported: boolean; + /** Kaynak türü. */ + discoverySource: DiscoverySource; + /** ECU firmware/kalibrasyon sürümü — biliniyorsa. */ + firmwareVersion?: string; +} + +/** + * Yeni kayıt üretir — TAM şekilli template (hidden-class kararlılığı + eksik alan yok). + * Zorunlu alanların varsayılanı güvenli boş değerdir; çağıran bildiğini geçer. + */ +export function createDiscoveryRecord(input: Partial & { + pidOrDid: string; + discoverySource: DiscoverySource; +}): DiscoveryRecord { + return { + timestamp: input.timestamp ?? Date.now(), + vehicleProfile: input.vehicleProfile ?? '', + ecuAddress: normalizeHex(input.ecuAddress ?? ''), + protocol: input.protocol ?? '', + mode: normalizeHex(input.mode ?? ''), + pidOrDid: normalizeHex(input.pidOrDid), + request: (input.request ?? '').trim(), + rawResponse: (input.rawResponse ?? '').trim(), + decodedValue: input.decodedValue, + supported: input.supported ?? false, + discoverySource: input.discoverySource, + firmwareVersion: input.firmwareVersion, + }; +} + +/** Hex/kimlik alanını normalize eder: boşluksuz, büyük harf. */ +export function normalizeHex(s: string): string { + return (s ?? '').replace(/\s+/g, '').toUpperCase(); +} + +/** + * Bir keşfin KİMLİK anahtarı — dedup bunun üzerinden yapılır. Zaman/yanıt/değer + * DAHİL DEĞİL: aynı ECU+mode+PID/DID+kaynak = TEK keşif (tekrar gözlem yeni kayıt değil). + */ +export function dedupKey(r: Pick): string { + return `${r.discoverySource}|${normalizeHex(r.mode)}|${normalizeHex(r.ecuAddress)}|${normalizeHex(r.pidOrDid)}`; +} + +/** + * FNV-1a 32-bit hash → 8 haneli hex. Saf/deterministik; harici bağımlılık yok. + * (Kriptografik değil — yalnız dedup kimliği; çakışma olasılığı bu ölçekte ihmal edilebilir.) + */ +export function fnv1a(str: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + // 32-bit FNV asal çarpımı (Math.imul ile taşma-güvenli) + h = Math.imul(h, 0x01000193); + } + // İşaretsiz 32-bit hex + return (h >>> 0).toString(16).padStart(8, '0'); +} + +/** Bir kaydın dedup hash'i (DiscoveryCache anahtarı). */ +export function discoveryHash(r: Pick): string { + return fnv1a(dedupKey(r)); +} diff --git a/src/platform/obd/discovery/index.ts b/src/platform/obd/discovery/index.ts new file mode 100644 index 00000000..695092db --- /dev/null +++ b/src/platform/obd/discovery/index.ts @@ -0,0 +1,40 @@ +/** + * discovery — Otomatik PID/DID keşif yakalama boru hattı (PR-DISC-1) genel yüzeyi. + * + * Katmanlar (Clean Architecture): + * discoveryModel → saf veri modeli + dedup kimliği/hash + * DiscoveryCache → hash-tabanlı sınırlı deduplikasyon + * DiscoveryQueue → offline-first kalıcı FIFO kuyruk (safeStorage; ağ YOK) + * discoveryExport → yerel JSON export (Supabase YOK) + * DiscoveryCaptureService → merkezî orkestratör (registry filtresi + dedup + kuyruk + tanı event) + */ + +export type { + DiscoveryRecord, + DiscoverySource, +} from './discoveryModel'; +export { + createDiscoveryRecord, + normalizeHex, + dedupKey, + discoveryHash, + fnv1a, +} from './discoveryModel'; + +export { DiscoveryCache } from './DiscoveryCache'; +export { DiscoveryQueue } from './DiscoveryQueue'; + +export { + exportDiscoveryJson, + buildDiscoveryEnvelope, + DISCOVERY_EXPORT_SCHEMA, + type DiscoveryExportEnvelope, +} from './discoveryExport'; + +export { + DiscoveryCaptureService, + discoveryCaptureService, + type DiscoveryInput, + type CaptureResult, + type DiscoveryCaptureOptions, +} from './DiscoveryCaptureService';