Watt Budget Charging calculator

A Home Assistant card for any electric car

Battery, range, charging, lock, tyres, mileage: one card for Renault, Tesla, Kia, BMW, Peugeot, Volkswagen and the others. You give a single entity, the battery, and the card finds the rest from your car's device.

  • Free, copy and paste
  • One entity to set
  • A single extension: button-card
  • Texts in 4 languages, following Home Assistant
My car
Home · seen 3 min ago
Plugged in
47%176 km●
050%100%
Doors
Locked
Odometer
33,950 km
Target
100%
The card with example values: only the rows for which the card finds data are shown.

What you need

  • Home Assistant, with the button-card extension installed from HACS (Frontend category).
  • Your car in Home Assistant, through any integration: the guide for your brand explains how.
  • The car's battery level, in %, as an entity (for example sensor.my_car_battery). The table below says which entity to use for each brand.

1Install the card

  1. Paste the cardDashboard → edit → add card → Manual, then paste the code from the next section.
  2. Set the batteryIn the variables block at the top, replace sensor.ma_voiture_batterie with your car's battery entity. Also change nom, and if you like the photo (image) and the battery capacity (capacite_kwh).
  3. See what shows upThe card shows the data it recognises (range, charging, plug, lock, mileage, tyres, openings…) and leaves out the rest, without breaking anything.
  4. Fix if neededIf a value is missing or wrong, force the entity to use with the matching variable (“Variables” table).

The card code

Paste it as is, then edit the variables block at the top.

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>';
  ]]]

Which entity for your brand?

For each brand, here is the sensor to put in the batterie variable, from the guides on this site. Exact names depend on the language of your Home Assistant: look for the sensor in Settings → Devices & services → Entities.

BrandSensor to give in “batterie”
XpengThe battery level sensor from XPENG Direct or from the WiCAN. For a Xpeng, the Xpeng card is more complete (battery diagnostics from the WiCAN).
Renault · DaciaThe Battery sensor of the Renault device (in %).
MGThe SoC sensor (battery level in %) of the car's MQTT device.
ŠkodaThe battery level sensor (in %) of the Škoda device.
Kia · Hyundai · GenesisThe EV Battery Level sensor (in %) of the Kia Uvo device.
TeslaThe Battery level sensor (in %) of the car's device.
Peugeot · Citroën · DS · OpelThe Battery sensor (in %) of the Stellantis Vehicles device.
Volkswagen · Cupra · SEAT · AudiThe battery level sensor (in %) of the VW Group Connect device.
BMW · MiniThe battery level sensor (in %) that BMW CarData creates for the car.
FordThe battery level sensor (in %) of the Ford device.
NissanThe Battery sensor (in %) of the NissanConnect device.
VolvoThe Battery sensor (in %) of the Volvo device.
PolestarThe battery level sensor (in %) of the Polestar device.
Mercedes-BenzThe battery level sensor (state of charge, in %) of the Mercedes device.
BYDThe battery level sensor (in %) the WiCAN publishes over MQTT.

How the card finds your data

  • It starts from the batterie entity, then looks for the other entities of the same device in Home Assistant (or, if the device is unknown, whose name starts the same way).
  • It recognises them by their class (distance, power, pressure, plug, lock, temperature, voltage) and by words in their name, in French, English, Spanish and German.
  • Pressures in kPa or psi, distances in miles and temperatures in °F are converted (bar, km, °C).
  • What is shown depends on what is found: missing data simply shows nothing.

Variables

VariablePurpose
nomName shown at the top of the card.
imageCar photo: '' for none, or /local/… (Home Assistant's config/www folder).
batterieThe only required entity: the battery level in %.
capacite_kwhBattery capacity in kWh, to estimate the kWh missing to the target. 0 to hide them.
pression_min, pression_maxTyre pressure range considered normal, in bar.
autonomieForce the range entity (km).
odometreForce the mileage entity.
en_chargeForce the “charging” entity (on/off sensor or charging state).
brancheForce the “plugged in” entity.
puissanceForce the charging power entity (kW or W).
limiteForce the charge limit or target entity (%).
temps_restantForce the remaining charging time entity (minutes, hours or seconds).
verrouForce the lock entity: a lock. entity or a sensor of the “lock” class.
positionForce the location tracker (device_tracker.), to show “At home”.
temp_habitacleForce the cabin temperature sensor.
pression_av_g, pression_av_d, pression_ar_g, pression_ar_dForce each tyre pressure sensor (front left, front right, rear left, rear right).

Good to know

Tested with simulated states, not with every brand

The card's logic was tested with simulated states imitating several integration styles (English or French names, pressures in kPa or psi, known or unknown device), but not in a real Home Assistant with every brand. Integrations do not all expose the same entities: if some data is not recognised, force it with the matching variable, or write to me so I can improve the detection.

Going further

Does your car reach Home Assistant another way? Share your method: the next guides will follow your feedback.

What does a charge really cost you? Off-peak tariffs, solar, rapid chargers and XCombo codes: Watt Budget works it all out, free and without an account.

Open Watt Budget