#!/usr/bin/env python3
import bz2
import io
import json
import os
import random
import requests
import threading
import time
import traceback
import datetime
from typing import BinaryIO , Iterator , List , Optional , Tuple
from cereal import log
import cereal . messaging as messaging
from openpilot . common . api import Api
from openpilot . common . params import Params
from openpilot . common . realtime import set_core_affinity
from openpilot . system . hardware . hw import Paths
from openpilot . system . loggerd . xattr_cache import getxattr , setxattr
from openpilot . common . swaglog import cloudlog
NetworkType = log . DeviceState . NetworkType
UPLOAD_ATTR_NAME = ' user.upload '
UPLOAD_ATTR_VALUE = b ' 1 '
UPLOAD_QLOG_QCAM_MAX_SIZE = 5 * 1e6 # MB
allow_sleep = bool ( os . getenv ( " UPLOADER_SLEEP " , " 1 " ) )
force_wifi = os . getenv ( " FORCEWIFI " ) is not None
fake_upload = os . getenv ( " FAKEUPLOAD " ) is not None
class FakeRequest :
def __init__ ( self ) :
self . headers = { " Content-Length " : " 0 " }
class FakeResponse :
def __init__ ( self ) :
self . status_code = 200
self . request = FakeRequest ( )
def get_directory_sort ( d : str ) - > List [ str ] :
return [ s . rjust ( 10 , ' 0 ' ) for s in d . rsplit ( ' -- ' , 1 ) ]
def listdir_by_creation ( d : str ) - > List [ str ] :
if not os . path . isdir ( d ) :
return [ ]
try :
paths = [ f for f in os . listdir ( d ) if os . path . isdir ( os . path . join ( d , f ) ) ]
paths = sorted ( paths , key = get_directory_sort )
return paths
except OSError :
cloudlog . exception ( " listdir_by_creation failed " )
return [ ]
def clear_locks ( root : str ) - > None :
for logdir in os . listdir ( root ) :
path = os . path . join ( root , logdir )
try :
for fname in os . listdir ( path ) :
if fname . endswith ( " .lock " ) :
os . unlink ( os . path . join ( path , fname ) )
except OSError :
cloudlog . exception ( " clear_locks failed " )
class Uploader :
def __init__ ( self , dongle_id : str , root : str ) :
self . dongle_id = dongle_id
self . api = Api ( dongle_id )
self . root = root
self . params = Params ( )
# stats for last successfully uploaded file
self . last_filename = " "
self . immediate_folders = [ " crash/ " , " boot/ " ]
self . immediate_priority = { " qlog " : 0 , " qlog.bz2 " : 0 , " qcamera.ts " : 1 }
def list_upload_files ( self , metered : bool ) - > Iterator [ Tuple [ str , str , str ] ] :
r = self . params . get ( " AthenadRecentlyViewedRoutes " , encoding = " utf8 " )
requested_routes = [ ] if r is None else r . split ( " , " )
for logdir in listdir_by_creation ( self . root ) :
path = os . path . join ( self . root , logdir )
try :
names = os . listdir ( path )
except OSError :
continue
if any ( name . endswith ( " .lock " ) for name in names ) :
continue
for name in sorted ( names , key = lambda n : self . immediate_priority . get ( n , 1000 ) ) :
key = os . path . join ( logdir , name )
fn = os . path . join ( path , name )
# skip files already uploaded
try :
ctime = os . path . getctime ( fn )
is_uploaded = getxattr ( fn , UPLOAD_ATTR_NAME ) == UPLOAD_ATTR_VALUE
except OSError :
cloudlog . event ( " uploader_getxattr_failed " , key = key , fn = fn )
# deleter could have deleted, so skip
continue
if is_uploaded :
continue
# limit uploading on metered connections
if metered :
dt = datetime . timedelta ( hours = 12 )
if logdir in self . immediate_folders and ( datetime . datetime . now ( ) - datetime . datetime . fromtimestamp ( ctime ) ) < dt :
continue
if name == " qcamera.ts " and not any ( logdir . startswith ( r . split ( ' | ' ) [ - 1 ] ) for r in requested_routes ) :
continue
yield name , key , fn
def next_file_to_upload ( self , metered : bool ) - > Optional [ Tuple [ str , str , str ] ] :
upload_files = list ( self . list_upload_files ( metered ) )
for name , key , fn in upload_files :
if any ( f in fn for f in self . immediate_folders ) :
return name , key , fn
for name , key , fn in upload_files :
if name in self . immediate_priority :
return name , key , fn
return None
def do_upload ( self , key : str , fn : str ) :
url_resp = self . api . get ( " v1.4/ " + self . dongle_id + " /upload_url/ " , timeout = 10 , path = key , access_token = self . api . get_token ( ) )
if url_resp . status_code == 412 :
return url_resp
url_resp_json = json . loads ( url_resp . text )
url = url_resp_json [ ' url ' ]
headers = url_resp_json [ ' headers ' ]
cloudlog . debug ( " upload_url v1.4 %s %s " , url , str ( headers ) )
if fake_upload :
return FakeResponse ( )
with open ( fn , " rb " ) as f :
data : BinaryIO
if key . endswith ( ' .bz2 ' ) and not fn . endswith ( ' .bz2 ' ) :
compressed = bz2 . compress ( f . read ( ) )
data = io . BytesIO ( compressed )
else :
data = f
return requests . put ( url , data = data , headers = headers , timeout = 10 )
def upload ( self , name : str , key : str , fn : str , network_type : int , metered : bool ) - > bool :
try :
sz = os . path . getsize ( fn )
except OSError :
cloudlog . exception ( " upload: getsize failed " )
return False
cloudlog . event ( " upload_start " , key = key , fn = fn , sz = sz , network_type = network_type , metered = metered )
if sz == 0 :
# tag files of 0 size as uploaded
success = True
elif name in self . immediate_priority and sz > UPLOAD_QLOG_QCAM_MAX_SIZE :
cloudlog . event ( " uploader_too_large " , key = key , fn = fn , sz = sz )
success = True
else :
start_time = time . monotonic ( )
stat = None
last_exc = None
try :
stat = self . do_upload ( key , fn )
except Exception as e :
last_exc = ( e , traceback . format_exc ( ) )
if stat is not None and stat . status_code in ( 200 , 201 , 401 , 403 , 412 ) :
self . last_filename = fn
dt = time . monotonic ( ) - start_time
if stat . status_code == 412 :
cloudlog . event ( " upload_ignored " , key = key , fn = fn , sz = sz , network_type = network_type , metered = metered )
else :
content_length = int ( stat . request . headers . get ( " Content-Length " , 0 ) )
speed = ( content_length / 1e6 ) / dt
cloudlog . event ( " upload_success " , key = key , fn = fn , sz = sz , content_length = content_length ,
network_type = network_type , metered = metered , speed = speed )
success = True
else :
success = False
cloudlog . event ( " upload_failed " , stat = stat , exc = last_exc , key = key , fn = fn , sz = sz , network_type = network_type , metered = metered )
if success :
# tag file as uploaded
try :
setxattr ( fn , UPLOAD_ATTR_NAME , UPLOAD_ATTR_VALUE )
except OSError :
cloudlog . event ( " uploader_setxattr_failed " , exc = last_exc , key = key , fn = fn , sz = sz )
return success
def step ( self , network_type : int , metered : bool ) - > Optional [ bool ] :
d = self . next_file_to_upload ( metered )
if d is None :
return None
name , key , fn = d
# qlogs and bootlogs need to be compressed before uploading
if key . endswith ( ( ' qlog ' , ' rlog ' ) ) or ( key . startswith ( ' boot/ ' ) and not key . endswith ( ' .bz2 ' ) ) :
key + = " .bz2 "
return self . upload ( name , key , fn , network_type , metered )
def main ( exit_event : Optional [ threading . Event ] = None ) - > None :
if exit_event is None :
exit_event = threading . Event ( )
try :
set_core_affinity ( [ 0 , 1 , 2 , 3 ] )
except Exception :
cloudlog . exception ( " failed to set core affinity " )
clear_locks ( Paths . log_root ( ) )
params = Params ( )
dongle_id = params . get ( " DongleId " , encoding = ' utf8 ' )
if dongle_id is None :
cloudlog . info ( " uploader missing dongle_id " )
raise Exception ( " uploader can ' t start without dongle id " )
sm = messaging . SubMaster ( [ ' deviceState ' ] )
uploader = Uploader ( dongle_id , Paths . log_root ( ) )
backoff = 0.1
while not exit_event . is_set ( ) :
sm . update ( 0 )
offroad = params . get_bool ( " IsOffroad " )
network_type = sm [ ' deviceState ' ] . networkType if not force_wifi else NetworkType . wifi
if network_type == NetworkType . none :
if allow_sleep :
time . sleep ( 60 if offroad else 5 )
continue
success = uploader . step ( sm [ ' deviceState ' ] . networkType . raw , sm [ ' deviceState ' ] . networkMetered )
if success is None :
backoff = 60 if offroad else 5
elif success :
backoff = 0.1
else :
cloudlog . info ( " upload backoff %r " , backoff )
backoff = min ( backoff * 2 , 120 )
if allow_sleep :
time . sleep ( backoff + random . uniform ( 0 , backoff ) )
if __name__ == " __main__ " :
main ( )