diff --git a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt index 8df60cb0..3caa2c4d 100644 --- a/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt +++ b/app/src/google/java/net/vonforst/evmap/auto/MapScreen.kt @@ -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) diff --git a/app/src/main/java/net/vonforst/evmap/adapter/DetailsAdapter.kt b/app/src/main/java/net/vonforst/evmap/adapter/DetailsAdapter.kt index 31d57535..1e365702 100644 --- a/app/src/main/java/net/vonforst/evmap/adapter/DetailsAdapter.kt +++ b/app/src/main/java/net/vonforst/evmap/adapter/DetailsAdapter.kt @@ -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, diff --git a/app/src/main/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModel.kt b/app/src/main/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModel.kt new file mode 100644 index 00000000..73b58960 --- /dev/null +++ b/app/src/main/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModel.kt @@ -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, +) { + /** + * 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 { + // Note: In OSM, the chargepoints are mapped as "socket: = " + val chargepoints = mutableListOf() + 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 " 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt b/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt index 522e0733..b2b6be7d 100644 --- a/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt +++ b/app/src/main/java/net/vonforst/evmap/model/ChargersModel.kt @@ -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, 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( diff --git a/app/src/main/res/layout/detail_view.xml b/app/src/main/res/layout/detail_view.xml index 1fc73a53..59730141 100644 --- a/app/src/main/res/layout/detail_view.xml +++ b/app/src/main/res/layout/detail_view.xml @@ -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" diff --git a/app/src/main/res/layout/item_favorite.xml b/app/src/main/res/layout/item_favorite.xml index e1be543e..b99442f8 100644 --- a/app/src/main/res/layout/item_favorite.xml +++ b/app/src/main/res/layout/item_favorite.xml @@ -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" diff --git a/app/src/test/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModelTest.kt b/app/src/test/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModelTest.kt new file mode 100644 index 00000000..1a79b585 --- /dev/null +++ b/app/src/test/java/net/vonforst/evmap/api/openstreetmap/OpenStreetMapModelTest.kt @@ -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")) + } +} \ No newline at end of file