implement availability detector with NewMotion API

This commit is contained in:
Johan von Forstner
2020-04-12 23:54:33 +02:00
parent fe43ab4a30
commit 80edbe66ce
17 changed files with 347 additions and 91 deletions

View File

@@ -12,7 +12,7 @@ import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import com.johan.evmap.api.ChargeLocation
import com.johan.evmap.api.goingelectric.ChargeLocation
const val REQUEST_LOCATION_PERMISSION = 1

View File

@@ -10,9 +10,9 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.johan.evmap.BR
import com.johan.evmap.R
import com.johan.evmap.api.ChargeLocation
import com.johan.evmap.api.Chargepoint
import com.johan.evmap.api.ChargepointStatus
import com.johan.evmap.api.availability.ChargepointStatus
import com.johan.evmap.api.goingelectric.ChargeLocation
import com.johan.evmap.api.goingelectric.Chargepoint
interface Equatable {
override fun equals(other: Any?): Boolean;

View File

@@ -8,7 +8,7 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.johan.evmap.R
import com.johan.evmap.api.ChargerPhoto
import com.johan.evmap.api.goingelectric.ChargerPhoto
import com.ortiz.touchview.TouchImageView
import com.squareup.picasso.Callback
import com.squareup.picasso.Picasso

View File

@@ -1,12 +1,12 @@
package com.johan.evmap.api
import android.location.Location
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import kotlin.coroutines.resumeWithException
@@ -35,4 +35,13 @@ suspend fun Call.await(): Response {
}
}
}
}
fun distanceBetween(
startLatitude: Double, startLongitude: Double,
endLatitude: Double, endLongitude: Double
): Float {
val distance = floatArrayOf(0f)
Location.distanceBetween(startLatitude, startLongitude, endLatitude, endLongitude, distance)
return distance[0]
}

View File

@@ -0,0 +1,76 @@
package com.johan.evmap.api.availability
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.johan.evmap.api.await
import com.johan.evmap.api.goingelectric.ChargeLocation
import com.johan.evmap.api.goingelectric.Chargepoint
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.util.concurrent.TimeUnit
interface AvailabilityDetector {
suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus
}
abstract class BaseAvailabilityDetector(private val client: OkHttpClient) : AvailabilityDetector {
protected suspend fun httpGet(url: String): String {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).await()
if (!response.isSuccessful) throw IOException(response.message())
val str = response.body()!!.string()
return str
}
protected fun getCorrespondingChargepoint(
cps: Iterable<Chargepoint>, type: String, power: Double
): Chargepoint? {
var filter = cps.filter {
it.type == type
}
if (filter.size > 1) {
filter = filter.filter {
if (power > 0) {
it.power == power
} else true
}
// TODO: handle not matching powers
/*if (filter.isEmpty()) {
filter = listOfNotNull(cps.minBy {
abs(it.power - power)
})
}*/
}
return filter.getOrNull(0)
}
}
data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String
)
enum class ChargepointStatus {
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED
}
class AvailabilityDetectorException(message: String) : Exception(message)
private val okhttp = OkHttpClient.Builder()
.addNetworkInterceptor(StethoInterceptor())
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.build()
val availabilityDetectors = listOf(
NewMotionAvailabilityDetector(okhttp)
/*ChargecloudAvailabilityDetector(
okhttp,
"606a0da0dfdd338ee4134605653d4fd8"
), // Maingau
ChargecloudAvailabilityDetector(
okhttp,
"6336fe713f2eb7fa04b97ff6651b76f8"
) // SW Kiel*/
)

View File

@@ -1,51 +1,35 @@
package com.johan.evmap.api
package com.johan.evmap.api.availability
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.johan.evmap.api.goingelectric.ChargeLocation
import com.johan.evmap.api.goingelectric.Chargepoint
import com.johan.evmap.api.iterator
import kotlinx.coroutines.ExperimentalCoroutinesApi
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
private const val radius = 200 // max radius in meters
interface AvailabilityDetector {
suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus
}
data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String
)
enum class ChargepointStatus {
AVAILABLE, UNKNOWN, CHARGING, OCCUPIED, FAULTED
}
class AvailabilityDetectorException(message: String) : Exception(message)
class ChargecloudAvailabilityDetector(
private val client: OkHttpClient,
client: OkHttpClient,
private val operatorId: String
) : AvailabilityDetector {
) : BaseAvailabilityDetector(client) {
@ExperimentalCoroutinesApi
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val url =
"https://app.chargecloud.de/emobility:ocpi/$operatorId/app/2.0/locations?latitude=${location.coordinates.lat}&longitude=${location.coordinates.lng}&radius=$radius&offset=0&limit=10"
val request = Request.Builder().url(url).build()
val response = client.newCall(request).await()
if (!response.isSuccessful) throw IOException(response.message())
val json = JSONObject(response.body()!!.string())
val json = JSONObject(httpGet(url))
val statusMessage = json.getString("status_message")
if (statusMessage != "Success") throw IOException(statusMessage)
val data = json.getJSONArray("data")
if (data.length() > 1) throw AvailabilityDetectorException("found multiple candidates.")
if (data.length() == 0) throw AvailabilityDetectorException("no candidates found.")
if (data.length() > 1) throw AvailabilityDetectorException(
"found multiple candidates."
)
if (data.length() == 0) throw AvailabilityDetectorException(
"no candidates found."
)
val evses = data.getJSONObject(0).getJSONArray("evses")
val chargepointStatus = mutableMapOf<Chargepoint, List<ChargepointStatus>>()
@@ -61,15 +45,25 @@ class ChargecloudAvailabilityDetector(
// find corresponding chargepoint from goingelectric to get correct power
val geChargepoint =
getCorrespondingChargepoint(location.chargepoints, type, power)
?: throw AvailabilityDetectorException("Chargepoints from chargecloud API and goingelectric do not match.")
chargepoint = Chargepoint(type, geChargepoint.power, 1)
?: throw AvailabilityDetectorException(
"Chargepoints from chargecloud API and goingelectric do not match."
)
chargepoint = Chargepoint(
type,
geChargepoint.power,
1
)
statusList = listOf(status)
} else {
val previousStatus = chargepointStatus[chargepoint]!!
statusList = previousStatus + listOf(status)
chargepointStatus.remove(chargepoint)
chargepoint =
Chargepoint(chargepoint.type, chargepoint.power, chargepoint.count + 1)
Chargepoint(
chargepoint.type,
chargepoint.power,
chargepoint.count + 1
)
}
chargepointStatus[chargepoint] = statusList
@@ -79,32 +73,17 @@ class ChargecloudAvailabilityDetector(
if (chargepointStatus.keys == location.chargepoints.toSet()) {
return ChargeLocationStatus(chargepointStatus, "chargecloud.de")
return ChargeLocationStatus(
chargepointStatus,
"chargecloud.de"
)
} else {
throw AvailabilityDetectorException("Chargepoints from chargecloud API and goingelectric do not match.")
throw AvailabilityDetectorException(
"Chargepoints from chargecloud API and goingelectric do not match."
)
}
}
private fun getCorrespondingChargepoint(
cps: Iterable<Chargepoint>, type: String, power: Double
): Chargepoint? {
var filter = cps.filter {
it.type == type &&
if (power > 0) {
it.power in power - 2..power + 2
} else true
}
if (filter.size > 1) {
filter = cps.filter {
it.type == type &&
if (power > 0) {
it.power == power
} else true
}
}
return filter.getOrNull(0)
}
private fun getType(string: String): String {
return when (string) {
"IEC_62196_T2" -> Chargepoint.TYPE_2
@@ -114,14 +93,4 @@ class ChargecloudAvailabilityDetector(
else -> throw IllegalArgumentException("unrecognized type $string")
}
}
}
private val okhttp = OkHttpClient.Builder()
.addNetworkInterceptor(StethoInterceptor())
.readTimeout(10, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.build()
val availabilityDetectors = listOf(
ChargecloudAvailabilityDetector(okhttp, "606a0da0dfdd338ee4134605653d4fd8"), // Maingau
ChargecloudAvailabilityDetector(okhttp, "6336fe713f2eb7fa04b97ff6651b76f8") // SW Kiel
)
}

View File

@@ -0,0 +1,184 @@
package com.johan.evmap.api.availability
import com.johan.evmap.api.distanceBetween
import com.johan.evmap.api.goingelectric.ChargeLocation
import com.johan.evmap.api.goingelectric.Chargepoint
import com.squareup.moshi.JsonClass
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
private const val coordRange = 0.1 // range of latitude and longitude for loading the map
private const val maxDistance = 15 // max distance between reported positions in meters
interface NewMotionApi {
@GET("markers/{lngMin}/{lngMax}/{latMin}/{latMax}")
suspend fun getMarkers(
@Path("lngMin") lngMin: Double,
@Path("lngMax") lngMax: Double,
@Path("latMin") latMin: Double,
@Path("latMax") latMax: Double
): List<NMMarker>
@GET("locations/{id}")
suspend fun getLocation(@Path("id") id: Long): NMLocation
@JsonClass(generateAdapter = true)
data class NMMarker(val coordinates: NMCoordinates, val locationUid: Long)
@JsonClass(generateAdapter = true)
data class NMCoordinates(val latitude: Double, val longitude: Double)
@JsonClass(generateAdapter = true)
data class NMLocation(
val uid: Long,
val coordinates: NMCoordinates,
val operatorName: String,
val evses: List<NMEvse>
)
@JsonClass(generateAdapter = true)
data class NMEvse(val evseId: String, val status: String, val connectors: List<NMConnector>)
@JsonClass(generateAdapter = true)
data class NMConnector(
val connectorType: String,
val electricalProperties: NMElectricalProperties
)
@JsonClass(generateAdapter = true)
data class NMElectricalProperties(val powerType: String, val voltage: Int, val amperage: Int) {
fun getPower(): Double {
val phases = when (powerType) {
"AC1Phase" -> 1
"AC3Phase" -> 3
else -> 1
}
val volt = when (voltage) {
277 -> 230
else -> voltage
}
val power = volt * amperage * phases
return when (power) {
3680 -> 3.7
11040 -> 11.0
22080 -> 22.0
43470 -> 43.0
else -> power / 1000.0
}
}
}
companion object {
fun create(client: OkHttpClient): NewMotionApi {
val retrofit = Retrofit.Builder()
.baseUrl("https://my.newmotion.com/api/map/v2/")
.addConverterFactory(MoshiConverterFactory.create())
.client(client)
.build()
return retrofit.create(NewMotionApi::class.java)
}
}
}
class NewMotionAvailabilityDetector(client: OkHttpClient) : BaseAvailabilityDetector(client) {
val api = NewMotionApi.create(client)
override suspend fun getAvailability(location: ChargeLocation): ChargeLocationStatus {
val lat = location.coordinates.lat
val lng = location.coordinates.lng
// find nearest station to this position
var markers =
api.getMarkers(lng - coordRange, lng + coordRange, lat - coordRange, lat + coordRange)
val nearest = markers.minBy { marker ->
distanceBetween(marker.coordinates.latitude, marker.coordinates.longitude, lat, lng)
} ?: throw AvailabilityDetectorException("no candidates found.")
// combine related stations
markers = markers.filter { marker ->
distanceBetween(
marker.coordinates.latitude,
marker.coordinates.longitude,
nearest.coordinates.latitude,
nearest.coordinates.longitude
) < maxDistance
}
// load details
var details = markers.map {
api.getLocation(it.locationUid)
}
// only include stations from same operator
details = details.filter {
it.operatorName == details[0].operatorName
}
val connectorStatus = details.flatMap { it.evses }.flatMap { evse ->
evse.connectors.map { connector ->
connector to evse.status
}
}
val chargepointStatus = mutableMapOf<Chargepoint, List<ChargepointStatus>>()
connectorStatus.forEach { (connector, statusStr) ->
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType) {
"Type2" -> Chargepoint.TYPE_2
"Domestic" -> Chargepoint.SCHUKO
"Type2Combo" -> Chargepoint.CCS
"TepcoCHAdeMO" -> Chargepoint.CHADEMO
else -> throw IllegalArgumentException("unrecognized type ${connector.connectorType}")
}
val status = when (statusStr) {
"Unavailable" -> ChargepointStatus.FAULTED
"Available" -> ChargepointStatus.AVAILABLE
"Occupied" -> ChargepointStatus.CHARGING
"Unspecified" -> ChargepointStatus.UNKNOWN
else -> ChargepointStatus.UNKNOWN
}
var chargepoint = getCorrespondingChargepoint(chargepointStatus.keys, type, power)
val statusList: List<ChargepointStatus>
if (chargepoint == null) {
// find corresponding chargepoint from goingelectric to get correct power
val geChargepoint =
getCorrespondingChargepoint(location.chargepoints, type, power)
?: throw AvailabilityDetectorException(
"Chargepoints from NewMotion API and goingelectric do not match."
)
chargepoint = Chargepoint(
type,
geChargepoint.power,
1
)
statusList = listOf(status)
} else {
val previousStatus = chargepointStatus[chargepoint]!!
statusList = previousStatus + listOf(status)
chargepointStatus.remove(chargepoint)
chargepoint =
Chargepoint(
chargepoint.type,
chargepoint.power,
chargepoint.count + 1
)
}
chargepointStatus[chargepoint] = statusList
}
if (chargepointStatus.keys == location.chargepoints.toSet()) {
return ChargeLocationStatus(
chargepointStatus,
"NewMotion"
)
} else {
throw AvailabilityDetectorException(
"Chargepoints from NewMotion API and goingelectric do not match."
)
}
}
}

View File

@@ -1,4 +1,4 @@
package com.johan.evmap.api
package com.johan.evmap.api.goingelectric
import com.squareup.moshi.*
import java.lang.reflect.Type
@@ -12,7 +12,9 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
moshi: Moshi
): JsonAdapter<*>? {
if (Types.getRawType(type) == ChargepointListItem::class.java) {
return ChargepointListItemJsonAdapter(moshi)
return ChargepointListItemJsonAdapter(
moshi
)
} else {
return null
}
@@ -24,9 +26,13 @@ internal class ChargepointListItemJsonAdapterFactory : JsonAdapter.Factory {
internal class ChargepointListItemJsonAdapter(val moshi: Moshi) :
JsonAdapter<ChargepointListItem>() {
private val clusterAdapter =
moshi.adapter<ChargeLocationCluster>(ChargeLocationCluster::class.java)
moshi.adapter<ChargeLocationCluster>(
ChargeLocationCluster::class.java
)
private val locationAdapter = moshi.adapter<ChargeLocation>(ChargeLocation::class.java)
private val locationAdapter = moshi.adapter<ChargeLocation>(
ChargeLocation::class.java
)
@FromJson
override fun fromJson(reader: JsonReader): ChargepointListItem {
@@ -69,9 +75,13 @@ internal class JsonObjectOrFalseAdapter<T> private constructor(
moshi: Moshi
): JsonAdapter<*>? {
val clazz = Types.getRawType(type)
return when (hasJsonObjectOrFalseAnnotation(annotations)) {
return when (hasJsonObjectOrFalseAnnotation(
annotations
)) {
false -> null
true -> JsonObjectOrFalseAdapter(moshi.adapter(clazz))
true -> JsonObjectOrFalseAdapter(
moshi.adapter(clazz)
)
}
}
}

View File

@@ -1,4 +1,4 @@
package com.johan.evmap.api
package com.johan.evmap.api.goingelectric
import com.facebook.stetho.okhttp3.StethoInterceptor
import com.squareup.moshi.Moshi

View File

@@ -1,4 +1,4 @@
package com.johan.evmap.api
package com.johan.evmap.api.goingelectric
import android.content.Context
import android.os.Parcelable

View File

@@ -37,10 +37,10 @@ import com.johan.evmap.REQUEST_LOCATION_PERMISSION
import com.johan.evmap.adapter.ConnectorAdapter
import com.johan.evmap.adapter.DetailAdapter
import com.johan.evmap.adapter.GalleryAdapter
import com.johan.evmap.api.ChargeLocation
import com.johan.evmap.api.ChargeLocationCluster
import com.johan.evmap.api.ChargepointListItem
import com.johan.evmap.api.ChargerPhoto
import com.johan.evmap.api.goingelectric.ChargeLocation
import com.johan.evmap.api.goingelectric.ChargeLocationCluster
import com.johan.evmap.api.goingelectric.ChargepointListItem
import com.johan.evmap.api.goingelectric.ChargerPhoto
import com.johan.evmap.databinding.FragmentMapBinding
import com.johan.evmap.ui.*
import com.johan.evmap.viewmodel.MapPosition

View File

@@ -12,7 +12,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.johan.evmap.R
import com.johan.evmap.api.Chargepoint
import com.johan.evmap.api.goingelectric.Chargepoint
@BindingAdapter("goneUnless")

View File

@@ -6,7 +6,7 @@ import androidx.interpolator.view.animation.FastOutLinearInInterpolator
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
import com.google.android.gms.maps.model.Marker
import com.johan.evmap.R
import com.johan.evmap.api.ChargeLocation
import com.johan.evmap.api.goingelectric.ChargeLocation
fun getMarkerTint(charger: ChargeLocation): Int = when {
charger.maxPower >= 100 -> R.color.charger_100kw

View File

@@ -5,7 +5,13 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.maps.model.LatLngBounds
import com.johan.evmap.api.*
import com.johan.evmap.api.availability.AvailabilityDetectorException
import com.johan.evmap.api.availability.ChargeLocationStatus
import com.johan.evmap.api.availability.availabilityDetectors
import com.johan.evmap.api.goingelectric.ChargeLocation
import com.johan.evmap.api.goingelectric.ChargepointList
import com.johan.evmap.api.goingelectric.ChargepointListItem
import com.johan.evmap.api.goingelectric.GoingElectricApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

View File

@@ -4,7 +4,7 @@
<data>
<import type="com.johan.evmap.api.ChargerPhoto" />
<import type="com.johan.evmap.api.goingelectric.ChargerPhoto" />
<import type="java.util.List" />

View File

@@ -5,11 +5,11 @@
<data>
<import type="com.johan.evmap.api.ChargeLocation" />
<import type="com.johan.evmap.api.goingelectric.ChargeLocation" />
<import type="com.johan.evmap.api.Chargepoint" />
<import type="com.johan.evmap.api.goingelectric.Chargepoint" />
<import type="com.johan.evmap.api.ChargeLocationStatus" />
<import type="com.johan.evmap.api.availability.ChargeLocationStatus" />
<import type="com.johan.evmap.adapter.DataBindingAdaptersKt" />

View File

@@ -1,6 +1,8 @@
package com.johan.evmap
import com.johan.evmap.api.*
import com.johan.evmap.api.availability.availabilityDetectors
import com.johan.evmap.api.goingelectric.ChargeLocation
import com.johan.evmap.api.goingelectric.GoingElectricApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import org.junit.Test