Files

277 lines
13 KiB
Python

"""Optional parked reporting; failures never participate in driving process health."""
from datetime import datetime, UTC
import math
import signal
import threading
import time
import re
import uuid
from typing import Any
from openpilot.starpilot.gps.source import GPS_SERVICES, select_location
from openpilot.starpilot.analytics.report import ReportClient, build_payload, fetch_branch_commit
SERVICES = ('deviceState', 'carParams', 'carState', 'carControl', 'selfdriveState',
'modelV2', 'drivingModelData', 'managerState', *GPS_SERVICES)
COUNTERS = ('drives', 'meters', 'seconds', 'current_months_meters', 'total_tracked_seconds',
'total_lateral_seconds', 'total_longitudinal_seconds', 'total_aol_seconds')
def executed_model(sm, params, now):
from openpilot.starpilot.models.jetlink_adapter import hooks
from openpilot.starpilot.models.runtime import snapshot
from openpilot.starpilot.models.status import ModelHealth
from openpilot.starpilot.models.catalog import model_entries
if bool(sm['modelV2'].big) and (remote := hooks().runtime()) is not None and remote.active and remote.sha256:
digest = remote.sha256
return {'driving_model': 'jetlink:' + digest[:16], 'metrics': {'model_sha256': digest, 'model_variant': 'jetlink'}}
status = snapshot(sm, params, now)
entries = model_entries()
if status.health == ModelHealth.ACTIVE and status.loaded_id in entries:
return {'driving_model': entries[status.loaded_id].name, 'metrics': {'model_sha256': status.artifact_sha256, 'model_variant': str(status.variant)}}
return {}
class AnalyticsWorker:
def __init__(self, params, source, *, metadata=None, identity=executed_model, collector=None,
client=None, token_reader=None, resolver=None, clock_valid=None, commit_reader=fetch_branch_commit,
monotonic=time.monotonic, wall=lambda: datetime.now(UTC).timestamp()):
from openpilot.common.time_helpers import system_time_valid
from openpilot.starpilot.analytics.credentials import read_token
from openpilot.starpilot.analytics.location import resolve_region
from openpilot.starpilot.analytics.settings import collect_settings
self.params, self.source = params, source
self.metadata = dict(metadata or {})
self.identity = identity
self.collector = collector or collect_settings
self.client = client or ReportClient(None)
self.token_reader = token_reader or read_token
self.resolver = resolver or resolve_region
self.clock_valid = clock_valid or system_time_valid
self.commit_reader, self.remote_commit = commit_reader, None
self.monotonic, self.wall = monotonic, wall
self.stop_event = threading.Event()
self.thread = None
saved = params.get('UsageStatsState')
saved = saved if isinstance(saved, dict) and saved.get('version') == 1 else {}
self.state: dict[str, Any] = {'version': 1}
if isinstance(saved.get('month'), str) and len(saved['month']) == 7:
self.state['month'] = saved['month']
self.state['counters'] = saved.get('counters', {})
region = saved.get('region')
cell = saved.get('coarse_cell')
if isinstance(region, dict) and isinstance(cell, list) and len(cell) == 2:
from openpilot.starpilot.analytics.location import coarse_cell
verified = coarse_cell({'latitude': cell[0], 'longitude': cell[1]})
if verified is not None and list(verified) == cell:
self.state['coarse_cell'] = cell
self.state['region'] = {key: value[:96] for key, value in region.items()
if key in ('city', 'state', 'country') and isinstance(value, str)}
self.state['region'].update(latitude=cell[0], longitude=cell[1])
saved_counters = self.state.get('counters', {})
if not isinstance(saved_counters, dict):
saved_counters = {}
self.counters = {key: saved_counters.get(key, 0) for key in COUNTERS}
self.counters = {key: float(value) if type(value) in (int, float) and math.isfinite(value) and 0 <= value < (1 << 63) else 0.0
for key, value in self.counters.items()}
stored_context = saved.get('context', {})
if not isinstance(stored_context, dict):
stored_context = {}
self.context: dict[str, Any] = {key: value for key, value in stored_context.items()
if key in ('car_make', 'car_model', 'driving_model') and isinstance(value, str) and len(value) <= 128}
stored_metrics = stored_context.get('metrics', {})
if isinstance(stored_metrics, dict):
self.context['metrics'] = {key: value for key, value in stored_metrics.items()
if (key in ('model_sha256', 'model_variant') and isinstance(value, str) and len(value) <= 64) or
(key in ('has_openpilot_longitudinal', 'has_pedal', 'using_stock_acc') and type(value) is bool)}
self.cp, self.gps = None, None
self.last_identity = -10.0
self.drive_engaged = False
self.uncommitted_seconds = 0.0
dongle = params.get('DongleId')
if isinstance(dongle, bytes):
dongle = dongle.decode('ascii', errors='ignore')
if not isinstance(dongle, str) or re.fullmatch(r'[a-zA-Z0-9_-]{8,64}', dongle) is None:
dongle = saved.get('anonymous_id')
if not isinstance(dongle, str) or re.fullmatch(r'anon-[0-9a-f]{32}', dongle) is None:
dongle = 'anon-' + uuid.uuid4().hex
self.state['anonymous_id'] = dongle
self.metadata['dongle_id'] = dongle
self.previous_started = None
self.last_poll = self.monotonic()
self.last_persist = self.last_poll
self.pending, self.initial_sent = False, False
self.attempts, self.retry_at = 0, 0.0
def fresh(self, service, max_age=2.0):
stamp = self.source.logMonoTime[service]
now = int(self.monotonic() * 1e9)
return bool(self.source.valid[service] and self.source.alive[service] and 0 < stamp <= now and now - stamp <= max_age * 1e9)
def allowed(self):
self.source.update(0)
return (not self.stop_event.is_set() and
self.params.get_bool('IsOffroad') and self.fresh('deviceState') and not self.source['deviceState'].started)
def persist(self):
self.counters = {key: min(value, float((1 << 62) - 1)) for key, value in self.counters.items()}
self.state.update(counters=dict(self.counters), context=dict(self.context))
self.params.put('UsageStatsState', self.state, block=True)
self.last_persist = self.monotonic()
def poll_once(self):
self.source.update(0)
now = self.monotonic()
elapsed = max(0.0, min(now - self.last_poll, 1.0))
self.last_poll = now
if not self.fresh('deviceState'):
return
if self.clock_valid():
month = time.strftime('%Y-%m', time.gmtime(self.wall()))
if self.state.get('month') != month:
self.state['month'] = month
self.counters['current_months_meters'] = 0
started = bool(self.source['deviceState'].started)
if started:
if self.previous_started is not True:
self.context = {}
self.cp = None
self.last_identity = -10.0
self.drive_engaged = False
self.uncommitted_seconds = 0.0
if self.fresh('carParams'):
self.cp = self.source['carParams'].as_builder()
self.context.update(car_make=str(self.cp.brand), car_model=str(self.cp.carFingerprint))
self.context.setdefault('metrics', {}).update(has_openpilot_longitudinal=bool(self.cp.openpilotLongitudinalControl),
has_pedal=bool(self.cp.enableGasInterceptor),
using_stock_acc=not bool(self.cp.openpilotLongitudinalControl))
if self.fresh('modelV2', .25) and now - self.last_identity >= 1:
identity = self.identity(self.source, self.params, int(now * 1e9))
self.context.setdefault('metrics', {}).update(identity.pop('metrics', {}))
self.context.update(identity)
self.last_identity = now
if self.fresh('carState', .25):
speed = float(self.source['carState'].vEgo)
if math.isfinite(speed) and 0 <= speed <= 100:
self.uncommitted_seconds += elapsed
engaged = ((self.fresh('selfdriveState', .25) and self.source['selfdriveState'].enabled) or
(self.fresh('carControl', .25) and self.source['carControl'].latActive))
if engaged and not self.drive_engaged:
self.drive_engaged = True
self.counters['drives'] += 1
if self.drive_engaged:
self.counters['seconds'] += self.uncommitted_seconds
self.counters['total_tracked_seconds'] += self.uncommitted_seconds
self.uncommitted_seconds = 0
self.counters['meters'] += speed * elapsed
if self.clock_valid():
month = time.strftime('%Y-%m', time.gmtime(self.wall()))
if self.state.get('month') != month:
self.state['month'] = month
self.counters['current_months_meters'] = 0
self.counters['current_months_meters'] += speed * elapsed
if self.fresh('carControl', .25):
control = self.source['carControl']
self.counters['total_lateral_seconds'] += elapsed * bool(control.latActive)
self.counters['total_longitudinal_seconds'] += elapsed * bool(control.longActive)
if self.fresh('selfdriveState', .25) and not self.source['selfdriveState'].enabled:
self.counters['total_aol_seconds'] += elapsed * bool(control.latActive)
fix = select_location(self.source, int(now * 1e9))
if fix is not None:
self.gps = {'latitude': fix[1].latitude, 'longitude': fix[1].longitude}
else:
if self.previous_started is True:
self.persist()
self.pending, self.attempts, self.retry_at = True, 0, now
if self.clock_valid() and not self.initial_sent:
self.pending, self.initial_sent = True, True
self.previous_started = started
if now - self.last_persist >= 60:
self.persist()
if not self.pending or self.attempts >= 5 or now < self.retry_at or not self.clock_valid() or not self.allowed():
return
token = self.token_reader()
if token is None:
self.params.put('UsageStatsStatus', {'state': 'awaiting_credentials'})
self.retry_at = now + 30
return
if self.remote_commit is None:
self.remote_commit = self.commit_reader(self.metadata.get('branch', ''), allowed=self.allowed)
from openpilot.starpilot.analytics.location import coarse_cell
cell = coarse_cell(self.gps)
if cell is not None and list(cell) != self.state.get('coarse_cell'):
region = self.resolver(self.gps, allowed=self.allowed)
if self.allowed():
self.state.update(coarse_cell=list(cell), region=region)
self.persist()
report = dict(self.metadata, **self.context, counters=dict(self.counters))
report['metrics'] = dict(self.metadata.get('metrics', {}), **self.context.get('metrics', {}))
if self.remote_commit is not None:
report['branch_commit'] = self.remote_commit
report['metrics']['up_to_date'] = report['metrics'].get('commit') == self.remote_commit
region = self.state.get('region', {})
if not isinstance(region, dict):
region = {}
report.update({key: region[key] for key in ('city', 'state', 'country') if key in region})
report['coarse_location'] = region
payload = build_payload(report, self.collector(self.params, self.cp), int(self.wall() * 1e9))
success = self.client.send(payload, token, allowed=self.allowed)
self.attempts += 1
self.pending = not success
self.retry_at = now + min(300, 15 * 2 ** self.attempts)
self.params.put('UsageStatsStatus', {'state': 'sent' if success else 'retrying', 'attempts': self.attempts})
def run(self):
while not self.stop_event.is_set():
try:
self.poll_once()
except Exception:
self.attempts += 1
self.retry_at = self.monotonic() + min(300, 15 * 2 ** min(self.attempts, 5))
try:
self.params.put('UsageStatsStatus', {'state': 'unavailable', 'attempts': min(self.attempts, 5)})
except Exception:
pass
self.stop_event.wait(.1)
self.client.close()
def start(self):
self.thread = threading.Thread(target=self.run, name='usage_stats', daemon=True)
self.thread.start()
return self
def close(self):
self.stop_event.set()
if self.thread is not None:
self.thread.join(timeout=.25)
if self.thread is None:
self.client.close()
def build_worker():
from openpilot.common.hardware import COMMA_HARDWARE, HARDWARE
if not COMMA_HARDWARE:
return None
from openpilot.cereal import messaging
from openpilot.common.params import Params
from openpilot.common.version import get_build_metadata
metadata = get_build_metadata()
return AnalyticsWorker(Params(), messaging.SubMaster(list(SERVICES)),
metadata={'branch': metadata.channel, 'device': HARDWARE.get_device_type(),
'metrics': {'commit': metadata.openpilot.git_commit}})
def main():
# Own process so the manager's heap stays small when it forks the onroad daemons.
worker = build_worker()
if worker is None:
return
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, lambda *_: worker.stop_event.set())
worker.run()
worker.persist()
if __name__ == "__main__":
main()