Watt Budget Calculateur de recharge

Une carte Home Assistant pour n'importe quelle voiture électrique

Batterie, autonomie, charge en cours, verrouillage, pneus, kilométrage : une seule carte pour Renault, Tesla, Kia, BMW, Peugeot, Volkswagen et les autres. Vous indiquez une seule entité, la batterie, et la carte retrouve le reste à partir de l'appareil de votre voiture.

  • Gratuite, à copier-coller
  • Une seule entité à indiquer
  • Une seule extension : button-card
  • Textes en 4 langues, selon Home Assistant
Ma voiture
À la maison · vu il y a 3 min
Branchée
47%176 km●
050 %100 %
Portes
Verrouillées
Compteur
33 950 km
Cible
100 %
La carte avec des valeurs d'exemple : seules les lignes dont la carte trouve la donnée s'affichent.

Ce qu'il vous faut

  • Home Assistant, avec l'extension button-card installée depuis HACS (catégorie Frontend).
  • Votre voiture dans Home Assistant, par n'importe quelle intégration : le tutoriel de votre marque explique comment faire.
  • Le niveau de batterie de la voiture, en %, comme entité (par exemple sensor.ma_voiture_batterie). Le tableau plus bas dit quelle entité prendre selon la marque.

1Installer la carte

  1. Collez la carteTableau de bord → modifier → ajouter une carte → Manuel, puis collez le code de la section suivante.
  2. Indiquez la batterieDans le bloc variables, en haut, remplacez sensor.ma_voiture_batterie par l'entité de batterie de votre voiture. Changez aussi nom, et si vous le souhaitez la photo (image) et la capacité de la batterie (capacite_kwh).
  3. Regardez ce qui s'afficheLa carte affiche les données qu'elle reconnaît (autonomie, charge, prise, verrouillage, kilométrage, pneus, ouvertures…) et laisse de côté les autres, sans rien casser.
  4. Corrigez si besoinSi une valeur manque ou est fausse, forcez l'entité à utiliser avec la variable correspondante (tableau « Variables »).

Le code de la carte

Collez-le tel quel, puis modifiez le bloc variables en haut.

type: custom:button-card
show_name: false
show_icon: false
show_state: false
show_label: true
tap_action:
  action: none
hold_action:
  action: none
triggers_update: all
grid_options:
  columns: full
variables:
  nom: Ma voiture                            # nom affiché en haut de la carte
  image: ''                                  # photo de la voiture : '' pour aucune, ou /local/… (dossier config/www)
  batterie: sensor.ma_voiture_batterie       # LA SEULE ENTITÉ OBLIGATOIRE : le niveau de batterie (en %) de votre voiture
  capacite_kwh: 0                            # capacité de la batterie en kWh (facultatif : sert à estimer les kWh manquants ; 0 pour ne pas les afficher)
  pression_min: 2.0                          # plage de pression des pneus jugée normale (bar)
  pression_max: 3.0
  # Facultatif : la carte retrouve seule les autres données à partir de l'appareil de la voiture.
  # Si l'une est fausse ou absente, indiquez l'entité à utiliser (ex. sensor.ma_voiture_autonomie).
  autonomie: ''
  odometre: ''
  en_charge: ''
  branche: ''
  puissance: ''
  limite: ''
  temps_restant: ''
  verrou: ''
  position: ''
  temp_habitacle: ''
  pression_av_g: ''
  pression_av_d: ''
  pression_ar_g: ''
  pression_ar_d: ''
extra_styles: >-
  @keyframes cvmv { to { background-position: 26px 0; } }
  @keyframes cvpulse { 50% { opacity: .4; } }
  .cvhalo { animation: cvpulse 2.4s ease-in-out infinite; }
  @keyframes cvpl { 50% { opacity: .7; } }
styles:
  card:
    - background: |
        [[[
        const s0 = parseFloat(states[variables.batterie]?.state);
        const s = isNaN(s0) ? 76 : Math.max(0, Math.min(100, s0));
        const S = [[0, [255, 93, 93]], [25, [255, 138, 61]], [45, [247, 209, 84]], [70, [61, 220, 151]], [100, [79, 163, 255]]];
        let c = [79, 163, 255];
        for (let i = 1; i < S.length; i++) { if (s <= S[i][0]) { const t = (s - S[i - 1][0]) / (S[i][0] - S[i - 1][0]); c = S[i - 1][1].map((x, j) => Math.round(x + (S[i][1][j] - x) * t)); break; } }
        return 'radial-gradient(60% 190px at 50% 0, rgba(' + c.join(',') + ',0.14), transparent 75%), #1c1f24';
        ]]]
    - border: 1px solid #2b3038
    - border-radius: 18px
    - padding: 16px
    - box-shadow: none
    - overflow: hidden
    - pointer-events: auto
  label:
    - width: 100%
    - text-align: left
    - white-space: normal
    - justify-self: stretch
    - pointer-events: auto
label: |
  [[[
  const V = variables;
  const S = e => (e ? states[e]?.state : undefined);
  const N = e => { const x = parseFloat(S(e)); return (e && !isNaN(x)) ? x : null; };
  const lang = String(hass.language || 'fr').slice(0, 2);
  const PC = lang === 'en' ? '%' : ' %';
  const fr = (x, d) => x === null ? '—' : x.toFixed(d).replace('.', lang === 'en' ? '.' : ',');
  const TT = {
    fr: { charging: 'En charge', driving: 'En route', parked: 'Garée', home: 'À la maison', away: 'Hors domicile', seen: 'vu', nodata: 'pas de donnée', now: "à l'instant", ago: (n, u) => 'il y a ' + n + ' ' + u, d: 'j', target: 'Cible', targetIn: (t, d) => t + PC + ' dans ' + d, maxAt: k => 'Max ' + k + ' km à 100 %', missing: k => 'manque ' + k + ' kWh', locked: 'Verrouillée', unlocked: 'Déverrouillée', plugged: 'Branchée', unplugged: 'Non branchée', homeChip: 'Maison', lvl: ['Critique', 'Bas', 'Moyen', 'Bon', 'Plein'], cabin: 'Habitacle', hv: 'Batterie', odo: 'Odomètre', outside: 'Extérieur', tw: 'Pneus et ouvertures', ok: 'Tout est OK', tyres: n => n + ' pneu' + (n > 1 ? 's' : '') + ' à vérifier', opens: n => n + ' ouverture' + (n > 1 ? 's' : '') + ' ouverte' + (n > 1 ? 's' : ''), hist: 'Historique de la batterie', noEnt: 'Entité introuvable : ', fix: 'Corrigez la variable « batterie » de la carte.' },
    en: { charging: 'Charging', driving: 'Driving', parked: 'Parked', home: 'At home', away: 'Away from home', seen: 'seen', nodata: 'no data', now: 'just now', ago: (n, u) => n + ' ' + u + ' ago', d: 'd', target: 'Target', targetIn: (t, d) => t + PC + ' in ' + d, maxAt: k => 'Max ' + k + ' km at 100%', missing: k => k + ' kWh missing', locked: 'Locked', unlocked: 'Unlocked', plugged: 'Plugged in', unplugged: 'Unplugged', homeChip: 'Home', lvl: ['Critical', 'Low', 'Medium', 'Good', 'Full'], cabin: 'Cabin', hv: 'Battery', odo: 'Odometer', outside: 'Outside', tw: 'Tyres and openings', ok: 'All good', tyres: n => n + ' tyre' + (n > 1 ? 's' : '') + ' to check', opens: n => n + ' opening' + (n > 1 ? 's' : '') + ' open', hist: 'Battery history', noEnt: 'Entity not found: ', fix: 'Fix the “batterie” variable of the card.' },
    es: { charging: 'Cargando', driving: 'En marcha', parked: 'Aparcado', home: 'En casa', away: 'Fuera de casa', seen: 'visto', nodata: 'sin datos', now: 'ahora mismo', ago: (n, u) => 'hace ' + n + ' ' + u, d: 'd', target: 'Objetivo', targetIn: (t, d) => t + PC + ' en ' + d, maxAt: k => 'Máx. ' + k + ' km al 100 %', missing: k => 'faltan ' + k + ' kWh', locked: 'Bloqueado', unlocked: 'Desbloqueado', plugged: 'Conectado', unplugged: 'Desconectado', homeChip: 'Casa', lvl: ['Crítico', 'Bajo', 'Medio', 'Bueno', 'Lleno'], cabin: 'Habitáculo', hv: 'Batería', odo: 'Cuentakm', outside: 'Exterior', tw: 'Neumáticos y aperturas', ok: 'Todo correcto', tyres: n => n + ' neumático' + (n > 1 ? 's' : '') + ' a revisar', opens: n => n + ' apertura' + (n > 1 ? 's' : '') + ' abierta' + (n > 1 ? 's' : ''), hist: 'Historial de la batería', noEnt: 'Entidad no encontrada: ', fix: 'Corrige la variable «batterie» de la tarjeta.' },
    de: { charging: 'Lädt', driving: 'Unterwegs', parked: 'Geparkt', home: 'Zu Hause', away: 'Nicht zu Hause', seen: 'zuletzt gesehen', nodata: 'keine Daten', now: 'gerade eben', ago: (n, u) => 'vor ' + n + ' ' + u, d: 'T', target: 'Ziel', targetIn: (t, d) => t + PC + ' in ' + d, maxAt: k => 'Max. ' + k + ' km bei 100 %', missing: k => 'es fehlen ' + k + ' kWh', locked: 'Verriegelt', unlocked: 'Entriegelt', plugged: 'Angesteckt', unplugged: 'Nicht angesteckt', homeChip: 'Zuhause', lvl: ['Kritisch', 'Niedrig', 'Mittel', 'Gut', 'Voll'], cabin: 'Innenraum', hv: 'Akku', odo: 'Kilometer', outside: 'Außen', tw: 'Reifen und Öffnungen', ok: 'Alles in Ordnung', tyres: n => n + ' Reifen prüfen', opens: n => n + ' Öffnung' + (n > 1 ? 'en' : '') + ' offen', hist: 'Akkuverlauf', noEnt: 'Entität nicht gefunden: ', fix: 'Korrigieren Sie die Variable „batterie“ der Karte.' }
  };
  const T = TT[lang] || TT.en;

  const C = { g: '#3ddc97', am: '#f5a524', rd: '#ff5d5d', bl: '#4fa3ff', yl: '#f7d154', vi: '#b18cff', or: '#ff8a3d', mu: '#8a919c' };
  if (!V.batterie || !states[V.batterie]) {
    return '<div style="color:#e8eaed;font-size:13px;line-height:1.4"><b style="color:' + C.am + '">' + T.noEnt + '<code>' + (V.batterie || '—') + '</code></b><br>' + T.fix + '</div>';
  }

  /* Retrouver les autres entités de la voiture : même appareil que l'entité « batterie » (ou, à défaut, même début de nom), puis classe d'appareil et mots du nom. */
  const REG = hass.entities || {};
  const dev = REG[V.batterie]?.device_id;
  const base = (V.batterie.split('.')[1] || '').replace(/_(battery_level|battery|soc|state_of_charge|ev_battery_level|batterie|niveau_de_batterie|niveau_batterie|ladezustand|nivel_de_bateria|bateria)$/, '');
  const cand = Object.keys(states).filter(k => dev ? REG[k]?.device_id === dev : k.indexOf('.' + base + '_') > 0);
  const at = k => states[k]?.attributes || {};
  const nm = k => (k.split('.')[1] + ' ' + (at(k).friendly_name || '')).toLowerCase();
  const dc = k => at(k).device_class;
  const un = k => String(at(k).unit_of_measurement || '');
  const find = (dom, test) => cand.find(k => k.startsWith(dom + '.') && test(k)) || null;
  const use = (v, ...fs) => { if (v) return v; for (const f of fs) { const r = f(); if (r) return r; } return null; };
  const ON = /^(on|true|yes|charging|in_progress|charge_in_progress|chargingactive|started|laden|en_charge|cargando)$/i;
  const PLUG = /^(on|true|yes|connected|plugged|plugged_in|plugged_in_charging|charging|angesteckt|branche|conectado)$/i;

  const eSoc = V.batterie;
  const eRng = use(V.autonomie, () => find('sensor', k => (dc(k) === 'distance' || /^(km|mi)$/.test(un(k))) && /range|autonom|reichweite/.test(nm(k)) && !/max|total|fuel|carburant|100|target|cible|limit/.test(nm(k))));
  const eOdo = use(V.odometre, () => find('sensor', k => (dc(k) === 'distance' || /^(km|mi)$/.test(un(k))) && /odom|mileage|kilom|kilometer|cuentakm|kilometrage|kilométrage/.test(nm(k))));
  const eChg = use(V.en_charge, () => find('binary_sensor', k => dc(k) === 'battery_charging'),
    () => find('binary_sensor', k => /charging|en_charge|laden|cargando/.test(nm(k)) && !/plug|cable|prise|stecker|connect|enchufe/.test(nm(k))),
    () => find('sensor', k => /charging_state|charge_state|charging_status|charge_status|etat_de_charge|ladestatus|ladevorgang|estado_de_carga/.test(nm(k)) && !/power|puiss|leistung|potencia|remain|restant|limit/.test(nm(k))));
  const ePlg = use(V.branche, () => find('binary_sensor', k => dc(k) === 'plug'),
    () => find('binary_sensor', k => /plug|cable|prise|stecker|enchufe/.test(nm(k)) && !/wifi|online|internet|remote/.test(nm(k))),
    () => find('sensor', k => /plug_state|plugged|prise|stecker|enchufe/.test(nm(k))));
  const ePow = use(V.puissance, () => find('sensor', k => (dc(k) === 'power' || /^k?w$/i.test(un(k))) && /charg|lad|carga|puiss|leist|potencia/.test(nm(k))));
  const eTgt = use(V.limite, () => cand.find(k => /^(sensor|number)\./.test(k) && un(k) === '%' && k !== eSoc && /limit|target|cible|objectif|ziel|max_?soc|objetivo/.test(nm(k))) || null);
  const eRem = use(V.temps_restant, () => find('sensor', k => /remain|restant|time_to_full|until_full|time_to_complete|verbleib|restante/.test(nm(k)) && (dc(k) === 'duration' || /^(min|h|s)$/.test(un(k)))));
  const eLck = use(V.verrou, () => find('lock', k => true), () => find('binary_sensor', k => dc(k) === 'lock'));
  const eTrk = use(V.position, () => find('device_tracker', k => true));
  const eCab = use(V.temp_habitacle, () => find('sensor', k => dc(k) === 'temperature' && /interior|cabin|inside|habitacle|innen|interior|cabina/.test(nm(k))));
  const eOut = find('sensor', k => dc(k) === 'temperature' && /outside|outdoor|exterior|ext[eé]rieur|außen|aussen/.test(nm(k)));
  const eBat = find('sensor', k => dc(k) === 'temperature' && /battery|batterie|hv|akku|bater/.test(nm(k)) && !/interior|cabin|habitacle/.test(nm(k)));
  const e12v = find('sensor', k => dc(k) === 'voltage' && /12|aux/.test(nm(k)));
  const eSpd = find('sensor', k => dc(k) === 'speed');
  const eSeen = find('sensor', k => dc(k) === 'timestamp' && /last.?(seen|update|updated|contact|sync|connection)|derni[eè]re|aktualis|actualiz/.test(nm(k)));

  const soc = N(eSoc);
  const socV = soc === null ? 0 : Math.max(0, Math.min(100, soc));
  const rngRaw = N(eRng);
  const rng = rngRaw !== null && /^mi$/.test(un(eRng)) ? rngRaw * 1.609 : rngRaw;
  const pwRaw = N(ePow), pw = pwRaw !== null && /^w$/i.test(un(ePow)) ? pwRaw / 1000 : pwRaw;
  const charging = (eChg ? ON.test(String(S(eChg))) : false) || (pw !== null && pw > 0.2);
  const plugged = charging || (ePlg ? PLUG.test(String(S(ePlg))) : false);
  const tgtRaw = N(eTgt), tgt = tgtRaw !== null && tgtRaw > 0 && tgtRaw <= 100 ? tgtRaw : 100;
  const miss = (V.capacite_kwh > 0 && soc !== null) ? Math.max(0, (tgt - soc) / 100 * V.capacite_kwh) : null;
  const remRaw = N(eRem), rem = remRaw === null ? null : Math.round(un(eRem) === 'h' ? remRaw * 60 : un(eRem) === 's' ? remRaw / 60 : remRaw);
  const spd = N(eSpd) || 0;
  const driving = !charging && spd > 3;
  let locked = null;
  if (eLck) { const l = String(S(eLck)).toLowerCase(); locked = eLck.startsWith('lock.') ? l === 'locked' : dc(eLck) === 'lock' ? l === 'off' : (l === 'locked' || l === 'on'); }

  let home = null;
  if (eTrk) {
    const ts = String(S(eTrk)).toLowerCase(), ta = at(eTrk), hz = states['zone.home']?.attributes;
    if (ts === 'home') home = true;
    else if (ta.latitude !== undefined && hz) { const dLa = (ta.latitude - hz.latitude) * 111320, dLo = (ta.longitude - hz.longitude) * 111320 * Math.cos(hz.latitude * Math.PI / 180); home = Math.sqrt(dLa * dLa + dLo * dLo) < 200; }
    else if (ts !== 'unknown' && ts !== 'unavailable') home = false;
  }
  const seenIso = (eSeen && S(eSeen)) || states[eSoc].last_reported || states[eSoc].last_updated;
  const age = seenIso ? Math.round((Date.now() - new Date(seenIso).getTime()) / 60000) : null;
  const ageTxt = age === null || isNaN(age) ? T.nodata : age < 1 ? T.now : age < 60 ? T.ago(age, 'min') : age < 1440 ? T.ago(Math.round(age / 60), 'h') : T.ago(Math.round(age / 1440), T.d);

  const STOPS = [[0, [255, 93, 93]], [25, [255, 138, 61]], [45, [247, 209, 84]], [70, [61, 220, 151]], [100, [79, 163, 255]]];
  const colAt = s => { for (let i = 1; i < STOPS.length; i++) { if (s <= STOPS[i][0]) { const a = STOPS[i - 1], b = STOPS[i], t = (s - a[0]) / (b[0] - a[0]); return '#' + a[1].map((v, j) => Math.round(v + (b[1][j] - v) * t).toString(16).padStart(2, '0')).join(''); } } return '#4fa3ff'; };
  const sc = colAt(socV);
  const lvl = T.lvl[socV < 20 ? 0 : socV < 40 ? 1 : socV < 60 ? 2 : socV <= 90 ? 3 : 4];
  const mix = (c, a) => c + Math.round(a * 255).toString(16).padStart(2, '0');
  const IC = (ic, n, c) => '<span style="width:' + n + 'px;height:' + n + 'px;flex:none;display:inline-flex;overflow:hidden;align-items:center;justify-content:center;line-height:0"><ha-icon icon="' + ic + '" style="--mdc-icon-size:' + n + 'px' + (c ? ';color:' + c : '') + '"></ha-icon></span>';

  let st, stIc, stC;
  if (charging) { st = T.charging + (pw ? ' ' + fr(pw, 1) + ' kW' : ''); stIc = 'mdi:lightning-bolt'; stC = C.g; }
  else if (driving) { st = T.driving; stIc = 'mdi:steering'; stC = C.bl; }
  else if (plugged) { st = T.plugged; stIc = 'mdi:power-plug'; stC = C.bl; }
  else { st = T.parked; stIc = 'mdi:parking'; stC = C.vi; }

  const where = home === null ? '' : home ? T.home : T.away;
  const sub = where + (where ? ' · ' : '') + (driving ? Math.round(spd) + ' km/h' : T.seen + ' ' + ageTxt);

  let line;
  if (charging) {
    line = rem ? T.targetIn(Math.round(tgt), rem >= 60 ? Math.floor(rem / 60) + ' h ' + String(rem % 60).padStart(2, '0') : rem + ' min') : T.target + ' ' + Math.round(tgt) + PC;
  } else if (driving) {
    line = (rng !== null && soc > 0) ? T.maxAt(fr(rng * 100 / soc, 0)) : '';
  } else {
    line = T.target + ' ' + Math.round(tgt) + PC + (miss !== null && miss > 0 ? ' · ' + T.missing(fr(miss, 1)) : '');
  }

  const chip = (ic, t, c) => '<span style="display:inline-flex;align-items:center;gap:5px;border-radius:20px;padding:4px 10px;font-size:12px;margin:0 4px 5px 0;background:' + mix(c, 0.14) + ';color:' + c + '">' + IC(ic, 15) + t + '</span>';
  let chips = '';
  if (locked !== null) chips += locked ? chip('mdi:lock', T.locked, C.g) : chip('mdi:lock-open-variant', T.unlocked, C.am);
  if (charging) chips += chip('mdi:ev-plug-type2', pw ? fr(pw, 1) + ' kW' : T.charging, C.yl);
  else if (eChg || ePlg) chips += plugged ? chip('mdi:power-plug', T.plugged, C.bl) : chip('mdi:power-plug-off', T.unplugged, C.mu);
  if (home) chips += chip('mdi:home', T.homeChip, C.vi);

  const tile = (ic, k, v, c, vc) => '<div style="background:#252a31;border-radius:12px;padding:8px 10px;min-width:0;text-align:center;border-top:2px solid ' + c + '"><div style="font-size:11px;color:' + C.mu + ';display:flex;align-items:center;justify-content:center;gap:5px;white-space:nowrap">' + IC(ic, 14, c) + '<span>' + k + '</span></div><div style="font-size:14px;font-weight:500;white-space:nowrap;margin-top:3px;text-align:center;color:' + (vc || '#e8eaed') + '">' + v + '</div></div>';
  const tmp = e => { const x = N(e); return x === null ? null : fr(/f/i.test(un(e)) ? (x - 32) * 5 / 9 : x, 0) + ' °C'; };
  const odoRaw = N(eOdo), odo = odoRaw !== null && /^mi$/.test(un(eOdo)) ? odoRaw * 1.609 : odoRaw;
  const v12 = N(e12v);
  let tiles = '';
  if (tmp(eCab) !== null) tiles += tile('mdi:car-seat', T.cabin, tmp(eCab), C.or);
  if (tmp(eOut) !== null) tiles += tile('mdi:thermometer', T.outside, tmp(eOut), C.bl);
  if (tmp(eBat) !== null) tiles += tile('mdi:battery-high', T.hv, tmp(eBat), C.bl);
  if (v12 !== null) tiles += tile('mdi:car-battery', '12 V', fr(v12, 1) + ' V', C.yl, v12 < 12.2 ? C.rd : null);
  if (odo !== null) tiles += tile('mdi:counter', T.odo, Math.round(odo).toLocaleString(lang) + ' km', C.vi);

  /* Pneus : par position (avant/arrière, gauche/droite) ; les pressions en kPa ou en psi sont converties en bar. */
  const POS = {
    fl: [V.pression_av_g, /front.?left|avant.?gauche|vorn.?links|vorne.?links|delantera.?izq|\bfl\b|\blf\b|\bav.?g\b/],
    fr: [V.pression_av_d, /front.?right|avant.?droit|vorn.?rechts|vorne.?rechts|delantera.?der|\bfr\b|\brf\b|\bav.?d\b/],
    rl: [V.pression_ar_g, /rear.?left|arri[eè]re.?gauche|hinten.?links|trasera.?izq|\brl\b|\blr\b|\bar.?g\b/],
    rr: [V.pression_ar_d, /rear.?right|arri[eè]re.?droit|hinten.?rechts|trasera.?der|\brr\b|\bar.?d\b/]
  };
  const tyreAll = cand.filter(k => k.startsWith('sensor.') && dc(k) === 'pressure' && /tire|tyre|pneu|reifen|neum/.test(nm(k)));
  const bar = e => { const x = N(e); if (x === null) return null; const u = un(e).toLowerCase(); return u === 'kpa' ? x / 100 : u === 'psi' ? x / 14.5038 : u === 'pa' ? x / 100000 : u === 'hpa' || u === 'mbar' ? x / 1000 : x; };
  const used = new Set(); const tyre = {};
  for (const p of ['fl', 'fr', 'rl', 'rr']) { const e = POS[p][0] || tyreAll.find(k => !used.has(k) && POS[p][1].test(nm(k))); if (e) { used.add(e); tyre[p] = e; } }
  const rest = tyreAll.filter(k => !used.has(k));
  let nTyre = 0;
  const pill = e => { const p = bar(e), ok = p !== null && p >= V.pression_min && p <= V.pression_max; if (!ok) nTyre++; const c = ok ? C.g : C.am;
    return '<div style="display:flex;align-items:center;gap:6px;background:#252a31;border-radius:10px;padding:6px 8px;font-size:13px"><span style="width:8px;height:8px;border-radius:50%;background:' + c + ';flex:none"></span><b style="font-weight:500">' + fr(p, 2) + '</b><span style="color:' + C.mu + ';font-size:11px;margin-left:auto">bar</span></div>'; };
  const tyreList = ['fl', 'fr', 'rl', 'rr'].filter(p => tyre[p]).map(p => tyre[p]).concat(rest.slice(0, 4 - Object.keys(tyre).length));
  const tyreHtml = tyreList.map(pill).join('');
  const nOpen = cand.filter(k => (k.startsWith('binary_sensor.') && (dc(k) === 'door' || dc(k) === 'window' || dc(k) === 'opening') && S(k) === 'on') || (k.startsWith('cover.') && dc(k) === 'window' && S(k) === 'open')).length;
  const hasOpen = cand.some(k => k.startsWith('binary_sensor.') && (dc(k) === 'door' || dc(k) === 'window'));
  const showTW = tyreList.length > 0 || hasOpen;
  const sumTxt = (nTyre === 0 && nOpen === 0) ? T.ok : [nTyre ? T.tyres(nTyre) : '', nOpen ? T.opens(nOpen) : ''].filter(x => x).join(' · ');
  const sumC = (nTyre === 0 && nOpen === 0) ? C.g : C.am;

  const SEC = 'background:#20242a;border:1px solid #2b3038;border-radius:14px;padding:10px 12px;margin-top:10px';
  const H4 = (ic, t, r, rc) => '<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;color:' + C.mu + ';margin-bottom:8px"><span style="display:flex;align-items:center;gap:5px">' + IC(ic, 15) + t + '</span><span style="color:' + rc + '">' + r + '</span></div>';
  const MORE = 'onclick="event.stopPropagation();this.dispatchEvent(new CustomEvent(\'hass-more-info\',{bubbles:true,composed:true,detail:{entityId:\'' + eSoc + '\'}}))" title="' + T.hist + '"';

  let H = '<div style="color:#e8eaed;font-size:13px;line-height:1.35">';
  H += '<div style="display:flex;justify-content:space-between;align-items:flex-start"><div><div style="font-size:18px;font-weight:500">' + V.nom + '</div><div style="font-size:12px;color:' + C.mu + '">' + sub + '</div></div>';
  H += '<span style="font-size:12px;padding:3px 10px;border-radius:20px;background:' + mix(stC, 0.18) + ';color:' + stC + ';display:inline-flex;gap:4px;align-items:center;white-space:nowrap">' + IC(stIc, 14) + st + '</span></div>';

  if (V.image) {
    H += '<div style="position:relative;margin:4px 0 0">';
    if (charging) H += '<div class="cvhalo" style="position:absolute;left:8%;right:8%;bottom:0;height:26%;border-radius:50%;background:radial-gradient(closest-side,' + mix(sc, 0.6) + ',transparent)"></div>';
    H += '<img src="' + V.image + '" style="width:100%;max-width:460px;display:block;margin:0 auto;position:relative"></div>';
  } else { H += '<div style="height:10px"></div>'; }

  H += '<div ' + MORE + ' style="display:flex;align-items:baseline;gap:10px;white-space:nowrap;cursor:pointer"><span style="font-size:34px;font-weight:500;line-height:1;color:' + sc + '">' + (soc === null ? '—' : Math.round(soc) + PC) + '</span><span style="font-size:16px">' + (rng === null ? '' : fr(rng, 0) + ' km') + '</span><span style="font-size:12px;margin-left:auto;color:' + sc + '">' + lvl + '</span></div>';
  H += '<div style="font-size:12px;color:' + C.mu + ';margin:5px 0 7px">' + line + '</div>';
  const SPEC = 'linear-gradient(90deg,#ff5d5d 0%,#ff8a3d 25%,#f7d154 45%,#3ddc97 70%,#4fa3ff 100%)';
  H += '<div ' + MORE + ' style="position:relative;height:12px;margin:2px 0 4px;cursor:pointer">';
  H += '<div style="position:absolute;inset:0;border-radius:6px;overflow:hidden"><div style="position:absolute;inset:0;background:' + SPEC + '"></div>';
  if (charging) H += '<div style="position:absolute;top:0;bottom:0;left:0;width:' + socV + '%;background:repeating-linear-gradient(110deg,rgba(255,255,255,.22) 0 8px,transparent 8px 20px);background-size:26px 100%;animation:cvmv .9s linear infinite"></div>';
  H += '<div style="position:absolute;top:0;bottom:0;right:0;width:' + (100 - socV) + '%;background:#252a31"></div></div>';
  H += '<div style="position:absolute;top:-1px;bottom:-1px;left:calc(' + Math.min(100, tgt) + '% - 1px);width:2px;background:#ffffff66;border-radius:1px"></div>';
  H += '<div style="position:absolute;top:-3px;bottom:-3px;left:calc(' + socV + '% - 2px);width:4px;border-radius:2px;background:#fff;box-shadow:0 0 8px #fff' + (charging ? ';animation:cvpl 1.6s ease-in-out infinite' : '') + '"></div></div>';
  H += '<div style="display:flex;justify-content:space-between;font-size:10px;color:#5d646e;margin-bottom:10px"><span>0</span><span>20</span><span>40</span><span>60</span><span>80</span><span>100</span></div>';
  if (chips) H += '<div>' + chips + '</div>';
  if (tiles) H += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(78px,1fr));gap:8px;margin-top:5px">' + tiles + '</div>';
  if (showTW) H += '<div style="' + SEC + '">' + H4('mdi:tire', T.tw, sumTxt, sumC) + (tyreHtml ? '<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px">' + tyreHtml + '</div>' : '') + '</div>';

  return H + '</div>';
  ]]]

Quelle entité pour votre marque ?

Pour chaque marque, voici le capteur à mettre dans la variable batterie, d'après les tutoriels du site. Les noms exacts dépendent de la langue de votre Home Assistant : cherchez le capteur dans Paramètres → Appareils et services → Entités.

MarqueCapteur à indiquer dans « batterie »
XpengLe capteur de niveau de batterie de XPENG Direct ou du WiCAN. Pour une Xpeng, la carte Xpeng est plus complète (diagnostic de la batterie par le WiCAN).
Renault · DaciaLe capteur Batterie de l'appareil Renault (en %).
MGLe capteur SoC (niveau de batterie en %) de l'appareil MQTT de la voiture.
ŠkodaLe capteur niveau de batterie (en %) de l'appareil Škoda.
Kia · Hyundai · GenesisLe capteur EV Battery Level (en %) de l'appareil Kia Uvo.
TeslaLe capteur Niveau de batterie (en %) de l'appareil de la voiture.
Peugeot · Citroën · DS · OpelLe capteur Battery (en %) de l'appareil Stellantis Vehicles.
Volkswagen · Cupra · SEAT · AudiLe capteur niveau de batterie (en %) de l'appareil VW Group Connect.
BMW · MiniLe capteur de niveau de batterie (en %) créé par BMW CarData pour la voiture.
FordLe capteur de niveau de batterie (en %) de l'appareil Ford.
NissanLe capteur Battery (en %) de l'appareil NissanConnect.
VolvoLe capteur Batterie (en %) de l'appareil Volvo.
PolestarLe capteur de niveau de batterie (en %) de l'appareil Polestar.
Mercedes-BenzLe capteur niveau de batterie (state of charge, en %) de l'appareil Mercedes.
BYDLe capteur de niveau de batterie (en %) publié par le WiCAN en MQTT.

Comment la carte retrouve vos données

  • Elle part de l'entité batterie, puis cherche les autres entités du même appareil Home Assistant (ou, si l'appareil n'est pas connu, dont le nom commence de la même façon).
  • Elle les reconnaît à leur classe (distance, puissance, pression, prise, verrou, température, tension) et à des mots de leur nom, en français, anglais, espagnol et allemand.
  • Les pressions en kPa ou en psi, les distances en miles et les températures en °F sont converties (bar, km, °C).
  • Ce qui s'affiche dépend de ce qui est trouvé : une donnée absente n'affiche simplement rien.

Variables

VariableRôle
nomNom affiché en haut de la carte.
imagePhoto de la voiture : '' pour aucune, ou /local/… (dossier config/www de Home Assistant).
batterieSeule entité obligatoire : le niveau de batterie en %.
capacite_kwhCapacité de la batterie en kWh, pour estimer les kWh manquants jusqu'à la cible. 0 pour ne pas les afficher.
pression_min, pression_maxPlage de pression des pneus jugée normale, en bar.
autonomieForcer l'entité d'autonomie (km).
odometreForcer l'entité du kilométrage.
en_chargeForcer l'entité « en charge » (capteur on/off ou état de charge).
brancheForcer l'entité « prise branchée ».
puissanceForcer l'entité de puissance de charge (kW ou W).
limiteForcer l'entité de limite ou de cible de charge (%).
temps_restantForcer l'entité de temps de charge restant (minutes, heures ou secondes).
verrouForcer l'entité de verrouillage : une entité lock. ou un capteur de classe « verrou ».
positionForcer le suivi de position (device_tracker.), pour afficher « À la maison ».
temp_habitacleForcer le capteur de température de l'habitacle.
pression_av_g, pression_av_d, pression_ar_g, pression_ar_dForcer chaque capteur de pression de pneu (avant gauche, avant droit, arrière gauche, arrière droit).

Ce qu'il faut savoir

Testée avec des états simulés, pas avec chaque marque

La logique de la carte a été testée avec des états simulés imitant plusieurs styles d'intégrations (noms anglais ou français, pressions en kPa ou en psi, appareil connu ou non), mais pas dans un vrai Home Assistant avec chaque marque. Les intégrations n'ont pas toutes les mêmes entités : si une donnée n'est pas reconnue, forcez-la avec la variable correspondante, ou écrivez-moi pour que j'améliore la détection.

Pour aller plus loin

Votre voiture remonte dans Home Assistant d'une autre façon ? Partagez votre méthode : les prochains tutoriels suivront les retours.

Combien vous coûte vraiment une recharge ? Heures creuses, Tempo, solaire, bornes rapides et codes XCombo : Watt Budget calcule tout, gratuitement et sans compte.

Ouvrir Watt Budget