Skip to content

Commit 360f3f7

Browse files
committed
add more direct action intents + update README
1 parent 6662658 commit 360f3f7

5 files changed

Lines changed: 315 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,30 @@ comptez quelques secondes avant que l'état final soit atteint.
493493
adb shell am broadcast -a com.mg4.control.action.SET --es key drive_mode --es value SPORT
494494
```
495495

496+
#### `NEXT` / `PREV` / `TOGGLE` — cycler sans connaître l'état
497+
498+
À la place d'une consigne, `value` accepte **`NEXT`** (cran suivant), **`PREV`** (cran précédent)
499+
ou **`TOGGLE`** (alias de `NEXT`, plus lisible sur un booléen). La nouvelle valeur est calculée à
500+
partir de l'état lu sur le véhicule, ce qui permet d'assigner « siège chauffant +1 » à un seul
501+
bouton de volant. Le cycle **reboucle** : au maximum, le cran suivant revient au minimum.
502+
503+
```bash
504+
adb shell am broadcast -a com.mg4.control.action.SET --es key seat_heat_left --es value NEXT
505+
adb shell am broadcast -a com.mg4.control.action.SET --es key ac --es value TOGGLE
506+
```
507+
508+
Clés cyclables : `seat_heat_left`, `seat_heat_right`, `steering_heat`, `hvac_power`, `ac`,
509+
`hvac_auto`, `hvac_temp`, `hvac_fan`, `hvac_recirc`, `defrost_front`, `defrost_rear`.
510+
511+
`drive_mode`, `regen` et `profile` en sont **volontairement exclus** : l'énumération des modes de
512+
conduite n'est pas filtrée par firmware (on cyclerait vers un mode absent du véhicule), la
513+
disponibilité de la régénération dépend de l'état courant (aucun niveau en mode Neige, One Pedal
514+
seul quand Éco énergie est actif), et il n'existe pas de notion de « profil courant ».
515+
516+
Si l'état courant est illisible, la commande est **refusée sans rien écrire** plutôt que de partir
517+
d'une valeur supposée — un point de départ deviné ferait descendre la valeur alors que vous
518+
appuyez pour la monter. Le refus est journalisé (`adb logcat -s MG4_API`).
519+
496520
### Lecture de l'état — ContentProvider
497521

498522
`content://com.mg4.control.state/state` (ou `com.mg4.control.offline.state` pour la variante
@@ -1008,6 +1032,30 @@ state is reached.
10081032
adb shell am broadcast -a com.mg4.control.action.SET --es key drive_mode --es value SPORT
10091033
```
10101034

1035+
#### `NEXT` / `PREV` / `TOGGLE` — cycling without knowing the state
1036+
1037+
Instead of a setpoint, `value` accepts **`NEXT`** (next notch), **`PREV`** (previous notch) or
1038+
**`TOGGLE`** (an alias of `NEXT`, easier to read on a boolean). The new value is computed from the
1039+
state read on the vehicle, so a single steering-wheel button can mean "seat heat +1". The cycle
1040+
**wraps around**: past the maximum, the next notch returns to the minimum.
1041+
1042+
```bash
1043+
adb shell am broadcast -a com.mg4.control.action.SET --es key seat_heat_left --es value NEXT
1044+
adb shell am broadcast -a com.mg4.control.action.SET --es key ac --es value TOGGLE
1045+
```
1046+
1047+
Cyclable keys: `seat_heat_left`, `seat_heat_right`, `steering_heat`, `hvac_power`, `ac`,
1048+
`hvac_auto`, `hvac_temp`, `hvac_fan`, `hvac_recirc`, `defrost_front`, `defrost_rear`.
1049+
1050+
`drive_mode`, `regen` and `profile` are **deliberately excluded**: the drive-mode enum is not
1051+
filtered per firmware (cycling could select a mode the car does not have), regen availability
1052+
depends on the current state (no level at all in Snow mode, One Pedal only while Energy Saving is
1053+
on), and there is no notion of a "current profile".
1054+
1055+
When the current state cannot be read, the command is **refused without writing anything** rather
1056+
than assuming a starting point — a guessed origin would move the value down while you press to
1057+
move it up. Refusals are logged (`adb logcat -s MG4_API`).
1058+
10111059
### Reading state — ContentProvider
10121060

10131061
`content://com.mg4.control.state/state` (or `com.mg4.control.offline.state` for the offline

‎app/src/main/java/com/mg4/control/api/ExternalApi.kt‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,14 @@ object ExternalApi {
4747
* Préfixe des actions « une commande = une action d'intent » :
4848
* `com.mg4.control.action.ONE_PEDAL`, `…ADAS_CYCLE`, `…PROFILE_PICKER`…
4949
*
50-
* ⚠️ RAISON D'ÊTRE, ne pas fusionner avec [ACTION_EXECUTE] en croyant simplifier :
51-
* KeyMapper ne sait saisir QUE la chaîne d'action, son éditeur d'intent n'a pas de champ
52-
* « extras ». Une API qui exige un extra lui est inutilisable. Cette forme-là ne demande
53-
* rien d'autre que l'action, donc elle marche avec n'importe quel outil capable d'émettre
54-
* un intent.
50+
* RAISON D'ÊTRE : cette forme ne demande QUE la chaîne d'action, donc elle marche avec
51+
* n'importe quel outil capable d'émettre un intent, même sans éditeur d'extras.
52+
*
53+
* ⚠️ CORRECTIF 2026-08 : ce bloc affirmait que l'éditeur d'intent de KeyMapper n'avait pas
54+
* de champ « extras » et que [ACTION_SET] lui était donc inutilisable. C'est FAUX — c'était
55+
* un artefact d'une version ancienne. KeyMapper (Broadcast receiver) comme Tasker
56+
* (misc/send intent) savent poser `key`/`value`. Les actions directes restent un confort,
57+
* elles ne sont plus une nécessité : ne pas rejustifier un choix de conception par là.
5558
*/
5659
const val ACTION_PREFIX = "com.mg4.control.action."
5760

@@ -118,6 +121,60 @@ object ExternalApi {
118121
const val SET_DEFROST_FRONT = "defrost_front" // 0|1
119122
const val SET_DEFROST_REAR = "defrost_rear" // 0|1
120123

124+
// ── Valeurs spéciales : cycle sur l'état courant ──────────────────────────
125+
126+
/**
127+
* Acceptées à la place d'une consigne : la nouvelle valeur est calculée à partir de celle
128+
* LUE sur le véhicule. Demandé par les utilisateurs KeyMapper, pour qui « un cran de plus »
129+
* sur un appui de volant ne devrait pas obliger à connaître l'état courant.
130+
*
131+
* [VALUE_TOGGLE] est un alias strict de [VALUE_NEXT] : sur une clé à deux états, avancer
132+
* d'un cran EST la bascule. Deux noms parce que « TOGGLE » se lit mieux sur un booléen.
133+
*/
134+
const val VALUE_NEXT = "NEXT"
135+
const val VALUE_PREV = "PREV"
136+
const val VALUE_TOGGLE = "TOGGLE"
137+
138+
/**
139+
* Clés dont le cycle est supporté — liste blanche, et surtout PAS « tout sauf ».
140+
*
141+
* Une clé n'est cyclable que si son état courant se lit de façon FIABLE, ce qui exclut :
142+
* • `drive_mode` : l'enum n'a aucun filtre firmware, on cyclerait vers un mode absent ;
143+
* • `regen` : l'ordre de déclaration n'est pas l'ordre d'usage (OFF est coincé entre
144+
* ADAPTIVE et ONE_PEDAL), et la disponibilité dépend de l'état courant — aucun niveau
145+
* en mode SNOW, ONE_PEDAL seul quand Éco énergie est actif ;
146+
* • `profile` : il n'existe pas de « profil courant » à faire avancer.
147+
*
148+
* Les rouvrir demande de régler ces points d'abord, pas seulement d'ajouter la clé ici.
149+
*/
150+
val CYCLABLE_KEYS = setOf(
151+
SET_SEAT_HEAT_LEFT, SET_SEAT_HEAT_RIGHT, SET_STEERING_HEAT,
152+
SET_HVAC_POWER, SET_HVAC_AC, SET_HVAC_AUTO, SET_HVAC_TEMP,
153+
SET_HVAC_FAN, SET_HVAC_RECIRC, SET_DEFROST_FRONT, SET_DEFROST_REAR
154+
)
155+
156+
/** +1 pour NEXT/TOGGLE, -1 pour PREV, null si [value] est une consigne ordinaire. */
157+
fun cycleDirection(value: String): Int? = when (value.trim().uppercase()) {
158+
VALUE_NEXT, VALUE_TOGGLE -> 1
159+
VALUE_PREV -> -1
160+
else -> null
161+
}
162+
163+
/**
164+
* Avance [cur] de [dir] cran(s) dans [min]..[max], en rebouclant aux deux bouts.
165+
*
166+
* ⚠️ Le double modulo n'est pas une coquetterie : en Kotlin `(-1) % 4` vaut `-1`, pas `3`.
167+
* Un modulo naïf ferait sortir PREV sous le minimum au lieu de reboucler sur le maximum.
168+
*
169+
* Le bouclage est voulu : sur un bouton de volant il n'existe pas de geste « redescendre »,
170+
* donc un cycle qui bute sur la borne devient un cul-de-sac.
171+
*/
172+
fun cycleStep(cur: Int, min: Int, max: Int, dir: Int): Int {
173+
if (max <= min) return min
174+
val span = max - min + 1
175+
return min + (((cur - min + dir) % span) + span) % span
176+
}
177+
121178
// ── Lecture (ContentProvider) ────────────────────────────────────────────
122179

123180
/**

‎app/src/main/java/com/mg4/control/hardware/MG4Hardware.kt‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1616,6 +1616,25 @@ object MG4Hardware {
16161616
fun getSeatHeatRight(): Int = getIntPropertyHvac(PROP_SEAT_HEAT_R, AREA_HVAC).coerceAtLeast(0)
16171617
fun isSteeringHeatOn(): Boolean = getIntPropertyHvac(PROP_STEERING_HEAT, AREA_HVAC) > 0
16181618

1619+
/**
1620+
* Variantes nullables des lectures de chauffage : `null` = propriété ILLISIBLE, ce qui n'est
1621+
* PAS « éteint ».
1622+
*
1623+
* Les trois lectures ci-dessus écrasent cette distinction — `coerceAtLeast(0)` et `> 0`
1624+
* transforment le -1 d'échec de [getIntPropertyHvac] en 0/false. C'est sans conséquence pour
1625+
* l'affichage, mais ça casse le cycle de l'API externe : partir d'un 0 supposé alors que la
1626+
* propriété est muette produirait un cycle bloqué à 1, et si le siège est réellement à 3
1627+
* l'utilisateur appuie pour monter et voit la valeur descendre.
1628+
*/
1629+
fun getSeatHeatLeftOrNull(): Int? =
1630+
getIntPropertyHvac(PROP_SEAT_HEAT_L, AREA_HVAC).takeIf { it >= 0 }
1631+
1632+
fun getSeatHeatRightOrNull(): Int? =
1633+
getIntPropertyHvac(PROP_SEAT_HEAT_R, AREA_HVAC).takeIf { it >= 0 }
1634+
1635+
fun getSteeringHeatOrNull(): Boolean? =
1636+
getIntPropertyHvac(PROP_STEERING_HEAT, AREA_HVAC).takeIf { it >= 0 }?.let { it > 0 }
1637+
16191638
fun getDriveMode(): DriveMode? {
16201639
val cpm = sCarPropertyManager ?: return null
16211640
return try {

‎app/src/main/java/com/mg4/control/service/MG4ControlService.kt‎

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,33 +211,91 @@ class MG4ControlService : Service() {
211211
AppLogger.i(ExternalApi.LOG_TAG, "SET $key=$value")
212212

213213
CoroutineScope(Dispatchers.IO).launch {
214+
// NEXT/PREV/TOGGLE sont résolus en valeur concrète AVANT le dispatch : le cycle
215+
// emprunte ensuite exactement le même chemin d'écriture qu'une consigne explicite,
216+
// donc les mêmes validations, le même clampage et le même verrou de vitesse.
217+
val dir = ExternalApi.cycleDirection(value)
218+
val v = if (dir == null) value else resolveCycle(key, dir)
219+
if (v == null) {
220+
AppLogger.w(ExternalApi.LOG_TAG,
221+
"CYCLE $key=$value refusé — clé non cyclable ou état courant illisible")
222+
return@launch
223+
}
224+
if (dir != null) AppLogger.i(ExternalApi.LOG_TAG, "CYCLE $key : $value → $v")
225+
214226
// Toutes ces écritures passent par MG4Hardware, donc par VehicleWriteGate : refusées
215227
// en roulant sans que l'API ait à s'en préoccuper.
216228
val ok = when (key) {
217229
ExternalApi.SET_DRIVE_MODE -> DriveMode.values()
218-
.firstOrNull { it.name.equals(value, true) }
230+
.firstOrNull { it.name.equals(v, true) }
219231
?.let { MG4Hardware.setDriveMode(it); true } ?: false
220232
ExternalApi.SET_REGEN -> RegenLevel.values()
221-
.firstOrNull { it.name.equals(value, true) }
233+
.firstOrNull { it.name.equals(v, true) }
222234
?.let { MG4Hardware.setRegenLevel(it); true } ?: false
223235
ExternalApi.SET_SEAT_HEAT_LEFT ->
224-
value.toIntOrNull()?.takeIf { it in 0..3 }
236+
v.toIntOrNull()?.takeIf { it in 0..3 }
225237
?.let { MG4Hardware.setSeatHeatLeft(it); true } ?: false
226238
ExternalApi.SET_SEAT_HEAT_RIGHT ->
227-
value.toIntOrNull()?.takeIf { it in 0..3 }
239+
v.toIntOrNull()?.takeIf { it in 0..3 }
228240
?.let { MG4Hardware.setSeatHeatRight(it); true } ?: false
229241
ExternalApi.SET_STEERING_HEAT -> {
230-
val on = value.equals("true", true) || value == "1"
242+
val on = v.equals("true", true) || v == "1"
231243
MG4Hardware.setSteeringHeat(on); true
232244
}
233-
ExternalApi.SET_PROFILE -> { applyProfileByName(value); true }
234-
else -> setClimateFromApi(key, value)
245+
ExternalApi.SET_PROFILE -> { applyProfileByName(v); true }
246+
else -> setClimateFromApi(key, v)
235247
}
236-
if (!ok) AppLogger.w(ExternalApi.LOG_TAG, "SET refusé — clé ou valeur invalide ($key=$value)")
248+
if (!ok) AppLogger.w(ExternalApi.LOG_TAG, "SET refusé — clé ou valeur invalide ($key=$v)")
237249
}
238250
return true
239251
}
240252

253+
/**
254+
* Traduit NEXT/PREV en valeur concrète pour [key], en partant de l'état LU sur le véhicule.
255+
* [dir] vaut +1 ou -1. Retourne null quand la clé n'est pas cyclable ou que son état courant
256+
* est illisible — et dans ce cas on n'écrit RIEN.
257+
*
258+
* ⚠️ Ne jamais « supposer » un point de départ pour sauver l'appel : sur un siège réellement
259+
* à 3 dont la propriété est muette, partir de 0 ferait descendre la valeur alors que
260+
* l'utilisateur appuie pour monter. Un refus journalisé est diagnosticable, pas ça.
261+
*
262+
* Le cycle boucle (max → min) : c'est indispensable sur un bouton de volant, qui n'a pas de
263+
* sens « descendre ». Appelée depuis le contexte IO de [handleExternalApiIntent].
264+
*/
265+
private fun resolveCycle(key: String, dir: Int): String? {
266+
if (key !in ExternalApi.CYCLABLE_KEYS) return null
267+
268+
fun step(cur: Int, min: Int, max: Int): String =
269+
ExternalApi.cycleStep(cur, min, max, dir).toString()
270+
fun flip(cur: Boolean?): String? = cur?.let { if (it) "0" else "1" }
271+
272+
when (key) {
273+
ExternalApi.SET_SEAT_HEAT_LEFT ->
274+
return MG4Hardware.getSeatHeatLeftOrNull()?.let { step(it, 0, 3) }
275+
ExternalApi.SET_SEAT_HEAT_RIGHT ->
276+
return MG4Hardware.getSeatHeatRightOrNull()?.let { step(it, 0, 3) }
277+
ExternalApi.SET_STEERING_HEAT ->
278+
return flip(MG4Hardware.getSteeringHeatOrNull())
279+
}
280+
281+
// Reste : la clim. Un SEUL getClimateState() — c'est une lecture binder, pas un champ.
282+
if (!MG4Hardware.hasClimateControl()) return null
283+
val st = MG4Hardware.getClimateState() ?: return null
284+
return when (key) {
285+
ExternalApi.SET_HVAC_POWER -> flip(st.powerOn)
286+
ExternalApi.SET_HVAC_AC -> flip(st.acOn)
287+
ExternalApi.SET_HVAC_AUTO -> flip(st.autoOn)
288+
ExternalApi.SET_DEFROST_FRONT -> flip(st.defrostFront)
289+
ExternalApi.SET_DEFROST_REAR -> flip(st.defrostRear)
290+
// Bornes lues sur le véhicule, jamais codées en dur : elles varient d'un firmware
291+
// à l'autre, et c'est sur elles que le cycle reboucle.
292+
ExternalApi.SET_HVAC_TEMP -> st.tempC?.let { step(it, st.tempMin, st.tempMax) }
293+
ExternalApi.SET_HVAC_FAN -> st.fanLevel?.let { step(it, st.fanMin, st.fanMax) }
294+
ExternalApi.SET_HVAC_RECIRC -> st.loopMode?.let { step(it, 0, 2) }
295+
else -> null
296+
}
297+
}
298+
241299
/**
242300
* Clés `SET` de climatisation. Retourne false si [key] n'en est pas une — l'appelant
243301
* s'en sert pour distinguer « clé inconnue » de « valeur invalide ».
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package com.mg4.control.api
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNull
5+
import org.junit.Assert.assertTrue
6+
import org.junit.Test
7+
8+
/**
9+
* Cycle NEXT/PREV de l'API externe — logique pure, aucun accès véhicule ni Android.
10+
*
11+
* Ce qui est réellement à risque ici, c'est le rebouclage : Kotlin rend `(-1) % 4 == -1`, donc
12+
* un modulo naïf sortirait des bornes sur PREV au lieu de revenir au maximum.
13+
*/
14+
class ExternalApiCycleTest {
15+
16+
// ── Vocabulaire ──────────────────────────────────────────────────────────
17+
18+
@Test
19+
fun `NEXT et TOGGLE avancent, PREV recule`() {
20+
assertEquals(1, ExternalApi.cycleDirection("NEXT"))
21+
assertEquals(1, ExternalApi.cycleDirection("TOGGLE"))
22+
assertEquals(-1, ExternalApi.cycleDirection("PREV"))
23+
}
24+
25+
@Test
26+
fun `la casse et les espaces sont tolerés`() {
27+
// Tasker et KeyMapper laissent facilement passer une espace en fin de champ.
28+
assertEquals(1, ExternalApi.cycleDirection(" next "))
29+
assertEquals(-1, ExternalApi.cycleDirection("Prev"))
30+
}
31+
32+
@Test
33+
fun `une consigne ordinaire n est pas un cycle`() {
34+
// Sans quoi une valeur legitime serait detournee en cycle.
35+
assertNull(ExternalApi.cycleDirection("2"))
36+
assertNull(ExternalApi.cycleDirection("AUTO"))
37+
assertNull(ExternalApi.cycleDirection(""))
38+
}
39+
40+
// ── Rebouclage ───────────────────────────────────────────────────────────
41+
42+
@Test
43+
fun `NEXT avance d un cran`() {
44+
assertEquals(2, ExternalApi.cycleStep(1, 0, 3, 1))
45+
}
46+
47+
@Test
48+
fun `NEXT reboucle du maximum vers le minimum`() {
49+
assertEquals(0, ExternalApi.cycleStep(3, 0, 3, 1))
50+
}
51+
52+
@Test
53+
fun `PREV reboucle du minimum vers le maximum`() {
54+
// Le cas qui casse avec un modulo naïf : (-1 % 4) vaut -1 en Kotlin.
55+
assertEquals(3, ExternalApi.cycleStep(0, 0, 3, -1))
56+
}
57+
58+
@Test
59+
fun `PREV recule d un cran`() {
60+
assertEquals(2, ExternalApi.cycleStep(3, 0, 3, -1))
61+
}
62+
63+
@Test
64+
fun `bornes ne commencant pas a zero - ventilation 1 a 10`() {
65+
assertEquals(2, ExternalApi.cycleStep(1, 1, 10, 1))
66+
assertEquals(1, ExternalApi.cycleStep(10, 1, 10, 1))
67+
assertEquals(10, ExternalApi.cycleStep(1, 1, 10, -1))
68+
}
69+
70+
@Test
71+
fun `bornes de temperature 16 a 32`() {
72+
assertEquals(17, ExternalApi.cycleStep(16, 16, 32, 1))
73+
assertEquals(16, ExternalApi.cycleStep(32, 16, 32, 1))
74+
assertEquals(32, ExternalApi.cycleStep(16, 16, 32, -1))
75+
}
76+
77+
@Test
78+
fun `une valeur hors bornes retombe dans les bornes`() {
79+
// Une propriete peut rendre une valeur inattendue ; le cycle ne doit pas la propager.
80+
val r = ExternalApi.cycleStep(99, 0, 3, 1)
81+
assertTrue("resultat hors bornes : $r", r in 0..3)
82+
}
83+
84+
@Test
85+
fun `plage degeneree ne boucle pas dans le vide`() {
86+
// max <= min : une division par zero serait le pire resultat possible ici.
87+
assertEquals(5, ExternalApi.cycleStep(5, 5, 5, 1))
88+
assertEquals(5, ExternalApi.cycleStep(9, 5, 4, -1))
89+
}
90+
91+
// ── Liste blanche ────────────────────────────────────────────────────────
92+
93+
@Test
94+
fun `les cles non cyclables restent exclues`() {
95+
// Exclusions raisonnees, pas des oublis : voir la doc de CYCLABLE_KEYS.
96+
assertTrue(ExternalApi.SET_DRIVE_MODE !in ExternalApi.CYCLABLE_KEYS)
97+
assertTrue(ExternalApi.SET_REGEN !in ExternalApi.CYCLABLE_KEYS)
98+
assertTrue(ExternalApi.SET_PROFILE !in ExternalApi.CYCLABLE_KEYS)
99+
}
100+
101+
@Test
102+
fun `les onze cles annoncees sont cyclables`() {
103+
listOf(
104+
ExternalApi.SET_SEAT_HEAT_LEFT, ExternalApi.SET_SEAT_HEAT_RIGHT,
105+
ExternalApi.SET_STEERING_HEAT, ExternalApi.SET_HVAC_POWER,
106+
ExternalApi.SET_HVAC_AC, ExternalApi.SET_HVAC_AUTO, ExternalApi.SET_HVAC_TEMP,
107+
ExternalApi.SET_HVAC_FAN, ExternalApi.SET_HVAC_RECIRC,
108+
ExternalApi.SET_DEFROST_FRONT, ExternalApi.SET_DEFROST_REAR
109+
).forEach { assertTrue("$it devrait etre cyclable", it in ExternalApi.CYCLABLE_KEYS) }
110+
assertEquals(11, ExternalApi.CYCLABLE_KEYS.size)
111+
}
112+
113+
@Test
114+
fun `aucune cle cyclable n est une action bloquee`() {
115+
// Le cycle ne doit surtout pas rouvrir une porte fermee par BLOCKED_ACTIONS.
116+
ExternalApi.CYCLABLE_KEYS.forEach {
117+
assertTrue("$it est bloquee", it.uppercase() !in ExternalApi.BLOCKED_ACTIONS)
118+
}
119+
}
120+
}

0 commit comments

Comments
 (0)