Realtime data: add time of last change

shown in connector details dialog
fixes #275
This commit is contained in:
johan12345
2023-12-17 20:38:43 +01:00
parent 22d24f3bd0
commit 4b38c0de2d
12 changed files with 196 additions and 62 deletions

View File

@@ -21,6 +21,7 @@ import net.vonforst.evmap.databinding.ItemChargepriceVehicleChipBinding
import net.vonforst.evmap.databinding.ItemConnectorButtonBinding
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.ui.CheckableConstraintLayout
import java.time.Instant
interface Equatable {
override fun equals(other: Any?): Boolean
@@ -98,7 +99,9 @@ class ConnectorDetailsAdapter : DataBindingAdapter<ConnectorDetailsAdapter.Conne
data class ConnectorDetails(
val chargepoint: Chargepoint,
val status: ChargepointStatus?,
val evseId: String?
val evseId: String?,
val label: String?,
val lastChange: Instant?
) :
Equatable

View File

@@ -23,6 +23,7 @@ import retrofit2.HttpException
import java.io.IOException
import java.net.CookieManager
import java.net.CookiePolicy
import java.time.Instant
import java.util.concurrent.TimeUnit
interface AvailabilityDetector {
@@ -140,7 +141,9 @@ data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String,
val evseIds: Map<Chargepoint, List<String>>? = null,
val labels: Map<Chargepoint, List<String?>>? = null,
val congestionHistogram: List<Double>? = null,
val lastChange: Map<Chargepoint, List<Instant?>>? = null,
val extraData: Any? = null // API-specific data
) {
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {

View File

@@ -1,6 +1,10 @@
package net.vonforst.evmap.api.availability
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import net.vonforst.evmap.api.availability.tesla.LocalTimeAdapter
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
@@ -10,6 +14,8 @@ import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import java.time.Instant
import java.time.LocalTime
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
private const val maxDistance = 60 // max distance between reported positions in meters
@@ -53,7 +59,8 @@ interface EnBwApi {
data class EnBwChargePoint(
val evseId: String?,
val status: String,
val connectors: List<EnBwConnector>
val connectors: List<EnBwConnector>,
val state: EnBwState?
)
@JsonClass(generateAdapter = true)
@@ -70,6 +77,11 @@ interface EnBwApi {
val upperRightLon: Double
)
@JsonClass(generateAdapter = true)
data class EnBwState(
val updatedAt: Instant?
)
companion object {
fun create(client: OkHttpClient, baseUrl: String? = null): EnBwApi {
val clientWithInterceptor = client.newBuilder()
@@ -85,7 +97,11 @@ interface EnBwApi {
}.build()
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://enbw-emp.azure-api.net/emobility-public-api/api/v1/")
.addConverterFactory(MoshiConverterFactory.create())
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(InstantAdapter()).build()
)
)
.client(clientWithInterceptor)
.build()
return retrofit.create(EnBwApi::class.java)
@@ -93,6 +109,23 @@ interface EnBwApi {
}
}
internal class InstantAdapter {
@FromJson
fun fromJson(value: Long?): Instant? = value?.let {
Instant.ofEpochMilli(it)
}
@ToJson
fun toJson(value: Instant?): Long? = value?.toEpochMilli()
}
data class EnBwStatus(
val conn: EnBwApi.EnBwConnector,
val status: String,
val evseId: String?,
val lastChange: Instant?
)
class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
BaseAvailabilityDetector(client) {
val api = EnBwApi.create(client, baseUrl)
@@ -157,14 +190,15 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
val connectorStatus = details.flatMap { it.chargePoints }.flatMap { cp ->
cp.connectors.map { connector ->
Triple(connector, cp.status, cp.evseId)
EnBwStatus(connector, cp.status, cp.evseId, cp.state?.updatedAt)
}
}
val enbwConnectors = mutableMapOf<Long, Pair<Double, String>>()
val enbwStatus = mutableMapOf<Long, ChargepointStatus>()
val enbwEvseId = mutableMapOf<Long, String>()
connectorStatus.forEachIndexed { index, (connector, statusStr, evseId) ->
val enbwLastChange = mutableMapOf<Long, Instant?>()
connectorStatus.forEachIndexed { index, (connector, statusStr, evseId, updatedAt) ->
val id = index.toLong()
val power = connector.maxPowerInKw ?: 0.0
val type = when (connector.plugTypeName) {
@@ -187,6 +221,7 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
}
enbwConnectors[id] = power to type
enbwStatus[id] = status
enbwLastChange[id] = updatedAt
evseId?.let { enbwEvseId[id] = it }
}
@@ -197,10 +232,13 @@ class EnBwAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
val evseIds = if (enbwEvseId.size == enbwStatus.size) match.mapValues { entry ->
entry.value.map { enbwEvseId[it]!! }
} else null
val lastChange =
if (enbwLastChange.size == enbwStatus.size) match.mapValues { entry -> entry.value.map { enbwLastChange[it] } } else null
return ChargeLocationStatus(
chargepointStatus,
"EnBW",
evseIds
evseIds,
lastChange = lastChange
)
}

View File

@@ -1,6 +1,10 @@
package net.vonforst.evmap.api.availability
import androidx.car.app.model.DateTimeWithZone
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import com.squareup.moshi.ToJson
import net.vonforst.evmap.model.ChargeLocation
import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.utils.distanceBetween
@@ -9,6 +13,11 @@ import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeParseException
import java.util.*
private const val coordRange = 0.005 // range of latitude and longitude for loading the map
@@ -42,7 +51,12 @@ interface NewMotionApi {
)
@JsonClass(generateAdapter = true)
data class NMEvse(val evseId: String?, val status: String, val connectors: List<NMConnector>)
data class NMEvse(
val evseId: String?,
val status: String,
val connectors: List<NMConnector>,
val updated: ZonedDateTime?
)
@JsonClass(generateAdapter = true)
data class NMConnector(
@@ -78,7 +92,11 @@ interface NewMotionApi {
fun create(client: OkHttpClient, baseUrl: String? = null): NewMotionApi {
val retrofit = Retrofit.Builder()
.baseUrl(baseUrl ?: "https://ui-map.shellrecharge.com/api/map/v2/")
.addConverterFactory(MoshiConverterFactory.create())
.addConverterFactory(
MoshiConverterFactory.create(
Moshi.Builder().add(ZonedDateTimeAdapter()).build()
)
)
.client(client)
.build()
return retrofit.create(NewMotionApi::class.java)
@@ -86,6 +104,21 @@ interface NewMotionApi {
}
}
internal class ZonedDateTimeAdapter {
@FromJson
fun fromJson(value: String): ZonedDateTime? = ZonedDateTime.parse(value)
@ToJson
fun toJson(value: ZonedDateTime): String = value.toString()
}
data class NmStatus(
val conn: NewMotionApi.NMConnector,
val status: String,
val evseId: String?,
val updated: ZonedDateTime?
)
class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = null) :
BaseAvailabilityDetector(client) {
val api = NewMotionApi.create(client, baseUrl)
@@ -111,9 +144,9 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
throw AvailabilityDetectorException("no candidates found")
}
if (nearest.evseCount < location.totalChargepoints) {
markers = if (nearest.evseCount < location.totalChargepoints) {
// combine related stations
markers = markers.filter { marker ->
markers.filter { marker ->
distanceBetween(
marker.coordinates.latitude,
marker.coordinates.longitude,
@@ -122,7 +155,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
) < maxDistance
}
} else {
markers = listOf(nearest)
listOf(nearest)
}
// load details
@@ -135,14 +168,15 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
}
val connectorStatus = details.flatMap { it.evses }.flatMap { evse ->
evse.connectors.map { connector ->
Triple(connector, evse.status, evse.evseId)
NmStatus(connector, evse.status, evse.evseId, evse.updated)
}
}
val nmConnectors = mutableMapOf<Long, Pair<Double, String>>()
val nmStatus = mutableMapOf<Long, ChargepointStatus>()
val nmEvseId = mutableMapOf<Long, String>()
connectorStatus.forEach { (connector, statusStr, evseId) ->
val nmUpdated = mutableMapOf<Long, ZonedDateTime>()
connectorStatus.forEach { (connector, statusStr, evseId, updated) ->
val id = connector.uid
val power = connector.electricalProperties.getPower()
val type = when (connector.connectorType.lowercase(Locale.ROOT)) {
@@ -168,6 +202,7 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
nmConnectors.put(id, power to type)
nmStatus.put(id, status)
evseId?.let { nmEvseId[id] = it }
updated?.let { nmUpdated[id] = it }
}
val match = matchChargepoints(nmConnectors, location.chargepointsMerged)
@@ -177,10 +212,12 @@ class NewMotionAvailabilityDetector(client: OkHttpClient, baseUrl: String? = nul
val evseIds = if (nmEvseId.size == nmStatus.size) match.mapValues { entry ->
entry.value.map { nmEvseId[it]!! }
} else null
val updated = match.mapValues { entry -> entry.value.map { nmUpdated[it]?.toInstant() } }
return ChargeLocationStatus(
chargepointStatus,
"NewMotion",
evseIds
evseIds,
lastChange = updated
)
}

View File

@@ -103,53 +103,62 @@ class TeslaGuestAvailabilityDetector(
"charger has unknown connectors"
)
var statusSorted = details.chargersAvailable.chargerDetails
.sortedBy { c ->
details.chargers.find { it.id == c.id }?.labelLetter
}
.sortedBy { c ->
details.chargers.find { it.id == c.id }?.labelNumber
}
.map { it.availability }
if (statusSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
val chargerDetails = details.chargersAvailable.chargerDetails
val chargers = details.chargers.associateBy { it.id }
var detailsSorted = chargerDetails
.sortedBy { chargers[it.id]?.labelLetter }
.sortedBy { chargers[it.id]?.labelNumber }
if (detailsSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
// apparently some connectors are missing in Tesla data
// If we have just one type of charger, we can still match
val numMissing =
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - statusSorted.size
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - detailsSorted.size
if ((scV2Connectors.isEmpty() || scV3Connectors.isEmpty()) && numMissing > 0) {
statusSorted =
statusSorted + List(numMissing) { ChargerAvailability.UNKNOWN }
detailsSorted =
detailsSorted + List(numMissing) {
TeslaChargingGuestGraphQlApi.ChargerDetail(
ChargerAvailability.UNKNOWN,
""
)
}
} else {
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
}
}
val statusMap = emptyMap<Chargepoint, List<ChargepointStatus>>().toMutableMap()
val detailsMap =
mutableMapOf<Chargepoint, List<TeslaChargingGuestGraphQlApi.ChargerDetail>>()
var i = 0
for (connector in scV2Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
if (scV2CCSConnectors.isNotEmpty()) {
i = 0
for (connector in scV2CCSConnectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
}
for (connector in scV3Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
val labelsMap = detailsMap.mapValues { it.value.map { chargers[it.id]?.label } }
val pricing = details.pricing.copy(memberRates = guestPricing.await()?.userRates)
return ChargeLocationStatus(
statusMap,
"Tesla",
labels = labelsMap,
extraData = pricing
)
}

View File

@@ -2,6 +2,7 @@ package net.vonforst.evmap.api.availability
import net.vonforst.evmap.api.availability.tesla.ChargerAvailability
import net.vonforst.evmap.api.availability.tesla.TeslaAuthenticationApi
import net.vonforst.evmap.api.availability.tesla.TeslaChargingGuestGraphQlApi
import net.vonforst.evmap.api.availability.tesla.TeslaChargingOwnershipGraphQlApi
import net.vonforst.evmap.api.availability.tesla.asTeslaCoord
import net.vonforst.evmap.model.ChargeLocation
@@ -85,47 +86,52 @@ class TeslaOwnerAvailabilityDetector(
"charger has unknown connectors"
)
var statusSorted = details.siteDynamic.chargerDetails
.sortedBy { c ->
c.charger.labelLetter
?: details.siteStatic.chargers.find { it.id == c.charger.id }?.labelLetter
}
.sortedBy { c ->
c.charger.labelNumber
?: details.siteStatic.chargers.find { it.id == c.charger.id }?.labelNumber
}
.map { it.availability }
if (statusSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
val chargerDetails = details.siteDynamic.chargerDetails
val chargers = details.siteStatic.chargers.associateBy { it.id }
var detailsSorted = chargerDetails
.sortedBy { c -> c.charger.labelLetter ?: chargers[c.charger.id]?.labelLetter }
.sortedBy { c -> c.charger.labelNumber ?: chargers[c.charger.id]?.labelNumber }
if (detailsSorted.size != scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count }) {
// apparently some connectors are missing in Tesla data
// If we have just one type of charger, we can still match
val numMissing =
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - statusSorted.size
scV2Connectors.sumOf { it.count } + scV3Connectors.sumOf { it.count } - detailsSorted.size
if ((scV2Connectors.isEmpty() || scV3Connectors.isEmpty()) && numMissing > 0) {
statusSorted =
statusSorted + List(numMissing) { ChargerAvailability.UNKNOWN }
detailsSorted =
detailsSorted + List(numMissing) {
TeslaChargingOwnershipGraphQlApi.ChargerDetail(
ChargerAvailability.UNKNOWN,
TeslaChargingOwnershipGraphQlApi.ChargerId(
TeslaChargingOwnershipGraphQlApi.Text(""),
null,
null
)
)
}
} else {
throw AvailabilityDetectorException("Tesla API chargepoints do not match data source")
}
}
val statusMap = emptyMap<Chargepoint, List<ChargepointStatus>>().toMutableMap()
val detailsMap =
emptyMap<Chargepoint, List<TeslaChargingOwnershipGraphQlApi.ChargerDetail>>().toMutableMap()
var i = 0
for (connector in scV2Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
if (scV2CCSConnectors.isNotEmpty()) {
i = 0
for (connector in scV2CCSConnectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
}
for (connector in scV3Connectors) {
statusMap[connector] =
statusSorted.subList(i, i + connector.count).map { it.toStatus() }
detailsMap[connector] =
detailsSorted.subList(i, i + connector.count)
i += connector.count
}
@@ -138,9 +144,17 @@ class TeslaOwnerAvailabilityDetector(
}
}
val statusMap = detailsMap.mapValues { it.value.map { it.availability.toStatus() } }
val labelsMap = detailsMap.mapValues {
it.value.map {
it.charger.label?.value ?: chargers[it.charger.id]?.label?.value
}
}
return ChargeLocationStatus(
statusMap,
"Tesla",
labels = labelsMap,
congestionHistogram = congestionHistogram,
extraData = details.pricing
)

View File

@@ -15,6 +15,7 @@ import net.vonforst.evmap.model.Chargepoint
import net.vonforst.evmap.model.FILTERS_DISABLED
import net.vonforst.evmap.storage.PreferenceDataSource
import net.vonforst.evmap.ui.MaterialDialogFragment
import java.time.Instant
class ConnectorDetailsDialog : MaterialDialogFragment() {
private lateinit var binding: DialogConnectorDetailsBinding
@@ -23,13 +24,17 @@ class ConnectorDetailsDialog : MaterialDialogFragment() {
fun getInstance(
chargepoint: Chargepoint,
status: List<ChargepointStatus>,
evseIds: List<String>? = null
evseIds: List<String>? = null,
labels: List<String?>? = null,
lastChange: List<Instant?>? = null
): ConnectorDetailsDialog {
val dialog = ConnectorDetailsDialog()
dialog.arguments = Bundle().apply {
putParcelable("chargepoint", chargepoint)
putParcelableArrayList("status", ArrayList(status))
putStringArrayList("evseIds", evseIds?.let { ArrayList(it) })
putStringArrayList("labels", labels?.let { ArrayList(it) })
putSerializable("lastChange", lastChange?.let { ArrayList(it) })
}
return dialog
}
@@ -54,10 +59,18 @@ class ConnectorDetailsDialog : MaterialDialogFragment() {
val status =
BundleCompat.getParcelableArrayList(args, "status", ChargepointStatus::class.java)
val evseIds = args.getStringArrayList("evseIds")
val labels = args.getStringArrayList("labels")
val lastChange = args.getSerializable("lastChange") as ArrayList<Instant>?
val items = List(chargepoint.count) { i ->
ConnectorDetailsAdapter.ConnectorDetails(chargepoint, status?.get(i), evseIds?.get(i))
}.sortedBy { it.evseId }
ConnectorDetailsAdapter.ConnectorDetails(
chargepoint,
status?.get(i),
evseIds?.get(i),
labels?.get(i),
lastChange?.get(i)
)
}.sortedBy { it.evseId ?: it.label }
binding.list.apply {
adapter = ConnectorDetailsAdapter().apply {

View File

@@ -821,7 +821,9 @@ class MapFragment : Fragment(), OnMapReadyCallback, MapsActivity.FragmentCallbac
val dialog = ConnectorDetailsDialog.getInstance(
item.chargepoint,
status,
it.evseIds?.get(item.chargepoint)
it.evseIds?.get(item.chargepoint),
it.labels?.get(item.chargepoint),
it.lastChange?.get(item.chargepoint),
)
dialog.show(parentFragmentManager, null)
}

View File

@@ -7,6 +7,7 @@ import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.text.SpannableString
import android.text.format.DateUtils
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
@@ -28,6 +29,7 @@ import com.google.android.material.slider.RangeSlider
import net.vonforst.evmap.*
import net.vonforst.evmap.api.availability.ChargepointStatus
import net.vonforst.evmap.api.iconForPlugType
import java.time.Instant
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
@@ -310,16 +312,27 @@ fun availabilityText(status: List<ChargepointStatus>?): String? {
} else available.toString()
}
fun availabilityText(status: ChargepointStatus?, context: Context): String? {
fun availabilityText(status: ChargepointStatus?, lastChange: Instant?, context: Context): String? {
if (status == null) return null
return when (status) {
val statusText = when (status) {
ChargepointStatus.UNKNOWN -> context.getString(R.string.status_unknown)
ChargepointStatus.AVAILABLE -> context.getString(R.string.status_available)
ChargepointStatus.CHARGING -> context.getString(R.string.status_charging)
ChargepointStatus.OCCUPIED -> context.getString(R.string.status_occupied)
ChargepointStatus.FAULTED -> context.getString(R.string.status_faulted)
}
return if (lastChange != null) {
val relativeTime = DateUtils.getRelativeTimeSpanString(
lastChange.toEpochMilli(),
Instant.now().toEpochMilli(),
0
).toString()
return context.getString(R.string.status_since, statusText, relativeTime)
} else {
statusText
}
}
fun flatten(it: Iterable<Iterable<ChargepointStatus>>?): List<ChargepointStatus>? {

View File

@@ -62,7 +62,7 @@
android:layout_marginTop="18dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="14dp"
android:text="@{item.evseId}"
android:text="@{(item.label != null &amp;&amp; item.evseId != null) ? item.label + &quot; · &quot; + item.evseId : (item.label ?? item.evseId)}"
app:layout_constraintBottom_toBottomOf="@+id/imageView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView"
@@ -76,7 +76,7 @@
android:layout_marginTop="2dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="14dp"
android:text="@{BindingAdaptersKt.availabilityText(item.status, context)}"
android:text="@{BindingAdaptersKt.availabilityText(item.status, item.lastChange, context)}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{item.status != null}"
app:layout_constraintBottom_toBottomOf="parent"

View File

@@ -373,5 +373,6 @@
<string name="status_charging">Lädt</string>
<string name="status_faulted">Defekt</string>
<string name="status_unknown">Status unbekannt</string>
<string name="status_since">%1$s seit %2$s</string>
<string name="charger_name">Ladestationsname</string>
</resources>

View File

@@ -373,5 +373,6 @@
<string name="status_charging">Charging</string>
<string name="status_faulted">Out of order</string>
<string name="status_unknown">Status Unknown</string>
<string name="status_since">%1$s since %2$s</string>
<string name="charger_name">Charger name</string>
</resources>