Model.kt
| 1 | package de.upapp |
| 2 | |
| 3 | import android.util.Log |
| 4 | import org.json.JSONArray |
| 5 | import org.json.JSONObject |
| 6 | import java.io.IOException |
| 7 | import java.time.DayOfWeek |
| 8 | import java.time.LocalDate |
| 9 | import java.time.LocalTime |
| 10 | import java.time.ZonedDateTime |
| 11 | import java.time.format.DateTimeFormatter |
| 12 | import java.util.Locale |
| 13 | |
| 14 | /** [soc] and [value] are null while the car has no reading. It then sends an empty string. */ |
| 15 | data class Battery(val name: String, val soc: Double?) |
| 16 | data class Range(val name: String, val value: Double?, val unit: String) |
| 17 | data class Info(val vin: String, val date: String, val time: String, val type: String, val language: String) |
| 18 | |
| 19 | /** Departure timer. The car stores [departureTime] in UTC. */ |
| 20 | data class Timer( |
| 21 | val id: String, val uri: String, val index: Int, val state: String, val cyclic: Boolean, |
| 22 | val weekdays: Set<DayOfWeek>, val departureTime: LocalTime, val departureDate: LocalDate?, val profileId: String?, |
| 23 | ) { |
| 24 | val active get() = state == TIMER_ON |
| 25 | } |
| 26 | |
| 27 | /** Charging location. */ |
| 28 | data class Profile( |
| 29 | val id: String, val uri: String, val name: String, val targetLevel: Int, val maxCurrent: Int, |
| 30 | val operations: List<String>, val providerId: String?, |
| 31 | ) |
| 32 | |
| 33 | /** Global charging options. The car returns them as the first profile. */ |
| 34 | data class Options( |
| 35 | val id: String, val uri: String, val operations: List<String>, val maxCurrent: Int, val minLevel: Int, val temperature: Double, |
| 36 | val providerId: String? = null, |
| 37 | ) { |
| 38 | val climateExtSupply get() = EXT_SUPPLY in operations |
| 39 | } |
| 40 | |
| 41 | /** Off-peak power time window. The car stores [start] and [end] in UTC. */ |
| 42 | data class Provider(val id: String, val uri: String, val cyclic: Boolean, val weekdays: Set<DayOfWeek>, val start: LocalTime, val end: LocalTime) { |
| 43 | // maps+more has no off switch. An empty window is off, and the car uses that for unset entries. |
| 44 | val active get() = start != end |
| 45 | |
| 46 | fun off() = copy(start = LocalTime.MIDNIGHT, end = LocalTime.MIDNIGHT) |
| 47 | } |
| 48 | |
| 49 | /** Live state. Values are the car's raw strings, null if the car does not report them. */ |
| 50 | data class Status( |
| 51 | val charge: String?, val plug: String?, val climate: String?, val outsideTemperature: Double?, val temperatureUnit: String?, |
| 52 | val chargeMode: String? = null, val remainingMinutes: Int? = null, |
| 53 | ) |
| 54 | |
| 55 | /** [options] is null while the car reports no profiles. */ |
| 56 | data class ChargingManager(val options: Options?, val profiles: List<Profile>, val timers: List<Timer>, val providers: List<Provider>) |
| 57 | |
| 58 | const val TIMER_ON = "scheduled" |
| 59 | const val TIMER_OFF = "idle" |
| 60 | const val EXT_SUPPLY = "climateExtSupply" |
| 61 | // The values maps+more knows. The car also lists climateExtSupply and charge on normal locations and options. |
| 62 | val OPERATIONS = setOf( |
| 63 | "charge", "climate", EXT_SUPPLY, "autoDefrost", "SeatHeaterFrontLeft", "SeatHeaterFrontRight", "SeatHeaterRearLeft", |
| 64 | "SeatHeaterRearRight", "WindowHeaterFront", "WindowHeaterRear", "ParkHeater", "ParkHeaterAutomatic", |
| 65 | ) |
| 66 | val TARGET_LEVELS = (50..100 step 10).toList() |
| 67 | val MIN_LEVELS = (0..100 step 10).toList() |
| 68 | // maps+more rule: 32 A only for the e-up! 300, i.e. VW model year L (2020) or later, and all other brands. |
| 69 | fun maxCurrents(vin: String) = |
| 70 | if (!vin.uppercase().startsWith("WVW") || vin.getOrElse(9) { 'A' }.uppercaseChar() >= 'L') listOf(5, 10, 13, 16, 32) else listOf(5, 10, 13, 16) |
| 71 | val TEMPERATURES = (31..60).map { it / 2.0 } |
| 72 | // maps+more allows this many locations besides the options entry. |
| 73 | const val MAX_LOCATIONS = 10 |
| 74 | // TODO: guessed limit. maps+more has none. Check the longest name the car accepts. |
| 75 | const val NAME_MAX = 20 |
| 76 | |
| 77 | private val HMS = DateTimeFormatter.ofPattern("HH:mm:ss") |
| 78 | private val DATE_IN = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH) |
| 79 | private val DATE_OUT = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH) |
| 80 | |
| 81 | private fun JSONObject.list(key: String): List<String> = |
| 82 | optJSONArray(key)?.let { a -> List(a.length()) { a.getString(it) } } ?: emptyList() |
| 83 | |
| 84 | private fun JSONObject.days() = list("weekdays").map { DayOfWeek.valueOf(it.uppercase()) }.toSet() |
| 85 | |
| 86 | private fun JSONObject.linkId(key: String) = optJSONObject(key)?.optString("id")?.ifEmpty { null } |
| 87 | |
| 88 | private fun Set<DayOfWeek>.toJson() = JSONArray(sorted().map { it.name.lowercase() }) |
| 89 | |
| 90 | private fun JSONObject.number(key: String) = optDouble(key).takeUnless { it.isNaN() } |
| 91 | |
| 92 | fun parseBattery(o: JSONObject) = Battery(o.optString("name"), o.number("soc")) |
| 93 | fun parseRange(o: JSONObject) = Range(o.optString("name"), o.number("value"), o.optString("valueUnit")) |
| 94 | fun parseInfo(o: JSONObject) = Info( |
| 95 | o.optString("vehicleIdenticationNumber"), o.optString("vehicleDate"), o.optString("vehicleTime"), |
| 96 | o.optString("vehicleType"), o.optString("language"), |
| 97 | ) |
| 98 | |
| 99 | fun parseTimer(o: JSONObject) = Timer( |
| 100 | id = o.getString("id"), |
| 101 | uri = o.getString("uri"), |
| 102 | index = o.getString("name").last().digitToInt(), |
| 103 | state = o.getString("state"), |
| 104 | cyclic = o.optBoolean("cyclic"), |
| 105 | weekdays = o.days(), |
| 106 | departureTime = LocalTime.parse(o.getString("departureTime"), HMS), |
| 107 | departureDate = o.optString("departureDate").ifEmpty { null }?.let { LocalDate.parse(it, DATE_IN) }, |
| 108 | profileId = o.linkId("profile"), |
| 109 | ) |
| 110 | |
| 111 | fun parseProfile(o: JSONObject) = Profile( |
| 112 | o.getString("id"), o.getString("uri"), o.getString("name"), o.getInt("targetLevel"), o.getInt("maxCurrent"), |
| 113 | o.list("operations"), o.linkId("powerProvider"), |
| 114 | ) |
| 115 | |
| 116 | fun parseOptions(o: JSONObject) = Options( |
| 117 | o.getString("id"), o.getString("uri"), o.list("operations"), o.getInt("maxCurrent"), o.getInt("minLevel"), o.getDouble("temperature"), |
| 118 | ) |
| 119 | |
| 120 | fun parseProvider(o: JSONObject) = Provider( |
| 121 | o.getString("id"), o.getString("uri"), o.optBoolean("cyclic"), o.days(), |
| 122 | LocalTime.parse(o.getString("preferredTimeStart"), HMS), LocalTime.parse(o.getString("preferredTimeEnd"), HMS), |
| 123 | ) |
| 124 | |
| 125 | private fun Car.items(path: String): List<JSONObject> = |
| 126 | JSONObject(get(path)).getJSONArray("data").let { a -> List(a.length()) { a.getJSONObject(it) } } |
| 127 | |
| 128 | fun Car.battery() = parseBattery(items("/car/batteries/").first()) |
| 129 | fun Car.range() = parseRange(items("/car/ranges/").first()) |
| 130 | fun Car.info() = parseInfo(items("/car/info/").first()) |
| 131 | |
| 132 | fun Car.status(): Status { |
| 133 | fun first(path: String) = try { |
| 134 | items(path).firstOrNull() |
| 135 | } catch (e: IOException) { |
| 136 | null |
| 137 | } |
| 138 | val env = first("/car/environments/") |
| 139 | val charge = first("/chargingmanager/batteryCharges/") |
| 140 | return Status( |
| 141 | charge?.optString("state"), |
| 142 | first("/chargingmanager/batteryPlugs/")?.optString("plug"), |
| 143 | first("/chargingmanager/batteryClimates/")?.optString("state"), |
| 144 | env?.number("outsideTemperature"), |
| 145 | env?.optString("outsideTemperatureUnit"), |
| 146 | charge?.optString("mode")?.ifEmpty { null }, |
| 147 | charge?.number("remainingTime")?.toInt(), |
| 148 | ) |
| 149 | } |
| 150 | |
| 151 | fun Car.chargingManager() = |
| 152 | chargingManagerOf(items("/chargingmanager/profiles/"), items("/chargingmanager/timers/"), items("/chargingmanager/providers/")) |
| 153 | |
| 154 | internal fun chargingManagerOf(profiles: List<JSONObject>, timers: List<JSONObject>, providers: List<JSONObject>): ChargingManager { |
| 155 | // Some cars send no powerProvider link. maps+more then pairs both lists by position, options included. |
| 156 | fun link(i: Int, id: String?) = id ?: providers.getOrNull(i)?.optString("id")?.takeIf { profiles.size == providers.size } |
| 157 | return ChargingManager( |
| 158 | profiles.firstOrNull()?.let { valid(it, ::parseOptions) }?.copy(providerId = link(0, null)), |
| 159 | profiles.withIndex().drop(1).mapNotNull { (i, o) -> valid(o, ::parseProfile)?.let { it.copy(providerId = link(i, it.providerId)) } }, |
| 160 | timers.mapNotNull { valid(it, ::parseTimer) }, |
| 161 | providers.mapNotNull { valid(it, ::parseProvider) }, |
| 162 | ) |
| 163 | } |
| 164 | |
| 165 | // Without ignition the car lists placeholder entries with empty strings. maps+more drops them too. |
| 166 | private fun <T> valid(o: JSONObject, parse: (JSONObject) -> T): T? = try { |
| 167 | parse(o) |
| 168 | } catch (e: Exception) { |
| 169 | Log.d("Model", "Skipping incomplete entry ${o.optString("uri")}: $e") |
| 170 | null |
| 171 | } |
| 172 | |
| 173 | fun Car.write(t: Timer) { |
| 174 | require(t.cyclic || t.departureDate != null) { "A one-time timer needs a date" } |
| 175 | require(!t.cyclic || t.weekdays.isNotEmpty()) { "A weekly timer needs at least one day" } |
| 176 | require(t.state == TIMER_ON || t.state == TIMER_OFF) { "Unknown timer state ${t.state}" } |
| 177 | // maps+more never sends the state together with the schedule. |
| 178 | update(t.uri, JSONObject() |
| 179 | .put("cyclic", t.cyclic) |
| 180 | .put("weekdays", t.weekdays.toJson()) |
| 181 | .put("departureTime", t.departureTime.format(HMS)) |
| 182 | .apply { t.departureDate?.let { put("departureDate", it.format(DATE_OUT)) } } |
| 183 | .apply { t.profileId?.let { put("profile", it) } }) |
| 184 | update(t.uri, JSONObject().put("state", t.state)) |
| 185 | } |
| 186 | |
| 187 | fun Car.write(p: Profile) { |
| 188 | require(p.maxCurrent in maxCurrents(vin)) { "Invalid max current ${p.maxCurrent}" } |
| 189 | update(p.uri, p.toJson()) |
| 190 | } |
| 191 | |
| 192 | /** Adds a charging location. The car creates the linked off-peak time itself. */ |
| 193 | fun Car.create(p: Profile) { |
| 194 | require(p.maxCurrent in maxCurrents(vin)) { "Invalid max current ${p.maxCurrent}" } |
| 195 | val before = chargingManager().profiles.map { it.id }.toSet() |
| 196 | require(before.size < MAX_LOCATIONS) { "The car has at most $MAX_LOCATIONS charging locations" } |
| 197 | post("/chargingmanager/profiles/", p.toJson()) |
| 198 | if (chargingManager().profiles.none { it.id !in before && it.name == p.name }) throw IOException("The car did not add ${p.name}") |
| 199 | } |
| 200 | |
| 201 | fun Car.delete(p: Profile) { |
| 202 | require(chargingManager().timers.none { it.profileId == p.id }) { "A timer uses this charging location" } |
| 203 | send("DELETE", p.uri).ok() |
| 204 | if (chargingManager().profiles.any { it.id == p.id }) throw IOException("The car did not delete ${p.name}") |
| 205 | } |
| 206 | |
| 207 | /** A new charging location with the maps+more defaults. */ |
| 208 | fun newProfile(vin: String) = Profile("", "", "", 100, maxCurrents(vin).last(), listOf("charge"), null) |
| 209 | |
| 210 | private fun Profile.toJson(): JSONObject { |
| 211 | require(name.isNotBlank() && name.length <= NAME_MAX) { "Name must have 1 to $NAME_MAX characters" } |
| 212 | require(targetLevel in TARGET_LEVELS) { "Invalid target level $targetLevel" } |
| 213 | require(operations.all { it in OPERATIONS }) { "Invalid operations $operations" } |
| 214 | return JSONObject() |
| 215 | .put("name", name) |
| 216 | .put("targetLevel", targetLevel) |
| 217 | .put("maxCurrent", maxCurrent) |
| 218 | .put("operations", JSONArray(operations)) |
| 219 | } |
| 220 | |
| 221 | fun Car.write(o: Options) { |
| 222 | require(o.maxCurrent in maxCurrents(vin)) { "Invalid max current ${o.maxCurrent}" } |
| 223 | require(o.minLevel in MIN_LEVELS) { "Invalid min level ${o.minLevel}" } |
| 224 | require(o.temperature in TEMPERATURES) { "Invalid temperature ${o.temperature}" } |
| 225 | require(o.operations.all { it in OPERATIONS }) { "Invalid operations ${o.operations}" } |
| 226 | update(o.uri, JSONObject() |
| 227 | .put("operations", JSONArray(o.operations)) |
| 228 | .put("maxCurrent", o.maxCurrent) |
| 229 | .put("minLevel", o.minLevel) |
| 230 | .put("temperature", o.temperature)) |
| 231 | } |
| 232 | |
| 233 | fun Car.write(p: Provider) = update(p.uri, JSONObject() |
| 234 | .put("weekdays", p.weekdays.toJson()) |
| 235 | .put("preferredTimeStart", p.start.format(HMS)) |
| 236 | .put("preferredTimeEnd", p.end.format(HMS))) |
| 237 | |
| 238 | // Like maps+more: only the time moves. Weekdays and dates stay local, even across midnight. |
| 239 | fun Timer.shifted(offsetSeconds: Int) = copy(departureTime = departureTime.plusSeconds(offsetSeconds.toLong())) |
| 240 | fun Provider.shifted(offsetSeconds: Int) = copy(start = start.plusSeconds(offsetSeconds.toLong()), end = end.plusSeconds(offsetSeconds.toLong())) |
| 241 | |
| 242 | /** The next active departure after [now]. [timers] must be in local time. */ |
| 243 | fun nextDeparture(timers: List<Timer>, now: ZonedDateTime): Pair<Timer, ZonedDateTime>? = |
| 244 | timers.filter { it.active }.mapNotNull { t -> |
| 245 | val dates = if (t.cyclic) (0L..7L).map { now.toLocalDate().plusDays(it) }.filter { it.dayOfWeek in t.weekdays } |
| 246 | else listOfNotNull(t.departureDate) |
| 247 | dates.map { ZonedDateTime.of(it, t.departureTime, now.zone) }.firstOrNull { it.isAfter(now) }?.let { t to it } |
| 248 | }.minByOrNull { it.second } |
| 249 | |
| 250 | // The car wraps single elements in {"status": "ok", "data": {...}}. |
| 251 | private fun Car.element(uri: String) = JSONObject(get(uri)).let { it.optJSONObject("data") ?: it } |
| 252 | |
| 253 | /** POSTs the fields of [fields] that differ on the car, then reads the element back to check them. */ |
| 254 | private fun Car.update(uri: String, fields: JSONObject) { |
| 255 | val before = element(uri) |
| 256 | val changes = JSONObject() |
| 257 | fields.keys().forEach { if (!matches(fields.get(it), before.opt(it))) changes.put(it, fields.get(it)) } |
| 258 | if (changes.length() == 0) return |
| 259 | post(uri, changes) |
| 260 | val actual = element(uri) |
| 261 | if (!matches(changes, actual)) throw IOException("The car did not apply all changes. Sent $changes, car has $actual") |
| 262 | } |
| 263 | |
| 264 | /** True if every value in [sent] is also in [actual]. [actual] may have more keys. A sent ID matches a linked object. */ |
| 265 | internal fun matches(sent: Any?, actual: Any?): Boolean = when (sent) { |
| 266 | is String -> sent == actual || (actual is JSONObject && sent == actual.opt("id")) |
| 267 | is JSONObject -> actual is JSONObject && sent.keys().asSequence().all { matches(sent.get(it), actual.opt(it)) } |
| 268 | is JSONArray -> actual is JSONArray && sent.length() == actual.length() && (0 until sent.length()).all { matches(sent.get(it), actual.get(it)) } |
| 269 | is Number -> actual is Number && sent.toDouble() == actual.toDouble() |
| 270 | else -> sent == actual |
| 271 | } |
| 272 |