Aller plus loin avec la carte custom:button-card

Cet article présente la carte custom:button-card, une carte très versatile, permettant de multiples paramétrages et mises en forme. Nous présenterons le principe et illustrerons l'usage avec des exemples aboutis.
Aller plus loin avec la carte  custom:button-card

Sommaire

Introduction

Cet article a pour but de vous initier à l'utilisation d'une carte personnalisée extrêmement puissante qui, à elle seule, va vous permettre de réaliser vos tableaux de bord les plus aboutis graphiquement.

Je veux parler ici de la carte suivante :

GitHub - custom-cards/button-card: ❇️ Lovelace button-card for home assistant
❇️ Lovelace button-card for home assistant. Contribute to custom-cards/button-card development by creating an account on GitHub.

Je ne vais pas détailler l'installation de la carte, ceci étant parfaitement décrit dans la documentation. Le premier chapitre va rappeler (ou donner) les principales notions à maitriser afin de mettre en œuvre cette carte. Nous irons ensuite plus loin avec des exemples concrets d'implémentations présentant chacun des points particuliers.

Je vous invite tout d'abord à lire la documentation fournie avec la carte, documentation qui est très fournie. Je vais reprendre dans ce chapitre les différents éléments la constituant afin que vous puissiez rapidement commencer à l'utiliser.

Présentation de la carte

Une carte "bouton" très évoluée

À la base, il s'agit d'une carte "bouton" associant des éléments graphiques et des actions lors du clic. Ainsi, elle s'utilise très simplement pour toutes les entités permettant le basculement ("toggle") d'un état "on" à un état "off" et inversement. C'est le cas notamment des entités de type interrupteurs ("switch") ou des entités de type lumières ("light"). Vous verrez tout au long de cette publication que les possibilités de cette carte permettent d'aller bien au-delà du simple bouton.

La carte est constituée de plusieurs éléments graphiques pouvant (ou pas) être utilisés et affichés :

  • le support (appelé "card")
  • le nom ("name")
  • une icône ou une image ("icon" ou "entity_picture")
  • une étiquette ("label")
  • un état ("state")
  • etc.

On pourra définir pour chaque élément différentes propriétés et attributs. La carte pourra être liée à une "entité" et on pourra définir les actions à effectuer lors du clic, du double clic, d'un clic maintenu, etc.

Des images seront plus parlantes, voilà ce que vous obtiendrez en ajoutant une custom:button-card sans autres précisions :

Nous obtenons donc le support vide de tout élément.

image|690x251

Pour ajouter un nom à la carte, il suffit donc d'ajouter au code une ligne définissant le nom (celui-ci sera automatiquement ajouté et positionné au centre de la carte).

Pour d'autres éléments, il faudra en plus spécifier de les afficher sur la carte :

image|690x308


On peut définir des éléments et décider de ne pas les afficher, ou alors décider de les afficher ou les masquer de manière conditionnelle (dans l'exemple ci-dessous, on affiche une icône différente en fonction de l'état d'une entrée de type "Interrupteur").
Etat de l'entité = "on" :

image|477x256


Etat de l'entité = "off" :

image|476x255

Le code :

type: custom:button-card
name: ceci est une button-card
icon: |
  [[[
    return states['input_boolean.lave_linge'].state === 'on' ? 'mdi:plus-circle' : 'mdi:minus-circle';
  ]]]
label: ceci est une étiquette
show_label: true
show_name: false
show_icon: true

Nous reviendrons ultérieurement sur l'affichage conditionnel.
Des propriétés vont pouvoir être déterminées directement, par exemple le ratio entre largeur et hauteur de la carte support ("aspect_ratio"), la taille de l'icône en pixels ou en pourcentage ("size"), etc. :

image|690x346

La liste des différentes propriétés ainsi que leurs buts et fonctionnements se trouvent dans le tableau "Main option" de la doc de la carte.

Nous allons nous attarder un peu plus longtemps sur deux propriétés essentielles de cette custom:button-card : la propriété "styles" et la propriété "state".

La propriété "styles"

Cette propriété va permettre de définir les attributs de chacun des éléments constitutifs de la carte (la card, le name, l'icon, le label, etc.).

Mettons que nous voulions faire un simple bouton rond avec une icône "power" (mdi:power), nous allons définir dans les styles du support ("card") l'attribut "aspect-ratio" (qui correspond à la propriété "aspect_ratio") à 1/1, l'attribut "border-radius" à 50%, et l'attribut "width" (la largeur de la carte) à 100px (pixels) pour ne pas avoir un bouton immense. Nous ajouterons une bordure bleue solide de 5 pixels d'épaisseur avec les attributs "border-width", "border-style" et "border-color". Nous allons ensuite définir l'attribut "width" de l'icône (nous aurions pu aussi jouer avec la propriété "size") et son attribut "color" :

image|690x494

Les styles de bordure possibles sont :
- border-style: solid : bordure pleine (qui est le style de bordure par défaut) (1)
- border-style: dashed : bordure en pointillés (2)
- border-style: dotted : bordure en points (3)
- border-style: double : bordure double (4)
- border-style: groove : effet sculpté (5)
- border-style: ridge : effet surélevé (6)
- border-style: inset : effet enfoncé (7)
- border-style: outset : effet en relief (8)

image|115x111

La couleur de la bordure peut se définir par son nom : - border-color: red, par la spécification des valeurs des trois composantes RGB ("red", "green" et blue") : rgb(255,0,0,), en ajoutant la composante Alpha (transparence) : rgba(255,0,0,0.5) ou par son code HTML : "#FF0000".

Le rayon de bordure peut être défini en pixels (- border-radius: 20px) ou en pourcentage (border-radius: 20%). Un border-radius de 50% sur une carte ayant un aspect-ratio de 1/1 donne une carte circulaire. On peut spécifier le border-width, le border-color et le border-style sur une seule ligne de la manière suivante : - border: 3px rgba(211,211,211,0.8) outset :

image|113x111


De la même manière, on peut spécifier l'épaisseur, la couleur et le style de la bordure de chacun des côtés de la carte :

Ci-dessous, la liste des éléments auxquels vont pouvoir être appliqués des styles :

1 - Liste des attributs de "styles" pour l'élément "card" :

📏 Taille et dimensions

  • width : largeur de la carte (par exemple : 80px, 50%)
  • height : hauteur de la carte (par exemple : 80px, auto)
  • max-width : largeur maximale (par exemple : 100px, 100%)
  • max-height : hauteur maximale (par exemple : 100px, 100%)
  • min-width : largeur minimale (par exemple : 50px)
  • min-height : hauteur minimale (par exemple : 50px)
  • aspect-ratio : rapport largeur/hauteur (par exemple : 1/1 pour un carré)

🎨 Couleurs et fonds

  • background : couleur ou image de fond (par exemple : red, url(/local/image.png))
  • background-color : couleur de fond (par exemple : rgba(255, 0, 0, 0.5))
  • box-shadow : ombre portée (par exemple : 0px 4px 6px rgba(0, 0, 0, 0.3))

🖋️ Bordures et formes

  • border-radius : arrondi des coins (par exemple : 10px, 50% pour un cercle)
  • border : bordure (par exemple : 2px solid white)
  • outline : contour extérieur (par exemple : 2px dashed red)

🖼️ Espacement et alignement

  • padding : marge intérieure (par exemple : 10px, 5px 10px)
  • margin : marge extérieure (par exemple : 5px, auto pour centrer)
  • align-self : alignement dans une disposition flex/grid (par exemple : center, flex-end)

📌 Position et affichage

  • position : relative, absolute, fixed
  • top, left, right, bottom : positionnement (par exemple : 10px, 50%)
  • display : flex, grid, block, inline-block
  • justify-content : alignement horizontal (par exemple : center, space-between)
  • align-items : alignement vertical (par exemple : center, flex-start)

2 - Liste des attributs de "styles" pour l'élément "icon" :

📏 Taille et dimensions

  • width : largeur de l'icône (par exemple : 40px, 50%)
  • height : hauteur de l'icône (par exemple : 40px, auto)
  • max-width : largeur max (par exemple : 100px)
  • max-height : hauteur max (par exemple : 100px)

🎨 Couleurs et apparence

  • color : couleur de l'icône (par exemple : red, rgb(255,140,0), var(--paper-item-icon-color))
  • opacity : transparence (par exemple : 0.5 pour semi-transparent)
  • filter : effets CSS (par exemple : brightness(1.2), grayscale(100%), drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.5)))

🖼️ Espacement et position

  • margin : marge extérieure (par exemple : 5px, auto)
  • padding : marge intérieure (par exemple : 10px)
  • align-self : alignement dans flex/grid (par exemple : center, flex-start)

📌 Rotation et transformation

  • transform : transformation CSS (par exemple : rotate(45deg), scale(1.2))
  • rotate : angle de rotation (par exemple : 90deg)

3 - Liste des attributs de "styles" pour les éléments "name", "label" et "state" :

  • color : couleur du texte (par exemple : color: red)
  • font-size : taille de la police (par exemple : font-size: 16px)
  • font-weight : gras / fin (par exemple : font-weight: bold)
  • text-align : alignement (par exemple : text-align: center)
  • text-transform : majuscules / minuscules (par exemple : text-transform: uppercase)
  • text-shadow : ombre du texte (par exemple : text-shadow: 2px 2px 4px black)
  • margin : espacement extérieur (par exemple : margin: 5px, margin-left: 5px)
  • padding : espacement extérieur (par exemple : padding: 5px, padding-top: 5px)
  • display : affichage (par exemple : display: none) cache le nom ou l'étiquette ce qui correspond à la propriété "show_name: false" ou "show_label: false"

4 - Liste des attributs de "styles" pour l'élément "grid" :

L'élément "grid" permet de disposer les éléments "icon" ou "entity_picture", "name", "state" et "label" dans une grille non visible. Les attributs sont les suivants :

  • grid-template-areas : définit l'agencement des zones (par exemple : '"i" "n" "s" "l"')
  • grid-template-columns : largeur des colonnes (par exemple : 1fr 2fr)
  • grid-template-rows : hauteur des lignes (par exemple : auto auto)
  • gap : espacement entre les éléments (par exemple : gap: 5px)
  • align-items : alignement vertical (par exemple : align-items: center)
  • justify-items : alignement horizontal (par exemple : justify-items: center)

Quelques exemples :

Tous les éléments alignés à la suite dans une seule colonne (c'est la disposition par défaut sans grille) :

styles:
  grid:
    - grid-template-areas: '"i" "n" "s" "l"'
    - grid-template-columns: 1fr
    - grid-template-rows: auto auto auto auto

Icône à gauche et texte à droite :

styles:
  grid:
    - grid-template-areas: '"i n" "i s" "i l"'
    - grid-template-columns: 60px auto
    - align-items: center

Icône en haut, nom et état côte à côte, étiquette en bas :

styles:
  grid:
    - grid-template-areas: '"i i" "n s" "l l"'
    - grid-template-columns: 1fr 1fr
    - grid-template-rows: auto auto auto
    - gap: 5px

5 - Liste des attributs de "styles" pour l'élément "lock" :

  • color : couleur du cadenas (par exemple : color: red)
  • position : position absolue / relative (par exemple : position: absolute)
  • top, right, bottom, left : positionnement (par exemple : top: 10px; right: 10px)
  • opacity : transparence (0 : invisible, 1 : complétement opaque) (par exemple : opacity: 0.5)
  • transform : transformation / rotation (par exemple : transform: scale(1.5))
  • display : affichage (par exemple : display: none) cache le cadenas

6 - Liste des attributs de "styles" pour l'élément "custom_fields" :

Nous aborderons ultérieurement dans divers exemples concrets ces attributs.

La propriété "state"

Cette propriété va permettre de modifier les styles en fonction de l'état "on" ou "off" d'une entité liée à la carte. Elle s'utilise avec des entités "basculables", par exemple des entités de type "interrupteur" (switch) ou de type "lumière" (light).

Voici un exemple d'emploi de cette propriété avec une entité basée sur une entrée de type "interrupteur" (nommée "input_boolean.lave_linge"). Le passage de cette entité de "off" à "on" va changer la couleur de l'icône, ajouter un "halo" autour de la carte (attribut "box-shadow") et modifier le style de la bordure (passage de "inset" à "outset").

type: custom:button-card
entity: input_boolean.lave_linge
icon: mdi:power
show_icon: true
show_state: false
show_name: false
size: 80%
state:
  - value: "on"
    styles:
      card:
        - border: 4px outset rgba(211,211,211,0.8)
        - box-shadow: 0px 0px 10px 4px rgba(0,255,0,0.8)
      icon:
        - color: rgba(0,164,0,1.0)
styles:
  card:
    - aspect-ratio: 1/1
    - width: 80px
    - border-radius: 50%
    - border: 4px inset rgba(211,211,211,0.8)
    - box-shadow: 2px 2px 4px 2px rgba(28,28,28,0.3)
  icon:
    - color: dimgray

L'aspect du bouton quand l'entité (input_boolean.lave_linge) est "off" :

L'aspect du bouton quand l'entité est "on" :

Le clic sur le bouton basculera automatiquement l'entité de "off" à "on" sans avoir besoin de déterminer l'action du clic ("tap_action").

"custom_fields" et "templates" (modèles)

Dans ce nouveau chapitre, nous allons aborder un élément essentiel de custom:button-card : l'élément "custom_fields" (champs personnalisés).

Cet élément va permettre d'incorporer à notre carte "button-card" n'importe quelle autre carte (native ou personnalisée) et notamment d'autres "button-cards"...

Illustration avec une carte des polluants

Pour aborder cet élément, nous allons créer de A à Z une carte permettant d'afficher les données atmosphériques issue d'Atmo France (recueil de données du niveau de concentration atmosphérique d'un certain nombre de polluants et de certains pollens).
Ces données vont être importées dans Home Assistant via l'intégration atmofrance (https://github.com/sebcaps/atmofrance).

Afin d'aborder les champs personnalisés, nous allons créer cette carte qui permettra en un coup d'œil de visualiser les informations issues de l'intégration :

Nous pouvons trouver sur la page du github de l'intégration les informations suivantes :

"L'intégration expose les données d'Atmo France pour une commune donnée. Les données exposées pour la pollution de l'air sont :

  • Niveau de pollution Dioxyde d'Azote (NO2)
  • Niveau de pollution Ozone (O3)
  • Niveau de pollution Dioxyde de Soufre (SO2)
  • Niveau de pollution Particules fines <2.5 µm (Pm25)
  • Niveau de pollution Particules fines <10 µm (Pm10)
  • Niveau global de qualité de l'air

Les données exposées pour les pollens sont :

  • Concentration en Ambroisie (µg/m3)
  • Concentration en Armoise (µg/m3)
  • Concentration en Aulne (µg/m3)
  • Concentration en Bouleau (µg/m3)
  • Concentration en Graminée (µg/m3)
  • Concentration en Olivier (µg/m3)
  • Niveau Ambroisie
  • Niveau Armoise
  • Niveau Aulne
  • Niveau Bouleau
  • Niveau Graminée
  • Niveau Olivier
  • Qualité globale Pollen

Sont disponibles, les données pour le jour courant (J) ainsi que les prévisions pour le jour suivant (J+1)."

Nous aborderons dans ce même chapitre un autre aspect essentiel de custom:button-card : la possibilité de faire des modèles (templates).

Création de la carte de base

Nous allons donc partir d'une custom:button-card vierge qui servira de support aux différents champs personnalisés (custom_fields) permettant d'afficher les données.

Nous commençons donc par définir le type de carte ("type: custom:button-card") puis nous ajoutons son nom ("name: Données atmosphériques") et nous allons définir les attributs des éléments "card" et "name" avec la propriété "styles".

Afin d'avoir un affichage dynamique en fonction de la résolution de l'écran (ordinateur, tablette ou téléphone portable) sur lequel la carte sera affichée, nous définirons certains paramètres selon si la largeur de la fenêtre d'affichage sera ou pas supérieure à 600 pixels : pour ce faire nous utiliserons la propriété JavaScript "window.innerWidth" afin de changer certains attributs (aspect-ratio de la carte, taille de la police et marge haute pour le nom) à l'aide d'opérateurs ternaires (l'expression s'écrira de la manière suivante : condition ? valeurSiVrai : valeurSiFaux;").

Pour modifier le rapport "hauteur/largeur" de la carte en fonction de l'écran, nous utiliserons le code suivant :

type: custom:button-card
name: Données atmosphériques
styles:
  card:
    - aspect-ratio: "[[[return window.innerWidth <= 600 ? '1/1.1' : '1/1']]]"

Nous définissons donc de cette manière la valeur de l'attribut "aspect-ratio" de l'élément "card" en fonction de la condition window.innerWidth <= 600. Si la condition est vrai (affichage sur téléphone portable), nous définissons la valeur de l'aspect-ratio à 1/1.1 et sinon la valeur de l'aspect-ratio sera définie à 1/1 (carte carré).

Nous allons ensuite définir les autres attributs de la carte permettant d'obtenir le visuel voulu :

- border-radius: 10px : permet de définir le rayon de l'arrondi des angles de la carte.

- box-shadow: 4px 4px 8px rgba(32, 32, 32, 0.5) : permet de définir l'ombre portée de la carte, le premier nombre (4px) spécifie le décalage horizontal de l'ombre par rapport à l'élément. Une valeur positive déplace l'ombre vers la droite, tandis qu'une valeur négative la déplace vers la gauche. Le deuxième nombre (4px) spécifie le décalage vertical de l'ombre. Une valeur positive déplace l'ombre vers le bas, tandis qu'une valeur négative la déplace vers le haut. Le troisième nombre (8px) définit le rayon de flou de l'ombre. Plus cette valeur est grande, plus les bords de l'ombre seront flous et étendus. rgba(32,32,32,0.5), ce composant spécifie la couleur de l'ombre en utilisant la notation RGBA (Red, Green, Blue, Alpha). Les valeurs 32,32,32 définissent la couleur de l'ombre, ici un gris foncé. 0.5, c'est le niveau d'opacité (alpha) de l'ombre. Une valeur de 0.5 signifie que l'ombre est semi-transparente.

- border: 1px rgba(32,32,32,0.5) outset : permet de définir une bordure de 1 pixel d'épaisseur, de couleur gris foncé semi-transparente, avec un style "outset" qui donne un effet de relief à l'élément.

- background: >-
linear-gradient(135deg, rgba(64,64,64,1) 0%, rgba(96,96,96,1) 25%, rgba(128,128,128,1) 100%)
: permet de définir le remplissage de la carte avec un dégradé linéaire qui passe progressivement d'un gris moyen à un gris plus clair, en suivant une direction diagonale de haut à gauche vers bas à droite.

- pointer-events: none : permet de définir l'absence d'évènement lors d'un clic.

Nous allons maintenant définir les attributs du nom et pour commencer, la taille de la police utilisée que nous allons modifier dynamiquement en fonction de la largeur de l'écran sur lequel sera affichée la carte :

type: custom:button-card
name: Données atmosphériques
styles:
  card:
    - aspect-ratio: "[[[return window.innerWidth <= 600 ? '1/1.1' : '1/1']]]"
    - background: >-
        linear-gradient(135deg, rgba(64,64,64,1) 0%, rgba(96,96,96,1) 25%,
        rgba(128,128,128,1) 100%)
    - border-radius: 10px
    - box-shadow: 4px 4px 8px rgba(32,32,32,0.5)
    - border: 1px rgba(32,32,32,0.5) outset
    - pointer-events: none
  name:
    - font-size: "[[[return window.innerWidth <= 600 ? '0.8em' : '1.1em']]]"

Nous allons à nouveau utiliser une expression JavaScript avec en condition "window.innerWidth <= 600", en valeur si vrai "0.8em" et en valeur si faux "1.1em". La taille de la police est donnée en unité relative (em), 1.1em correspondant pour cette carte à 16.8 pixels.

Nous allons ensuite définir les autres attributs du nom :

- font-weight: bold (police en gras)

- color: lightgray (couleur de la police)

- text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.9) (ombre portée du texte)

- align-self: start (alignement vertical)

- justify-self: start (alignement horizontal)

- padding-left: 3% (éloignement de la bordure gauche de la carte)

- margin-top: "[[[return window.innerWidth <= 600 ? '-10px' : '-15px']]]"

Une fois encore, nous utilisons une expression JavaScript afin de définir dynamiquement la position du nom par rapport à la marge haute (margin-top) de la carte : -10 pixels si l'écran d'affichage à une largeur inférieure ou égale à 600 pixels et -15 pixels dans le cas contraire.

Nous avons donc maintenant notre carte support avec le visuel que nous voulions obtenir :

Le code :

type: custom:button-card
name: Données atmosphériques
styles:
  card:
    - aspect-ratio: "[[[return window.innerWidth <= 600 ? '1/1.1' : '1/1']]]"
    - background: >-
        linear-gradient(135deg, rgba(64,64,64,1) 0%, rgba(96,96,96,1) 25%,
        rgba(128,128,128,1) 100%)
    - border-radius: 10px
    - box-shadow: 4px 4px 8px rgba(32,32,32,0.5)
    - border: 1px rgba(32,32,32,0.5) outset
    - pointer-events: none
  name:
    - font-size: "[[[return window.innerWidth <= 600 ? '0.8em' : '1.1em']]]"
    - font-weight: bold
    - color: lightgray
    - text-shadow: 2px 2px 2px rgba(0,0,0,0.9)
    - align-self: start
    - justify-self: start
    - padding-left: 3%
    - margin-top: "[[[return window.innerWidth <= 600 ? '-10px' : '-15px']]]"

Ajout du sélecteur de jours

Nous allons maintenant passer au vif du sujet : les custom_fields (champs personnalisés). Nous allons donc commencer par ajouter des custom_fields qui permettront d'afficher les données du jour ou celles du lendemain avec une zone d'affichage indiquant s'il s'agit des données du jour ("J") ou celles du lendemain ("J+1"). Nous allons donc ajouter un custom_fields pour le bouton avec une flèche pointant vers la gauche, un custom_fields pour le bouton avec la flèche pointant vers la droite et un custom_fields pour afficher le jour en cours.

Le principe de mise en œuvre des custom_fields est toujours le même (c'est un point que ni ChatGPT, ni Mistral AI, ni Copilot n'ont intégré à ce jour) :

On commence par définir le custom_fields à la racine de la carte support en l'introduisant avec "custom_fields:" puis en dessous avec un décalage (indentation) de 2 espaces le nom du custom_fields puis en dessous avec un décalage de 2 espaces supplémentaire "card:" et encore en dessous avec un nouveau décalage de 2 espaces "type:" suivi du type de carte que nous voulons ajouter. (Pour rappel, un custom_fields permet d'intégrer dans une custom:button-card n'importe quelle carte existante, native ou personnalisée).

Dans notre exemple, nous allons utiliser des custom:button-cards pour tous nos custom_fields. Nous appellerons le premier des trois custom_fields "previous", le deuxième "day" et le dernier "next".

type: custom:button-card
name: Données atmosphériques
custom_fields:
  previous:
    card:
      type: custom:button-card
  day:
    card:
      type: custom:button-card
  next:
    card:
      type: custom:button-card
styles:
  card:
    - aspect-ratio: "[[[return window.innerWidth <= 600 ? '1/1.1' : '1/1']]]"
    - background: >-
        linear-gradient(135deg, rgba(64,64,64,1) 0%, rgba(96,96,96,1) 25%,
        rgba(128,128,128,1) 100%)
    - border-radius: 10px
    - box-shadow: 4px 4px 8px rgba(32,32,32,0.5)
    - border: 1px rgba(32,32,32,0.5) outset
    - pointer-events: none
  name:
    - font-size: "[[[return window.innerWidth <= 600 ? '0.8em' : '1.1em']]]"
    - font-weight: bold
    - color: lightgray
    - text-shadow: 2px 2px 2px rgba(0,0,0,0.9)
    - align-self: start
    - justify-self: start
    - padding-left: 3%
    - margin-top: "[[[return window.innerWidth <= 600 ? '-10px' : '-15px']]]"

A ce stade, nous verrons sur la carte support, un petit point blanc en bas à droite de la carte (en fait, il y a 3 points superposés, chaque point correspondant à un des custom_fields) :

Pour résumer, la définition des custom_fields se fait à la racine de la carte support. Il faudra ensuite gérer le positionnement de ceux-ci sur la carte support. La gestion du positionnement se fait au niveau des "styles:" de la carte support de la manière suivante :

styles:
  card:
    - aspect-ratio: ...
  name:
    - font-size: ...
  custom_fields:
    previous: (nom du custom_fields)
      - position: absolute (ou relative)
      - top (ou bottom): 0.7% (distance par rapport au bord haut ou au bord bas
        de la carte support exprimée en pourcentage ou en pixels)
      - right: 174px (distance par rapport au bord droit ou au bord gauche de 
        la carte support exprimée en pourcentage ou en pixels)

Là encore, nous définissons la position de chaque custom_fields par rapport au bord droit de la carte support, dynamiquement en fonction de la largeur de l'écran sur lequel est affichée la carte.

Afin de pouvoir sélectionner quelles valeurs seront affichées (celles du jour courant ou celles du lendemain), nous allons créer une entrée de type "Liste déroulante" dans laquelle nous allons ajouter deux valeurs : "today" et "tomorrow"

Une fois l'entrée créée, nous allons pouvoir définir les boutons de façon à obtenir le visuel suivant :

Pour le premier bouton ("previous"), nous voulons avoir une forme arrondie à gauche ayant un ratio largeur sur hauteur identique avec une icône "play" retournée horizontalement (triangle pointant vers la gauche). Nous définissons donc l'élément "icon" puis nous déterminons les attributs de l'élément "card" et ceux de l'élément "icon" avec les "styles"

previous:
  card:
    type: custom:button-card
    icon: mdi:play
    styles:
      card:
(1)     - aspect-ratio: 1/1            
        - align-items: center
(2)     - width: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
        - background-color: gray
(3)     - border-radius: 0
(3)     - border: none
(3)     - border-top-left-radius: 10px
(3)     - border-bottom-left-radius: 10px
      icon:
(4)     - width: 100%
(5)     - rotate: 180deg
        - opacity: 50%

(1) nous fixons le rapport largeur sur hauteur à 1/1 (forme carrée) ;

(2) nous fixons la largeur du bouton en fonction de la largeur de l'écran sur lequel sera affichée la carte (22 pixels si la fenêtre d'affichage à une largeur inférieure à 600 pixels et 26 pixels dans le cas contraire) ;

(3) nous fixons l'arrondi global du bouton à 0 et supprimons la bordure visible puis nous définissons un arrondi avec un rayon de 10 pixels pour le coin supérieur gauche et le point inférieur gauche ;

(4) nous définissons la largeur de l'icône à 100% ;

(5) nous retournons l'icône de 180 degrés ;

Nous allons maintenant définir l'opacité du bouton, le comportement sur clic et l'aspect du curseur de la souris en fonction de l'item sélectionné dans notre liste déroulante créée juste avant. Le but est d'avoir un bouton "désactivé" (opacité diminuée de moitié, curseur en forme de flèche et aucune action sur clic) si l'item sélectionné est "today" et un bouton "activé" (bouton "cliquable" avec une opacité normale et un curseur de souris en forme de doigt lors du survol) si l'item sélectionné est "tomorrow" à l'aide d'expressions ternaires :

- opacity: >-
    [[[return states['input_select.select_day'].state === 'today' ? '0.5' : '1']]]
- pointer-events: >-
    [[[return states['input_select.select_day'].state === 'today' ? 'none' : 'auto']]]
- cursor: >-
    [[[return states['input_select.select_day'].state === 'today' ? 'auto' : 'pointer']]]

Pour le bouton "day", nous allons procéder de la même manière que le bouton "previous" en ajoutant un élément "name" qui servira à afficher "J" si l'item sélectionné dans la liste déroulante est "today" et "J+1" si l'item sélectionné est "tomorrow", nous n'ajouterons pas d'élément "icon" et le bouton sera non "cliquable" (pas d'action lors d'un clic et curseur en forme de flèche lors du survol de la souris) :

day:
  card:
    type: custom:button-card
    name: >-
      [[[return states['input_select.select_day'].state === 'today' ? 'J' :     'J+1']]]
    styles:
      card:
        - align-items: center
        - width: 44px
        - height: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
        - background-color: gray
        - border-radius: 0
        - border: none
        - pointer-events: none
        - cursor: auto
      name:
        - font-size: 1em
        - font-weight: bold
        - opacity: 50%

Nous allons définir le bouton "next" de la même manière que le bouton "previous" (sans faire de rotation de l'icône) :

next:
  card:
    type: custom:button-card
    icon: mdi:play
    styles:
      card:
        - aspect-ratio: 1/1
        - align-items: center
        - width: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
        - background-color: gray
        - border-radius: 0
        - border: none
        - border-top-right-radius: 10px
        - border-bottom-right-radius: 10px
        - opacity: >-
            [[[return states['input_select.select_day'].state === 'tomorrow' ?
            '0.5' : '1']]]
        - pointer-events: >-
            [[[return states['input_select.select_day'].state === 'tomorrow' ?
            'none' : 'auto']]]
        - cursor: >-
            [[[return states['input_select.select_day'].state === 'tomorrow' ?
            'auto' : 'pointer']]]
      icon:
        - width: 100%
        - opacity: 50%

Pour avoir un bouton "previous" et un bouton "next" fonctionnels, nous allons ajouter ce que doivent faire ces boutons lors d'un clic ("tap_action") :

tap_action:
  action: call-service
  service: input_select.select_option
  service_data:
    entity_id: input_select.select_day
    option: today (bouton "previous" ou tomorrow pour le bouton "next")

Un clic sur le bouton va appeler le service (call-service) : "input_select.select_option" (action pour sélectionner une option d'une liste déroulante). Nous définissons quelle est la liste déroulante concernée dans "service_data:" et déterminons l'option (item) devant être sélectionnée via "option:".

Voici le code complet actuel :

type: custom:button-card
name: Données atmosphériques
custom_fields:
  previous:
    card:
      type: custom:button-card
      icon: mdi:play
      tap_action:
        action: call-service
        service: input_select.select_option
        service_data:
          entity_id: input_select.select_day
          option: today
      styles:
        card:
          - aspect-ratio: 1/1
          - align-items: center
          - width: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - border-top-left-radius: 10px
          - border-bottom-left-radius: 10px
          - opacity: >-
              [[[return states['input_select.select_day'].state === 'today' ?
              '0.5' : '1']]]
          - pointer-events: >-
              [[[return states['input_select.select_day'].state === 'today' ?
              'none' : 'auto']]]
          - cursor: >-
              [[[return states['input_select.select_day'].state === 'today' ?
              'auto' : 'pointer']]]
        icon:
          - width: 100%
          - rotate: 180deg
          - opacity: 50%
  day:
    card:
      type: custom:button-card
      name: >-
        [[[return states['input_select.select_day'].state === 'today' ? 'J' :
        'J+1']]]
      styles:
        card:
          - align-items: center
          - width: 44px
          - height: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - pointer-events: none
          - cursor: auto
        name:
          - font-size: 1em
          - font-weight: bold
          - opacity: 50%
  next:
    card:
      type: custom:button-card
      icon: mdi:play
      tap_action:
        action: call-service
        service: input_select.select_option
        service_data:
          entity_id: input_select.select_day
          option: tomorrow
      styles:
        card:
          - aspect-ratio: 1/1
          - align-items: center
          - width: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - border-top-right-radius: 10px
          - border-bottom-right-radius: 10px
          - opacity: >-
              [[[return states['input_select.select_day'].state === 'tomorrow' ?
              '0.5' : '1']]]
          - pointer-events: >-
              [[[return states['input_select.select_day'].state === 'tomorrow' ?
              'none' : 'auto']]]
          - cursor: >-
              [[[return states['input_select.select_day'].state === 'tomorrow' ?
              'auto' : 'pointer']]]
        icon:
          - width: 100%
          - opacity: 50%
styles:
  card:
    - aspect-ratio: "[[[return window.innerWidth <= 600 ? '1/1.1' : '1/1']]]"
    - background: >-
        linear-gradient(135deg, rgba(64,64,64,1) 0%, rgba(96,96,96,1) 25%,
        rgba(128,128,128,1) 100%)
    - border-radius: 10px
    - box-shadow: 4px 4px 8px rgba(32,32,32,0.5)
    - border: 1px rgba(32,32,32,0.5) outset
    - pointer-events: none
  name:
    - font-size: "[[[return window.innerWidth <= 600 ? '0.8em' : '1.1em']]]"
    - font-weight: bold
    - color: lightgray
    - text-shadow: 2px 2px 2px rgba(0,0,0,0.9)
    - align-self: start
    - justify-self: start
    - padding-left: 3%
    - margin-top: "[[[return window.innerWidth <= 600 ? '-10px' : '-15px']]]"
  custom_fields:
    previous:
      - position: absolute
      - top: 0.7%
      - right: "[[[return window.innerWidth <= 600 ? '170px' : '174px']]]"
    day:
      - position: absolute
      - top: 0.7%
      - right: "[[[return window.innerWidth <= 600 ? '124px' : '126px']]]"
    next:
      - position: absolute
      - top: 0.7%
      - right: "[[[return window.innerWidth <= 600 ? '100px' : '96px']]]"

Ajout des cadres

Nous allons maintenant ajouter les cadres comprenant les polluants atmosphériques et les pollens pour l'autre. Les cadres étant identiques, nous allons définir le premier et faire un copié-collé pour le deuxième. Ces cadres seront à nouveau des custom_fields basés sur des custom:button-cards et ils serviront de supports aux custom_fields des différents polluants et pollens.

Voici le code du premier cadre (à ajouter à la suite des trois premiers custom_fields) :

indice_air:
  card:
    type: custom:button-card
    name: Qualité<br>de l'air
    styles:
      card:
        - background-color: rgba(96,96,96,1)
        - height: "[[[return window.innerWidth <= 600 ? '377px' : '448px']]]"
        - border: 2px rgba(32,32,32,0.5) inset
        - border-radius: 5px
        - pointer-events: none
      name:
        - font-size: 0.8em
        - font-weight: bold
        - color: gray
        - text-shadow: 1px 1px 1px rgba(0,0,0,0.9)
        - align-self: start
        - justify-self: start
        - padding-left: 3%
        - margin-top: "-3px"

Nous allons ensuite faire un copié-collé de ce code à la suite et nous changerons le nom du custom_fields (de "indice_air" à "indice_pollen") et le "name" de "Qualité de l'air" à "Pollens". Ensuite, nous positionnerons ces deux cadres au niveau des styles de la carte support :

type: custom:button-card
name: Données atmosphériques
custom_fields:
  previous:
    card:
      type: custom:button-card
      icon: mdi:play
      tap_action:
        action: call-service
        service: input_select.select_option
        service_data:
          entity_id: input_select.select_day
          option: today
      styles:
        card:
          - aspect-ratio: 1/1
          - align-items: center
          - width: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - border-top-left-radius: 10px
          - border-bottom-left-radius: 10px
          - opacity: >-
              [[[return states['input_select.select_day'].state === 'today' ?
              '0.5' : '1']]]
          - pointer-events: >-
              [[[return states['input_select.select_day'].state === 'today' ?
              'none' : 'auto']]]
          - cursor: >-
              [[[return states['input_select.select_day'].state === 'today' ?
              'auto' : 'pointer']]]
        icon:
          - width: 100%
          - rotate: 180deg
          - opacity: 50%
  day:
    card:
      type: custom:button-card
      name: >-
        [[[return states['input_select.select_day'].state === 'today' ? 'J' :
        'J+1']]]
      styles:
        card:
          - align-items: center
          - width: 44px
          - height: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - pointer-events: none
          - cursor: auto
        name:
          - font-size: 1em
          - font-weight: bold
          - opacity: 50%
  next:
    card:
      type: custom:button-card
      icon: mdi:play
      tap_action:
        action: call-service
        service: input_select.select_option
        service_data:
          entity_id: input_select.select_day
          option: tomorrow
      styles:
        card:
          - aspect-ratio: 1/1
          - align-items: center
          - width: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - border-top-right-radius: 10px
          - border-bottom-right-radius: 10px
          - opacity: >-
              [[[return states['input_select.select_day'].state === 'tomorrow' ?
              '0.5' : '1']]]
          - pointer-events: >-
              [[[return states['input_select.select_day'].state === 'tomorrow' ?
              'none' : 'auto']]]
          - cursor: >-
              [[[return states['input_select.select_day'].state === 'tomorrow' ?
              'auto' : 'pointer']]]
        icon:
          - width: 100%
          - opacity: 50%
  indice_air:
    card:
      type: custom:button-card
      name: Qualité<br>de l'air
      styles:
        card:
          - background-color: rgba(96,96,96,1)
          - height: "[[[return window.innerWidth <= 600 ? '377px' : '448px']]]"
          - border: 2px rgba(32,32,32,0.5) inset
          - border-radius: 5px
          - pointer-events: none
        name:
          - font-size: 0.8em
          - font-weight: bold
          - color: gray
          - text-shadow: 1px 1px 1px rgba(0,0,0,0.9)
          - align-self: start
          - justify-self: start
          - padding-left: 3%
          - margin-top: "-3px"
  indice_pollens:
    card:
      type: custom:button-card
      name: Pollens
      styles:
        card:
          - background-color: rgba(96,96,96,1)
          - height: "[[[return window.innerWidth <= 600 ? '377px' : '448px']]]"
          - border: 2px rgba(32,32,32,0.5) inset
          - border-radius: 5px
          - pointer-events: none
        name:
          - font-size: 0.8em
          - font-weight: bold
          - color: gray
          - text-shadow: 1px 1px 1px rgba(0,0,0,0.9)
          - align-self: start
          - justify-self: start
          - padding-left: 3%
          - margin-top: "-3px"
styles:
  card:
    - aspect-ratio: "[[[return window.innerWidth <= 600 ? '1/1.1' : '1/1']]]"
    - background: >-
        linear-gradient(135deg, rgba(64,64,64,1) 0%, rgba(96,96,96,1) 25%,
        rgba(128,128,128,1) 100%)
    - border-radius: 10px
    - box-shadow: 4px 4px 8px rgba(32,32,32,0.5)
    - border: 1px rgba(32,32,32,0.5) outset
    - pointer-events: none
  name:
    - font-size: "[[[return window.innerWidth <= 600 ? '0.8em' : '1.1em']]]"
    - font-weight: bold
    - color: lightgray
    - text-shadow: 2px 2px 2px rgba(0,0,0,0.9)
    - align-self: start
    - justify-self: start
    - padding-left: 3%
    - margin-top: "[[[return window.innerWidth <= 600 ? '-10px' : '-15px']]]"
  custom_fields:
    previous:
      - position: absolute
      - top: 0.7%
      - right: "[[[return window.innerWidth <= 600 ? '170px' : '174px']]]"
    day:
      - position: absolute
      - top: 0.7%
      - right: "[[[return window.innerWidth <= 600 ? '124px' : '126px']]]"
    next:
      - position: absolute
      - top: 0.7%
      - right: "[[[return window.innerWidth <= 600 ? '100px' : '96px']]]"
    indice_air:
      - position: absolute
      - top: 7%
      - left: 2%
      - width: 47%
    indice_pollens:
      - position: absolute
      - top: 7%
      - right: 2%
      - width: 47%

Ajout de jauges

Nous allons maintenant créer un custom_fields qui va afficher l'indice global de qualité de l'air sous forme de jauge. L'indice global reprend la valeur de polluant la plus péjorative (à gauche la valeur de l'indice global pour Lyon ce jour et à droite sa valeur pour demain) :

Nous voyons dans les "Outils de développement" que les éléments qui nous intéressent vont pouvoir être récupérés dans les attributs du capteur (sensor), notamment la couleur et le libellé de l'indice. Le libellé donne la valeur de l'indice et peut prendre les valeurs "bon", Moyen", Dégradé", "Mauvais", "Très mauvais", "Extrêmement mauvais" ou "Indisponible" en l'absence de données :

Nous allons donc faire notre custom_fields basé sur une custom:button-card. Nous allons lui donner une forme de jauge, avec en couleur de fond, la couleur donnée par le sensor mais avec une transparence de 50%, une barre de remplissage ayant une longueur proportionnelle au niveau de l'indice et la couleur de celui-ci, le libellé centré dans le custom_fields et une bordure donnant un aspect "enfoncé" :

La première difficulté va être de transformer la couleur hexadécimale en couleur RGB de façon à pouvoir y appliquer la transparence :

Nous allons commencer par convertir la couleur hexadécimale (#RRGGBB) en un entier 24 bits. Nous allons donc récupérer la valeur de l'attribut "Couleur" du sensor :

  • const hex = states[variables.current_sensor]?.attributes?.Couleur : nous récupérons donc la couleur"#f0e641" ;
  • const bigint = parseInt(hex.replace('#',''), 16) : nous convertissons la chaîne hexadécimale en un nombre entier interprété en base 16 après avoir supprimé de la chaine le "#". Nous récupérons donc le nombre entier : 15790273 ;
  • nous allons ensuite extraire les composantes rouges (RR), verte (GG) et bleue (BB). Nous décalons les bits de 16 positions à droite (pour ne garder que les 8 bits de poids fort correspondant à RR) en appliquant un masque binaire (& 255) : const r = (bigint >> 16) & 255 ;
  • Nous faisons de même pour la composante verte (GG) en décalant les bits de 8 positions vers la droite : const g = (bigint >> 8) & 255 et pour la composante bleue : const b = bigint & 255 (on ne garde que les 8 bits de poids faible) ;
  • Nous obtenons donc 240 pour la composante rouge, 230 pour la composante verte et 65 pour la composante bleue.

Pour remplir la carte (background), nous allons utiliser un dégradé linéaire (de gauche à droite) avec deux couleurs : la couleur donnée par l'attribut "Couleur" du sensor et cette même couleur avec une opacité de 50%. La limite entre les deux couleurs sera donnée par l'attribut "Libellé" du sensor : 17% de la largeur de la carte pour le libellé "Bon", 33% pour le libellé "Moyen", 50% pour le libellé "Dégradé", etc. :

- background: |
    [[[
      const day = states['input_select.select_day']?.state;
      const sensor = states[day === 'tomorrow' ? 'sensor.qualite_globale_lyon_j_1' : 'sensor.qualite_globale_lyon'];
      if (!sensor || !sensor.attributes) return 'rgba(221,221,221,0.5)';
      const hex = sensor.attributes.Couleur;
      const libelle = sensor.attributes.Libellé || 'Indisponible';
      if (!hex || libelle === 'Indisponible') return 'rgba(221,221,221,0.5)';
      const levels = {
        "Bon": 17,
        "Moyen": 33,
        "Dégradé": 50,
        "Mauvais": 67,
        "Très mauvais": 83,
        "Extrêmement mauvais": 100
      };
      const percent = levels[libelle] || 0;
      if (hex.startsWith("#")) {
        const bigint = parseInt(hex.replace("#", ""), 16);
        const r = (bigint >> 16) & 255;
        const g = (bigint >> 8) & 255;
        const b = bigint & 255;
        return `linear-gradient(to right, rgba(${r},${g},${b},1) 0%, rgba(${r},${g},${b},1) ${percent}%, rgba(${r},${g},${b},0.5) ${percent}%, rgba(${r},${g},${b},0.5) 100%)`;
      }
      return hex;
    ]]]

Récupération des composantes rouge, verte et bleue à partir de "Couleur" :

const hex = sensor.attributes.Couleur;
const bigint = parseInt(hex.replace('#',''), 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;

Récupération de l'attribut "Libellé" et remplissage du custom_fields en gris clair semi-transparent si "hex" ne retourne rien ou si le libellé est "Indisponible" :

const libelle = sensor.attributes.Libellé || 'Indisponible';
if (!hex || libelle === 'Indisponible') {
  return 'rgba(221,221,221,0.5)';
}

Définition des valeurs de pourcentage fixant la limite entre couleur opaque et couleur semi-transparente pour le dégradé linéaire :

const levels = {
  'Bon': 17,
  'Moyen': 33,
  'Dégradé': 50,
  'Mauvais': 67,
  'Très mauvais': 83,
  'Extrêmement mauvais': 100
  };
const percent = levels[libelle] || 0;

Remplissage avec le dégradé (de 0% à "percent"% avec la couleur opaque et de "percent"% à 100% avec la couleur semi-transparente) :

return `linear-gradient(to right,
  rgba(${r},${g},${b},1) 0%,
  rgba(${r},${g},${b},1) ${percent}%,
  rgba(${r},${g},${b},0.5) ${percent}%,
  rgba(${r},${g},${b},0.5) 100%)`;

Si la valeur de l'input_select.select_day sélectionnée est "tomorrow" nous utilisons l'état du sensor "sensor.qualite_globale_lyon_j_1" et sinon, nous utilisons l'état du sensor "sensor.qualite_globale_lyon".

Une fois le copié-collé fait et les modifications pour la jauge globale des pollens, nous avons donc le code suivant :

type: custom:button-card
name: Données atmosphériques
custom_fields:
  previous:
    card:
      type: custom:button-card
      icon: mdi:play
      tap_action:
        action: call-service
        service: input_select.select_option
        service_data:
          entity_id: input_select.select_day
          option: today
      styles:
        card:
          - aspect-ratio: 1/1
          - align-items: center
          - width: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - border-top-left-radius: 10px
          - border-bottom-left-radius: 10px
          - opacity: >-
              [[[return states['input_select.select_day'].state === 'today' ?
              '0.5' : '1']]]
          - pointer-events: >-
              [[[return states['input_select.select_day'].state === 'today' ?
              'none' : 'auto']]]
          - cursor: >-
              [[[return states['input_select.select_day'].state === 'today' ?
              'auto' : 'pointer']]]
        icon:
          - width: 100%
          - rotate: 180deg
          - opacity: 50%
  day:
    card:
      type: custom:button-card
      name: >-
        [[[return states['input_select.select_day'].state === 'today' ? 'J' :
        'J+1']]]
      styles:
        card:
          - align-items: center
          - width: 44px
          - height: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - pointer-events: none
          - cursor: auto
        name:
          - font-size: 1em
          - font-weight: bold
          - opacity: 50%
  next:
    card:
      type: custom:button-card
      icon: mdi:play
      tap_action:
        action: call-service
        service: input_select.select_option
        service_data:
          entity_id: input_select.select_day
          option: tomorrow
      styles:
        card:
          - aspect-ratio: 1/1
          - align-items: center
          - width: "[[[return window.innerWidth <= 600 ? '22px' : '26px']]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - border-top-right-radius: 10px
          - border-bottom-right-radius: 10px
          - opacity: >-
              [[[return states['input_select.select_day'].state === 'tomorrow' ?
              '0.5' : '1']]]
          - pointer-events: >-
              [[[return states['input_select.select_day'].state === 'tomorrow' ?
              'none' : 'auto']]]
          - cursor: >-
              [[[return states['input_select.select_day'].state === 'tomorrow' ?
              'auto' : 'pointer']]]
        icon:
          - width: 100%
          - opacity: 50%
  indice_air:
    card:
      type: custom:button-card
      name: Qualité<br>de l'air
      custom_fields:
        global_air:
          card:
            type: custom:button-card
            name: >-
              [[[
                const day = states['input_select.select_day']?.state;
                const sensor = states[day === 'tomorrow' ?            'sensor.qualite_globale_lyon_j_1' : 'sensor.qualite_globale_lyon'];
                return sensor?.attributes?.Libellé || 'Indisponible']]]
            styles:
              card:
                - background: >-
                    [[[
                      const day = states['input_select.select_day']?.state;
                      const sensor = states[day === 'tomorrow' ? 'sensor.qualite_globale_lyon_j_1' : 'sensor.qualite_globale_lyon'];
                      if (!sensor || !sensor.attributes) return 'rgba(221,221,221,0.5)';
                      const hex = sensor.attributes.Couleur;
                      const libelle = sensor.attributes.Libellé || 'Indisponible';
                      if (!hex || libelle === 'Indisponible') return 'rgba(221,221,221,0.5)';
                      const levels = {
                        'Bon':17,
                        'Moyen':33,
                        'Dégradé':50,
                        'Mauvais':67,
                        'Très mauvais':83,
                        'Extrêmement mauvais':100
                      }; 
                      const percent = levels[libelle] || 0;
                      if (hex.startsWith("#")) {
                        const bigint = parseInt(hex.replace('#',''), 16);
                        const r = (bigint >> 16) & 255;
                        const g = (bigint >> 8) & 255;
                        const b = bigint & 255;
                        return `linear-gradient(to right,
                          rgba(${r},${g},${b},1) 0%,
                          rgba(${r},${g},${b},1) ${percent}%, 
                          rgba(${r},${g},${b},0.5) ${percent}%,
                          rgba(${r},${g},${b},0.5) 100%)`;
                      }
                      return hex;
                  ]]]
                - width: "[[[return window.innerWidth <= 600 ? '105px' : '140px']]]"
                - height: 30px
                - border: 2px rgba(32,32,32,0.5) inset
                - border-radius: 15px
                - pointer-events: none
              name:
                - position: absolute
                - top: 50%
                - left: 50%
                - transform: translate(-50%, -50%)
                - color: white
                - font-size: 0.6em
                - font-weight: bold
                - text-shadow: 1px 1px 2px black
      styles:
        card:
          - background-color: rgba(96,96,96,1)
          - height: "[[[return window.innerWidth <= 600 ? '377px' : '448px']]]"
          - border: 2px rgba(32,32,32,0.5) inset
          - border-radius: 5px
          - pointer-events: none
        name:
          - font-size: 0.8em
          - font-weight: bold
          - color: gray
          - text-shadow: 1px 1px 1px rgba(0,0,0,0.9)
          - align-self: start
          - justify-self: start
          - padding-left: 3%
          - margin-top: "-3px"
        custom_fields:
          global_air:
            - position: absolute
            - top: 1.5%
            - right: 3%
  indice_pollens:
    card:
      type: custom:button-card
      name: Pollens
      custom_fields:
        global_pollens:
          card:
            type: custom:button-card
            name: |
              [[[
                const day = states['input_select.select_day']?.state;
                const sensor = states[day === 'tomorrow' ? 'sensor.qualite_globale_pollen_lyon_j_1' : 'sensor.qualite_globale_pollen_lyon']; 
                return sensor?.attributes?.Libellé || 'Indisponible']]]
            styles:
              card:
                - background: |
                    [[[
                      const day = states['input_select.select_day']?.state;
                      const sensor = states[day === 'tomorrow' ? 'sensor.qualite_globale_pollen_lyon_j_1' : 'sensor.qualite_globale_pollen_lyon']; 
                      if (!sensor || !sensor.attributes) return 'rgba(221,221,221,0.5)';
                      const hex = sensor.attributes.Couleur;
                      const libelle = sensor.attributes.Libellé || 'Indisponible';
                      if (!hex || libelle === 'Indisponible') return 'rgba(221,221,221,0.5)';
                      const levels = {
                        'Très faible':17,
                        'Faible':33,
                        'Modéré':50,
                        'Elevé':67,
                        'Très élevé':83,
                        'Extrêmement elevé':100
                      };
                      const percent = levels[libelle] || 0;
                      if (hex.startsWith('#')) {
                        const bigint = parseInt(hex.replace('#',''), 16);
                        const r = (bigint >> 16) & 255;
                        const g = (bigint >> 8) & 255;
                        const b = bigint & 255;
                        return `linear-gradient(to right,
                          rgba(${r},${g},${b},1) 0%,
                          rgba(${r},${g},${b},1) ${percent}%,
                          rgba(${r},${g},${b},0.5) ${percent}%,
                          rgba(${r},${g},${b},0.5) 100%)`;
                      }
                        return hex;
                    ]]]
                - width: "[[[return window.innerWidth <= 600 ? '105px' : '140px']]]"
                - height: 30px
                - border: 2px rgba(32,32,32,0.5) inset
                - border-radius: 15px
                - pointer-events: none
              name:
                - position: absolute
                - top: 50%
                - left: 50%
                - transform: translate(-50%, -50%)
                - color: white
                - font-size: 0.6em
                - font-weight: bold
                - text-shadow: 1px 1px 2px black
      styles:
        card:
          - background-color: rgba(96,96,96,1)
          - height: "[[[return window.innerWidth <= 600 ? '377px' : '448px']]]"
          - border: 2px rgba(32,32,32,0.5) inset
          - border-radius: 5px
          - pointer-events: none
        name:
          - font-size: 0.8em
          - font-weight: bold
          - color: gray
          - text-shadow: 1px 1px 1px rgba(0,0,0,0.9)
          - align-self: start
          - justify-self: start
          - padding-left: 3%
          - margin-top: "-3px"
        custom_fields:
          global_pollens:
            - position: absolute
            - top: 1.5%
            - right: 3%
styles:
  card:
    - aspect-ratio: "[[[return window.innerWidth <= 600 ? '1/1.1' : '1/1']]]"
    - background: >-
        linear-gradient(135deg, rgba(64,64,64,1) 0%, rgba(96,96,96,1) 25%,
        rgba(128,128,128,1) 100%)
    - border-radius: 10px
    - box-shadow: 4px 4px 8px rgba(32,32,32,0.5)
    - border: 1px rgba(32,32,32,0.5) outset
    - pointer-events: none
  name:
    - font-size: "[[[return window.innerWidth <= 600 ? '0.8em' : '1.1em']]]"
    - font-weight: bold
    - color: lightgray
    - text-shadow: 2px 2px 2px rgba(0,0,0,0.9)
    - align-self: start
    - justify-self: start
    - padding-left: 3%
    - margin-top: "[[[return window.innerWidth <= 600 ? '-10px' : '-15px']]]"
  custom_fields:
    previous:
      - position: absolute
      - top: 0.7%
      - right: "[[[return window.innerWidth <= 600 ? '170px' : '174px']]]"
    day:
      - position: absolute
      - top: 0.7%
      - right: "[[[return window.innerWidth <= 600 ? '124px' : '126px']]]"
    next:
      - position: absolute
      - top: 0.7%
      - right: "[[[return window.innerWidth <= 600 ? '100px' : '96px']]]"
    indice_air:
      - position: absolute
      - top: 7%
      - left: 2%
      - width: 47%
    indice_pollens:
      - position: absolute
      - top: 7%
      - right: 2%
      - width: 47%

Ajout d'une première mini-carte

Nous allons maintenant créer les "mini-cartes" servant à afficher les différents polluants et leurs niveaux ainsi que les pollens, leurs niveaux et leurs concentrations.

Pour les pollens, la concentration est donnée par un autre sensor :

Voici donc le code que nous allons utiliser pour la première "mini-carte" des pollens (pollens d'aulne). Nous allons utiliser une grille pour afficher l'un au-dessus de l'autre le name (n), l'icon (i), le label (l) et un custom_fields concentration pour afficher la concentration :

type: custom:button-card
name: Pollens<br>d'aulne
icon: mdi:tree
show_label: true
label: |
  [[[
    const day = states["input_select.select_day"]?.state;
    const sensor = day === 'tomorrow' ? 'sensor.niveau_aulne_lyon_j_1' : 'sensor.niveau_aulne_lyon';
    let libelle = states[sensor]?.attributes?.Libellé || 'Indisponible';
    if (libelle.includes('Extrêmement ')) {
      libelle = libelle.replace('Extrêmement ', 'Extrêmement<br>');
    }
    return `<span style="font-size: 1.1em; font-weight: bold;">Niveau :</span><br>${libelle}`;
  ]]]
custom_fields:
  concentration:
    card:
      type: custom:button-card
      name: |
        [[[
          const day = states['input_select.select_day']?.state;
          const sensor = day === 'tomorrow' ? 'sensor.concentration_aulne_lyon_j_1' : 'sensor.concentration_aulne_lyon';
          return states[sensor]?.state ? `${states[sensor].state} µg/m³` : 'N/A';
        ]]]
      styles:
        card:
          - width: "[[[ return window.innerWidth <= 600 ? '90px' : '100px' ]]]"
          - height: 20px
          - padding: 2px
          - border: none
          - border-radius: 0px
          - background: none
        name:
          - font-size: "[[[ return window.innerWidth <= 600 ? '0.8em' : '0.85em' ]]]"
          - font-weight: bold
          - padding: 2px
          - line-height: 1
          - color: |
              [[[
                const day = states['input_select.select_day']?.state;
                const sensor = day === 'tomorrow' ? 'sensor.niveau_aulne_lyon_j_1' : 'sensor.niveau_aulne_lyon';
                return states[sensor]?.attributes?.Couleur || '#DDDDDD';
              ]]]
styles:
  card:
    - background-color: rgba(64,64,64,0.2)
    - border: none
    - border-radius: 0px
    - height: 125px
    - width: 100px
  grid:
    - grid-template-areas: '"n" "i" "l" "concentration"'
    - grid-template-rows: |
        [[[
          const day = states['input_select.select_day']?.state;
          const sensor = day === 'tomorrow' ? 'sensor.niveau_aulne_lyon_j_1' : 'sensor.niveau_aulne_lyon';
          const libelle = states[sensor]?.attributes?.Libellé || '';
          return libelle.includes('Extrêmement') ? '20% 30% 35% 15%' : '20% 40% 20% 20%';
        ]]]
    - grid-template-columns: 1fr
    - row-gap: 0px
  name:
    - font-size: 0.65em
    - font-weight: bold
    - color: |
        [[[
          const day = states['input_select.select_day']?.state;
          const sensor = day === 'tomorrow' ? 'sensor.niveau_aulne_lyon_j_1' : 'sensor.niveau_aulne_lyon';
          return states[sensor]?.attributes?.Couleur || '#DDDDDD';
        ]]]
    - align-self: center
    - justify-self: center
  label:
    - font-size: 0.6em
    - font-weight: normal
    - color: |
        [[[
          const day = states['input_select.select_day']?.state;
          const sensor = day === 'tomorrow' ? 'sensor.niveau_aulne_lyon_j_1' : 'sensor.niveau_aulne_lyon';
          return states[sensor]?.attributes?.Couleur || '#DDDDDD';
        ]]]
    - align-self: center
    - justify-self: center
  icon:
    - color: |
        [[[
          const day = states['input_select.select_day']?.state;
          const sensor = day === 'tomorrow' ? 'sensor.niveau_aulne_lyon_j_1' : 'sensor.niveau_aulne_lyon';
          return states[sensor]?.attributes?.Couleur || '#DDDDDD';
        ]]]
    - width: 80%
    - align-self: center
    - justify-self: center
  custom_fields:
    concentration:
      - align-self: center
      - justify-self: center

Voici quelques explications supplémentaires pour le code pour l'élément label et pour l'élément grid (grille) :

  • pour l'élément label :
label: |
  [[[
    const day = states["input_select.select_day"]?.state;
    const sensor = day === 'tomorrow' ? 'sensor.niveau_aulne_lyon_j_1' : 'sensor.niveau_aulne_lyon';
    let libelle = states[sensor]?.attributes?.Libellé || 'Indisponible';
    if (libelle.includes('Extrêmement ')) {
      libelle = libelle.replace('Extrêmement ', 'Extrêmement<br>');
    }
    return `<span style="font-size: 1.1em; font-weight: bold;">Niveau :</span><br>${libelle}`;
  ]]]

Nous allons ajouter une modification du texte si le libellé contient "Extrêmement". Dans ce cas, nous allons ajouter une balise HTML de passage à la ligne ("<br>") car sans cette balise le texte ne serait pas affiché correctement (il serait trop long).

Nous allons aussi modifier le style d'une portion du texte ("Niveau :") avec un container "<span ...... </span>" pour mettre en gras et augmenter la taille de police de "Niveaux :" sans affecter le libellé.

  • pour l'élément grid :

  grid:
    - grid-template-areas: '"n" "i" "l" "concentration"'
    - grid-template-rows: |
        [[[
          const day = states['input_select.select_day']?.state;
          const sensor = day === 'tomorrow' ? 'sensor.niveau_aulne_lyon_j_1' : 'sensor.niveau_aulne_lyon';
          const libelle = states[sensor]?.attributes?.Libellé || '';
          return libelle.includes('Extrêmement') ? '20% 30% 35% 15%' : '20% 40% 20% 20%';
        ]]]
    - grid-template-columns: 1fr
    - row-gap: 0px

Nous allons définir la hauteur des lignes en fonction de la présence ou non de "Extrêmement" dans le libellé. En l'absence d'"Extrêmement" dans le libellé, nous définirons une hauteur de 20% pour le name (n), une hauteur de 40% pour l'icon (i), une hauteur de 20% pour le label (l) et une hauteur de 20% pour le custom_fields concentration. Dans le cas contraire, nous définirons les hauteurs de la manière suivante : '20% 30% 35% 15%' ce qui aura pour conséquence de diminuer la taille de l'icône et de permettre d'afficher correctement le niveau avec le passage à la ligne.

Un modèle pour les autres mini-cartes

Nous pourrions ajouter cette mini-carte en tant que custom_fields à la suite du custom_fields global_pollens et faire un copié-collé pour chacun des six pollens existant puis modifier le code en supprimant notamment le custom_fields concentration pour les polluants atmosphériques et refaire un copié-collé pour chacun des cinq polluants.

Ceci aurait pour conséquence d'augmenter le nombre de lignes de code de plus de 1100 lignes...

Comme nous devrions avoir au final 11 mini-cartes très proches, nous allons plutôt créer un modèle que nous pourrons utiliser pour chaque pollen et chaque polluant.

Afin de créer ce modèle (template), il faut commencer par déterminer quels sont les variations entre les différentes mini-cartes :

Le niveau et la couleur varient aussi mais sont donnés par des attributs du sensor. Donc ce qu'il faut prendre en compte, c'est la variation du sensor pour les mini-cartes. Pour les mini-cartes des pollens, il faut prendre en compte la variation du sensor "concentration" pour chacun des pollens et donc le fait qu'il y a deux sensors pour les pollens et un seul pour les polluants atmosphériques.

Nous avons évoqué les variations entre les différentes mini-cartes. Qui dit variations, dit "variables" et cela nous permet d'entrevoir un élément fondamental des modèles (templates) : un modèle doit être constitué d'éléments fixes, identiques à toutes les "cartes" basées sur le modèle, et des variables permettant de spécifier les éléments qui diffèrent en fonction des "cartes".

Personnellement, j'ai fait le choix de définir mes modèles au niveau du tableau de bord où ils sont utilisés. Il est apparemment possible de les définir dans le fichier "configuration.yaml", dans un fichier spécifique appelé par le fichier "configuration.yaml" ou au niveau du tableau de bord. Je n'ai jamais essayé de définir mes modèles ailleurs qu'au niveau du tableau de bord. Définir les modèles au niveau du fichier "configuration.yaml" pourrait vite devenir compliqué à gérer en fonction de la multiplication des modèles et toute modification d'un fichier devrait être suivie d'un redémarrage de Home Assistant pour être effective. De la même manière, la définition des modèles dans un fichier spécifique pourrait compliquer la tâche pour retrouver le modèle en cas de besoin de modification et toute modification devrait être validée par le redémarrage de Home Assistant.

Le choix de définir les modèles au niveau du tableau de bord permet de pouvoir effectuer des modifications de ceux-ci très facilement et que ces modifications soient effectives dès l'enregistrement. L'utilisation des modèles permet, quand c'est possible de diminuer le nombre de lignes de code de la carte custom:button-card.

Pour définir un modèle au niveau du tableau de bord, il va suffire de cliquer sur le "crayon" en haut à droite du tableau de bord :

puis sur les trois petits points, toujours en haut à droite :

et ensuite cliquer sur "Editeur de configuration" :

La définition du modèle va se faire en ajoutant du code au-dessus de la ligne "views:" :

button_card_templates:
  mini_card_template:
    variables:
      ...
      ...
      ...
    name: ...
    icon: ...
    label: ...
    show_label: true
    custom_fields:
       ...
views:
  ...
  ...

Nous commençons par spécifier au tableau de bord l'existence de modèles avec la ligne : "button_card_templates" puis nous indiquons le nom du premier modèle, ici : "mini_card_template". Nous pouvons définir plusieurs modèles pour le tableau de bord, il suffit de les mettre à la suite avant la ligne "views:".

Nous introduisons nos différentes variables par "variables:" et les définissons l'une en-dessous de l'autre.

Une fois les variables introduites, nous définissons les éléments de la carte et leurs propriétés avec utilisation des variables là où c'est requis :

button_card_templates:
  mini_card_template:
    variables:
(1)   sensor_level: ''
(2)   sensor_concentration: ''
(3)   card_name: ''
(4)   card_icon: none
(5)   card_type: ''
(6) name: '[[[ return variables.card_name ]]]'
(6) icon: '[[[ return variables.card_icon ]]]'
(7) show_label: true
(6) label: |
      [[[
        let libelle = states[variables.sensor_level]?.attributes?.Libellé || 'N/A';
        if (libelle.includes('Extrêmement ')) {
          libelle = libelle.replace('Extrêmement ', 'Extrêmement<br>');
        }
        return `<span style="font-size: 1.1em; font-weight: bold;">Niveau :</span><br>${libelle}`;
      ]]]
(7) custom_fields:
      concentration:
        card:
          type: custom:button-card
          name: |
            [[[
(8)           if (variables.card_type === 'pollens' && states[variables.sensor_concentration]?.state) {
                return `${states[variables.sensor_concentration].state} µg/m³`;
              }
              return '';
            ]]]
          styles:
            card:
              - width: "[[[ return window.innerWidth <= 600 ? '90px' : '100px' ]]]"
              - height: 20px
              - padding: 2px
              - border: none
              - border-radius: 0px
              - background: none
            name:
              - font-size: "[[[ return window.innerWidth <= 600 ? '0.8em' : '0.85em' ]]]"
              - font-weight: bold
              - padding: 2px
              - line-height: 1
              - color: >-
                  [[[ return states[variables.sensor_level]?.attributes?.Couleur
                  || '#DDDDDD' ]]]
    styles:
      card:
        - background-color: rgba(64,64,64,0.2)
        - border: none
        - border-radius: 0px
        - height: "[[[ return window.innerWidth <= 600 ? '110px' : '125px' ]]]"
      grid:
        - grid-template-areas: '"n" "i" "l" "concentration"'
        - grid-template-rows: |
            [[[
              const libelle = states[variables.sensor_level]?.attributes?.Libellé || '';
              if (variables.card_type === 'pollens' && states[variables.sensor_concentration]?.state) {
                return libelle.includes('Extrêmement') ? '20% 30% 35% 15%' : '20% 40% 20% 20%';
              }
              return '35% 40% 25% 0%';
            ]]]
        - grid-template-columns: 1fr
        - row-gap: 0px
      name:
        - font-size: 0.65em
        - font-weight: bold
        - color: >-
            [[[ return states[variables.sensor_level]?.attributes?.Couleur ||
            '#DDDDDD' ]]]
        - align-self: center
        - justify-self: center
      label:
        - font-size: 0.6em
        - font-weight: normal
        - color: >-
            [[[ return states[variables.sensor_level]?.attributes?.Couleur ||
            '#DDDDDD' ]]]
        - align-self: center
        - justify-self: center
      icon:
        - color: >-
            [[[ return states[variables.sensor_level]?.attributes?.Couleur ||
            '#DDDDDD' ]]]
        - width: 80%
        - align-self: center
        - justify-self: center
      custom_fields:
        concentration:
          - align-self: center
          - justify-self: center

(1) nous définissons une variable appelée "sensor_level" pour spécifier quel sensor doit être utilisé dans la mini-carte (sensor.niveau_aulne_lyon pour les pollens d'aulne ou sensor.dioxyde_d_azote_lyon pour le polluant NO2 par exemple) ;

(2) nous définissons une variable appelée "sensor_concentration" pour spécifier quel sensor de concentration doit être utilisée dans la mini-carte pour les pollens (sensor.concentration_aulne_lyon par exemple);

(3) nous définissons une variable nommée "card_name" pour spécifier le nom (name) de la mini-carte;

(4) une variable "card_icon" pour spécifier l'icône (icon);

(5) une variable "card_type" qui permettra de modifier l'affichage si la mini-carte est utilisée pour afficher un pollen ou pour afficher un polluant (affichage ou non de la concentration);

(6) nous utilisons la variable "sensor_level" pour afficher dans l'élément "label" le niveau (très faible, faible, modéré, etc. ou bon, moyen, dégradé, etc.) et la mise en forme du texte (passage à la ligne si il y a "Extrêmement" dans l'attribut "Libellé" du sensor);

(7) nous utilisons la variable "sensor_concentration" pour afficher la concentration des pollens dans le name du custom_fields "concentration";

(8) nous utilisons la variable "card_type" une première fois dans le custom_fields "concentration" pour afficher la concentration dans le name du custom_fields ou ne rien afficher ("' '") si "card_type" n'est pas "pollens" puis nous l'utilisons à nouveau dans la définition des hauteurs de lignes de la "grid".

Pour utiliser les modèles dans la carte en tant que custom_fields, il suffira d'ajouter les lignes suivantes pour chaque polluant et pollen :

        card_pm10:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.pm10_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: none
              card_name: Particules fines<br>< à 10 µm<br>(PM <sub>10</sub>)
              card_icon: mdi:blur
              card_type: polluants

Mini-carte "Particules fines < 10 µm"

Puis ensuite d'ajouter sa position sur la carte dans les styles du custom_fields parent :

          card_pm10:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '-1%' : '2%' ]]]"
            - top: 10%
        card_aulne:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.niveau_aulne_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.concentration_aulne_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              card_name: Pollens<br>d'aulne
              card_icon: mdi:tree
              card_type: pollens

Mini-carte "Pollens d'aulne"

          card_aulne:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '-2%' : '2%' ]]]"
            - top: "[[[ return window.innerWidth <= 600 ? '9%' : '10%' ]]]"

Conclusion

Nous finissons donc ici cette illustration d'une utilisation avancée de la custom:button-card.

Voici le code complet de la carte "Données atmosphériques" avec le template puis la carte :


button_card_templates:
  mini_card_template:
    variables:
      sensor_level: ''
      sensor_concentration: ''
      card_name: ''
      card_icon: none
      card_type: ''
    name: '[[[ return variables.card_name ]]]'
    icon: '[[[ return variables.card_icon ]]]'
    show_label: true
    label: |
      [[[
        let libelle = states[variables.sensor_level]?.attributes?.Libellé || 'N/A';
        if (libelle.includes('Extrêmement ')) {
          libelle = libelle.replace('Extrêmement ', 'Extrêmement<br>');
        }
        return `<span style="font-size: 1.1em; font-weight: bold;">Niveau :</span><br>${libelle}`;
      ]]]
    custom_fields:
      concentration:
        card:
          type: custom:button-card
          name: |
            [[[
              if (variables.card_type === 'pollens' && states[variables.sensor_concentration]?.state) {
                return `${states[variables.sensor_concentration].state} µg/m³`;
              }
              return '';
            ]]]
          styles:
            card:
              - width: '[[[ return window.innerWidth <= 600 ? ''90px'' : ''100px'' ]]]'
              - height: 20px
              - padding: 2px
              - border: none
              - border-radius: 0px
              - background: none
            name:
              - font-size: '[[[ return window.innerWidth <= 600 ? ''0.8em'' : ''0.85em'' ]]]'
              - font-weight: bold
              - padding: 2px
              - line-height: 1
              - color: >-
                  [[[ return states[variables.sensor_level]?.attributes?.Couleur
                  || '#DDDDDD' ]]]
    styles:
      card:
        - background-color: rgba(64,64,64,0.2)
        - border: none
        - border-radius: 0px
        - height: '[[[ return window.innerWidth <= 600 ? ''110px'' : ''125px'' ]]]'
      grid:
        - grid-template-areas: '"n" "i" "l" "concentration"'
        - grid-template-rows: |
            [[[
              const libelle = states[variables.sensor_level]?.attributes?.Libellé || '';
              if (variables.card_type === 'pollens' && states[variables.sensor_concentration]?.state) {
                return libelle.includes('Extrêmement') ? '20% 40% 25% 15%' : '20% 40% 20% 20%';
              }
              return '35% 40% 25% 0%';
            ]]]
        - grid-template-columns: 1fr
        - row-gap: 0px
      name:
        - font-size: 0.65em
        - font-weight: bold
        - color: >-
            [[[ return states[variables.sensor_level]?.attributes?.Couleur ||
            '#DDDDDD' ]]]
        - align-self: center
        - justify-self: center
      label:
        - font-size: 0.6em
        - font-weight: normal
        - color: >-
            [[[ return states[variables.sensor_level]?.attributes?.Couleur ||
            '#DDDDDD' ]]]
        - align-self: center
        - justify-self: center
      icon:
        - color: >-
            [[[ return states[variables.sensor_level]?.attributes?.Couleur ||
            '#DDDDDD' ]]]
        - width: 80%
        - align-self: center
        - justify-self: center
      custom_fields:
        concentration:
          - align-self: center
          - justify-self: center

Code du modèle (template) pour les mini-cartes

type: custom:button-card
name: Données atmosphériques
variables:
  day_selected: today
custom_fields:
  previous:
    card:
      type: custom:button-card
      icon: mdi:play
      tap_action:
        action: call-service
        service: input_select.select_option
        service_data:
          entity_id: input_select.select_day
          option: today
      styles:
        card:
          - aspect-ratio: 1/1
          - align-items: center
          - width: "[[[ return window.innerWidth <= 600 ? '22px' : '26px' ]]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - border-top-left-radius: 10px
          - border-bottom-left-radius: 10px
          - opacity: >-
              [[[ return states['input_select.select_day'].state === 'today' ?
              '0.5' : '1' ]]]
          - pointer-events: >-
              [[[ return states['input_select.select_day'].state === 'today' ?
              'none' : 'auto' ]]]
          - cursor: >-
              [[[ return states['input_select.select_day'].state === 'today' ?
              'auto' : 'pointer' ]]]
        icon:
          - width: 100%
          - rotate: 180deg
          - opacity: 50%
  day:
    card:
      type: custom:button-card
      name: >-
        [[[ return states['input_select.select_day'].state === 'today' ? 'J' :
        'J+1' ]]]
      styles:
        card:
          - align-items: center
          - width: 44px
          - height: "[[[ return window.innerWidth <= 600 ? '22px' : '26px' ]]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - pointer-events: none
          - cursor: auto
        name:
          - font-size: 1em
          - font-weight: bold
          - opacity: 50%
  next:
    card:
      type: custom:button-card
      icon: mdi:play
      tap_action:
        action: call-service
        service: input_select.select_option
        service_data:
          entity_id: input_select.select_day
          option: tomorrow
      styles:
        card:
          - aspect-ratio: 1/1
          - align-items: center
          - width: "[[[ return window.innerWidth <= 600 ? '22px' : '26px' ]]]"
          - background-color: gray
          - border-radius: 0
          - border: none
          - border-top-right-radius: 10px
          - border-bottom-right-radius: 10px
          - opacity: >-
              [[[ return states['input_select.select_day'].state === 'tomorrow'
              ? '0.5' : '1' ]]]
          - pointer-events: >-
              [[[ return states['input_select.select_day'].state === 'tomorrow'
              ? 'none' : 'auto' ]]]
          - cursor: >-
              [[[ return states['input_select.select_day'].state === 'tomorrow'
              ? 'auto' : 'pointer' ]]]
        icon:
          - width: 100%
          - opacity: 50%
  indice_air:
    card:
      type: custom:button-card
      name: Qualité<br>de l'air
      custom_fields:
        global_air:
          card:
            type: custom:button-card
            name: |
              [[[
                const day = states['input_select.select_day']?.state;
                const sensor = states[day === 'tomorrow' ? 'sensor.qualite_globale_lyon_j_1' : 'sensor.qualite_globale_lyon'];
                return sensor?.attributes?.Libellé || 'Indisponible';
              ]]]
            styles:
              card:
                - background: |
                    [[[
                      const day = states['input_select.select_day']?.state;
                      const sensor = states[day === 'tomorrow' ? 'sensor.qualite_globale_lyon_j_1' : 'sensor.qualite_globale_lyon'];
                      if (!sensor || !sensor.attributes) return 'rgba(221,221,221,0.5)';
                      const hex = sensor.attributes.Couleur;
                      const libelle = sensor.attributes.Libellé || 'Indisponible';
                      if (!hex || libelle === 'Indisponible') return 'rgba(221,221,221,0.5)';
                      const levels = {
                        'Bon': 17,
                        'Moyen': 33,
                        'Dégradé': 50,
                        'Mauvais': 67,
                        'Très mauvais': 83,
                        'Extrêmement mauvais': 100
                      };
                      const percent = levels[libelle] || 0;
                      if (hex.startsWith('#')) {
                        const bigint = parseInt(hex.replace('#', ''), 16);
                        const r = (bigint >> 16) & 255;
                        const g = (bigint >> 8) & 255;
                        const b = bigint & 255;
                        return `linear-gradient(to right, rgba(${r},${g},${b},1) 0%, rgba(${r},${g},${b},1) ${percent}%, rgba(${r},${g},${b},0.5) ${percent}%, rgba(${r},${g},${b},0.5) 100%)`;
                      }
                      return hex;
                    ]]]
                - width: "[[[ return window.innerWidth <= 600 ? '105px' : '140px' ]]]"
                - height: "[[[ return window.innerWidth <= 600 ? '24px' : '30px' ]]]"
                - border: 2px rgba(32,32,32,0.5) inset
                - border-radius: "[[[ return window.innerWidth <= 600 ? '12px' : '15px' ]]]"
                - pointer-events: none
              name:
                - position: absolute
                - top: 50%
                - left: 50%
                - transform: translate(-50%, -50%)
                - color: white
                - font-size: 0.6em
                - font-weight: bold
                - text-shadow: 1px 1px 2px black
        card_pm10:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.pm10_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: none
              card_name: Particules fines<br>< à 10 µm<br>(PM <sub>10</sub>)
              card_icon: mdi:blur
              card_type: polluants
        card_pm25:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.pm25_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: none
              card_name: Particules fines<br>< à 2.5 µm<br>(PM <sub>2.5</sub>)
              card_icon: mdi:blur
              card_type: polluants
        card_no2:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.dioxyde_d_azote_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: none
              card_name: Dioxyde d'azote<br>(NO<sub>2</sub>)
              card_icon: mdi:molecule
              card_type: polluants
        card_o3:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.ozone_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: none
              card_name: Ozone<br>(O<sub>3</sub>)
              card_icon: mdi:molecule
              card_type: polluants
        card_so2:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.dioxyde_de_soufre_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: none
              card_name: Dioxyde de soufre<br>(SO<sub>2</sub>)
              card_icon: mdi:molecule
              card_type: polluants
      styles:
        card:
          - background-color: rgba(96,96,96,1)
          - height: "[[[ return window.innerWidth <= 600 ? '377px' : '448px' ]]]"
          - border: 2px rgba(32,32,32,0.5) inset
          - border-radius: 5px
          - pointer-events: none
        name:
          - font-size: 0.8em
          - font-weight: bold
          - color: gray
          - text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.9)
          - align-self: flex-start
          - justify-self: start
          - padding-left: 3%
          - margin-top: "-3px"
        custom_fields:
          global_air:
            - position: absolute
            - right: 5%
            - top: 1.5%
            - width: 60%
          card_pm10:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '-1%' : '2%' ]]]"
            - top: 10%
          card_pm25:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '50%' : '51%' ]]]"
            - top: 10%
          card_no2:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '-1%' : '2%' ]]]"
            - top: 39%
          card_o3:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '50%' : '51%' ]]]"
            - top: 39%
          card_so2:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '25%' : '29%' ]]]"
            - top: 68%
  indice_pollens:
    card:
      type: custom:button-card
      name: Pollens
      custom_fields:
        global_pollens:
          card:
            type: custom:button-card
            name: |
              [[[
                const day = states['input_select.select_day']?.state;
                const sensor = states[day === 'tomorrow' ? 'sensor.qualite_globale_pollen_lyon_j_1' : 'sensor.qualite_globale_pollen_lyon'];
                return sensor?.attributes?.Libellé || 'Indisponible';
              ]]]
            styles:
              card:
                - background: |
                    [[[
                      const day = states['input_select.select_day']?.state;
                      const sensor = states[day === 'tomorrow' ? 'sensor.qualite_globale_pollen_lyon_j_1' : 'sensor.qualite_globale_pollen_lyon'];
                      if (!sensor || !sensor.attributes) return 'rgba(221,221,221,0.5)';
                      const hex = sensor.attributes.Couleur;
                      const libelle = sensor.attributes.Libellé || 'Indisponible';
                      if (!hex || libelle === 'Indisponible') return 'rgba(221,221,221,0.5)';
                      const levels = {
                        'Très faible': 17,
                        'Faible': 33,
                        'Modéré': 50,
                        'Elevé': 67,
                        'Très élevé': 83,
                        'Extrêmement elevé': 100
                      };
                      const percent = levels[libelle] || 0;
                      if (hex.startsWith('#')) {
                        const bigint = parseInt(hex.replace('#', ''), 16);
                        const r = (bigint >> 16) & 255;
                        const g = (bigint >> 8) & 255;
                        const b = bigint & 255;
                        return `linear-gradient(to right, rgba(${r},${g},${b},1) 0%, rgba(${r},${g},${b},1) ${percent}%, rgba(${r},${g},${b},0.5) ${percent}%, rgba(${r},${g},${b},0.5) 100%)`;
                      }
                      return hex;
                    ]]]
                - width: "[[[ return window.innerWidth <= 600 ? '105px' : '140px' ]]]"
                - height: "[[[ return window.innerWidth <= 600 ? '24px' : '30px' ]]]"
                - border: 2px rgba(32,32,32,0.5) inset
                - border-radius: "[[[ return window.innerWidth <= 600 ? '12px' : '15px' ]]]"
                - pointer-events: none
              name:
                - position: absolute
                - top: 50%
                - left: 50%
                - transform: translate(-50%, -50%)
                - color: white
                - font-size: 0.6em
                - font-weight: bold
                - text-shadow: 1px 1px 2px black
        card_aulne:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.niveau_aulne_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.concentration_aulne_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              card_name: Pollens<br>d'aulne
              card_icon: mdi:tree
              card_type: pollens
        card_ambroisie:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.niveau_ambroisie_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.concentration_ambroisie_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              card_name: Pollens<br>d'ambroisie
              card_icon: mdi:sprout
              card_type: pollens
        card_armoise:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.niveau_armoise_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.concentration_armoise_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              card_name: Pollens<br>d'armoise
              card_icon: mdi:sprout
              card_type: pollens
        card_bouleau:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.niveau_bouleau_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.concentration_bouleau_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              card_name: Pollens<br>de bouleau
              card_icon: mdi:tree
              card_type: pollens
        card_graminees:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.niveau_gramine_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.concentration_gramine_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              card_name: Pollens<br>de graminées
              card_icon: mdi:grass
              card_type: pollens
        card_olivier:
          card:
            type: custom:button-card
            template: mini_card_template
            variables:
              sensor_level: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.niveau_olivier_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              sensor_concentration: |
                [[[
                  const day = states['input_select.select_day']?.state;
                  const base = 'sensor.concentration_olivier_lyon';
                  return day === 'tomorrow' ? base + '_j_1' : base;
                ]]]
              card_name: Pollens<br>d'olivier
              card_icon: mdi:tree
              card_type: pollens
      styles:
        card:
          - background-color: rgba(96,96,96,1)
          - height: "[[[ return window.innerWidth <= 600 ? '377px' : '448px' ]]]"
          - border: 2px rgba(32,32,32,0.5) inset
          - border-radius: 5px
          - pointer-events: none
        name:
          - font-size: 0.8em
          - font-weight: bold
          - color: gray
          - text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.9)
          - align-self: flex-start
          - justify-self: start
          - padding-left: 3%
          - margin-top: "-3px"
        custom_fields:
          global_pollens:
            - position: absolute
            - right: 5%
            - top: 1.5%
            - width: 60%
          card_aulne:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '-2%' : '2%' ]]]"
            - top: "[[[ return window.innerWidth <= 600 ? '9%' : '10%' ]]]"
          card_ambroisie:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '49%' : '51%' ]]]"
            - top: "[[[ return window.innerWidth <= 600 ? '9%' : '10%' ]]]"
          card_armoise:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '-2%' : '2%' ]]]"
            - top: "[[[ return window.innerWidth <= 600 ? '40%' : '40%' ]]]"
          card_bouleau:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '49%' : '51%' ]]]"
            - top: "[[[ return window.innerWidth <= 600 ? '40%' : '40%' ]]]"
          card_graminees:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '-2%' : '2%' ]]]"
            - top: "[[[ return window.innerWidth <= 600 ? '71%' : '70%' ]]]"
          card_olivier:
            - position: absolute
            - left: "[[[ return window.innerWidth <= 600 ? '49%' : '51%' ]]]"
            - top: "[[[ return window.innerWidth <= 600 ? '71%' : '70%' ]]]"
styles:
  card:
    - aspect-ratio: "[[[ return window.innerWidth <= 600 ? '1/1.1' : '1/1' ]]]"
    - background: >-
        linear-gradient(135deg, rgba(64,64,64,1) 0%, rgba(96,96,96,1) 25%,
        rgba(128,128,128,1) 100%)
    - border-radius: 10px
    - box-shadow: 4px 4px 8px rgba(32,32,32,0.5)
    - border: 1px rgba(32,32,32,0.5) outset
    - pointer-events: none
  name:
    - font-size: "[[[ return window.innerWidth <= 600 ? '0.8em' : '1.1em' ]]]"
    - font-weight: bold
    - color: lightgray
    - text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.9)
    - align-self: flex-start
    - justify-self: start
    - padding-left: 3%
    - margin-top: "[[[ return window.innerWidth <= 600 ? '-10px' : '-15px' ]]]"
  custom_fields:
    previous:
      - position: absolute
      - top: 0.7%
      - right: "[[[ return window.innerWidth <= 600 ? '170px' : '174px' ]]]"
    day:
      - position: absolute
      - top: 0.7%
      - right: "[[[ return window.innerWidth <= 600 ? '124px' : '126px' ]]]"
    next:
      - position: absolute
      - top: 0.7%
      - right: "[[[ return window.innerWidth <= 600 ? '100px' : '96px' ]]]"
    indice_air:
      - position: absolute
      - top: 7%
      - left: 2%
      - width: 47%
    indice_pollens:
      - position: absolute
      - top: 7%
      - right: 2%
      - width: 47%

Code de la carte "Données atmosphériques"

Affichage sur écran d'ordinateur
Affichage sur téléphone portable