mirror of
https://github.com/ev-map/EVMap.git
synced 2026-04-29 10:34:41 -04:00
@@ -105,7 +105,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
chargers?.take(maxRows)?.let { chargerList ->
|
||||
val builder = ItemList.Builder()
|
||||
// only show the city if not all chargers are in the same city
|
||||
val showCity = chargerList.map { it.address.city }.distinct().size > 1
|
||||
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
|
||||
chargerList.forEach { charger ->
|
||||
builder.addItem(formatCharger(charger, showCity))
|
||||
}
|
||||
@@ -177,7 +177,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
|
||||
return Row.Builder().apply {
|
||||
// only show the city if not all chargers are in the same city (-> showCity == true)
|
||||
// and the city is not already contained in the charger name
|
||||
if (showCity && charger.address.city != null && charger.address.city !in charger.name) {
|
||||
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
|
||||
setTitle(
|
||||
CarText.Builder("${charger.name} · ${charger.address.city}")
|
||||
.addVariant(charger.name)
|
||||
|
||||
@@ -44,13 +44,13 @@ fun buildDetails(
|
||||
if (loc == null) return emptyList()
|
||||
|
||||
return listOfNotNull(
|
||||
DetailsAdapter.Detail(
|
||||
if (loc.address != null) DetailsAdapter.Detail(
|
||||
R.drawable.ic_address,
|
||||
R.string.address,
|
||||
loc.address.toString(),
|
||||
loc.locationDescription,
|
||||
clickable = true
|
||||
),
|
||||
) else null,
|
||||
if (loc.operator != null) DetailsAdapter.Detail(
|
||||
R.drawable.ic_operator,
|
||||
R.string.operator,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package net.vonforst.evmap.api.openstreetmap
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import net.vonforst.evmap.model.*
|
||||
import okhttp3.internal.immutableListOf
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
private data class OsmSocket(
|
||||
// The OSM socket name (e.g. "type2_combo")
|
||||
val osmSocketName: String,
|
||||
// The socket identifier used in EVMap.
|
||||
// TODO: This should probably be a separate enum-like type, not a string.
|
||||
val evmapKey: String?,
|
||||
) {
|
||||
/**
|
||||
* Return the OSM socket base tag (e.g. "socket:type2_combo").
|
||||
*/
|
||||
fun osmSocketBaseTag(): String {
|
||||
return "socket:${this.osmSocketName}"
|
||||
}
|
||||
}
|
||||
|
||||
// List of all OSM socket types that are relevant for EVs:
|
||||
// https://wiki.openstreetmap.org/wiki/Key:socket
|
||||
private val SOCKET_TYPES = immutableListOf(
|
||||
// Type 1
|
||||
OsmSocket("type1", Chargepoint.TYPE_1),
|
||||
OsmSocket("type1_combo", Chargepoint.CCS_TYPE_1),
|
||||
|
||||
// Type 2
|
||||
OsmSocket("type2", Chargepoint.TYPE_2_SOCKET), // Type2 socket (or unknown)
|
||||
OsmSocket("type2_cable", Chargepoint.TYPE_2_PLUG), // Type2 plug
|
||||
OsmSocket("type2_combo", Chargepoint.CCS_TYPE_2), // CCS
|
||||
|
||||
// CHAdeMO
|
||||
OsmSocket("chademo", Chargepoint.CHADEMO),
|
||||
|
||||
// Tesla
|
||||
OsmSocket("tesla_standard", null),
|
||||
OsmSocket("tesla_supercharger", Chargepoint.SUPERCHARGER),
|
||||
|
||||
// CEE
|
||||
OsmSocket("cee_blue", Chargepoint.CEE_BLAU), // Also known as "caravan socket"
|
||||
OsmSocket("cee_red_16a", Chargepoint.CEE_ROT),
|
||||
OsmSocket("cee_red_32a", Chargepoint.CEE_ROT),
|
||||
OsmSocket("cee_red_63a", Chargepoint.CEE_ROT),
|
||||
OsmSocket("cee_red_125a", Chargepoint.CEE_ROT),
|
||||
|
||||
// Europe
|
||||
OsmSocket("schuko", Chargepoint.SCHUKO),
|
||||
|
||||
// Switzerland
|
||||
OsmSocket("sev1011_t13", null),
|
||||
OsmSocket("sev1011_t15", null),
|
||||
OsmSocket("sev1011_t23", null),
|
||||
OsmSocket("sev1011_t25", null),
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class OSMChargingStation(
|
||||
// Unique numeric ID
|
||||
val id: Long,
|
||||
// Latitude (WGS84)
|
||||
val lat: Double,
|
||||
// Longitude (WGS84)
|
||||
val lon: Double,
|
||||
// Timestamp of last update
|
||||
@Json(name = "timestamp") val lastUpdateTimestamp: ZonedDateTime,
|
||||
// Numeric, monotonically increasing version number
|
||||
val version: Int,
|
||||
// User that last modified this POI
|
||||
val user: String,
|
||||
// Raw key-value OSM tags
|
||||
val tags: Map<String, String>,
|
||||
) {
|
||||
/**
|
||||
* Convert the [OSMChargingStation] to a generic [ChargeLocation].
|
||||
*
|
||||
* The [dataFetchTimestamp] should be set to the timestamp when the data was last
|
||||
* refreshed / fetched from OSM. It will always be later than the [lastUpdateTimestamp],
|
||||
* which contains the timestamp when the data was last _edited_ in OSM.
|
||||
*/
|
||||
fun convert(dataFetchTimestamp: Instant) = ChargeLocation(
|
||||
id,
|
||||
"openstreetmap",
|
||||
getName(),
|
||||
Coordinate(lat, lon),
|
||||
null, // TODO: Can we determine this with overpass?
|
||||
getChargepoints(),
|
||||
tags["network"],
|
||||
"https://www.openstreetmap.org/node/$id",
|
||||
"https://www.openstreetmap.org/edit?node=$id",
|
||||
null,
|
||||
false, // We don't know
|
||||
tags["authentication:none"] == "yes",
|
||||
tags["operator"],
|
||||
tags["description"],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
getOpeningHours(),
|
||||
getCost(),
|
||||
"© OpenStreetMap contributors",
|
||||
null,
|
||||
dataFetchTimestamp,
|
||||
true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Return the name for this charging station.
|
||||
*/
|
||||
private fun getName(): String {
|
||||
// Ideally this station has a name.
|
||||
// If not, fall back to the operator.
|
||||
// If that is missing as well, use a generic "Charging Station" string.
|
||||
return tags["name"]
|
||||
?: tags["operator"]
|
||||
?: "Charging Station";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the chargepoints for this charging station.
|
||||
*/
|
||||
private fun getChargepoints(): List<Chargepoint> {
|
||||
// Note: In OSM, the chargepoints are mapped as "socket:<type> = <count>"
|
||||
val chargepoints = mutableListOf<Chargepoint>()
|
||||
for (socket in SOCKET_TYPES) {
|
||||
val count = try {
|
||||
(this.tags[socket.osmSocketBaseTag()] ?: "0").toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
0
|
||||
}
|
||||
if (count > 0) {
|
||||
if (socket.evmapKey != null) {
|
||||
val outputPower = parseOutputPower(this.tags["${socket.osmSocketBaseTag()}:output"])
|
||||
chargepoints.add(Chargepoint(socket.evmapKey, outputPower, count))
|
||||
}
|
||||
}
|
||||
}
|
||||
return chargepoints
|
||||
}
|
||||
|
||||
private fun getOpeningHours(): OpeningHours? {
|
||||
val rawOpeningHours = tags["opening_hours"] ?: return null
|
||||
|
||||
// Handle the simple 24/7 case
|
||||
if (rawOpeningHours == "24/7") {
|
||||
return OpeningHours(true, null, null)
|
||||
}
|
||||
|
||||
// TODO: Try to convert other formats as well?
|
||||
//
|
||||
// Note: The current {@link OpeningHours} format is not flexible enough to handle
|
||||
// all rules that OSM can represent and might need to be updated.
|
||||
// This library could help: https://github.com/simonpoole/OpeningHoursParser
|
||||
//
|
||||
// Alternatively, with the opening-hours-evaluator library
|
||||
// https://github.com/leonardehrenfried/opening-hours-evaluator
|
||||
// we could implement an "open now" feature.
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getCost(): Cost? {
|
||||
val freecharging = when (tags["fee"]?.lowercase()) {
|
||||
"yes", "y" -> false
|
||||
"no", "n" -> true
|
||||
else -> null
|
||||
}
|
||||
val freeparking = when (tags["parking:fee"]?.lowercase()) {
|
||||
"no", "n" -> true
|
||||
"yes", "y", "interval" -> false
|
||||
else -> null
|
||||
}
|
||||
return Cost(freecharging, freeparking)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse raw OSM output power.
|
||||
*
|
||||
* The proper format to map output power for an EV charging station is "<amount> kW",
|
||||
* for example "22 kW" or "3.7 kW". Some fields in the wild are tagged with the unit "kVA"
|
||||
* instead of "kW", those can be treated as equivalent.
|
||||
*
|
||||
* Sometimes people also mapped plain numbers (e.g. 7000, I assume that's 7 kW),
|
||||
* ranges (5,5 - 11 kW, huh?) or even current (32 A), which is wrong. If we cannot parse,
|
||||
* just ignore the field.
|
||||
*/
|
||||
fun parseOutputPower(rawOutput: String?): Double? {
|
||||
if (rawOutput == null) {
|
||||
return null;
|
||||
}
|
||||
val pattern = Regex("([0-9.,]+)\\s*(kW|kVA)", setOf(RegexOption.IGNORE_CASE))
|
||||
val matchResult = pattern.matchEntire(rawOutput) ?: return null
|
||||
val numberString = matchResult.groupValues[1].replace(',', '.')
|
||||
return numberString.toDoubleOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,28 @@ sealed class ChargepointListItem
|
||||
/**
|
||||
* A whole charging site (potentially with multiple chargepoints).
|
||||
*
|
||||
* @param id A unique number per charging site
|
||||
* @param dataSource The name of the data source
|
||||
* @param coordinates The latitude / longitude of this charge location
|
||||
* @param address The charge location address
|
||||
* @param chargepoints List of chargepoints at this location
|
||||
* @param network The charging network (Mobility Service Provider, MSP)
|
||||
* @param url A link to this charging site
|
||||
* @param editUrl A link to a website where this charging site can be edited
|
||||
* @param faultReport Set this if the charging site is reported to be out of service
|
||||
* @param verified For crowdsourced data sources, this means that the data has been verified
|
||||
* by an independent person
|
||||
* @param barrierFree Whether this charge location can be used without prior registration
|
||||
* @param operator The operator of this charge location (Charge Point Operator, CPO)
|
||||
* @param generalInformation General information about this charging site that does not fit anywhere else
|
||||
* @param amenities Description of amenities available at or near the charging site (toilets, food, accommodation, landmarks, etc.)
|
||||
* @param locationDescription Directions on how to find the charger (e.g. "In the parking garage on level 5")
|
||||
* @param photos List of photos of this charging site
|
||||
* @param chargecards List of charge cards accepted here
|
||||
* @param openinghours List of times when this charging site can be accessed / used
|
||||
* @param cost The cost for charging and/or parking
|
||||
* @param license How the data about this chargepoint is licensed
|
||||
* @param chargepriceData Additional data needed for the Chargeprice implementation
|
||||
* @param timeRetrieved Time when this information was retrieved from the data source
|
||||
* @param isDetailed Whether this data includes all available details (for many data sources,
|
||||
* API calls that return a list may only give a compact representation)
|
||||
@@ -39,7 +61,7 @@ data class ChargeLocation(
|
||||
val dataSource: String,
|
||||
val name: String,
|
||||
@Embedded val coordinates: Coordinate,
|
||||
@Embedded val address: Address,
|
||||
@Embedded val address: Address?,
|
||||
val chargepoints: List<Chargepoint>,
|
||||
val network: String?,
|
||||
val url: String,
|
||||
@@ -307,6 +329,9 @@ data class Address(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One socket with a certain power, which may be available multiple times at a ChargeLocation.
|
||||
*/
|
||||
@Parcelize
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Chargepoint(
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
android:maxLines="1"
|
||||
android:text="@{charger.data.address.toString()}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:invisibleUnless="@{charger.data.address != null}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/guideline2"
|
||||
app:layout_constraintStart_toStartOf="@+id/guideline"
|
||||
app:layout_constraintTop_toBottomOf="@+id/txtName"
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
android:maxLines="1"
|
||||
android:text="@{item.charger.address.toString()}"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
app:invisibleUnless="@{item.charger.address != null}"
|
||||
app:layout_constraintEnd_toStartOf="@+id/textView7"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView15"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package net.vonforst.evmap.api.openstreetmap
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import net.vonforst.evmap.api.openchargemap.ZonedDateTimeAdapter
|
||||
import net.vonforst.evmap.model.Chargepoint
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.time.Month
|
||||
import java.time.ZoneOffset
|
||||
|
||||
const val JSON_SINGLE = "{\n" +
|
||||
" \"id\": 9084665785,\n" +
|
||||
" \"lat\": 46.1137872,\n" +
|
||||
" \"lon\": 7.0778715,\n" +
|
||||
" \"timestamp\": \"2021-09-12T19:36:56Z\",\n" +
|
||||
" \"version\": 1,\n" +
|
||||
" \"user\": \"Voonosm\",\n" +
|
||||
" \"tags\": {\n" +
|
||||
" \"amenity\": \"charging_station\",\n" +
|
||||
" \"authentication:app\": \"yes\",\n" +
|
||||
" \"authentication:contactless\": \"yes\",\n" +
|
||||
" \"bicycle\": \"no\",\n" +
|
||||
" \"capacity\": \"2\",\n" +
|
||||
" \"cover\": \"no\",\n" +
|
||||
" \"fee\": \"yes\",\n" +
|
||||
" \"motorcar\": \"yes\",\n" +
|
||||
" \"network\": \"Swisscharge\",\n" +
|
||||
" \"opening_hours\": \"24/7\",\n" +
|
||||
" \"operator\": \"GOFAST\",\n" +
|
||||
" \"parking:fee\": \"no\",\n" +
|
||||
" \"payment:credit_cards\": \"yes\",\n" +
|
||||
" \"socket:chademo\": \"2\",\n" +
|
||||
" \"socket:chademo:output\": \"60 kW\",\n" +
|
||||
" \"socket:type2\": \"1\",\n" +
|
||||
" \"socket:type2:output\": \"22 kW\",\n" +
|
||||
" \"socket:type2_combo\": \"2\",\n" +
|
||||
" \"socket:type2_combo:output\": \"150 kW\"\n" +
|
||||
" }\n" +
|
||||
"}"
|
||||
|
||||
class OpenStreetMapModelTest {
|
||||
@Test
|
||||
fun parseFromJson() {
|
||||
val moshi = Moshi.Builder()
|
||||
.add(ZonedDateTimeAdapter())
|
||||
.build()
|
||||
val deserialized = moshi
|
||||
.adapter(OSMChargingStation::class.java)
|
||||
.fromJson(JSON_SINGLE)!!
|
||||
assertEquals(9084665785, deserialized.id)
|
||||
assertEquals(1, deserialized.version)
|
||||
assertEquals(12, deserialized.lastUpdateTimestamp.dayOfMonth)
|
||||
assertEquals(Month.SEPTEMBER, deserialized.lastUpdateTimestamp.month)
|
||||
assertEquals(36, deserialized.lastUpdateTimestamp.minute)
|
||||
assertEquals(ZoneOffset.UTC, deserialized.lastUpdateTimestamp.offset)
|
||||
assertEquals("Swisscharge", deserialized.tags["network"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun convert() {
|
||||
val osmChargingStation = Moshi.Builder()
|
||||
.add(ZonedDateTimeAdapter())
|
||||
.build()
|
||||
.adapter(OSMChargingStation::class.java)
|
||||
.fromJson(JSON_SINGLE)!!
|
||||
val now = Instant.now()
|
||||
val chargeLocation = osmChargingStation.convert(now)
|
||||
|
||||
// Basics
|
||||
assertEquals("openstreetmap", chargeLocation.dataSource)
|
||||
assertEquals("https://www.openstreetmap.org/node/9084665785", chargeLocation.url)
|
||||
assertEquals(true, chargeLocation.openinghours?.twentyfourSeven)
|
||||
assertEquals("GOFAST", chargeLocation.name) // Fallback to operator because name is not set
|
||||
assertEquals(false, chargeLocation.barrierFree) // False because `authentication:none` isn't set
|
||||
assertEquals(now, chargeLocation.timeRetrieved)
|
||||
|
||||
// Cost
|
||||
assertEquals(false, chargeLocation.cost?.freecharging)
|
||||
assertEquals(true, chargeLocation.cost?.freeparking)
|
||||
|
||||
// Chargepoints
|
||||
assertEquals(3, chargeLocation.chargepoints.size)
|
||||
val ccs = chargeLocation.chargepoints.single { it.type == Chargepoint.CCS_TYPE_2 }
|
||||
val type2 = chargeLocation.chargepoints.single { it.type == Chargepoint.TYPE_2_SOCKET }
|
||||
val chademo = chargeLocation.chargepoints.single { it.type == Chargepoint.CHADEMO }
|
||||
assertEquals(2, ccs.count)
|
||||
assertEquals(150.0, ccs.power)
|
||||
assertEquals(1, type2.count)
|
||||
assertEquals(22.0, type2.power)
|
||||
assertEquals(2, chademo.count)
|
||||
assertEquals(60.0, chademo.power)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseOutputPower() {
|
||||
// Null input -> null output
|
||||
assertNull(OSMChargingStation.parseOutputPower(null))
|
||||
|
||||
// Invalid input -> null output
|
||||
assertNull(OSMChargingStation.parseOutputPower(""))
|
||||
assertNull(OSMChargingStation.parseOutputPower("a"))
|
||||
assertNull(OSMChargingStation.parseOutputPower("22 A"))
|
||||
|
||||
// Invalid number -> null output
|
||||
assertNull(OSMChargingStation.parseOutputPower("22.0.1 kW"))
|
||||
|
||||
// Valid output power values
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22 kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22 kVA"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22. kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22.0 kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22,0 kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22kW"))
|
||||
assertEquals(22.0, OSMChargingStation.parseOutputPower("22 kW"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user