Model.kt
⎇
Raw
1package de.upapp
2
3import android.util.Log
4import org.json.JSONArray
5import org.json.JSONObject
6import java.io.IOException
7import java.time.DayOfWeek
8import java.time.LocalDate
9import java.time.LocalTime
10import java.time.ZonedDateTime
11import java.time.format.DateTimeFormatter
12import java.util.Locale
13
14/** [soc] and [value] are null while the car has no reading. It then sends an empty string. */
15data class Battery(val name: String, val soc: Double?)
16data class Range(val name: String, val value: Double?, val unit: String)
17data 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. */
20data 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. */
28data 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. */
34data 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. */
42data 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. */
50data 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. */
56data class ChargingManager(val options: Options?, val profiles: List<Profile>, val timers: List<Timer>, val providers: List<Provider>)
57
58const val TIMER_ON = "scheduled"
59const val TIMER_OFF = "idle"
60const val EXT_SUPPLY = "climateExtSupply"
61// The values maps+more knows. The car also lists climateExtSupply and charge on normal locations and options.
62val OPERATIONS = setOf(
63 "charge", "climate", EXT_SUPPLY, "autoDefrost", "SeatHeaterFrontLeft", "SeatHeaterFrontRight", "SeatHeaterRearLeft",
64 "SeatHeaterRearRight", "WindowHeaterFront", "WindowHeaterRear", "ParkHeater", "ParkHeaterAutomatic",
65)
66val TARGET_LEVELS = (50..100 step 10).toList()
67val 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.
69fun 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)
71val TEMPERATURES = (31..60).map { it / 2.0 }
72// maps+more allows this many locations besides the options entry.
73const val MAX_LOCATIONS = 10
74// TODO: guessed limit. maps+more has none. Check the longest name the car accepts.
75const val NAME_MAX = 20
76
77private val HMS = DateTimeFormatter.ofPattern("HH:mm:ss")
78private val DATE_IN = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)
79private val DATE_OUT = DateTimeFormatter.ofPattern("dd MMM yyyy", Locale.ENGLISH)
80
81private fun JSONObject.list(key: String): List<String> =
82 optJSONArray(key)?.let { a -> List(a.length()) { a.getString(it) } } ?: emptyList()
83
84private fun JSONObject.days() = list("weekdays").map { DayOfWeek.valueOf(it.uppercase()) }.toSet()
85
86private fun JSONObject.linkId(key: String) = optJSONObject(key)?.optString("id")?.ifEmpty { null }
87
88private fun Set<DayOfWeek>.toJson() = JSONArray(sorted().map { it.name.lowercase() })
89
90private fun JSONObject.number(key: String) = optDouble(key).takeUnless { it.isNaN() }
91
92fun parseBattery(o: JSONObject) = Battery(o.optString("name"), o.number("soc"))
93fun parseRange(o: JSONObject) = Range(o.optString("name"), o.number("value"), o.optString("valueUnit"))
94fun parseInfo(o: JSONObject) = Info(
95 o.optString("vehicleIdenticationNumber"), o.optString("vehicleDate"), o.optString("vehicleTime"),
96 o.optString("vehicleType"), o.optString("language"),
97)
98
99fun 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
111fun 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
116fun 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
120fun 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
125private fun Car.items(path: String): List<JSONObject> =
126 JSONObject(get(path)).getJSONArray("data").let { a -> List(a.length()) { a.getJSONObject(it) } }
127
128fun Car.battery() = parseBattery(items("/car/batteries/").first())
129fun Car.range() = parseRange(items("/car/ranges/").first())
130fun Car.info() = parseInfo(items("/car/info/").first())
131
132fun 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
151fun Car.chargingManager() =
152 chargingManagerOf(items("/chargingmanager/profiles/"), items("/chargingmanager/timers/"), items("/chargingmanager/providers/"))
153
154internal 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.
166private 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
173fun 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
187fun 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. */
193fun 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
201fun 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. */
208fun newProfile(vin: String) = Profile("", "", "", 100, maxCurrents(vin).last(), listOf("charge"), null)
209
210private 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
221fun 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
233fun 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.
239fun Timer.shifted(offsetSeconds: Int) = copy(departureTime = departureTime.plusSeconds(offsetSeconds.toLong()))
240fun 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. */
243fun 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": {...}}.
251private 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. */
254private 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. */
265internal 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