Compare commits

..

12 Commits

Author SHA1 Message Date
johan12345
67b29917c0 Use official Tesla API for availability data
does not work yet
2023-10-16 19:14:32 +02:00
johan12345
bc91c0571b Chargeprice: Fix switching between vehicles 2023-10-14 19:28:54 +02:00
johan12345
a83102a97e Tesla API: fix nullability 2023-10-14 19:14:07 +02:00
johan12345
f52a98540c Tesla API: add missing waiting estimate bucket 2023-10-14 19:11:42 +02:00
johan12345
e0d97e7219 fix NPE in PlaceSearchScreen 2023-10-14 19:10:16 +02:00
johan12345
3bbd20a57e possibly fix IllegalStateException 2023-10-14 19:05:39 +02:00
Hosted Weblate
3279c5eceb Translated using Weblate (Portuguese)
Currently translated at 100.0% (355 of 355 strings)

Co-authored-by: Celso Azevedo <mail@celsoazevedo.com>
Translate-URL: https://hosted.weblate.org/projects/evmap/android/pt/
Translation: EVMap/Android
2023-10-14 12:25:53 +02:00
Johan von Forstner
03d958ac2c Chargeprice: possibly fix IllegalStateException when switching vehicles 2023-10-07 20:10:50 +02:00
Johan von Forstner
b1fd370101 OpenChargeMap: use map.openchargemap.io as link
id query parameter added since
3330c9de96

#236
2023-10-07 19:36:19 +02:00
johan12345
bdc96fcd57 Android Auto: use simpler navigation intent URI without POI name
Workaround for TomTom GO, which seems to not handle intents with description correctly
2023-09-27 22:00:49 +02:00
johan12345
0d54e17eb4 update version code to retry Play Store release 2023-09-26 22:27:56 +02:00
johan12345
b1d0081fb7 re-enable CarAppTest
see https://github.com/robolectric/robolectric/issues/8404#issuecomment-1733309468
2023-09-26 17:47:03 +02:00
16 changed files with 110 additions and 85 deletions

View File

@@ -6,4 +6,5 @@
<string name="openchargemap_key" translatable="false">ci</string>
<string name="fronyx_key" translatable="false">ci</string>
<string name="acra_credentials" translatable="false">ci:ci</string>
<string name="tesla_credentials" translatable="false">ci:ci</string>
</resources>

View File

@@ -19,7 +19,7 @@ android {
minSdkVersion 21
targetSdkVersion 34
// NOTE: always increase versionCode by 2 since automotive flavor uses versionCode + 1
versionCode 202
versionCode 204
versionName "1.7.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -152,6 +152,13 @@ android {
if (acraKey != null) {
variant.resValue "string", "acra_credentials", acraKey
}
def teslaKey = env.TESLA_CREDENTIALS ?: project.findProperty("TESLA_CREDENTIALS")
if (teslaKey == null && project.hasProperty("TESLA_CREDENTIALS_ENCRYPTED")) {
teslaKey = decode(project.findProperty("TESLA_CREDENTIALS_ENCRYPTED"), "FmK.d,-f*p+rD+WK!eds")
}
if (teslaKey != null) {
variant.resValue "string", "tesla_credentials", teslaKey
}
}
packagingOptions {

View File

@@ -209,8 +209,8 @@ class CheckableChargepriceCarAdapter : DataBindingAdapter<ChargepriceCar>() {
checkedItem = item
root.post {
notifyDataSetChanged()
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
onCheckedItemChangedListener?.invoke(getCheckedItem()!!)
}
}
}

View File

@@ -3,6 +3,7 @@ package net.vonforst.evmap.api.availability
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.vonforst.evmap.R
import net.vonforst.evmap.addDebugInterceptors
import net.vonforst.evmap.api.RateLimitInterceptor
import net.vonforst.evmap.api.await
@@ -170,8 +171,15 @@ class AvailabilityRepository(context: Context) {
.connectTimeout(10, TimeUnit.SECONDS)
.cookieJar(JavaNetCookieJar(cookieManager))
.build()
private val teslaAvailabilityDetector =
TeslaAvailabilityDetector(okhttp, EncryptedPreferenceDataStore(context))
private val teslaAvailabilityDetector = run {
val (clientId, clientSecret) = context.getString(R.string.tesla_credentials).split(":")
TeslaAvailabilityDetector(
okhttp,
EncryptedPreferenceDataStore(context),
clientId,
clientSecret
)
}
private val availabilityDetectors = listOf(
RheinenergieAvailabilityDetector(okhttp),
teslaAvailabilityDetector,

View File

@@ -35,22 +35,24 @@ interface TeslaAuthenticationApi {
@JsonClass(generateAdapter = true)
class AuthCodeRequest(
val code: String,
@Json(name = "code_verifier") val codeVerifier: String,
@Json(name = "redirect_uri") val redirectUri: String = "https://auth.tesla.com/void/callback",
scope: String = "openid email offline_access",
@Json(name = "client_id") clientId: String = "ownerapi"
) : OAuth2Request(scope, clientId)
@Json(name = "redirect_uri") val redirectUri: String = "https://ev-map.app/void/callback",
scope: String = "openid offline_access vehicle_device_data",
@Json(name = "client_id") clientId: String,
@Json(name = "client_secret") clientSecret: String
) : OAuth2Request(scope, clientId, clientSecret)
@JsonClass(generateAdapter = true)
class RefreshTokenRequest(
@Json(name = "refresh_token") val refreshToken: String,
scope: String = "openid email offline_access",
@Json(name = "client_id") clientId: String = "ownerapi"
) : OAuth2Request(scope, clientId)
scope: String = "openid offline_access vehicle_device_data",
@Json(name = "client_id") clientId: String,
@Json(name = "client_secret") clientSecret: String,
) : OAuth2Request(scope, clientId, clientSecret)
sealed class OAuth2Request(
val scope: String,
val clientId: String
val clientId: String,
val clientSecret: String
)
@JsonClass(generateAdapter = true)
@@ -85,36 +87,15 @@ interface TeslaAuthenticationApi {
return retrofit.create(TeslaAuthenticationApi::class.java)
}
fun generateCodeVerifier(): String {
val code = ByteArray(64)
SecureRandom().nextBytes(code)
return Base64.encodeToString(
code,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
}
fun generateCodeChallenge(codeVerifier: String): String {
val bytes = codeVerifier.toByteArray()
val messageDigest = MessageDigest.getInstance("SHA-256")
messageDigest.update(bytes, 0, bytes.size)
return Base64.encodeToString(
messageDigest.digest(),
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
)
}
fun buildSignInUri(codeChallenge: String): Uri =
fun buildSignInUri(clientId: String): Uri =
Uri.parse("https://auth.tesla.com/oauth2/v3/authorize").buildUpon()
.appendQueryParameter("client_id", "ownerapi")
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://auth.tesla.com/void/callback")
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", "https://ev-map.app/void/callback")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", "openid email offline_access")
.appendQueryParameter("scope", "openid offline_access vehicle_device_data")
.appendQueryParameter("state", "123").build()
val resultUrlPrefix = "https://auth.tesla.com/void/callback"
val resultUrlPrefix = "https://ev-map.app/void/callback"
}
}
@@ -287,7 +268,7 @@ interface TeslaGraphQlApi {
data class GetChargingSiteInformationResponseData(val charging: GetChargingSiteInformationResponseDataCharging)
@JsonClass(generateAdapter = true)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation)
data class GetChargingSiteInformationResponseDataCharging(val site: ChargingSiteInformation?)
@JsonClass(generateAdapter = true)
data class ChargingSiteInformation(
@@ -445,6 +426,9 @@ interface TeslaGraphQlApi {
@Json(name = "WAIT_ESTIMATE_BUCKET_APPROXIMATELY_20_MINUTES")
APPROXIMATELY_20_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_GREATER_THAN_25_MINUTES")
GREATER_THAN_25_MINUTES,
@Json(name = "WAIT_ESTIMATE_BUCKET_UNKNOWN")
UNKNOWN
}
@@ -497,6 +481,8 @@ fun Coordinate.asTeslaCoord() =
class TeslaAvailabilityDetector(
private val client: OkHttpClient,
private val tokenStore: TokenStore,
private val clientId: String,
private val clientSecret: String,
private val baseUrl: String? = null
) :
BaseAvailabilityDetector(client) {
@@ -544,7 +530,7 @@ class TeslaAvailabilityDetector(
TeslaGraphQlApi.VehicleMakeType.NON_TESLA
)
)
).data.charging.site
).data.charging.site ?: throw AvailabilityDetectorException("no candidates found.")
val scV2Connectors = location.chargepoints.filter { it.type == Chargepoint.SUPERCHARGER }
@@ -641,7 +627,9 @@ class TeslaAvailabilityDetector(
val response =
authApi.getToken(
TeslaAuthenticationApi.RefreshTokenRequest(
refreshToken
refreshToken,
clientId = clientId,
clientSecret = clientSecret
)
)
tokenStore.teslaAccessToken = response.accessToken

View File

@@ -64,8 +64,8 @@ data class OCMChargepoint(
addressInfo.toAddress(refData),
connections.map { it.convert(refData) },
operatorInfo?.title,
"https://openchargemap.org/site/poi/details/$id",
"https://openchargemap.org/site/poi/edit/$id",
"https://map.openchargemap.io/?id=$id",
"https://map.openchargemap.io/?id=$id",
convertFaultReport(),
recentlyVerified,
null,

View File

@@ -520,7 +520,7 @@ class ChargerDetailScreen(ctx: CarContext, val chargerSparse: ChargeLocation) :
val intent =
Intent(
CarContext.ACTION_NAVIGATE,
Uri.parse("geo:0,0?q=${coord.lat},${coord.lng}(${charger.name})")
Uri.parse("geo:${coord.lat},${coord.lng}")
)
carContext.startCarApp(intent)
}

View File

@@ -116,7 +116,7 @@ class PlaceSearchScreen(
setOnClickListener {
lifecycleScope.launch {
val placeDetails = getDetails(place.id)
val placeDetails = getDetails(place.id) ?: return@launch
prefs.placeSearchResultAndroidAuto = placeDetails.latLng
prefs.placeSearchResultAndroidAutoName =
place.primaryText.toString()
@@ -226,9 +226,9 @@ class PlaceSearchScreen(
}
}
suspend fun getDetails(id: String): PlaceWithBounds {
suspend fun getDetails(id: String): PlaceWithBounds? {
val provider = currentProvider!!
val result = resultList!!.find { it.id == id }!!
val result = resultList?.find { it.id == id } ?: return null
val recentPlace = recentResults.find { it.id == id }
if (recentPlace != null) return recentPlace.asPlaceWithBounds()

View File

@@ -8,9 +8,6 @@ import android.content.pm.PackageManager.NameNotFoundException
import android.hardware.Sensor
import android.hardware.SensorManager
import android.net.Uri
import android.os.Bundle
import android.os.IInterface
import android.text.Html
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.CarToast
@@ -23,7 +20,6 @@ import androidx.core.graphics.drawable.IconCompat
import androidx.core.text.HtmlCompat
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.TeslaAuthenticationApi
@@ -240,12 +236,9 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
}
private fun teslaLogin() {
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
val (clientId, _) = carContext.getString(R.string.tesla_credentials).split(":")
val args = OAuthLoginFragmentArgs(
uri.toString(),
TeslaAuthenticationApi.buildSignInUri(clientId = clientId).toString(),
TeslaAuthenticationApi.resultUrlPrefix,
"#000000"
).toBundle()
@@ -261,7 +254,7 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
OAuthLoginFragment.EXTRA_URL,
Uri::class.java
)
teslaGetAccessToken(url!!, codeVerifier)
teslaGetAccessToken(url!!)
}
}, IntentFilter(OAuthLoginFragment.ACTION_OAUTH_RESULT))
@@ -276,22 +269,27 @@ class DataSettingsScreen(ctx: CarContext) : Screen(ctx) {
}
}
private fun teslaGetAccessToken(url: Uri, codeVerifier: String) {
private fun teslaGetAccessToken(url: Uri) {
teslaLoggingIn = true
invalidate()
val code = url.getQueryParameter("code") ?: return
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
val (clientId, clientSecret) = carContext.getString(R.string.tesla_credentials).split(":")
val request = TeslaAuthenticationApi.AuthCodeRequest(
code,
clientId = clientId,
clientSecret = clientSecret
)
lifecycleScope.launch {
try {
val time = Instant.now().epochSecond
val response =
TeslaAuthenticationApi.create(okhttp).getToken(request)
val userResponse =
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
// val userResponse =
// TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
encryptedPrefs.teslaEmail = userResponse.response.email
encryptedPrefs.teslaEmail = "user@example.com"
encryptedPrefs.teslaAccessToken = response.accessToken
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
encryptedPrefs.teslaRefreshToken = response.refreshToken

View File

@@ -168,7 +168,7 @@ class ChargepriceFragment : Fragment() {
chargepriceAdapter.myTariffsAll = it
}
vm.chargePricesForChargepoint.observe(viewLifecycleOwner) {
it?.data?.let { chargepriceAdapter.submitList(it) }
chargepriceAdapter.submitList(it?.data ?: emptyList())
}
val connectorsAdapter = CheckableConnectorAdapter()

View File

@@ -1224,26 +1224,29 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
MenuCompat.setGroupDividerEnabled(popup.menu, true)
popup.setForceShowIcon(true)
popup.setOnMenuItemClickListener {
val navController = requireView().findNavController()
when (it.itemId) {
R.id.menu_edit_filters -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
lifecycleScope.launch {
vm.copyFiltersToCustom()
requireView().findNavController().safeNavigate(
navController.safeNavigate(
MapFragmentDirections.actionMapToFilterFragment()
)
}
true
}
R.id.menu_manage_filter_profiles -> {
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
requireView().findNavController().safeNavigate(
navController.safeNavigate(
MapFragmentDirections.actionMapToFilterProfilesFragment()
)
true
}
else -> {
val profileId = profilesMap.inverse[it]
if (profileId != null) {

View File

@@ -139,10 +139,8 @@ class DataSettingsFragment : BaseSettingsFragment() {
}
private fun teslaLogin() {
val codeVerifier = TeslaAuthenticationApi.generateCodeVerifier()
val codeChallenge = TeslaAuthenticationApi.generateCodeChallenge(codeVerifier)
val uri = TeslaAuthenticationApi.buildSignInUri(codeChallenge)
val (clientId, _) = getString(R.string.tesla_credentials).split(":")
val uri = TeslaAuthenticationApi.buildSignInUri(clientId = clientId)
val args = OAuthLoginFragmentArgs(
uri.toString(),
TeslaAuthenticationApi.resultUrlPrefix,
@@ -150,28 +148,33 @@ class DataSettingsFragment : BaseSettingsFragment() {
).toBundle()
setFragmentResultListener(uri.toString()) { _, result ->
teslaGetAccessToken(result, codeVerifier)
teslaGetAccessToken(result)
}
findNavController().navigate(R.id.oauth_login, args)
}
private fun teslaGetAccessToken(result: Bundle, codeVerifier: String) {
private fun teslaGetAccessToken(result: Bundle) {
teslaAccountPreference.summary = getString(R.string.logging_in)
val url = Uri.parse(result.getString("url"))
val code = url.getQueryParameter("code") ?: return
val okhttp = OkHttpClient.Builder().addDebugInterceptors().build()
val request = TeslaAuthenticationApi.AuthCodeRequest(code, codeVerifier)
val (clientId, clientSecret) = getString(R.string.tesla_credentials).split(":")
val request = TeslaAuthenticationApi.AuthCodeRequest(
code,
clientId = clientId,
clientSecret = clientSecret
)
lifecycleScope.launch {
try {
val time = Instant.now().epochSecond
val response =
TeslaAuthenticationApi.create(okhttp).getToken(request)
val userResponse =
TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
// val userResponse =
// TeslaOwnerApi.create(okhttp, response.accessToken).getUserInfo()
encryptedPrefs.teslaEmail = userResponse.response.email
encryptedPrefs.teslaEmail = "user@example.com"
encryptedPrefs.teslaAccessToken = response.accessToken
encryptedPrefs.teslaAccessTokenExpiry = time + response.expiresIn
encryptedPrefs.teslaRefreshToken = response.refreshToken

View File

@@ -116,6 +116,7 @@ class ChargepriceViewModel(
MediatorLiveData<Resource<List<ChargePrice>>>().apply {
value = state["chargePrices"] ?: Resource.loading(null)
listOf(
vehicle,
batteryRange,
batteryRangeSliderDragging,
vehicleCompatibleConnectors,

View File

@@ -13,7 +13,7 @@
<string name="rename">Renomear</string>
<string name="chargeprice_donation_dialog_detail">Você faz grande uso da comparação de preços. Ajude a cobrir os custos de acesso à informação apoiando o EVMap com uma doação.</string>
<string name="verified">verificado</string>
<string name="chargeprice_select_connector">Escolhe o conector</string>
<string name="chargeprice_select_connector">Escolha o conector</string>
<string name="verified_desc">O carregador foi marcado como funcional por um membro da comunidade %s</string>
<string name="charge_price_format">%2$s%1$.2f</string>
<string name="charge_price_average_format">⌀ %2$s%1$.2f/kWh</string>
@@ -58,7 +58,7 @@
<string name="fault_report_date">Com problemas (atualizado: %s)</string>
<string name="filter_chargecards">Formas de pagamento</string>
<string name="pref_language">Língua da app</string>
<string name="all_selected">Todas selecionadas</string>
<string name="all_selected">Todos selecionados</string>
<string name="edit">editar</string>
<string name="pref_darkmode">Modo escuro</string>
<string name="connection_error">Não foi possível carregar a lista de carregadores</string>
@@ -76,7 +76,7 @@
<string name="category_public_authorities">Autoridades públicas</string>
<string name="category_private_charger">Carregador privado</string>
<string name="category_rest_area">Área de descanso</string>
<string name="edit_at_datasource">Editado em %s</string>
<string name="edit_at_datasource">Editar em %s</string>
<string name="categories">Categorias</string>
<string name="category_service_on_motorway">Área de serviço (autoestrada)</string>
<string name="category_service_off_motorway">Área de serviço (fora da autoestrada)</string>
@@ -96,7 +96,7 @@
<string name="save_profile_enter_name">Insira o nome do perfil com este filtro:</string>
<string name="save_as_profile">Guardar como perfil</string>
<string name="filterprofiles_empty_state">Não existem filtros guardados</string>
<string name="welcome_2">Cada cor corresponde a potência máxima do carregador</string>
<string name="welcome_2">Cada cor corresponde à potência máxima do carregador</string>
<string name="welcome_to_evmap">Bem-vindo(a) ao EVMap</string>
<string name="pref_darkmode_always_off">Sempre desligado</string>
<string name="welcome_2_title">Escolha a potência</string>
@@ -153,14 +153,14 @@
<string name="lets_go">Vamos lá</string>
<string name="crash_report_text">O EVMap encontrou um problema. Por favor envie um relatório do erro para o criador da app.</string>
<string name="crash_report_comment_prompt">Pode adicionar um comentário abaixo:</string>
<string name="pref_search_provider">Fornecedor da pesquisa</string>
<string name="pref_search_provider">Provedor da pesquisa</string>
<string name="powered_by_mapbox">via Mapbox</string>
<string name="github_sponsors">GitHub Sponsors</string>
<string name="donate_desc">Apoie o desenvolvimento do EVMap com uma única doação</string>
<string name="pref_map_rotate_gestures_on">Use dois dedos para girar o mapa</string>
<string name="pref_map_rotate_gestures_off">Rotação desligada (norte sempre para cima)</string>
<string name="refresh_live_data">atualizar estado em tempo real</string>
<string name="pref_search_provider_info">As pesquisas são caras, especialmente quando o Google Maps é usado. Por favor considere doar através de \"Sobre\" → \"Doar\".</string>
<string name="pref_search_provider_info">As pesquisas são caras, especialmente se o Google Maps for utilizado. Por favor considere doar através de \"Sobre\" → \"Doar\".</string>
<string name="github_sponsors_desc">Apoie o EVMap através do GitHub</string>
<string name="unnamed_filter_profile">Filtro sem nome</string>
<string name="deleted_recent_search_results">As pesquisas recentes foram eliminadas</string>
@@ -325,7 +325,7 @@
<string name="settings_cache_clear">Limpar cache</string>
<string name="settings_cache_count_summary">%d carregadores na base de dados, %.1f MB</string>
<string name="settings_caching">Caching (base de dados local)</string>
<string name="settings_cache_clear_summary">Elimina todos os carregadores guardados na base de dados local, com a exceção dos seus favoritos</string>
<string name="settings_cache_clear_summary">Elimina todos os carregadores guardados localmente, com a exceção dos seus favoritos</string>
<string name="auto_no_chargers_found">Não foram encontrados carregadores próximo de si</string>
<string name="auto_no_favorites_found">Nenhum favorito encontrado</string>
<string name="opened_on_phone">Aberto no telefone</string>
@@ -371,5 +371,5 @@
<string name="pref_units_imperial">Imperial</string>
<string name="referrals_info">Também pode usar um dos seguintes links de afiliado para apoiar o desenvolvedor da app com a sua compra.</string>
<string name="generic_connection_error">Não foi possível carregar a informação</string>
<string name="powered_by_fronyx">previsão feita pelo fronyx</string>
<string name="powered_by_fronyx">previsão feita por fronyx</string>
</resources>

View File

@@ -15,11 +15,12 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.internal.DoNotInstrument
@RunWith(RobolectricTestRunner::class)
@DoNotInstrument
@Ignore("Disabled because Robolectric does not yet support API 34")
@Config(sdk = [33]) // Robolectric does not yet support SDK 34
class CarAppTest {
private val testCarContext =
TestCarContext.createCarContext(ApplicationProvider.getApplicationContext()).apply {

View File

@@ -149,6 +149,21 @@ in German.
</details>
### **Tesla**
[API documentation](https://developer.tesla.com/docs/fleet-api)
<details>
<summary>How to obtain an API key</summary>
1. [Sign up](https://www.tesla.com/teslaaccount) for a Tesla account
2. In the [Tesla Developer Portal](https://developer.tesla.com/), click on "Request app access"
3. Enter the details of your app
4. You will receive a *Client ID* and *Client Secret*. Enter them both into `tesla_credentials`,
separated by a colon (`:`).
</details>
Pricing providers
-----------------