EVHardware: translate the French comments, log texts and test names

The vendored library's French and half-translated comments, its French
log messages and the French test names and assertion messages are now
English. Comments, log texts and test names only; no code changed. The
French resources, place names in test data and two quoted phrases stay;
"Katman" (Turkish for layer) stays as the layers' name and is explained.
SPECS: the Grafana host is confirmed; both items ticked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-25 21:53:15 +01:00
co-authored by Claude Opus 5.5
parent aca9e090d8
commit 0ff56285bc
7 changed files with 290 additions and 283 deletions
File diff suppressed because it is too large Load Diff
@@ -30,19 +30,19 @@ class FirmwareSupportTest {
ConditionType.FIRMWARE_GEN,
ConditionType.LOCATION_WITHIN,
ConditionType.PHYSICAL_BUTTON,
// Lus dans Android, pas dans la voiture : rien à router par génération.
// Read from Android, not from the car: nothing to route per generation.
ConditionType.MEDIA_PLAYING,
ConditionType.WIFI_SSID,
ConditionType.IN_CALL,
ConditionType.DRIVE_DURATION,
ConditionType.RANDOM_CHANCE,
// Services des applications du poste (adaptateur de navigation, météo) : ce ne sont
// pas des services du SDK véhicule, rien ne les route par génération. Ils répondent
// ou non, comme le SMS.
// Services of the head unit's apps (navigation adapter, weather): these are not
// vehicle-SDK services, nothing routes them per generation. They answer or they do
// not, like SMS.
ConditionType.ODOMETER,
ConditionType.WEATHER_NOW,
// La connexion du poste, comptée par Android : elle dépend d'une permission, pas
// d'une génération de firmware.
// The head unit's connection, as counted by Android: it depends on a permission, not
// on a firmware generation.
ConditionType.DATA_USED_TODAY,
ConditionType.DATA_USED_MONTH
)
@@ -54,10 +54,10 @@ class FirmwareSupportTest {
ActionType.WEBHOOK,
ActionType.ASK_CONFIRM,
ActionType.DELAY,
// Le SMS part par le profil Bluetooth MAP, pas par un service SAIC : rien à router
// par génération de firmware. Sa disponibilité se lit au bind, pas dans la matrice.
// SMS goes out over the Bluetooth MAP profile, not through a SAIC service: nothing to
// route per firmware generation. Whether it is available shows at bind, not in the matrix.
ActionType.SEND_SMS,
// Règles, touche média et radios du poste : rien de tout cela ne passe par la voiture.
// Rules, the media key and the head unit's radios: none of this goes through the car.
ActionType.ENABLE_RULE,
ActionType.DISABLE_RULE,
ActionType.MEDIA_CONTROL,
@@ -7,11 +7,11 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Ce que la preuve relevée sur une voiture change — et ce qu'elle ne change pas.
* What evidence recorded on a car changes — and what it does not.
*
* L'enjeu : `writeProven` parle du projet, `effectProven` parle de la voiture. Confondre les
* deux rendrait soit les actions vitres définitivement inaccessibles, soit accessibles sur des
* voitures où rien n'a été constaté.
* What is at stake: `writeProven` speaks about the project, `effectProven` about the car.
* Mixing the two up would make the window actions either unreachable for good, or reachable on
* cars where nothing was observed.
*/
class GlassEvidenceTest {
@@ -21,7 +21,7 @@ class GlassEvidenceTest {
fun tearDown() = GlassEvidence.setForTest(null)
@Test
fun `sans preuve, effectProven suit writeProven`() {
fun `without evidence, effectProven follows writeProven`() {
GlassEvidence.setForTest(null)
ActionType.entries.forEach {
assertTrue("${it.name}", it.writeProven == it.effectProven)
@@ -29,29 +29,29 @@ class GlassEvidenceTest {
}
@Test
fun `la preuve debloque les actions vitres, et elles seules`() {
fun `evidence unlocks the window actions, and only them`() {
GlassEvidence.setForTest(GlassEvidence.Proof(5, 2, generation, 0L))
GlassEvidence.ACTIONS.forEach {
assertFalse("${it.name} reste non prouvée dans le catalogue", it.writeProven)
assertTrue("${it.name} doit être débloquée par la preuve", it.effectProven)
assertFalse("${it.name} stays unproven in the catalogue", it.writeProven)
assertTrue("${it.name} must be unlocked by the evidence", it.effectProven)
}
// Une preuve sur les vitres ne dit rien d'une autre action : le catalogue reste seul
// juge partout ailleurs.
// Evidence about the windows says nothing about another action: everywhere else the
// catalogue alone decides.
ActionType.entries.filterNot { it in GlassEvidence.ACTIONS }.forEach {
assertTrue("${it.name}", it.writeProven == it.effectProven)
}
}
@Test
fun `une preuve d'un autre firmware ne vaut rien`() {
// Les commandes sont propres au SDK de la génération : ce qui ouvre sur l'une n'a
// aucune raison d'ouvrir sur l'autre, et une mise à jour ne doit pas laisser derrière
// elle une autorisation qui n'a plus de fondement.
fun `evidence from another firmware counts for nothing`() {
// The commands belong to each generation's SDK: what opens on one has no reason to
// open on another, and an update must not leave behind a permission that no longer
// has any grounds.
GlassEvidence.setForTest(GlassEvidence.Proof(5, 2, "SOME_OTHER_GEN", 0L))
GlassEvidence.ACTIONS.forEach {
assertFalse("${it.name} ne doit pas hériter d'une preuve étrangère", it.effectProven)
assertFalse("${it.name} must not inherit evidence from elsewhere", it.effectProven)
}
}
}
@@ -8,15 +8,15 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Ce que la sonde doit établir sans voiture : l'enchaînement des deux passes et les refus.
* What the probe must establish without a car: the sequence of its two passes, and its refusals.
*
* La vitre simulée reproduit le comportement qui a rendu la sonde nécessaire : le service
* accepte n'importe quelle commande et n'en applique que deux. Une passe unique ne peut donc
* pas voir les deux sens — c'est exactement ce que ces tests vérifient.
* The simulated window reproduces the behaviour that made the probe necessary: the service
* accepts any command and applies only two of them. A single pass therefore cannot see both
* directions — which is exactly what these tests check.
*/
class GlassProbeTest {
/** Vitre où [openCommand] ouvre en grand et [closeCommand] referme ; le reste est inerte. */
/** A window where [openCommand] opens fully and [closeCommand] closes; the rest do nothing. */
private class FakeGlass(
private val openCommand: Int,
private val closeCommand: Int,
@@ -48,9 +48,9 @@ class GlassProbeTest {
)
@Test
fun `les deux sens sont trouves depuis une vitre fermee`() {
// Depuis 0 %, la commande qui ferme ne bouge rien : sans seconde passe elle passerait
// pour inerte, et l'action « fermer » resterait à jamais non prouvée.
fun `both directions are found from a closed window`() {
// From 0 %, the closing command moves nothing: without a second pass it would pass
// for inert, and the "close" action would stay unproven forever.
val glass = FakeGlass(openCommand = 5, closeCommand = 2)
val result = probe(glass)
@@ -58,27 +58,27 @@ class GlassProbeTest {
assertEquals(2, result.closeCommand)
assertTrue(result.proven)
assertNull(result.refusal)
// Repartie de 0 %, la vitre doit y revenir : une sonde qui laisse la vitre ouverte
// est un défaut, pas un résultat.
// Having started at 0 %, the window must end there: a probe that leaves the window
// open is a defect, not a result.
assertTrue(result.restored)
assertEquals(0, glass.percent)
}
@Test
fun `une commande deja classee n'est pas renvoyee`() {
fun `a command already classified is not sent again`() {
val glass = FakeGlass(openCommand = 0, closeCommand = 1)
val result = probe(glass)
assertEquals(0, result.openCommand)
assertEquals(1, result.closeCommand)
// 0 ouvre, 1 referme, les deux sens sont connus : la sonde s'arrête là, plus une
// remise en place. Rien au-delà de la commande 1 ne doit avoir été envoyé.
// 0 opens, 1 closes, both directions are known: the probe stops there, plus one
// restore. Nothing beyond command 1 may have been sent.
assertEquals(listOf(0, 1), glass.sent.take(2))
assertTrue(glass.sent.none { it > 1 })
}
@Test
fun `une vitre inerte ne prouve rien`() {
fun `an inert window proves nothing`() {
val glass = FakeGlass(openCommand = 99, closeCommand = 98)
val result = probe(glass)
@@ -86,28 +86,28 @@ class GlassProbeTest {
assertNull(result.closeCommand)
assertEquals(GlassProbe.Refusal.NOTHING_MOVED, result.refusal)
assertFalse(result.proven)
// Les huit commandes, deux fois : la seconde passe ne peut être sautée sur la foi de
// la première, qui ne voit pas les fermetures.
// All eight commands, twice: the second pass cannot be skipped on the strength of
// the first, which does not see closing.
assertEquals(16, glass.sent.size)
}
@Test
fun `un seul sens trouve n'est pas une preuve`() {
fun `one direction found is not proof`() {
val glass = FakeGlass(openCommand = 3, closeCommand = 99)
val result = probe(glass)
assertEquals(3, result.openCommand)
assertNull(result.closeCommand)
assertEquals(GlassProbe.Refusal.ONE_DIRECTION_ONLY, result.refusal)
// Rien ne peut refermer la vitre : il faut le dire, pas laisser croire au contraire.
// Nothing can close the window: that must be said, not left to look otherwise.
assertFalse(result.restored)
}
@Test
fun `la voiture qui repart interrompt la sonde`() {
fun `the car moving off interrupts the probe`() {
val glass = FakeGlass(openCommand = 5, closeCommand = 2)
var checks = 0
// Arrêtée pour la lecture initiale et la première commande, puis en mouvement.
// Stopped for the first reading and the first command, then moving.
val result = probe(glass, stopped = { checks++ < 2 })
assertEquals(GlassProbe.Refusal.NOT_STOPPED, result.refusal)
@@ -115,7 +115,7 @@ class GlassProbeTest {
}
@Test
fun `contact coupe, aucune commande n'est envoyee`() {
fun `ignition off, no command is sent`() {
val glass = FakeGlass(openCommand = 5, closeCommand = 2)
val result = probe(glass, ignitionRun = { false })
@@ -124,7 +124,7 @@ class GlassProbeTest {
}
@Test
fun `une position illisible arrete tout`() {
fun `an unreadable position stops everything`() {
val glass = FakeGlass(openCommand = 5, closeCommand = 2).apply { readable = false }
val result = probe(glass)
@@ -109,7 +109,7 @@ class PhysicalButtonEventDecoderTest {
}
@Test fun `three presses give one double and then a single`() {
// Sinon un troisième appui rendrait un second DOUBLE, et la règle partirait deux fois.
// Otherwise a third press would make a second DOUBLE, and the rule would fire twice.
val decoder = PhysicalButtonEventDecoder()
val presses = listOf(0L, 100L, 200L).map {
decoder.accept(17, true, false, atMillis = it)
@@ -13,9 +13,8 @@ import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Garde-fous sur le catalogue. Les erreurs visées ne cassent pas la compilation : elles
* produisent une entrée d'interface qui ne fait rien, ou pire, une écriture véhicule qui
* contourne le verrou de vitesse.
* Guard rails on the catalogue. The mistakes they catch do not break the build: they produce a
* UI entry that does nothing or, worse, a vehicle write that gets round the speed lock.
*/
class CatalogConsistencyTest {
@@ -30,42 +29,42 @@ class CatalogConsistencyTest {
}
@Test
fun `la destination et le point utilisent des controles distincts`() {
// Un point a un rayon, une destination n'en a pas : deux ValueKind, sinon l'éditeur
// demanderait un rayon pour une adresse.
fun `the destination and the point use separate controls`() {
// A point has a radius, a destination does not: two ValueKinds, or the editor would
// ask for a radius for an address.
assertEquals(ValueKind.DESTINATION, ActionType.NAVIGATE_TO.spec.kind)
assertEquals(ValueKind.LOCATION, ConditionType.LOCATION_WITHIN.spec.kind)
assertEquals(0, ActionType.NAVIGATE_TO.spec.max)
}
@Test
fun `le sms porte un destinataire et un message`() {
// Le destinataire est un numéro (ou un contact du répertoire PBAP) et le message est
// du texte libre : un seul contrôle ne pourrait pas porter les deux.
fun `an sms carries a recipient and a message`() {
// The recipient is a number (or a contact from the PBAP phone book) and the message is
// free text: a single control could not carry both.
assertEquals(ValueKind.SMS, ActionType.SEND_SMS.spec.kind)
assertTrue(ActionType.SEND_SMS.spec.hintRes != 0)
assertFalse("le SMS n'écrit rien dans le véhicule", ActionType.SEND_SMS.gated)
assertFalse("SMS writes nothing to the vehicle", ActionType.SEND_SMS.gated)
}
@Test
fun `la demande de confirmation porte sa question et son delai`() {
// Le texte EST la question posée au conducteur ; un exemple est indispensable, la
// formulation par défaut d'un champ vide n'apprend rien. Le nombre est l'attente en
// secondes, bornée pour qu'une question reste lisible sans bloquer le cycle.
fun `the confirmation request carries its question and its delay`() {
// The text IS the question put to the driver; an example is essential, the default
// wording of an empty field teaches nothing. The number is the wait in seconds,
// bounded so that a question stays readable without blocking the cycle.
assertEquals(ValueKind.CONFIRM, ActionType.ASK_CONFIRM.spec.kind)
assertTrue(ActionType.ASK_CONFIRM.spec.hintRes != 0)
assertTrue(ActionType.ASK_CONFIRM.spec.min > 0)
assertTrue(ActionType.ASK_CONFIRM.spec.max > ActionType.ASK_CONFIRM.spec.min)
// Le défaut des règles enregistrées sans délai doit rester dans les bornes offertes.
// The default for rules saved without a delay must stay within the offered bounds.
assertTrue(ActionType.ASK_CONFIRM_DEFAULT_SECONDS in
ActionType.ASK_CONFIRM.spec.min..ActionType.ASK_CONFIRM.spec.max)
assertFalse("la question ne touche pas le véhicule", ActionType.ASK_CONFIRM.gated)
assertFalse("the question does not touch the vehicle", ActionType.ASK_CONFIRM.gated)
}
@Test
fun `toute action vehicule cible une action du pont`() {
// bridgeAction=null est réservé aux actions locales (lancer une app, notifier).
// Une action véhicule sans cible serait silencieusement ignorée à l'exécution.
fun `every vehicle action targets a bridge action`() {
// bridgeAction=null is reserved for local actions (launch an app, notify).
// A vehicle action without a target would be silently ignored when run.
val localOnly = setOf(
ActionType.LAUNCH_APP, ActionType.SHOW_NOTIFICATION, ActionType.SPEAK_TEXT,
ActionType.NAVIGATE_TO, ActionType.WEBHOOK, ActionType.ASK_CONFIRM,
@@ -75,12 +74,12 @@ class CatalogConsistencyTest {
)
ActionType.entries.filterNot { it in localOnly }.forEach { type ->
assertNotNull("${type.name} n'a pas de bridgeAction", type.bridgeAction)
assertNotNull("${type.name} has no bridgeAction", type.bridgeAction)
}
}
@Test
fun `les actions locales ne passent pas par le pont`() {
fun `local actions do not go through the bridge`() {
listOf(
ActionType.LAUNCH_APP, ActionType.SHOW_NOTIFICATION, ActionType.SPEAK_TEXT,
ActionType.NAVIGATE_TO, ActionType.WEBHOOK, ActionType.ASK_CONFIRM,
@@ -89,17 +88,17 @@ class CatalogConsistencyTest {
ActionType.SET_BLUETOOTH, ActionType.SET_WIFI
).forEach { type ->
assertTrue(
"${type.name} ne touche pas le véhicule et ne doit pas avoir de bridgeAction",
"${type.name} does not touch the vehicle and must not have a bridgeAction",
type.bridgeAction == null
)
}
}
@Test
fun `les ecritures de comportement routier sont marquees gated`() {
// Le marquage ne remplace PAS le verrou côté EVProfile, qui reste l'autorité.
// Il conditionne l'avertissement affiché à l'utilisateur au moment du choix :
// une action gated non marquée se présenterait comme applicable en roulant.
fun `writes that change driving behaviour are marked gated`() {
// The marking does NOT replace the lock on the EVProfile side, which stays the authority.
// It decides the warning shown to the user when choosing:
// an unmarked gated action would present itself as usable while driving.
val mustBeGated = setOf(
ActionType.APPLY_PROFILE,
ActionType.SET_DRIVE_MODE, ActionType.SET_REGEN_LEVEL, ActionType.SET_ONE_PEDAL,
@@ -113,16 +112,16 @@ class CatalogConsistencyTest {
)
mustBeGated.forEach { type ->
assertTrue("${type.name} modifie le comportement routier et doit être gated", type.gated)
assertTrue("${type.name} changes driving behaviour and must be gated", type.gated)
}
}
@Test
fun `couper le vehicule n est pas automatisable`() {
// Une règle ne doit jamais pouvoir éteindre la voiture : c'est irréversible pour
// le conducteur, et ça reste réservé à un geste humain dans EVProfile.
fun `switching the vehicle off cannot be automated`() {
// A rule must never be able to switch the car off: that is irreversible for the
// driver, and it stays reserved for a human gesture in EVProfile.
assertFalse(
"aucune action du catalogue ne doit couper le véhicule",
"no catalogue action may switch the vehicle off",
ActionType.entries.any {
it.bridgeAction?.contains("POWER_OFF", ignoreCase = true) == true
}
@@ -130,9 +129,9 @@ class CatalogConsistencyTest {
}
@Test
fun `toute condition vehicule lit une cle de l instantane`() {
// Les conditions calculées localement (heure, jour, Bluetooth) n'ont pas de clé ;
// toutes les autres en ont besoin, sinon elles seraient toujours indisponibles.
fun `every vehicle condition reads a snapshot key`() {
// Conditions computed locally (time, day, Bluetooth) have no key; all the others need
// one, or they would always be unavailable.
val localOnly = setOf(
ConditionType.BT_DEVICE_CONNECTED,
ConditionType.ANY_BT_CONNECTED,
@@ -142,12 +141,12 @@ class CatalogConsistencyTest {
ConditionType.DAY_OF_WEEK,
ConditionType.DATE,
ConditionType.LOCATION_WITHIN,
// Tiré au sort à l'évaluation : il n'y a rien à lire.
// Drawn at random when evaluated: there is nothing to read.
ConditionType.RANDOM_CHANCE
)
ConditionType.entries.filterNot { it in localOnly }.forEach { type ->
assertNotNull("${type.name} n'a pas de clé d'instantané", type.snapshotKey)
assertNotNull("${type.name} has no snapshot key", type.snapshotKey)
}
}
@@ -363,32 +362,32 @@ class CatalogConsistencyTest {
}
@Test
fun `les enumerations ont des options`() {
// Une ENUM sans option produit une liste déroulante vide : l'utilisateur ne peut
// rien choisir et la règle reste inutilisable.
fun `enumerations have options`() {
// An ENUM without options gives an empty drop-down: the user cannot choose anything
// and the rule stays unusable.
ConditionType.entries
.filter { it.spec.kind == ValueKind.ENUM && it != ConditionType.FIRMWARE_GEN }
.forEach { assertTrue("${it.name} : ENUM sans option", it.spec.options.isNotEmpty()) }
.forEach { assertTrue("${it.name}: ENUM without options", it.spec.options.isNotEmpty()) }
// RADIO dessine aussi une liste déroulante — celle des bandes — donc la même règle
// s'applique : sans option, l'action ne peut nommer aucune bande.
// RADIO draws a drop-down too — the bands — so the same rule applies: without
// options, the action cannot name any band.
ActionType.entries
.filter { it.spec.kind == ValueKind.ENUM || it.spec.kind == ValueKind.RADIO }
.forEach { assertTrue("${it.name} : ENUM sans option", it.spec.options.isNotEmpty()) }
.forEach { assertTrue("${it.name}: ENUM without options", it.spec.options.isNotEmpty()) }
}
@Test
fun `les bornes numeriques sont coherentes`() {
fun `numeric bounds are consistent`() {
val specs = ConditionType.entries.map { it.name to it.spec } +
ActionType.entries.map { it.name to it.spec }
specs.filter { it.second.kind == ValueKind.NUMBER }.forEach { (name, spec) ->
if (spec.max >= 0) {
assertTrue("$name : max doit dépasser min", spec.max > spec.min)
assertTrue("$name: max must exceed min", spec.max > spec.min)
} else {
// max = -1 : borne résolue à l'exécution. Le repli doit rester utilisable
// même si le véhicule ne répond pas.
assertTrue("$name : fallbackMax doit dépasser min", spec.fallbackMax > spec.min)
// max = -1: the bound is resolved at run time. The fallback must stay usable
// even if the vehicle does not answer.
assertTrue("$name: fallbackMax must exceed min", spec.fallbackMax > spec.min)
}
}
}
+12 -4
View File
@@ -48,7 +48,8 @@ on the cluster since 2026-10-25).
(telemetry, consumption, charging, pairing, settings from the phone) and Grafana 13.2.3 (all
dashboard queries return data).
- [x] Manifests validated offline (kubeconform, with Traefik CRDs). **Applied by Nils only.**
- [ ] Check Grafana's host in `k8s/ingress.yaml` (placeholder `car-grafana.k8s.stinnesbeck.com`).
- [x] Grafana's host in `k8s/ingress.yaml` is `car-grafana.k8s.stinnesbeck.com`, the one in use
(owner, 2026-10-25).
- [x] Screen on the car: 1920×720 px at 160 dpi, app window 1778×720 px (Logs → SCREEN).
- [x] Car-colour probe on the car: only `ro.build.mtk2712.vehicle_type = AS28`; the paint colour
is not in the system properties. It may be in the VHAL (a debug-build VhalProbe listing
@@ -88,9 +89,7 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
write the file. To run more than one replica, first move Grafana to PostgreSQL or MySQL
(`GF_DATABASE_TYPE`, `GF_DATABASE_HOST`, …); the replicas then share that database instead
of a volume. The car data itself is in InfluxDB and unaffected.
- [ ] **EVHardware's comments are partly French.** About 120 comment lines in the vendored
`EVHardware/` library (mostly `EVHardware.kt`, some tests), half-translated upstream. The
app, the docs and the build files are English.
- [x] **EVHardware's comments are partly French.** Translated 2026-10-25 (see the done log).
- [ ] **Idea: climate energy on the Overview (motor vs climate).** Nothing the app reads splits
energy by consumer: battery power (voltage × current) is the total of motor, climate, 12 V
and the rest. SAIC's charging service, which EVHardware already binds, has counters that
@@ -139,6 +138,15 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-25 — EVHardware in English: the French comments (and the half-translated ones like
"l'screen", "registersment", "initialisesd") in `EVHardware.kt` and five test files, the French
log messages ("aucun init() fonctionnel", "introuvable", "state initial", "ERREUR"), the French
test names and assertion messages (`CatalogConsistencyTest`, `GlassEvidenceTest`,
`GlassProbeTest`). Comments, log texts and test names only: no code changed (checked in the
diff). Kept on purpose: the French translation `values-fr/strings.xml`, place names in test
data, the quoted French sentence in `ChargeStopPlan` and the voice phrase in `SaicNavGuidance`,
and "Katman" (Turkish for layer), which names the layers in function names and logs — now
explained at the top of `EVHardware.kt`. EVHardware, app tests and lint pass.
- 2026-10-25 — README.md brought up to date: the tab screenshots retaken with 2.35.2 on the
emulator right after installing (`screenshots/nilsmg4-2.35.2-*.png`, the 2.18.1 ones removed),
"read-only" now names the drive mode exception, the upload frequency matches the app (1 min by