Skip to content

Commit 2f2d691

Browse files
smalnuitsacclaude
andcommitted
feat(catalog): the outlook, by the day
The head unit does carry a forecast — the same service the current conditions come from answers a daily outlook on another transaction. My earlier reading stopped at the launcher's broadcast, which only ever carries what the sky is doing now. Daily is the grain, and the catalogue offers only what that supports: WEATHER_TOMORROW as a phrase, TEMP_MAX_TODAY and TEMP_MIN_TOMORROW as numbers. Tomorrow's low is the one a departure rule wants — whether to pre-heat in the morning is decided the night before, and OUTSIDE_TEMP only knows the air now. "Rain in three hours" stays unanswerable and no entry pretends otherwise. The two queries share their plumbing: the callback binder, the bounded wait and the Result envelope were written once. Decoding walks fields a parcel does not name, so the day count is bounded — a wrong parcel would otherwise be consumed as strings until it ran out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3b3a219 commit 2f2d691

13 files changed

Lines changed: 226 additions & 27 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ All notable changes to this project are documented here. Format follows
1919
- `SaicNav`, the head unit's navigation adapter, and `ConditionType.ODOMETER`. Trip distance
2020
is deliberately absent: remaining distance and time only arrive as callbacks into that
2121
service from the running navigation app, so reading them is a subscription, not a getter.
22-
- `SaicWeather` and `ConditionType.WEATHER_NOW` — the weather service the head unit's own map
23-
stack queries, asked for the car's position. The only asynchronous service here: the answer
22+
- `SaicWeather` with `ConditionType.WEATHER_NOW`, `WEATHER_TOMORROW`, `TEMP_MAX_TODAY` and
23+
`TEMP_MIN_TOMORROW` — the weather service the head unit's own map stack queries, asked for
24+
the car's position. The outlook is **daily**: the service answers one entry per day, so
25+
"rain tomorrow" is a question it takes and "rain in three hours" is not, and the catalogue
26+
offers only what the data supports. The only asynchronous service here: the answer
2427
arrives on a callback binder and is awaited with a bounded wait, so a slow query comes back
2528
unreadable instead of holding a rule cycle open.
2629
- `SaicAidl` now marshals `Double` and `IBinder` arguments, for that callback.

‎docs/firmware-matrix.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
| DRIVE_DURATION | — | — | — | — | — | — |
2121
| RANDOM_CHANCE | — | — | — | — | — | — |
2222
| WEATHER_NOW | — | — | — | — | — | — |
23+
| WEATHER_TOMORROW | — | — | — | — | — | — |
24+
| TEMP_MAX_TODAY | — | — | — | — | — | — |
25+
| TEMP_MIN_TOMORROW | — | — | — | — | — | — |
2326
| ODOMETER | — | — | — | — | — | — |
2427
| OUTSIDE_TEMP | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
2528
| IGNITION_STATE | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |

‎lib/src/main/java/com/evsuite/hardware/catalog/ConditionType.kt‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,36 @@ enum class ConditionType(
199199
SnapshotKeys.KEY_WEATHER_TEXT
200200
),
201201

202+
/**
203+
* What the outlook says about tomorrow, matched as a fragment like [WEATHER_NOW].
204+
*
205+
* **Tomorrow, not later today.** The service answers one entry per day, so this is the
206+
* finest grain there is: a rule that wants to shut the windows before a shower cannot be
207+
* written from it, and a rule that reminds the driver the night before can.
208+
*/
209+
WEATHER_TOMORROW(
210+
R.string.cond_weather_tomorrow, ConditionGroup.ENVIRONMENT,
211+
ValueSpec(ValueKind.TEXT, hintRes = R.string.cond_weather_hint),
212+
SnapshotKeys.KEY_WEATHER_TOMORROW
213+
),
214+
/** Today's forecast high — for a rule that decides before the heat arrives. */
215+
TEMP_MAX_TODAY(
216+
R.string.cond_temp_max_today, ConditionGroup.ENVIRONMENT,
217+
number(-30, 50, R.string.unit_celsius),
218+
SnapshotKeys.KEY_TEMP_MAX_TODAY, comparable = true
219+
),
220+
/**
221+
* Tomorrow's forecast low.
222+
*
223+
* The one a departure rule wants: whether to pre-heat tomorrow morning is decided tonight,
224+
* and [OUTSIDE_TEMP] only knows what the air is doing now.
225+
*/
226+
TEMP_MIN_TOMORROW(
227+
R.string.cond_temp_min_tomorrow, ConditionGroup.ENVIRONMENT,
228+
number(-30, 50, R.string.unit_celsius),
229+
SnapshotKeys.KEY_TEMP_MIN_TOMORROW, comparable = true
230+
),
231+
202232
// ── Driving ──────────────────────────────────────────────────────────────
203233
@SupportedOn(SWI133, SWI132, SWI68, SWI69, SWI131, SWI165)
204234
IGNITION_STATE(

‎lib/src/main/java/com/evsuite/hardware/catalog/SnapshotKeys.kt‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ object SnapshotKeys {
121121
const val KEY_ODOMETER_KM = "odometerKm"
122122
/** The weather service's own description of the conditions where the car is. */
123123
const val KEY_WEATHER_TEXT = "weatherText"
124+
/**
125+
* The daily outlook: tomorrow's phrase, today's high and tomorrow's low.
126+
*
127+
* Days, not hours — the service answers one entry per day, so "rain tomorrow" is a
128+
* question it can take and "rain in three hours" is not. All three come from one query.
129+
*/
130+
const val KEY_WEATHER_TOMORROW = "weatherTomorrow"
131+
const val KEY_TEMP_MAX_TODAY = "tempMaxTodayC"
132+
const val KEY_TEMP_MIN_TOMORROW = "tempMinTomorrowC"
124133

125134
// ── Platform context (no vehicle involved) ───────────────────────────────
126135
// Read from Android, not from the car, so they carry no firmware annotation. Absent

‎lib/src/main/java/com/evsuite/hardware/saic/SaicWeather.kt‎

Lines changed: 134 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ object SaicWeather {
3434
private const val CALLBACK_DESCRIPTOR = "com.saicmotor.mapservice.ICallBack"
3535

3636
private const val TX_QUERY_WEATHER_BY_POSITION = 2
37+
private const val TX_QUERY_FORECAST_BY_POSITION = 30
3738
private const val TX_CALLBACK_ON_RESULT = 1
3839

3940
/**
@@ -42,6 +43,7 @@ object SaicWeather {
4243
* nothing.
4344
*/
4445
private const val DATA_OBJECT_WEATHER = "data_object_weather"
46+
private const val DATA_OBJECT_WEATHER_FUTURE = "data_object_weather_future"
4547

4648
/**
4749
* How long a reading may hold the rule cycle.
@@ -52,6 +54,12 @@ object SaicWeather {
5254
*/
5355
private const val TIMEOUT_MS = 2_000L
5456

57+
/**
58+
* A daily outlook is a handful of days. A larger count means the parcel is not what this
59+
* expects, and reading on would consume whatever follows it as strings.
60+
*/
61+
private const val MAX_FORECAST_DAYS = 16
62+
5563
private val service = SaicService.byComponent(PACKAGE, CLASS, "weather")
5664

5765
val isAvailable: Boolean get() = binder() != null
@@ -66,17 +74,59 @@ object SaicWeather {
6674
* [languageCode] reaches the provider and decides the language of [Reading.text], which is
6775
* the field a rule compares against — so it is the caller's, not a constant here.
6876
*/
69-
fun currentAt(latitude: Double, longitude: Double, languageCode: String): Reading? {
77+
fun currentAt(latitude: Double, longitude: Double, languageCode: String): Reading? =
78+
// Longitude first, then latitude, and both as strings — the query takes them in that
79+
// order, so swapping them would ask about a plausible-looking wrong place rather than
80+
// failing outright.
81+
query(TX_QUERY_WEATHER_BY_POSITION, ::readCurrent) { target, callback ->
82+
SaicAidl.callVoid(
83+
target, DESCRIPTOR, TX_QUERY_WEATHER_BY_POSITION,
84+
longitude.toString(), latitude.toString(), languageCode, callback
85+
)
86+
}
87+
88+
/**
89+
* The days ahead, today first.
90+
*
91+
* **Days, not hours.** The service answers a daily outlook — a phrase and a high/low per
92+
* day — so "will it rain tomorrow" is answerable here and "will it rain in three hours" is
93+
* not. A rule that wants to close the windows before a shower is not served by this, and
94+
* nothing in the catalogue pretends otherwise.
95+
*
96+
* The position arrives as doubles on this transaction where the current-conditions query
97+
* takes strings. That is the interface, not a choice.
98+
*/
99+
fun forecastAt(latitude: Double, longitude: Double, languageCode: String): List<Day>? =
100+
query(TX_QUERY_FORECAST_BY_POSITION, ::readForecast) { target, callback ->
101+
SaicAidl.callVoid(
102+
target, DESCRIPTOR, TX_QUERY_FORECAST_BY_POSITION,
103+
longitude, latitude, languageCode, callback
104+
)
105+
}
106+
107+
/**
108+
* One query, made synchronous.
109+
*
110+
* The service takes a callback binder and answers on it, so every reading here is a send
111+
* plus a bounded wait. A binder that never calls back leaves the latch untripped and the
112+
* reading null, which the catalogue treats as "cannot tell" — the same as any other
113+
* unreadable signal.
114+
*/
115+
private fun <T> query(
116+
code: Int,
117+
decode: (Parcel) -> T?,
118+
send: (IBinder, Binder) -> Boolean
119+
): T? {
70120
val target = binder() ?: return null
71-
var reading: Reading? = null
121+
var answer: T? = null
72122
val answered = CountDownLatch(1)
73123

74124
val callback = object : Binder() {
75-
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
76-
if (code != TX_CALLBACK_ON_RESULT) return super.onTransact(code, data, reply, flags)
125+
override fun onTransact(tx: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
126+
if (tx != TX_CALLBACK_ON_RESULT) return super.onTransact(tx, data, reply, flags)
77127
data.enforceInterface(CALLBACK_DESCRIPTOR)
78-
reading = runCatching { readResult(data) }
79-
.onFailure { AppLogger.d(TAG, "weather: undecodable answer: ${it.message}") }
128+
answer = runCatching { decode(data) }
129+
.onFailure { AppLogger.d(TAG, "weather#$code: undecodable answer: ${it.message}") }
80130
.getOrNull()
81131
answered.countDown()
82132
reply?.writeNoException()
@@ -86,15 +136,8 @@ object SaicWeather {
86136
override fun getInterfaceDescriptor(): String = CALLBACK_DESCRIPTOR
87137
}
88138

89-
// Longitude first, then latitude: the query takes them in that order, and both as
90-
// strings — swapping them would ask about a plausible-looking wrong place rather
91-
// than failing.
92-
val sent = SaicAidl.callVoid(
93-
target, DESCRIPTOR, TX_QUERY_WEATHER_BY_POSITION,
94-
longitude.toString(), latitude.toString(), languageCode, callback
95-
)
96-
if (!sent) return null
97-
return if (answered.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) reading else null
139+
if (!send(target, callback)) return null
140+
return if (answered.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) answer else null
98141
}
99142

100143
/**
@@ -104,11 +147,8 @@ object SaicWeather {
104147
* boot classpath — the same reason every transaction here is written out rather than
105148
* called through an SDK.
106149
*/
107-
private fun readResult(parcel: Parcel): Reading? {
108-
parcel.readInt() // code — the payload's presence is the real answer
109-
parcel.readString() // message
110-
if (parcel.readString() != DATA_OBJECT_WEATHER) return null
111-
parcel.readString() // the parcelable's own class name
150+
private fun readCurrent(parcel: Parcel): Reading? {
151+
if (!openPayload(parcel, DATA_OBJECT_WEATHER)) return null
112152
val text = parcel.readString()
113153
val icon = parcel.readString()
114154
parcel.readByte() // daytime
@@ -125,15 +165,71 @@ object SaicWeather {
125165
)
126166
}
127167

168+
/**
169+
* The daily outlook: an envelope, a place, then the list of days.
170+
*
171+
* The nine strings before the list are the place and the provider's headline — read past
172+
* rather than skipped, because a parcel has no field names and the only way to reach the
173+
* list is to consume exactly what precedes it.
174+
*/
175+
private fun readForecast(parcel: Parcel): List<Day>? {
176+
if (!openPayload(parcel, DATA_OBJECT_WEATHER_FUTURE)) return null
177+
parcel.readInt() // the payload's own code
178+
parcel.readString() // the nested parcelable's class name
179+
repeat(9) { parcel.readString() } // place, coordinates and headline
180+
val days = parcel.readInt()
181+
// A negative or absurd count is a parcel that does not hold what this expects; reading
182+
// on would consume whatever follows as strings.
183+
if (days <= 0 || days > MAX_FORECAST_DAYS) return null
184+
return (0 until days).map {
185+
val date = parcel.readString().orEmpty()
186+
val maxValue = parcel.readString()
187+
parcel.readString() // night icon
188+
parcel.readString() // day icon
189+
parcel.readString() // epoch date
190+
val nightPhrase = parcel.readString().orEmpty()
191+
val maxUnit = parcel.readString()
192+
val minUnit = parcel.readString()
193+
parcel.readString() // max unit type
194+
val minValue = parcel.readString()
195+
parcel.readString() // min unit type
196+
val dayPhrase = parcel.readString().orEmpty()
197+
Day(
198+
date = date,
199+
dayText = dayPhrase,
200+
nightText = nightPhrase,
201+
highCelsius = toCelsius(maxValue?.toDoubleOrNull(), maxUnit),
202+
lowCelsius = toCelsius(minValue?.toDoubleOrNull(), minUnit)
203+
)
204+
}
205+
}
206+
207+
/**
208+
* Reads the `Result` envelope and stops unless it holds the expected payload.
209+
*
210+
* A code, a message, a tag naming what follows, then the tagged object. Another tag is
211+
* another shape, and answering from a half-read parcel would be worse than saying nothing.
212+
*/
213+
private fun openPayload(parcel: Parcel, expectedTag: String): Boolean {
214+
parcel.readInt() // code — the payload's presence is the real answer
215+
parcel.readString() // message
216+
if (parcel.readString() != expectedTag) return false
217+
parcel.readString() // the parcelable's own class name
218+
return true
219+
}
220+
128221
/**
129222
* The service answers in whatever unit it was configured with and names it, so the name is
130223
* what decides. Anything it does not name is left alone rather than assumed Celsius: a
131224
* Fahrenheit value passed through unconverted would read as a mild summer day in winter.
132225
*/
133-
private fun toCelsius(value: Double, unit: String?): Double? = when (unit?.trim()?.uppercase()) {
134-
"C", "°C" -> value
135-
"F", "°F" -> (value - 32.0) * 5.0 / 9.0
136-
else -> null
226+
private fun toCelsius(value: Double?, unit: String?): Double? {
227+
if (value == null) return null
228+
return when (unit?.trim()?.uppercase()) {
229+
"C", "°C" -> value
230+
"F", "°F" -> (value - 32.0) * 5.0 / 9.0
231+
else -> null
232+
}
137233
}
138234

139235
/**
@@ -142,6 +238,20 @@ object SaicWeather {
142238
* [text] is the provider's own description ("Rain", "Partly cloudy"), which is what a rule
143239
* compares against; [temperatureCelsius] is null when the answer named no unit.
144240
*/
241+
/**
242+
* One day of the outlook.
243+
*
244+
* [dayText] and [nightText] are the provider's phrases, in the language the query asked
245+
* for. The temperatures are null when the answer named no unit — see [toCelsius].
246+
*/
247+
data class Day(
248+
val date: String,
249+
val dayText: String,
250+
val nightText: String,
251+
val highCelsius: Double?,
252+
val lowCelsius: Double?
253+
)
254+
145255
data class Reading(
146256
val text: String,
147257
val icon: String,

‎lib/src/main/res/values-de/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,7 @@
195195
<string name="cond_odometer">Kilometerstand</string>
196196
<string name="cond_weather">Wetter</string>
197197
<string name="cond_weather_hint">z. B. Regen</string>
198+
<string name="cond_weather_tomorrow">Wetter morgen</string>
199+
<string name="cond_temp_max_today">Höchstwert heute</string>
200+
<string name="cond_temp_min_tomorrow">Tiefstwert morgen</string>
198201
</resources>

‎lib/src/main/res/values-es/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,7 @@
195195
<string name="cond_odometer">Cuentakilómetros</string>
196196
<string name="cond_weather">Tiempo</string>
197197
<string name="cond_weather_hint">p. ej. lluvia</string>
198+
<string name="cond_weather_tomorrow">Tiempo mañana</string>
199+
<string name="cond_temp_max_today">Máxima de hoy</string>
200+
<string name="cond_temp_min_tomorrow">Mínima de mañana</string>
198201
</resources>

‎lib/src/main/res/values-fr/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,4 +217,7 @@
217217
<string name="cond_odometer">Compteur kilométrique</string>
218218
<string name="cond_weather">Météo</string>
219219
<string name="cond_weather_hint">ex. pluie</string>
220+
<string name="cond_weather_tomorrow">Météo demain</string>
221+
<string name="cond_temp_max_today">Maximum aujourd\'hui</string>
222+
<string name="cond_temp_min_tomorrow">Minimum demain</string>
220223
</resources>

‎lib/src/main/res/values-it/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,7 @@
195195
<string name="cond_odometer">Contachilometri</string>
196196
<string name="cond_weather">Meteo</string>
197197
<string name="cond_weather_hint">es. pioggia</string>
198+
<string name="cond_weather_tomorrow">Meteo domani</string>
199+
<string name="cond_temp_max_today">Massima di oggi</string>
200+
<string name="cond_temp_min_tomorrow">Minima di domani</string>
198201
</resources>

‎lib/src/main/res/values-pt/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,7 @@
195195
<string name="cond_odometer">Conta-quilómetros</string>
196196
<string name="cond_weather">Meteorologia</string>
197197
<string name="cond_weather_hint">p. ex. chuva</string>
198+
<string name="cond_weather_tomorrow">Meteorologia amanhã</string>
199+
<string name="cond_temp_max_today">Máxima de hoje</string>
200+
<string name="cond_temp_min_tomorrow">Mínima de amanhã</string>
198201
</resources>

0 commit comments

Comments
 (0)