@@ -23,7 +23,6 @@ class MotionDetector:
2323 2. GPS (primary) - 2 minute window, most accurate for small movements
2424 3. Door lock state - if doors unlocked after GPS stale → car stopped
2525 4. Mileage (fallback) - must show actual odometer increase since GPS went stale
26- 5. MQTT silence - no MQTT stream data for 2 min → NOT MOVING (car off)
2726 """
2827
2928 # Door lock states that indicate the car is driving (doors auto-lock while moving)
@@ -35,9 +34,6 @@ class MotionDetector:
3534 # Mileage movement window (longer, less frequent updates)
3635 MILEAGE_ACTIVE_WINDOW_MINUTES : ClassVar [float ] = 7.0
3736
38- # MQTT silence threshold - no MQTT stream data for this long → car is off
39- MQTT_SILENCE_MINUTES : ClassVar [float ] = 2.0
40-
4137 # Park zone radius - GPS readings within this distance are considered "parked jitter"
4238 PARK_RADIUS_M : ClassVar [float ] = 35.0
4339
@@ -48,6 +44,11 @@ class MotionDetector:
4844 # Max GPS readings to keep for centroid calculation while parked
4945 MAX_PARK_READINGS : ClassVar [int ] = 10
5046
47+ # Minimum time span (seconds) for the 3 park-confirming readings.
48+ # BMW sends GPS in bursts (3 readings within <1s at the same position).
49+ # Without this guard the burst immediately parks the car while driving.
50+ MIN_PARK_SPAN_SECONDS : ClassVar [float ] = 30.0
51+
5152 # Minutes without GPS update to consider GPS unavailable (switch to mileage fallback)
5253 # Longer than MOTION_ACTIVE_WINDOW to handle BMW's bursty GPS (every 2-3 min)
5354 GPS_UPDATE_STALE_MINUTES : ClassVar [float ] = 5.0
@@ -198,21 +199,30 @@ def update_location(self, vin: str, lat: float, lon: float) -> bool:
198199 self ._park_readings [vin ] = park_readings
199200
200201 # Check if we've been stationary for enough readings to confirm parked
201- # Need at least 3 readings within park radius to confirm stop
202+ # Need at least 3 readings within park radius AND spread over time
203+ # (BMW sends GPS in bursts — 3 readings within <1s is not real parking)
202204 if len (park_readings ) >= 3 :
203205 # Check if ALL recent readings are within park radius
204206 all_within_park = all (
205207 self ._calculate_distance (park_anchor [0 ], park_anchor [1 ], r [0 ], r [1 ]) <= self .PARK_RADIUS_M
206208 for r in park_readings [- 3 :]
207209 )
208210 if all_within_park :
211+ # Require readings to span a minimum time window to avoid
212+ # treating a single GPS burst as parking
213+ first_park_time = park_readings [- 3 ][2 ]
214+ time_span = (now - first_park_time ).total_seconds ()
215+ if time_span < self .MIN_PARK_SPAN_SECONDS :
216+ # Burst detection: readings too close together, not real parking
217+ # Stay in driving mode
218+ return True
219+
209220 # Vehicle has stopped - exit driving mode
210221 # Backdate last movement to when the car first entered the park zone
211- # (oldest of the 3 confirming readings) for accurate timing
212- first_park_time = park_readings [- 3 ][2 ]
213222 _LOGGER .debug (
214- "Motion: %s stopped (3 readings within park radius) - NOW PARKED" ,
223+ "Motion: %s stopped (3 readings within park radius over %.0fs ) - NOW PARKED" ,
215224 redact_vin (vin ),
225+ time_span ,
216226 )
217227 self ._is_driving [vin ] = False
218228 self ._last_location_change [vin ] = first_park_time
@@ -339,7 +349,7 @@ def update_mqtt_activity(self, vin: str) -> None:
339349 """Record that an MQTT stream message was received for this VIN.
340350
341351 Only call this for real MQTT stream messages, NOT telematics API responses.
342- Used as a last-resort fallback: if no MQTT for 2 min, car is off .
352+ Currently unused by motion detection but kept for diagnostics .
343353 """
344354 self ._last_mqtt_stream_at [vin ] = datetime .now (UTC )
345355
@@ -377,12 +387,11 @@ def is_moving(self, vin: str) -> bool | None:
377387 Priority chain:
378388 1. Charging → always False (absolute override, trumps everything)
379389 2. GPS (primary) → fresh GPS within 2 min, with driving mode trust
380- 3. GPS gap + MQTT active → trust driving mode (unless doors unlocked )
390+ 3. GPS gap → trust driving mode during gap (door lock overrides )
381391 4. Door lock state → doors changed from locked/selectiveLocked → stopped
382392 5. Mileage (fallback) → actual odometer increase since GPS went stale
383- 6. MQTT silence → no MQTT stream for 2 min → False (car is off)
384- 7. No data at all → None (fall back to BMW-provided isMoving)
385- 8. Default → False
393+ 6. No data at all → None (fall back to BMW-provided isMoving)
394+ 7. Default → False
386395
387396 Returns:
388397 True - Active proof of movement
@@ -403,11 +412,9 @@ def is_moving(self, vin: str) -> bool | None:
403412 last_gps_change = self ._last_location_change .get (vin )
404413 last_mileage = self ._last_mileage .get (vin )
405414 last_mileage_change = self ._last_mileage_change .get (vin )
406- last_mqtt = self ._last_mqtt_stream_at .get (vin )
407415
408416 # Calculate ages (None if no data)
409417 gps_update_age = (now - last_gps_update ).total_seconds () / 60.0 if last_gps_update else None
410- mqtt_age = (now - last_mqtt ).total_seconds () / 60.0 if last_mqtt else None
411418
412419 # 2. GPS PRIMARY - GPS data arrived within 2 minutes (freshest source)
413420 if gps_update_age is not None and gps_update_age < self .MOTION_ACTIVE_WINDOW_MINUTES :
@@ -443,30 +450,28 @@ def is_moving(self, vin: str) -> bool | None:
443450 return result
444451
445452 # 3. GPS GAP HANDLING - GPS between 2-5 min old, driving mode active
446- # BMW GPS arrives in bursts every 2-3 min; trust driving mode if MQTT is still active
447- # BUT: check door lock first - if doors unlocked, driver exited → not moving
453+ # BMW GPS arrives in bursts every 2-3 min; trust driving mode during gaps.
454+ # Door lock overrides: if doors changed from locked → unlocked, car stopped.
448455 if self ._is_driving .get (vin , False ) and gps_update_age is not None :
449456 if gps_update_age < self .GPS_UPDATE_STALE_MINUTES :
450457 # Door lock override: if doors changed from driving state, car stopped
451458 door_unlocked_at = self ._door_unlocked_at .get (vin )
452459 if door_unlocked_at is not None and last_gps_update is not None and door_unlocked_at > last_gps_update :
453460 door_state = self ._door_lock_state .get (vin , "unknown" )
454461 _LOGGER .debug (
455- "Motion: %s door lock changed to '%s' after GPS stale - NOT MOVING" ,
462+ "Motion: %s door state changed to '%s' after GPS stale - NOT MOVING" ,
456463 redact_vin (vin ),
457464 door_state ,
458465 )
459466 self ._is_driving [vin ] = False
460467 return False
461468
462- if mqtt_age is not None and mqtt_age < self .MQTT_SILENCE_MINUTES :
463- _LOGGER .debug (
464- "Motion: %s driving mode, GPS gap (%.1f min) but MQTT active (%.1f min) - MOVING" ,
465- redact_vin (vin ),
466- gps_update_age ,
467- mqtt_age ,
468- )
469- return True
469+ _LOGGER .debug (
470+ "Motion: %s driving mode, GPS gap (%.1f min) - MOVING" ,
471+ redact_vin (vin ),
472+ gps_update_age ,
473+ )
474+ return True
470475
471476 # 4. DOOR LOCK FALLBACK - GPS stale, doors changed from driving to parked state
472477 # This catches the case where GPS is >5 min stale but door state signals arrival
@@ -517,17 +522,7 @@ def is_moving(self, vin: str) -> bool | None:
517522 )
518523 self ._is_driving [vin ] = False
519524
520- # 6. MQTT SILENCE FALLBACK - no MQTT stream data for 2+ min → car is off
521- if mqtt_age is not None and mqtt_age >= self .MQTT_SILENCE_MINUTES :
522- _LOGGER .debug (
523- "Motion: %s MQTT silent for %.1f min (threshold=%.1f) - NOT MOVING" ,
524- redact_vin (vin ),
525- mqtt_age ,
526- self .MQTT_SILENCE_MINUTES ,
527- )
528- return False
529-
530- # 7. No GPS data at all for this VIN — use mileage if available,
525+ # 6. No GPS data at all for this VIN — use mileage if available,
531526 # otherwise return None so the caller falls back to BMW-provided vehicle.isMoving.
532527 if last_gps_update is None :
533528 if last_mileage_change is not None :
@@ -536,7 +531,7 @@ def is_moving(self, vin: str) -> bool | None:
536531 return True
537532 return None
538533
539- # 8 . DEFAULT: Not moving
534+ # 7 . DEFAULT: Not moving
540535 return False
541536
542537 def has_signaled_entity (self , vin : str ) -> bool :
0 commit comments