From 0ca0b8da33f1e59bd95a1cac222acfed62d84cad Mon Sep 17 00:00:00 2001 From: Selim Date: Sat, 11 Jul 2026 22:11:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(sensors):=20compass=20foreground=20demand-?= =?UTF-8?q?gating=20=E2=80=94=20talep-g=C3=BCd=C3=BCml=C3=BC=20pusula=20ab?= =?UTF-8?q?oneli=C4=9Fi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ÖNCE: gpsService._startCompassListener() startGPSTracking() ile KOŞULSUZ açılıyor, uygulama ön plandayken (Ayarlar/profil/medya/haritasız ekran dahil) HİÇ kapanmıyordu → cihaz QA'sında boşta ana ekranda rot_vec + game_rotvec 60 Hz açık ölçüldü (Ledger #42'de bilinen sınırlama: "Compass Settings'te KAPATILMADI"). ŞİMDİ: küçük, ref-count'lu, owner-bazlı compass demand contract: - compassDemand.ts (yeni): acquire/release/has/getCount + subscribe (0↔1 geçişleri). Owner bounded(32), duplicate acquire yok, release idempotent, timer/polling yok, import yan etkisiz, zero-leak. - gpsService: compass artık talep-güdümlü — ilk acquire → gate aboneliği AÇILIR, son release → KAPANIR (yalnız GPS tracking açıkken). Konum takibi ETKİLENMEZ. - FullMapView: mount'ta 'map:full' acquire / unmount'ta release (heading-up harita). - MiniMapWidget: YALNIZ kendi mevcut isDriving histerezisinde (giriş >5, çıkış <3 km/h) 'map:mini' acquire — park/kuzey-yukarı statikte talep YOK (yeni eşik eklenmedi). KORUNAN: blend/smoothing (computeBlendedHeading/applyCompassSmoothing/COMPASS_THROTTLE_MS), izin + fallback yolu, background/foreground gate (visibility) davranışı, GPS konum watch. Native samplingPeriod düşüşü İDDİA EDİLMEZ (Chromium 60 Hz sabit); Generic Sensor API yok. Kapsam-dışı dokunulmadı: blackBox, smartDriving, FullMapView PR #61, GPS PR #62, Platform Kernel PR #55, SystemBoot, OBD/CAN, SQL. Testler: compassForegroundDemand.test.ts (yeni, 29 test). PR #58 kilit dosyası orientationDemandGating.test.ts'in 1/3/5/6/7 kilitleri (compass koşulsuz-acquire varsayan) yeni talep-güdümlü davranışa GÜNCELLENDİ (silinmedi). Tam suite 3229 yeşil, tsc temiz, eslint 0 error. Ledger: #43 🔴 "cihazda test edilmedi" — kabul ölçütü Xiaomi dumpsys sensorservice önce/sonra + idle CPU/SensorsHandlerThread + Mali-400. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014VxojH1dYGXbiAxzETXm6r --- docs/DEVICE_VALIDATION_LEDGER.md | 1 + src/__tests__/compassForegroundDemand.test.ts | 319 ++++++++++++++++++ src/__tests__/orientationDemandGating.test.ts | 42 ++- src/components/map/FullMapView.tsx | 12 + src/components/map/MiniMapWidget.tsx | 26 ++ src/platform/gps/compassDemand.ts | 103 ++++++ src/platform/gpsService.ts | 31 +- 7 files changed, 518 insertions(+), 16 deletions(-) create mode 100644 src/__tests__/compassForegroundDemand.test.ts create mode 100644 src/platform/gps/compassDemand.ts diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index 4987739f..6d5548c5 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 | |---|---------|--------|------------------------------------------|---------| +| 43 | **🔴 Device Validation Pending — Compass Foreground Demand Gating** — PR #42'nin (Ledger #42) bilinçli bıraktığı "Compass Settings ekranında KAPATILMADI" sınırlamasını KAPATIR. Önce `gpsService._startCompassListener()` `startGPSTracking()` ile KOŞULSUZ açılıyor, uygulama ön plandayken (Ayarlar/profil/medya/haritasız ekran dahil) HİÇ kapanmıyordu → cihaz QA'sında boşta ana ekranda `rot_vec`+`game_rotvec` 60 Hz açık. **BU PR:** küçük, ref-count'lu, owner-bazlı **compass demand contract** (`compassDemand.ts`) — `acquireCompassDemand/releaseCompassDemand/hasCompassDemand/getCompassDemandCount` + `subscribeCompassDemand` (0↔1 geçişlerinde bildirir). gpsService bu talebi dinler: **ilk acquire → gate compass aboneliği AÇILIR; son release → KAPANIR** (yalnız GPS tracking açıkken). **Tüketiciler:** `FullMapView` mount'ta `map:full` acquire / unmount'ta release (heading-up harita gerçekten heading kullanır); `MiniMapWidget` YALNIZ kendi mevcut `isDriving` histerezisinde (giriş >5 km/h, çıkış <3 km/h) `map:mini` acquire — park/kuzey-yukarı statikte talep YOK (yeni eşik eklenmedi). **KORUNAN:** GPS konum takibi (talep yokken watch aynen sürer, yalnız compass kapanır), blend/smoothing (`computeBlendedHeading`/`applyCompassSmoothing`/`COMPASS_THROTTLE_MS`), izin ve fallback yolu, background/foreground gate (visibility) davranışı — HİÇBİRİ değişmez. **YALIN:** owner bounded(32), duplicate acquire yok, release idempotent, timer/polling YOK, import yan etkisiz, gate'in sahibi değil, zero-leak (stopGPSTracking demand unsub'ı bırakır; MiniMap unmount cleanup'ı release eder). ⚠️ **Native samplingPeriod düşüşü İDDİA EDİLMEZ** (Chromium DeviceOrientation 60 Hz sabit); Generic Sensor API EKLENMEZ. Kapsam-dışı dokunulmadı: blackBox, smartDriving, FullMapView PR #61, GPS PR #62, Platform Kernel PR #55, SystemBoot, OBD/CAN, SQL. | `platform/gps/compassDemand.ts` (yeni), `platform/gpsService.ts` (compass talep-güdümlü), `components/map/FullMapView.tsx` (acquire/release), `components/map/MiniMapWidget.tsx` (isDriving'e bağlı acquire/release), `__tests__/compassForegroundDemand.test.ts` (yeni, 29 test), `__tests__/orientationDemandGating.test.ts` (PR #58 1/3/5/6/7 kilitleri yeni talep-güdümlü davranışa güncellendi) | Gerçek Xiaomi cihazda (önce/sonra, `dumpsys sensorservice` + idle CPU/`SensorsHandlerThread`): (1) **Ayarlar/haritasız ekranda** uygulama adına `rot_vec`/`game_rotvec` (rotation-vector/game-rotation) sensörleri AÇIK OLMAMASI; (2) harita/navigasyon açılınca compass sensörlerinin GERİ GELMESİ; (3) heading/pusula yönünün DOĞRU olması; (4) harita kapanınca compass sensörlerinin SÖKÜLMESİ; (5) background davranışının BOZULMAMASI; (6) duplicate listener OLMAMASI; (7) Xiaomi'de idle CPU ve `SensorsHandlerThread`'in önce/sonra ölçülmesi (düşüş beklenir); (8) Mali-400'de regresyon yok. ⚠️ **Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 42 | **🔴 Device Validation Pending — Orientation Sensor Demand Gating (PR 3/3)** — İki always-on tüketiciyi (gpsService compass + smartDrivingEngine accelerometer) talep-güdümlü hale getirir. **ACCEL (gerçek foreground demand-gate):** `_accelMagnitude` YALNIZ `detectDrivingMode` Kademe 4'te (OBD+GPS+decayed hepsi yok/stale) okunuyor → yardımcı son-çare. `_evaluateAccelDemand()` yalnız TAZE güvenilir hız kaynağı YOKKEN gate'ten `subscribeMotion` ile abone olur; taze kaynak (OBD **veya** GPS — `recordSpeed` ile beslenen `_lastSpeedEstimate`, yaş>DECAY_MAX_SEC=stale) varken release. `attachAccelerometer` fail-safe (kaynak yoksa acquire), `recordSpeed` her çağrıda re-eval, `detachAccelerometer` release. İvmeölçer TEK BAŞINA safety-critical/kesin hareket üretmez (yalnız Kademe 4 ipucu). Magic number YOK (DECAY_MAX_SEC mevcut sabit). **COMPASS (gpsService):** `_startCompassListener` ham `window.addEventListener` → gate `subscribeOrientationAbsolute/subscribeOrientation`; mevcut JS throttle (COMPASS_THROTTLE_MS, mevcut) + heading/smoothing/blend davranışı KORUNUR. Kazanım: **background auto-pause** (gate visibility hidden→fiziksel listener söker) + dedup. **YALIN:** yalnız 2 dosya değişti; yeni timer/rAF/polling YOK, Generic Sensor API YOK, yeni JS throttle YOK, yeni bağımlılık yok, import yan etkisiz; gate'in sahibi değil. ⚠️ **DÜRÜST SINIRLAMALAR:** (1) **Compass Settings ekranında KAPATILMADI** — foreground Settings-gating "harita ekranda mı" tüketici sinyali gerektirir (global görünüm store'u yok, MainLayout lokal `drawer` state'i; tüketiciye dokunmak KAPSAM DIŞI). Compass foreground davranışı bilinçli KORUNDU (map-heading-while-not-navigating bozulmasın). (2) **Accel mid-drive kaynak kaybı** (fresh sonra stale) re-acquire'ı sonraki `recordSpeed`/attach-döngüsünde olur (timer yok) — accel non-critical fallback + decay(20s)+Kademe5 kapsar. ⚠️ **Bu PR native samplingPeriod düşüşü İDDİA ETMEZ.** | `platform/gpsService.ts` (compass→gate), `platform/smartDrivingEngine.ts` (accel→gate+demand), `__tests__/orientationDemandGating.test.ts` (yeni, 26 test), `__tests__/orientationGateConsumerMigration.test.ts` (PR2 #21/#22 scope-kilidi yeni davranışa güncellendi) | Gerçek Xiaomi cihazda (önce/sonra): (1) sürüş sırasında (GPS/OBD taze) `dumpsys sensorservice` altında smartDriving accel `devicemotion` kaydı KALMAMASI; (2) GPS fix yok + OBD yok senaryosunda accel fallback'in ÇALIŞMASI (mod tespiti sürüyor); (3) background'da (app hidden) compass+accel fiziksel listener'ın SÖKÜLMESİ; (4) foreground dönüşünde geri bağlanması; (5) heading/pusula/navigation bearing DEĞİŞMEMESİ; (6) duplicate listener/cleanup leak OLMAMASI; (7) idle CPU (SensorsHandlerThread+CrRendererMain) sürüş+background senaryolarında düşüşü; (8) BASIC_JS/Mali-400'de regresyon yok. ⚠️ **Compass Settings-gating bu PR'da YOK (tüketici demand sinyali gerektirir — ayrı iş). Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 41 | **🔴 Device Validation Pending — Orientation Sensor Gate On-Demand Consumer Migration (PR 2/3)** — Yaşam-döngüsüyle açılıp kapanan DÖRT sensör tüketicisini ham `window.addEventListener` yerine Orientation Sensor Gate (#40) üzerinden çalıştırır. **Taşınanlar:** `arAlignmentService` (deviceorientationabsolute+deviceorientation+devicemotion → `subscribeOrientationAbsolute/subscribeOrientation/subscribeMotion`, 3 release ref, capture-fazı kaldırıldı=pencere-hedefli device event teslimatı faz-bağımsız), `dashcamService` (devicemotion → `subscribeMotion`, `_motionRelease`), `blackBoxService._startAccelerometer` (gate release aynen döner→`_accelUnsub` sözleşmesi değişmez), `deviceApi.subscribeToAccelerometer` (gate release döner→sentryEngine `_unsubAccel` yolu değişmez). **Model:** her tüketici gate release'i kendi lifecycle alanında tutar; start/stop İDEMPOTENT (mevcut guard'lar korundu); duplicate acquire yok; stop/dispose'ta release; gate'in SAHİBİ DEĞİL, gate'i dispose ETMEZ. **Davranış korundu:** handler'lar/callback sırası/işaretleri/smoothing/filtering/permission (feature-detect `'DeviceMotionEvent' in window` korundu, iOS requestPermission EKLENMEDİ) aynı; event payload aynı callback'e ulaşır. **Visibility:** tüketiciler kendi visibilitychange listener'ını EKLEMEZ (gate yönetir; çift listener yok). **Kazanım:** aynı ham event için birden fazla fiziksel JS listener yerine tek gate listener + background'da otomatik pause + temiz ownership/cleanup. **YALIN:** event başına yeni allocation yok, yeni timer/rAF/polling yok, callback gövdesi büyümedi, yeni bağımlılık yok, import yan etkisiz. ⚠️ **gpsService ve smartDrivingEngine bu PR'da DEĞİŞMEDİ** (hâlâ always-on) → **Settings ekranında native sensör kaydı ve native 60 Hz sampling DEVAM EDEBİLİR; bu PR native samplingPeriod'u düşürdüğünü İDDİA ETMEZ ve Settings CPU sorununu ÇÖZMEZ** — bunlar PR 3 kapsamı (always-on talep-güdümlü acquire). | `platform/arAlignmentService.ts`, `platform/dashcamService.ts`, `platform/security/blackBoxService.ts`, `platform/deviceApi.ts`, `__tests__/orientationGateConsumerMigration.test.ts` (yeni, 30 test) | Gerçek cihazda (AR navigasyon/dashcam/black-box/sentry aktifken): (1) AR/Dashcam/BlackBox/Sentry aktifken sensör olayları çalışıyor (heading/G-force/crash-detect aynı); (2) özellik durduğunda ilgili consumer release oluyor; (3) birden fazla consumer tek fiziksel listener paylaşıyor; (4) background'da (visibility hidden) fiziksel listener sökülüyor; (5) foreground dönüşünde yalnız aktif consumer geri bağlanıyor; (6) duplicate listener/cleanup leak yok; (7) BASIC_JS/Mali-400'de regresyon yok. ⚠️ **gpsService ve smartDrivingEngine bu PR KAPSAMI DIŞI; Settings ve native 60 Hz sorunu PR 3'te çözülür. Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 40 | **🔴 Device Validation Pending — Orientation Sensor Subscription Gate Foundation (PR 1/3)** — Cihaz yön/hareket Web event'leri (`deviceorientationabsolute`/`deviceorientation`/`devicemotion`) için MERKEZİ, ref-count'lu abonelik kapısı (multiplexer). Bugün bu event'lere ≥6 servis (gpsService compass, smartDrivingEngine, arAlignmentService, dashcamService, blackBoxService, deviceApi) AYRI `addEventListener` ile abone; ikisi (compass + accelerometer) kök layout'ta app-ömrü bağlı → Ayarlar dahil her ekranda sensör aktif (QA: `dumpsys sensorservice` samplingPeriod 16.67ms=60Hz, SensorsHandlerThread ~%24 + CrRendererMain ~%21). Kapı: **event türü başına TEK fiziksel window listener** (dedup); callback Set ile ref-count (ilk consumer→bağla, son→sök); `visibilityState!=='visible'`→fiziksel listener sökülür, görünür+aktif consumer→yeniden bağlanır; deterministik fan-out (insertion order, kararlı dizi snapshot'ı); **event başına YENİ array/object allocation YOK** (index'li döngü); callback exception İZOLE (fail-soft, diğerleri sürer); public API throw ETMEZ; bounded (kanal başı 64, aşım fail-soft no-op+droppedCount); dispose zero-leak + sonrası güvenli no-op; reset yeniden kullanılabilir. API: subscribeOrientationAbsolute/subscribeOrientation/subscribeMotion (idempotent release), getSubscriberCounts, getStatus, reset, dispose. **YALIN:** HİÇ import YOK (tam bağımsız→import yan etkisiz); modül seviyesinde listener kurulmaz (ilk abonelikte `visibilitychange` bağlanır); timer/rAF/polling YOK; Generic Sensor API YOK; JS throttle YOK; permission tüketicide kalır. ⚠️ **KAPSAM: foundation — HİÇBİR tüketici bu kapıya bağlanmadı (wiring YOK).** ⚠️ **Bu PR tek başına CPU veya samplingPeriod DÜŞÜRMEZ.** Gerçek kazanım PR 2 (on-demand tüketiciler) ve özellikle PR 3 (always-on compass + smartDriving talep-güdümlü acquire + background pause) wiring'inden SONRA ölçülebilir. **Legacy event API aktifken native sampling 60 Hz OLABİLİR** (frekans parametresi yok — kanıt: gpsService zaten JS'te 10Hz throttle'lı ama dumpsys 60Hz). | `platform/sensors/orientationSensorGate.ts` (yeni), `platform/sensors/index.ts` (yeni), `__tests__/orientationSensorGate.test.ts` (yeni, 30 test), `__tests__/regression.guards.test.ts` (gate zero-leak + bağımsızlık kilidi) | Gerçek cihazda (tüketici wiring PR 2/3 bağlandıktan SONRA): (1) Ayarlar ekranında `dumpsys sensorservice` altında CockpitOS için orientation/motion 60 Hz kaydı kalmaması; (2) background'da (visibility hidden) sensör kaydı kalmaması; (3) harita/navigation açılınca sensörün yeniden çalışması; (4) heading/pusula davranışının korunması; (5) duplicate listener ve cleanup sızıntısı olmaması; (6) idle CPU'nun (SensorsHandlerThread + CrRendererMain) Ayarlar ve background senaryolarında belirgin düşmesi; (7) BASIC_JS/Mali-400'de regresyon olmaması. ⚠️ **PR 1 tek başına ölçülebilir kazanç VERMEZ — foundation. Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | diff --git a/src/__tests__/compassForegroundDemand.test.ts b/src/__tests__/compassForegroundDemand.test.ts new file mode 100644 index 00000000..9f2d0fb2 --- /dev/null +++ b/src/__tests__/compassForegroundDemand.test.ts @@ -0,0 +1,319 @@ +/** + * compassForegroundDemand.test.ts — Compass Foreground Demand Gating (saha fix 2026-07-11). + * + * SAHA KANITI (Xiaomi zircon, `dumpsys sensorservice`): uygulama BOŞTA ANA EKRANDA + * `rot_vec` (0x0b, absolute orientation) ve `game_rotvec` (0x0f, relative) sensörlerini + * 60 Hz'de açık tutuyordu. Kaynak: `gpsService._startCompassListener()` `startGPSTracking()` + * ile koşulsuz açılıyor, ön planda HİÇ kapanmıyordu (Ledger #42'deki bilinen sınırlama). + * + * BU PR: compass artık yalnız GERÇEK heading tüketicisi talep ettiğinde açılır: + * - FullMapView (heading-up harita ekranı) → mount'ta acquire, unmount'ta release + * - MiniMapWidget → YALNIZ kendi `isDriving` histerezisi açıkken (park = kuzey-yukarı → talep yok) + * Konum takibi, blend/smoothing, izin ve fallback davranışı DEĞİŞMEZ. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + acquireCompassDemand, + releaseCompassDemand, + hasCompassDemand, + getCompassDemandCount, + getCompassOwners, + subscribeCompassDemand, + resetCompassDemand, + MAX_COMPASS_OWNERS, +} from '../platform/gps/compassDemand'; + +const read = (...p: string[]) => readFileSync(join(process.cwd(), ...p), 'utf8'); +const GPS_SRC = read('src', 'platform', 'gpsService.ts'); +const FULL_SRC = read('src', 'components', 'map', 'FullMapView.tsx'); +const MINI_SRC = read('src', 'components', 'map', 'MiniMapWidget.tsx'); +const BLACK_SRC = read('src', 'platform', 'security', 'blackBoxService.ts'); +const SMART_SRC = read('src', 'platform', 'smartDrivingEngine.ts'); +const GATE_SRC = read('src', 'platform', 'sensors', 'orientationSensorGate.ts'); + +/** gpsService'in compass gate aboneliğini modelleyen sadık simülasyon. */ +function createCompassSim() { + let tracking = false; + let listenerOn = false; + let physicalSubscribes = 0; // gate'e yapılan fiziksel abonelik sayısı + let unsub: (() => void) | null = null; + + const apply = () => { + const want = tracking && hasCompassDemand(); + if (want && !listenerOn) { listenerOn = true; physicalSubscribes++; } + if (!want && listenerOn) { listenerOn = false; } + }; + + return { + startGPS() { tracking = true; if (!unsub) unsub = subscribeCompassDemand(apply); apply(); }, + stopGPS() { tracking = false; if (unsub) { unsub(); unsub = null; } apply(); }, + get compassOn() { return listenerOn; }, + get subscribeCount() { return physicalSubscribes; }, + get hasGpsSub() { return unsub !== null; }, + }; +} + +beforeEach(() => { resetCompassDemand(); }); +afterEach(() => { resetCompassDemand(); vi.restoreAllMocks(); }); + +/* ── 1-5: Talep → compass yaşam döngüsü ──────────────────────────────────── */ + +describe('Talep → compass aboneliği', () => { + it('1. talep yokken compass aboneliği YOK', () => { + const sim = createCompassSim(); + sim.startGPS(); + expect(hasCompassDemand()).toBe(false); + expect(sim.compassOn).toBe(false); + expect(sim.subscribeCount).toBe(0); + }); + + it('2. ilk harita tüketicisi compass\'ı AÇAR', () => { + const sim = createCompassSim(); + sim.startGPS(); + acquireCompassDemand('map:full'); + expect(hasCompassDemand()).toBe(true); + expect(sim.compassOn).toBe(true); + expect(sim.subscribeCount).toBe(1); + }); + + it('3. ikinci tüketici DUPLICATE fiziksel abonelik üretmez', () => { + const sim = createCompassSim(); + sim.startGPS(); + acquireCompassDemand('map:full'); + acquireCompassDemand('map:mini'); + expect(getCompassDemandCount()).toBe(2); + expect(sim.subscribeCount).toBe(1); // tek fiziksel abonelik + }); + + it('4. bir tüketici ayrılınca diğeri DEVAM eder', () => { + const sim = createCompassSim(); + sim.startGPS(); + acquireCompassDemand('map:full'); + acquireCompassDemand('map:mini'); + releaseCompassDemand('map:mini'); + expect(hasCompassDemand()).toBe(true); + expect(sim.compassOn).toBe(true); + }); + + it('5. SON tüketici ayrılınca compass KAPANIR', () => { + const sim = createCompassSim(); + sim.startGPS(); + acquireCompassDemand('map:full'); + acquireCompassDemand('map:mini'); + releaseCompassDemand('map:mini'); + releaseCompassDemand('map:full'); + expect(hasCompassDemand()).toBe(false); + expect(sim.compassOn).toBe(false); + }); +}); + +/* ── 6-10: Ekran senaryoları ─────────────────────────────────────────────── */ + +describe('Ekran senaryoları', () => { + it('6. AYARLAR ekranında talep YOK → compass kapalı', () => { + const sim = createCompassSim(); + sim.startGPS(); + const releaseMap = acquireCompassDemand('map:full'); // haritadaydık + releaseMap(); // Ayarlar'a geçildi (harita unmount) + expect(hasCompassDemand()).toBe(false); + expect(sim.compassOn).toBe(false); + }); + + it('7. harita mount → acquire, unmount → release', () => { + const sim = createCompassSim(); + sim.startGPS(); + const release = acquireCompassDemand('map:full'); // mount + expect(sim.compassOn).toBe(true); + release(); // unmount + expect(sim.compassOn).toBe(false); + expect(getCompassOwners()).toEqual([]); + }); + + it('8. navigasyon aktifken (harita açık) compass AÇIK', () => { + const sim = createCompassSim(); + sim.startGPS(); + acquireCompassDemand('map:full'); // navigasyon full map üzerinde koşar + expect(sim.compassOn).toBe(true); + }); + + it('9. navigasyon bitince, başka tüketici yoksa compass KAPALI', () => { + const sim = createCompassSim(); + sim.startGPS(); + const release = acquireCompassDemand('map:full'); + release(); // harita kapandı + expect(sim.compassOn).toBe(false); + }); + + it('10. HEADING-UP kapalıysa (mini harita park/kuzey-yukarı) gereksiz acquire YOK', () => { + const sim = createCompassSim(); + sim.startGPS(); + // Mini harita park dalında → talep etmez + expect(hasCompassDemand()).toBe(false); + expect(sim.compassOn).toBe(false); + + // Sürüş başlayınca (isDriving true) talep eder + acquireCompassDemand('map:mini'); + expect(sim.compassOn).toBe(true); + + // Park'a dönünce bırakır + releaseCompassDemand('map:mini'); + expect(sim.compassOn).toBe(false); + }); +}); + +/* ── 11-13: Davranış koruması ────────────────────────────────────────────── */ + +describe('Davranış koruması', () => { + it('11. izin/fallback davranışı KORUNUYOR (gate ve izin yolu değişmedi)', () => { + // gpsService hâlâ gate'ten abone oluyor (ham window listener yok) + expect(GPS_SRC).toMatch(/subscribeOrientationAbsolute\(_onDeviceOrientation\)/); + expect(GPS_SRC).toMatch(/subscribeOrientation\(_onDeviceOrientation\)/); + expect(GPS_SRC).not.toMatch(/window\.addEventListener\(['"]deviceorientation/); + // GPS izin akışı dokunulmadı + expect(GPS_SRC).toMatch(/Geolocation\.requestPermissions\(\)/); + }); + + it('12. bearing/smoothing DEĞİŞMEDİ (blend çekirdeği aynı)', () => { + expect(GPS_SRC).toMatch(/computeBlendedHeading/); + expect(GPS_SRC).toMatch(/applyCompassSmoothing/); + expect(GPS_SRC).toMatch(/COMPASS_THROTTLE_MS\s*=\s*100/); // mevcut JS throttle korunuyor + }); + + it('13. background/foreground gate davranışı KORUNUYOR (gate visibility yönetir)', () => { + expect(GATE_SRC).toMatch(/visibilitychange/); + // gpsService gate'in SAHİBİ değil — visibility'yi kendisi yönetmiyor + expect(GPS_SRC).not.toMatch(/visibilitychange/); + }); + + it('13b. GPS KONUM takibi compass talebinden BAĞIMSIZ (talep yokken de sürer)', () => { + // _applyCompassDemand yalnız compass listener'ını açıp kapatır; watch'a dokunmaz + expect(GPS_SRC).toMatch(/function _applyCompassDemand\(\): void \{[\s\S]{0,200}_startCompassListener\(\)[\s\S]{0,120}_stopCompassListener\(\)/); + expect(GPS_SRC).not.toMatch(/_applyCompassDemand[\s\S]{0,200}clearWatch/); + }); +}); + +/* ── 14-16: Sözleşme kuralları ───────────────────────────────────────────── */ + +describe('Sözleşme — duplicate / idempotent / bounded / zero-leak', () => { + it('14. DUPLICATE owner engelleniyor (aynı owner sayacı artırmaz)', () => { + acquireCompassDemand('map:full'); + acquireCompassDemand('map:full'); + acquireCompassDemand('map:full'); + expect(getCompassDemandCount()).toBe(1); + }); + + it('15. release İDEMPOTENT (bilinmeyen/tekrar release güvenli)', () => { + const sim = createCompassSim(); + sim.startGPS(); + const release = acquireCompassDemand('map:full'); + release(); + release(); // ikinci kez + releaseCompassDemand('hic-olmayan'); // bilinmeyen owner + expect(getCompassDemandCount()).toBe(0); + expect(sim.compassOn).toBe(false); + }); + + it('16. dispose/cleanup ZERO-LEAK: stopGPS gate aboneliğini bırakır', () => { + const sim = createCompassSim(); + sim.startGPS(); + acquireCompassDemand('map:full'); + expect(sim.hasGpsSub).toBe(true); + sim.stopGPS(); + expect(sim.hasGpsSub).toBe(false); + expect(sim.compassOn).toBe(false); + }); + + it('16b. owner sayısı BOUNDED (kaçak tüketici belleği şişiremez)', () => { + for (let i = 0; i < MAX_COMPASS_OWNERS + 10; i++) acquireCompassDemand(`owner-${i}`); + expect(getCompassDemandCount()).toBe(MAX_COMPASS_OWNERS); + expect(hasCompassDemand()).toBe(true); // fail-safe: talep var → compass açık kalır + }); + + it('16c. talep dinleyicisi YALNIZ 0↔1 geçişlerinde tetiklenir (gereksiz iş yok)', () => { + const cb = vi.fn(); + subscribeCompassDemand(cb); + acquireCompassDemand('a'); // 0→1 → tetikler + acquireCompassDemand('b'); // 1→2 → tetiklemez + releaseCompassDemand('b'); // 2→1 → tetiklemez + releaseCompassDemand('a'); // 1→0 → tetikler + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('16d. bozuk owner (boş string) fail-soft yok sayılır', () => { + const r = acquireCompassDemand(''); + expect(getCompassDemandCount()).toBe(0); + expect(() => r()).not.toThrow(); + }); +}); + +/* ── 17-23: Kapsam kilitleri ─────────────────────────────────────────────── */ + +describe('Kapsam kilitleri — bu PR yalnız compass demand-gating', () => { + it('17. blackBoxService DEĞİŞMEDİ (always-on devicemotion aynen duruyor)', () => { + expect(BLACK_SRC).toMatch(/subscribeMotion\(handler\)/); + expect(BLACK_SRC).not.toMatch(/compassDemand|acquireCompassDemand/); + }); + + it('18. smartDrivingEngine DEĞİŞMEDİ (talep-güdümlü accel aynen)', () => { + expect(SMART_SRC).toMatch(/_evaluateAccelDemand/); + expect(SMART_SRC).toMatch(/subscribeMotion\(_handleDeviceMotion\)/); + expect(SMART_SRC).not.toMatch(/compassDemand|acquireCompassDemand/); + }); + + it('19. Generic Sensor API EKLENMEDİ', () => { + for (const src of [GPS_SRC, GATE_SRC, FULL_SRC, MINI_SRC]) { + expect(src).not.toMatch(/new (Accelerometer|Gyroscope|AbsoluteOrientationSensor|RelativeOrientationSensor|Magnetometer)\b/); + } + }); + + it('20. native sampling rate DÜŞÜŞÜ İDDİA EDİLMİYOR (frequency/samplingPeriod KOD AYARI yok)', () => { + // Bare kelime DEĞİL, gerçek bir kod ayarı (option key / atama) aranır: gate'in + // foundation yorumu (PR #56) "samplingPeriod"dan düz metin olarak bahseder — + // bu bir sampling-rate iddiası değildir, testi tripletmemeli. + for (const src of [GPS_SRC, GATE_SRC]) { + expect(src).not.toMatch(/frequency\s*:/); + expect(src).not.toMatch(/samplingPeriod\s*[:=]/); + } + }); + + it('21. FullMapView\'da YALNIZ compass talebi eklendi — render/kamera yolu korunuyor', () => { + expect(FULL_SRC).toMatch(/acquireCompassDemand\('map:full'\)/); + // Mevcut harita davranış yolları duruyor (silinmedi) + expect(FULL_SRC).toMatch(/setDrivingView\(/); + expect(FULL_SRC).toMatch(/updateUserMarker\(/); + expect(FULL_SRC).toMatch(/requestAnimationFrame\(tick\)/); + // GPS abonelik yoluna dokunulmadı + expect(FULL_SRC).not.toMatch(/watchPosition|clearWatch/); + }); + + it('22. GPS KONUM abonelikleri (watch) bu PR\'da değişmedi', () => { + expect(GPS_SRC).toMatch(/Geolocation\.watchPosition\(/); + expect(GPS_SRC).toMatch(/navigator\.geolocation\.watchPosition\(/); + // Tek-abonelik (PR #62) mantığı bu branch'te YOK — kapsam ayrı + expect(GPS_SRC).not.toMatch(/_maybeSuspendCapacitorWatch/); + }); + + it('23. Platform Kernel / HAL / Event Bus / Deep Scan import EDİLMEDİ', () => { + for (const src of [GPS_SRC, FULL_SRC, MINI_SRC]) { + expect(src).not.toMatch(/from\s+['"].*(platformKernel|vehicleHal|platformEventBus|capabilityRegistry|deepScan)/i); + } + }); + + it('23b. compassDemand import YAN ETKİSİZ (timer/polling/listener yok)', () => { + const SRC = read('src', 'platform', 'gps', 'compassDemand.ts'); + expect(SRC).not.toMatch(/setInterval|setTimeout|requestAnimationFrame/); + expect(SRC).not.toMatch(/addEventListener/); + // modül yüklendi ama hiçbir talep yok + expect(getCompassDemandCount()).toBe(0); + }); + + it('23c. MiniMapWidget talebi KENDİ isDriving histerezisine bağlı (yeni eşik yok)', () => { + expect(MINI_SRC).toMatch(/_setCompassDemand\(isDriving\)/); + expect(MINI_SRC).toMatch(/releaseCompassDemand\(MINI_MAP_COMPASS_OWNER\)/); + // yeni hız eşiği eklenmedi — mevcut 5/3 km/h histerezisi kullanılıyor + expect(MINI_SRC).toMatch(/_effKmh > 5[\s\S]{0,80}_effKmh < 3/); + }); +}); diff --git a/src/__tests__/orientationDemandGating.test.ts b/src/__tests__/orientationDemandGating.test.ts index b1b661d1..89a210cb 100644 --- a/src/__tests__/orientationDemandGating.test.ts +++ b/src/__tests__/orientationDemandGating.test.ts @@ -3,10 +3,12 @@ * * İki always-on tüketici talep-güdümlü hale getirildi: * • gpsService COMPASS → Orientation Sensor Gate (background auto-pause + dedup). - * Foreground davranışı KORUNUR (GPS tracking'te acquire). ⚠️ Bu PR compass'ı - * Settings ekranında KAPATMAZ — foreground Settings-gating "harita ekranda mı" - * tüketici sinyali gerektirir (global görünüm store'u yok, MainLayout lokal - * drawer state'i; tüketiciye dokunmak KAPSAM DIŞI). Bkz. PR notu. + * ✅ GÜNCELLEME (Compass Foreground Demand Gating, 2026-07-11): PR #58'in bıraktığı + * "foreground Settings-gating KAPSAM DIŞI" sınırlaması ARTIK KAPATILDI. Compass + * koşulsuz açılmaz; yalnız gerçek bir heading tüketicisi (heading-up harita / + * aktif navigasyon) `acquireCompassDemand` ile talep ettiğinde açılır, son + * tüketici bırakınca kapanır. Aşağıdaki 1/3/5/6/7 kilitleri bu YENİ davranışa + * göre güncellendi (talep var → gate acquire; talep yok → gate boş). * • smartDrivingEngine ACCELEROMETER → gate + TALEP-GÜDÜMLÜ: yalnız TAZE * güvenilir hız kaynağı (OBD/GPS, recordSpeed) YOKKEN acquire; taze kaynak * varken release. Fail-safe: hiç kaynak yoksa açık. @@ -31,6 +33,7 @@ vi.mock('../platform/crashLogger', () => ({ logError: vi.fn() })); import { getSubscriberCounts, getStatus, reset as gateReset } from '../platform/sensors'; import { startGPSTracking, stopGPSTracking, getGPSState } from '../platform/gpsService'; +import { acquireCompassDemand, resetCompassDemand } from '../platform/gps/compassDemand'; import { attachAccelerometer, detachAccelerometer, recordSpeed, detectDrivingMode, } from '../platform/smartDrivingEngine'; @@ -78,15 +81,18 @@ describe('PR 3 — gpsService compass gate demand/visibility', () => { mockNavigatorGeolocation(); await stopGPSTracking(); gateReset(); + resetCompassDemand(); // her test taze talep tablosuyla başlar }); afterEach(async () => { await stopGPSTracking(); gateReset(); + resetCompassDemand(); vi.clearAllMocks(); }); - /* 1 — gps start compass acquire eder */ - it('1: GPS tracking başlayınca compass gate\'ten acquire edilir', async () => { + /* 1 — talep varken gps start compass acquire eder (talep-güdümlü) */ + it('1: compass talebi varken GPS tracking gate\'ten acquire eder', async () => { + acquireCompassDemand('test:map'); // heading tüketicisi (ör. harita) talep etti await startGPSTracking(); const c = getSubscriberCounts(); expect(c.orientationAbsolute).toBe(1); @@ -101,8 +107,9 @@ describe('PR 3 — gpsService compass gate demand/visibility', () => { expect(getSubscriberCounts().orientation).toBe(0); }); - /* 3 — duplicate start ekstra abonelik yok */ + /* 3 — duplicate start ekstra abonelik yok (talep varken) */ it('3: duplicate GPS start ekstra compass aboneliği oluşturmaz', async () => { + acquireCompassDemand('test:map'); await startGPSTracking(); await startGPSTracking(); expect(getSubscriberCounts().orientationAbsolute).toBe(1); @@ -114,8 +121,9 @@ describe('PR 3 — gpsService compass gate demand/visibility', () => { expect(getGPSState()).toHaveProperty('heading'); // null olabilir — yapı korunur }); - /* 5 — background'da fiziksel compass listener sökülür */ + /* 5 — background'da fiziksel compass listener sökülür (talep varken) */ it('5: background (hidden) → compass fiziksel listener sökülür, kayıt korunur', async () => { + acquireCompassDemand('test:map'); await startGPSTracking(); expect(getStatus().channels.orientationAbsolute.listenerAttached).toBe(true); setVisibility('hidden'); @@ -123,21 +131,25 @@ describe('PR 3 — gpsService compass gate demand/visibility', () => { expect(getSubscriberCounts().orientationAbsolute).toBe(1); // consumer kaydı korunur }); - /* 6 — foreground dönüşünde geri bağlanır */ + /* 6 — foreground dönüşünde geri bağlanır (talep sürerken) */ it('6: foreground dönüşünde compass yeniden bağlanır', async () => { + acquireCompassDemand('test:map'); await startGPSTracking(); setVisibility('hidden'); setVisibility('visible'); expect(getStatus().channels.orientationAbsolute.listenerAttached).toBe(true); }); - /* 7 — DÜRÜST SINIR: compass Settings'te KAPATILMADI (foreground korunur) */ - it('7: compass foreground davranışı korunur (bu PR Settings-gating YAPMAZ)', async () => { - // Compass, GPS tracking aktifken ekrandan bağımsız acquire edilir (bugünkü - // davranış korunur). Settings-foreground-gating tüketici demand sinyali - // gerektirir → bu PR KAPSAMI DIŞI. Kaynakta ekran/nav koşulu YOK. - await startGPSTracking(); + /* 7 — YENİ DAVRANIŞ: talep YOKKEN compass açılmaz; talep gelince açılır */ + it('7: talep YOKKEN GPS tracking compass açmaz, talep gelince açar (foreground demand-gating)', async () => { + // PR #58'de compass GPS tracking'te KOŞULSUZ açılırdı (Settings/haritasız + // ekranlarda dahi sensör açıktı). Bu PR onu talep-güdümlü yaptı: + await startGPSTracking(); // hiç heading tüketicisi yok + expect(getSubscriberCounts().orientationAbsolute).toBe(0); // → compass KAPALI + // Bir harita/navigasyon tüketicisi talep edince gate anında açılır: + acquireCompassDemand('test:map'); expect(getSubscriberCounts().orientationAbsolute).toBe(1); + // gpsService gate'in sahibi değildir — kendi içinde ekran/nav koşulu barındırmaz. expect(gpsSrc).not.toMatch(/isNavigating|drawer|activeScreen|currentView/); }); }); diff --git a/src/components/map/FullMapView.tsx b/src/components/map/FullMapView.tsx index 1e2ab16a..63f2507e 100644 --- a/src/components/map/FullMapView.tsx +++ b/src/components/map/FullMapView.tsx @@ -39,6 +39,7 @@ import { } from '../../platform/mapService'; import { useHazardStore } from '../../store/useHazardStore'; import { useGPSSource, onGPSLocation, type GPSLocation } from '../../platform/gpsService'; +import { acquireCompassDemand } from '../../platform/gps/compassDemand'; import { enterMapLiteInteraction, exitMapLiteInteraction } from '../../platform/map/mapLiteMode'; import { pauseWakeWordForInteraction, resumeWakeWordAfterInteraction } from '../../platform/wakeWordService'; import { useThermalState } from '../../platform/thermalWatchdog'; @@ -370,6 +371,17 @@ export const FullMapView = memo(function FullMapView({ onClose, onOpenDrawer }: return unsub; }, []); + // ── Compass TALEBİ (saha fix 2026-07-11) ───────────────────────────────── + // Tam ekran harita heading-up çalışır (setDrivingView / setMapHeading takip kamerası + // ve Rover marker yönü blend edilmiş heading'i kullanır) → compass'a GERÇEKTEN + // ihtiyaç duyar. Mount'ta talep açılır, unmount'ta bırakılır; talep yokken + // gpsService compass aboneliğini kapatır (konum takibi ETKİLENMEZ). + // NOT: bu efekt yalnız talep kaydeder — sensör aboneliğinin sahibi gpsService/gate'tir. + useEffect(() => { + const release = acquireCompassDemand('map:full'); + return release; + }, []); + // Testability: DOM attribute set — ana thread'den tahliye: kritik render yoluna girmiyor useEffect(() => { const el = outerDivRef.current; diff --git a/src/components/map/MiniMapWidget.tsx b/src/components/map/MiniMapWidget.tsx index 931548a4..2d23e47a 100644 --- a/src/components/map/MiniMapWidget.tsx +++ b/src/components/map/MiniMapWidget.tsx @@ -21,6 +21,10 @@ import { applyMapDayNight, } from '../../platform/mapService'; import { useGPSLocation, useGPSHeading, useGPSState } from '../../platform/gpsService'; +import { acquireCompassDemand, releaseCompassDemand } from '../../platform/gps/compassDemand'; + +/** Mini haritanın compass talep kimliği (owner) — tek örnek varsayımı korunur. */ +const MINI_MAP_COMPASS_OWNER = 'map:mini'; import { useFusedSpeed } from '../../platform/speedFusion'; import { getMapStyle, useMapMode, setMapNight, notifyLowFPS } from '../../platform/mapSourceManager'; import type { MapMode } from '../../platform/mapSourceManager'; @@ -63,6 +67,25 @@ export const MiniMapWidget = memo(function MiniMapWidget({ // uygulanmış son konumu izleyerek; park halinde hareket eşik altındaysa kamera/marker // işini TAMAMEN atlarız (GL render burst'ü kesilir). Sürüşte davranış değişmez. const wasDrivingRef = useRef(false); + // ── Compass TALEBİ (saha fix 2026-07-11) ──────────────────────────────── + // Mini harita heading'i YALNIZ sürüş dalında kullanır (setDrivingView rotasyonu + + // marker yönü). Park/dur dalında kuzey-yukarı statiktir → compass İSTEMEZ. + // Bu yüzden talep, widget'ın KENDİ mevcut isDriving histerezisine (giriş >5 km/h, + // çıkış <3 km/h) bağlanır; yeni eşik/yeni state EKLENMEZ. + const compassHeldRef = useRef(false); + const _setCompassDemand = (want: boolean): void => { + if (want === compassHeldRef.current) return; // idempotent + compassHeldRef.current = want; + if (want) acquireCompassDemand(MINI_MAP_COMPASS_OWNER); + else releaseCompassDemand(MINI_MAP_COMPASS_OWNER); + }; + // Zero-leak: widget unmount olursa (drawer/ekran değişimi) talep MUTLAKA bırakılır. + useEffect(() => () => { + if (compassHeldRef.current) { + compassHeldRef.current = false; + releaseCompassDemand(MINI_MAP_COMPASS_OWNER); + } + }, []); const lastAppliedLatRef = useRef(0); const lastAppliedLngRef = useRef(0); // SAHA 2026-07-04: yer-değiştirme hızı için zaman çapası — fix kadansından @@ -319,6 +342,9 @@ export const MiniMapWidget = memo(function MiniMapWidget({ : _effKmh < 3 ? false : wasDrivingRef.current; + // Sürüş → heading-up rotasyon var → compass gerekli. Park → kuzey-yukarı → gereksiz. + _setCompassDemand(isDriving); + if (!mapRef.current._initialized) { addUserMarker(mapRef.current, latitude, longitude, hdg); // Başlangıç zoom: sokak seviyesi (16 = tek tek sokaklar görünür) diff --git a/src/platform/gps/compassDemand.ts b/src/platform/gps/compassDemand.ts new file mode 100644 index 00000000..6879d5f1 --- /dev/null +++ b/src/platform/gps/compassDemand.ts @@ -0,0 +1,103 @@ +/** + * compassDemand.ts — Compass (cihaz yönü) TALEP SÖZLEŞMESİ. + * + * NEDEN: Cihaz QA'sında (Xiaomi zircon, 2026-07-11) uygulamanın boşta ana ekranda + * `rot_vec` (absolute orientation) ve `game_rotvec` (relative orientation) sensörlerini + * **60 Hz'de sürekli açık** tuttuğu ölçüldü. Kaynağı: `gpsService._startCompassListener()` + * `startGPSTracking()` ile birlikte açılıyor ve uygulama ön plandayken **hiç kapanmıyordu** + * (Orientation Sensor Gate arka planda söküyor ama ön plan talebini bilmiyor — + * Ledger #42'de bu sınırlama zaten yazılıydı). + * + * BU MODÜL: compass'a GERÇEKTEN ihtiyaç duyan tüketicilerin (heading-up harita, + * aktif navigasyon) ref-count'lu talep kaydı. gpsService bu talebi dinler: + * ilk acquire → compass gate aboneliği AÇILIR + * son release → compass gate aboneliği KAPANIR + * + * SÖZLEŞME: + * - Owner bazlı: aynı owner iki kez acquire ederse sayaç BİR kez artar (duplicate yok). + * - release idempotent: bilinmeyen/zaten bırakılmış owner → sessiz no-op. + * - Owner sayısı SINIRLI (bounded) — kaçak tüketici belleği şişiremez. + * - Timer/polling YOK; yalnız olay-güdümlü bildirim. + * - Import YAN ETKİSİZ: bu dosyayı import etmek hiçbir sensör açmaz/kapatmaz. + * - gpsService bu kapının SAHİBİ DEĞİLDİR — yalnız abonesidir. + * + * KAPSAM DIŞI (bilinçli): native örnekleme hızı (Chromium DeviceOrientation 60 Hz sabit), + * Generic Sensor API, heading blend/smoothing algoritması — HİÇBİRİ değişmez. + */ + +/** Talep sahibi kimliği — okunabilir ve teşhis edilebilir olmalı. */ +export type CompassOwner = string; + +/** Aynı anda kayıtlı olabilecek en fazla owner (bounded — kaçak tüketici koruması). */ +export const MAX_COMPASS_OWNERS = 32; + +type DemandListener = (hasDemand: boolean) => void; + +const _owners = new Set(); +const _listeners = new Set(); + +function _notify(): void { + const has = _owners.size > 0; + for (const l of _listeners) { + try { l(has); } catch { /* fail-soft: bir abone patlarsa diğerleri etkilenmez */ } + } +} + +/** + * Compass talebini kaydeder. Aynı owner için tekrar çağrılırsa sayaç ARTMAZ. + * @returns bu owner'ın talebini bırakan idempotent fonksiyon + */ +export function acquireCompassDemand(owner: CompassOwner): () => void { + if (typeof owner !== 'string' || owner.length === 0) { + return () => undefined; // fail-soft: geçersiz owner sessizce yok sayılır + } + if (_owners.has(owner)) { + return () => releaseCompassDemand(owner); // duplicate acquire → aynı release + } + if (_owners.size >= MAX_COMPASS_OWNERS) { + // Bounded: yeni owner kaydedilmez. Talep zaten >0 olduğundan compass AÇIK kalır + // (fail-safe yön: sensörü erken kapatmaktansa açık bırak). + return () => undefined; + } + + const wasEmpty = _owners.size === 0; + _owners.add(owner); + if (wasEmpty) _notify(); // 0 → 1 geçişi: compass AÇ + return () => releaseCompassDemand(owner); +} + +/** Talebi bırakır. İdempotent — bilinmeyen owner sessiz no-op. */ +export function releaseCompassDemand(owner: CompassOwner): void { + if (!_owners.delete(owner)) return; + if (_owners.size === 0) _notify(); // 1 → 0 geçişi: compass KAPAT +} + +/** Şu an compass'a ihtiyaç duyan bir tüketici var mı? */ +export function hasCompassDemand(): boolean { + return _owners.size > 0; +} + +/** Kayıtlı owner sayısı (teşhis/test). */ +export function getCompassDemandCount(): number { + return _owners.size; +} + +/** Kayıtlı owner adları (yalnız teşhis — kopya döner). */ +export function getCompassOwners(): CompassOwner[] { + return [..._owners]; +} + +/** + * Talep değişimlerini dinler (0↔1 geçişlerinde tetiklenir; ara sayaç değişimlerinde DEĞİL). + * gpsService bunu kullanır. Dönen fonksiyon aboneliği kaldırır (zero-leak). + */ +export function subscribeCompassDemand(listener: DemandListener): () => void { + _listeners.add(listener); + return () => { _listeners.delete(listener); }; +} + +/** Test/teardown: tüm owner ve dinleyicileri temizler (zero-leak). */ +export function resetCompassDemand(): void { + _owners.clear(); + _listeners.clear(); +} diff --git a/src/platform/gpsService.ts b/src/platform/gpsService.ts index f59c2d90..1a875012 100644 --- a/src/platform/gpsService.ts +++ b/src/platform/gpsService.ts @@ -18,6 +18,7 @@ import { } from './gps/gpsUtils'; import { getThermalLevel } from './thermalWatchdog'; import { subscribeOrientationAbsolute, subscribeOrientation } from './sensors'; +import { hasCompassDemand, subscribeCompassDemand } from './gps/compassDemand'; // Capacitor global tip tanımı — (window as any) yerine declare global { @@ -181,6 +182,8 @@ function _startFirstFixFallback(): void { let _compassHeading: number | null = null; let _smoothedHeading: number | null = null; let _compassListenerOn = false; +// GPS takibi açık mı — compass talebi yalnız takip açıkken uygulanır. +let _gpsTrackingOn = false; // Orientation Sensor Gate release fonksiyonları (ham window aboneliği yerine). let _compassAbsRelease: (() => void) | null = null; let _compassRelRelease: (() => void) | null = null; @@ -208,6 +211,25 @@ function _onDeviceOrientation(e: DeviceOrientationEvent): void { _compassHeading = applyCompassSmoothing(_compassHeading, deg); } +/* ── Compass TALEP kapısı (saha fix 2026-07-11) ──────────────────────────────── + * ÖNCE: _startCompassListener() startGPSTracking() ile birlikte açılıyor ve uygulama + * ön plandayken HİÇ kapanmıyordu → cihaz QA'sında boşta ana ekranda `rot_vec` + + * `game_rotvec` 60 Hz açık ölçüldü (Ledger #42'deki bilinen sınırlama). + * ŞİMDİ: compass yalnız GERÇEK bir heading tüketicisi (heading-up harita / aktif + * navigasyon) talep ettiğinde açılır; son tüketici bırakınca kapanır. + * KONUM TAKİBİ ETKİLENMEZ — talep yokken GPS watch aynen çalışır, yalnız compass kapanır. + * Blend/smoothing, izin ve fallback davranışı DEĞİŞMEZ; native örnekleme hızı (60 Hz) + * bu PR'da DÜŞÜRÜLMEZ (Chromium sabiti). + */ +let _compassDemandUnsub: (() => void) | null = null; + +/** Talep durumuna göre compass gate aboneliğini aç/kapat. İdempotent. */ +function _applyCompassDemand(): void { + // GPS takibi kapalıysa compass da kapalı (talep olsa bile). + if (_gpsTrackingOn && hasCompassDemand()) _startCompassListener(); + else _stopCompassListener(); +} + function _startCompassListener(): void { if (_compassListenerOn) return; _compassListenerOn = true; @@ -263,7 +285,12 @@ export async function startGPSTracking(): Promise { // 5 saniye içinde gerçek fix gelmezse fallback devreye girer _startFirstFixFallback(); - _startCompassListener(); + + // Compass TALEP-GÜDÜMLÜ: artık koşulsuz açılmıyor. Talep değişimlerini dinle ve + // mevcut talebi hemen uygula (harita/navigasyon zaten açıksa compass anında açılır). + _gpsTrackingOn = true; + if (!_compassDemandUnsub) _compassDemandUnsub = subscribeCompassDemand(() => _applyCompassDemand()); + _applyCompassDemand(); if (isNativePlatform()) { await startNativeGPSTracking(); @@ -510,6 +537,8 @@ export async function stopGPSTracking(): Promise { if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; } _clearFirstFixTimer(); _hasValidFirstFix = false; + _gpsTrackingOn = false; + if (_compassDemandUnsub) { _compassDemandUnsub(); _compassDemandUnsub = null; } // zero-leak _stopCompassListener(); _prevForSpeed = null; _smoothedHeading = null;