package de.upapp import android.util.Log import org.json.JSONArray import org.json.JSONObject import java.io.IOException import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalTime import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.Locale /** [soc] and [value] are null while the car has no reading. It then sends an empty string. */ data class Battery(val name: String, val soc: Double?) data class Range(val name: String, val value: Double?, val unit: String) data class Info(val vin: String, val date: String, val time: String, val type: String, val language: String) /** Departure timer. The car stores [departureTime] in UTC. */ data class Timer( val id: String, val uri: String, val index: Int, val state: String, val cyclic: Boolean, val weekdays: Set, val departureTime: LocalTime, val departureDate: LocalDate?, val profileId: String?, ) { val active get() = state == TIMER_ON } /** Charging location. */ data class Profile( val id: String, val uri: String, val name: String, val targetLevel: Int, val maxCurrent: Int, val operations: List, val providerId: String?, ) /** Global charging options. The car returns them as the first profile. */ data class Options( val id: String, val uri: String, val operations: List, val maxCurrent: Int, val minLevel: Int, val temperature: Double, val providerId: String? = null, ) { val climateExtSupply get() = EXT_SUPPLY in operations } /** Off-peak power time window. The car stores [start] and [end] in UTC. */ data class Provider(val id: String, val uri: String, val cyclic: Boolean, val weekdays: Set, val start: LocalTime, val end: LocalTime) { // maps+more has no off switch. An empty window is off, and the car uses that for unset entries. val active get() = start != end fun off() = copy(start = LocalTime.MIDNIGHT, end = LocalTime.MIDNIGHT) } /** Live state. Values are the car's raw strings, null if the car does not report them. */ data class Status( val charge: String?, val plug: String?, val climate: String?, val outsideTemperature: Double?, val temperatureUnit: String?, val chargeMode: String? = null, val remainingMinutes: Int? = null, ) /** [options] is null while the car reports no profiles. */ data class ChargingManager(val options: Options?, val profiles: List, val timers: List, val providers: List) const val TIMER_ON = "scheduled" const val TIMER_OFF = "idle" const val EXT_SUPPLY = "climateExtSupply" // The values maps+more knows. The car also lists climateExtSupply and charge on normal locations and options. val OPERATIONS = setOf( "charge", "climate", EXT_SUPPLY, "autoDefrost", "SeatHeaterFrontLeft", "SeatHeaterFrontRight", "SeatHeaterRearLeft", "SeatHeaterRearRight", "WindowHeaterFront", "WindowHeaterRear", "ParkHeater", "ParkHeaterAutomatic", ) val TARGET_LEVELS = (50..100 step 10).toList() val MIN_LEVELS = (0..100 step 10).toList() // maps+more rule: 32 A only for the e-up! 300, i.e. VW model year L (2020) or later, and all other brands. fun maxCurrents(vin: String) = if (!vin.uppercase().startsWith("WVW") || vin.getOrElse(9) { 'A' }.uppercaseChar() >= 'L') listOf(5, 10, 13, 16, 32) else listOf(5, 10, 13, 16) val TEMPERATURES = (31..60).map { it / 2.0 } // maps+more allows this many locations besides the options entry. const val MAX_LOCATIONS = 10 // TODO: guessed limit. maps+more has none. Check the longest name the car accepts. const val NAME_MAX = 20 private val HMS = DateTimeFormatter.ofPattern("HH:mm:ss") private val DATE_IN = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH) private val DATE_OUT = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH) private fun JSONObject.list(key: String): List = optJSONArray(key)?.let { a -> List(a.length()) { a.getString(it) } } ?: emptyList() private fun JSONObject.days() = list("weekdays").map { DayOfWeek.valueOf(it.uppercase()) }.toSet() private fun JSONObject.linkId(key: String) = optJSONObject(key)?.optString("id")?.ifEmpty { null } private fun Set.toJson() = JSONArray(sorted().map { it.name.lowercase() }) private fun JSONObject.number(key: String) = optDouble(key).takeUnless { it.isNaN() } fun parseBattery(o: JSONObject) = Battery(o.optString("name"), o.number("soc")) fun parseRange(o: JSONObject) = Range(o.optString("name"), o.number("value"), o.optString("valueUnit")) fun parseInfo(o: JSONObject) = Info( o.optString("vehicleIdenticationNumber"), o.optString("vehicleDate"), o.optString("vehicleTime"), o.optString("vehicleType"), o.optString("language"), ) fun parseTimer(o: JSONObject) = Timer( id = o.getString("id"), uri = o.getString("uri"), index = o.getString("name").last().digitToInt(), state = o.getString("state"), cyclic = o.optBoolean("cyclic"), weekdays = o.days(), departureTime = LocalTime.parse(o.getString("departureTime"), HMS), departureDate = o.optString("departureDate").ifEmpty { null }?.let { LocalDate.parse(it, DATE_IN) }, profileId = o.linkId("profile"), ) fun parseProfile(o: JSONObject) = Profile( o.getString("id"), o.getString("uri"), o.getString("name"), o.getInt("targetLevel"), o.getInt("maxCurrent"), o.list("operations"), o.linkId("powerProvider"), ) fun parseOptions(o: JSONObject) = Options( o.getString("id"), o.getString("uri"), o.list("operations"), o.getInt("maxCurrent"), o.getInt("minLevel"), o.getDouble("temperature"), ) fun parseProvider(o: JSONObject) = Provider( o.getString("id"), o.getString("uri"), o.optBoolean("cyclic"), o.days(), LocalTime.parse(o.getString("preferredTimeStart"), HMS), LocalTime.parse(o.getString("preferredTimeEnd"), HMS), ) private fun Car.items(path: String): List = JSONObject(get(path)).getJSONArray("data").let { a -> List(a.length()) { a.getJSONObject(it) } } fun Car.battery() = parseBattery(items("/car/batteries/").first()) fun Car.range() = parseRange(items("/car/ranges/").first()) fun Car.info() = parseInfo(items("/car/info/").first()) fun Car.status(): Status { fun first(path: String) = try { items(path).firstOrNull() } catch (e: IOException) { null } val env = first("/car/environments/") val charge = first("/chargingmanager/batteryCharges/") return Status( charge?.optString("state"), first("/chargingmanager/batteryPlugs/")?.optString("plug"), first("/chargingmanager/batteryClimates/")?.optString("state"), env?.number("outsideTemperature"), env?.optString("outsideTemperatureUnit"), charge?.optString("mode")?.ifEmpty { null }, charge?.number("remainingTime")?.toInt(), ) } fun Car.chargingManager() = chargingManagerOf(items("/chargingmanager/profiles/"), items("/chargingmanager/timers/"), items("/chargingmanager/providers/")) internal fun chargingManagerOf(profiles: List, timers: List, providers: List): ChargingManager { // Some cars send no powerProvider link. maps+more then pairs both lists by position, options included. fun link(i: Int, id: String?) = id ?: providers.getOrNull(i)?.optString("id")?.takeIf { profiles.size == providers.size } return ChargingManager( profiles.firstOrNull()?.let { valid(it, ::parseOptions) }?.copy(providerId = link(0, null)), profiles.withIndex().drop(1).mapNotNull { (i, o) -> valid(o, ::parseProfile)?.let { it.copy(providerId = link(i, it.providerId)) } }, timers.mapNotNull { valid(it, ::parseTimer) }, providers.mapNotNull { valid(it, ::parseProvider) }, ) } // Without ignition the car lists placeholder entries with empty strings. maps+more drops them too. private fun valid(o: JSONObject, parse: (JSONObject) -> T): T? = try { parse(o) } catch (e: Exception) { Log.d("Model", "Skipping incomplete entry ${o.optString("uri")}: $e") null } fun Car.write(t: Timer) { require(t.cyclic || t.departureDate != null) { "A one-time timer needs a date" } require(!t.cyclic || t.weekdays.isNotEmpty()) { "A weekly timer needs at least one day" } require(t.state == TIMER_ON || t.state == TIMER_OFF) { "Unknown timer state ${t.state}" } // maps+more never sends the state together with the schedule. update(t.uri, JSONObject() .put("cyclic", t.cyclic) .put("weekdays", t.weekdays.toJson()) .put("departureTime", t.departureTime.format(HMS)) .apply { t.departureDate?.let { put("departureDate", it.format(DATE_OUT)) } } .apply { t.profileId?.let { put("profile", it) } }) update(t.uri, JSONObject().put("state", t.state)) } fun Car.write(p: Profile) { require(p.maxCurrent in maxCurrents(vin)) { "Invalid max current ${p.maxCurrent}" } update(p.uri, p.toJson()) } /** Adds a charging location. The car creates the linked off-peak time itself. */ fun Car.create(p: Profile) { require(p.maxCurrent in maxCurrents(vin)) { "Invalid max current ${p.maxCurrent}" } val before = chargingManager().profiles.map { it.id }.toSet() require(before.size < MAX_LOCATIONS) { "The car has at most $MAX_LOCATIONS charging locations" } post("/chargingmanager/profiles/", p.toJson()) if (chargingManager().profiles.none { it.id !in before && it.name == p.name }) throw IOException("The car did not add ${p.name}") } fun Car.delete(p: Profile) { require(chargingManager().timers.none { it.profileId == p.id }) { "A timer uses this charging location" } send("DELETE", p.uri).ok() if (chargingManager().profiles.any { it.id == p.id }) throw IOException("The car did not delete ${p.name}") } /** A new charging location with the maps+more defaults. */ fun newProfile(vin: String) = Profile("", "", "", 100, maxCurrents(vin).last(), listOf("charge"), null) private fun Profile.toJson(): JSONObject { require(name.isNotBlank() && name.length <= NAME_MAX) { "Name must have 1 to $NAME_MAX characters" } require(targetLevel in TARGET_LEVELS) { "Invalid target level $targetLevel" } require(operations.all { it in OPERATIONS }) { "Invalid operations $operations" } return JSONObject() .put("name", name) .put("targetLevel", targetLevel) .put("maxCurrent", maxCurrent) .put("operations", JSONArray(operations)) } fun Car.write(o: Options) { require(o.maxCurrent in maxCurrents(vin)) { "Invalid max current ${o.maxCurrent}" } require(o.minLevel in MIN_LEVELS) { "Invalid min level ${o.minLevel}" } require(o.temperature in TEMPERATURES) { "Invalid temperature ${o.temperature}" } require(o.operations.all { it in OPERATIONS }) { "Invalid operations ${o.operations}" } update(o.uri, JSONObject() .put("operations", JSONArray(o.operations)) .put("maxCurrent", o.maxCurrent) .put("minLevel", o.minLevel) .put("temperature", o.temperature)) } fun Car.write(p: Provider) = update(p.uri, JSONObject() .put("weekdays", p.weekdays.toJson()) .put("preferredTimeStart", p.start.format(HMS)) .put("preferredTimeEnd", p.end.format(HMS))) // Like maps+more: only the time moves. Weekdays and dates stay local, even across midnight. fun Timer.shifted(offsetSeconds: Int) = copy(departureTime = departureTime.plusSeconds(offsetSeconds.toLong())) fun Provider.shifted(offsetSeconds: Int) = copy(start = start.plusSeconds(offsetSeconds.toLong()), end = end.plusSeconds(offsetSeconds.toLong())) /** The next active departure after [now]. [timers] must be in local time. */ fun nextDeparture(timers: List, now: ZonedDateTime): Pair? = timers.filter { it.active }.mapNotNull { t -> val dates = if (t.cyclic) (0L..7L).map { now.toLocalDate().plusDays(it) }.filter { it.dayOfWeek in t.weekdays } else listOfNotNull(t.departureDate) dates.map { ZonedDateTime.of(it, t.departureTime, now.zone) }.firstOrNull { it.isAfter(now) }?.let { t to it } }.minByOrNull { it.second } // The car wraps single elements in {"status": "ok", "data": {...}}. private fun Car.element(uri: String) = JSONObject(get(uri)).let { it.optJSONObject("data") ?: it } /** POSTs the fields of [fields] that differ on the car, then reads the element back to check them. */ private fun Car.update(uri: String, fields: JSONObject) { val before = element(uri) val changes = JSONObject() fields.keys().forEach { if (!matches(fields.get(it), before.opt(it))) changes.put(it, fields.get(it)) } if (changes.length() == 0) return post(uri, changes) val actual = element(uri) if (!matches(changes, actual)) throw IOException("The car did not apply all changes. Sent $changes, car has $actual") } /** True if every value in [sent] is also in [actual]. [actual] may have more keys. A sent ID matches a linked object. */ internal fun matches(sent: Any?, actual: Any?): Boolean = when (sent) { is String -> sent == actual || (actual is JSONObject && sent == actual.opt("id")) is JSONObject -> actual is JSONObject && sent.keys().asSequence().all { matches(sent.get(it), actual.opt(it)) } is JSONArray -> actual is JSONArray && sent.length() == actual.length() && (0 until sent.length()).all { matches(sent.get(it), actual.get(it)) } is Number -> actual is Number && sent.toDouble() == actual.toDouble() else -> sent == actual }