[Article] Ventilateur Xiaomi Smart Desktop Air Circulation Fan

Je viens de publier un nouvel article sur notre portail. :fire:

Venez le découvrir dès maintenant !

Merci pour vos commentaires et retours. :blush:

Bonne lecture !

Pour récupérer les tokens et autre bindkey il est également possible d'utiliser cette intégration. A la base c'est fait pour gérer diverses passerelles Xiaomi (Zigbee et BT) en local, mais si on branche l'intégration sur Mi Home on obtient en retour les infos de chaque objets connectés dans les deux régions, la même chose que Tokens Extractor en GUI.


Merci pour ton code, j'utilise cette carte plus trop maintenue

Salut,
Oui aussi, il y a plusieurs façons, en plus j'utilise cette intégration :sweat_smile:. Mais j'ai trouvé que c'était plus simple un exe à lancer sur un PC.

Je l'utilisais, mais pas d'oscillation verticale et le P70, n'est pas complètement supporté. Puis plus maintenu. Alors, je suis parti sur une nouvelle carte maison., je m'en sers pour mon P18 aussi.

Salut Warc0zes,

Je trouve ta carte très attrayante. Pourrais tu nous partager le code s'il te plait ?

Salut,
le code est dans l'article, comme il y a des cartes grid, il faut juste supprimer des boutons, pour avoir le visuel au-dessus.

Mais voici le code.

Code button_card_templates
button_card_templates:
  p70_small_compact:
    show_icon: true
    show_name: true
    show_state: false
    show_label: true
    tap_action:
      haptic: light
    styles:
      card:
        - min-height: 48px
        - padding: 5px 2px
        - border-radius: 11px
        - box-shadow: none
        - border: 1px solid rgba(127, 127, 127, 0.16)
        - background: rgba(127, 127, 127, 0.06)
      grid:
        - grid-template-areas: "\"i n\" \"i l\""
        - grid-template-columns: 25px 1fr
        - grid-template-rows: min-content min-content
        - column-gap: 3px
      icon:
        - width: 24px
        - color: var(--secondary-text-color)
      name:
        - justify-self: start
        - font-size: 14px
        - line-height: 15px
        - font-weight: 650
        - color: var(--primary-text-color)
      label:
        - justify-self: start
        - margin-top: 6px
        - font-size: 12px
        - line-height: 13px
        - color: var(--secondary-text-color)
  p70_toggle_compact:
    template: p70_small_compact
    variables:
      active: false
      service_on: null
      service_off: null
    tap_action:
      action: call-service
      service: >
        [[[ return variables.active ? variables.service_off :
        variables.service_on; ]]]
      target:
        entity_id: "[[[ return entity.entity_id; ]]]"
      haptic: light
    state:
      - operator: template
        value: "[[[ return Boolean(variables.active); ]]]"
        styles:
          card:
            - background: rgba(68, 115, 158, 0.15)
            - border: 1px solid '#01be9e'
          icon:
            - color: "#44739E"
          name:
            - color: "#44739E"
          label:
            - color: "#44739E"
  p70_move_compact:
    show_icon: true
    show_name: false
    show_state: false
    variables:
      direction: left
    tap_action:
      action: call-service
      service: xiaomi_miio_fan.fan_turn
      target:
        entity_id: "[[[ return entity.entity_id; ]]]"
      data:
        direction: "[[[ return variables.direction; ]]]"
      haptic: light
    hold_action:
      action: call-service
      service: xiaomi_miio_fan.fan_turn
      target:
        entity_id: "[[[ return entity.entity_id; ]]]"
      data:
        direction: "[[[ return variables.direction; ]]]"
      repeat: 350
      haptic: selection
    styles:
      card:
        - min-height: 34px
        - padding: 0
        - border-radius: 10px
        - box-shadow: none
        - border: 1px solid rgba(127, 127, 127, 0.16)
        - background: rgba(127, 127, 127, 0.06)
      icon:
        - width: 24px
        - color: "#44739E"

Code button-card
type: custom:button-card
entity: fan.mi_smart_standing_fan_2
name: Mi Smart Standing Fan 2
show_icon: false
show_state: false
show_label: false
show_ripple: false
disable_kbd: true
tap_action:
  action: none
hold_action:
  action: more-info
double_tap_action:
  action: none
extra_styles: |
  @keyframes p70-spin {
    from { transform: rotate(0deg); }
    to   { transform: rotate(360deg); }
  }
  @keyframes p70-swing-h {
    0%, 100% { transform: perspective(400px) rotateY(-25deg); }
    50% { transform: perspective(400px) rotateY(25deg); }
  }
  @keyframes p70-swing-v {
    0%, 100% { transform: translateY(-5px); }
    50%      { transform: translateY(5px); }
  }
  .p70-status {
    display: flex;
    align-items: center;
    gap: 5px;
    font-size: 14px;
    line-height: 12px;
    font-weight: 500;
    color: #44739E;
  }
  .p70-dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: var(--disabled-text-color);
  }
  .p70-dot.on {
    background: #01be9e;
    box-shadow: 0 0 7px rgba(1, 190, 158, .85);
  }
  .p70-visual {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 142px;
  }
  .p70-h.active {
    animation: p70-swing-h 4s ease-in-out infinite;
    transform-origin: 50% 88%;
  }
  .p70-v.active {
    animation: p70-swing-v 3.2s ease-in-out infinite;
  }
  .p70-product-stage {
    position: relative;
    width: 126px;
    height: 126px;
    display: grid;
    place-items: center;
    isolation: isolate;
  }
  .p70-product-stage::before {
    content: '';
    position: absolute;
    z-index: 0;
    width: 78px;
    height: 78px;
    left: 24px;
    top: 15px;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(127,127,127,.15) 0 46%, transparent 72%);
    opacity: .42;
    transition: opacity .25s ease, box-shadow .25s ease;
  }
  .p70-product-stage.on::before {
    opacity: 1;
    box-shadow: 0 0 24px rgba(1,190,158,.22);
    background: radial-gradient(circle, rgba(1,190,158,.22) 0 34%, rgba(1,190,158,.08) 52%, transparent 73%);
  }
  .p70-rotor {
    position: absolute;
    z-index: 1;
    width: 112px;
    height: 112px;
    left: 7px;
    top: 4.5px;
    color: rgba(105, 115, 122, .64);
    opacity: .58;
    filter: drop-shadow(0 0 3px rgba(0,0,0,.12));
  }
  .p70-product-stage.on .p70-rotor {
    color: white;
    opacity: .42;
    filter: drop-shadow(0 0 5px rgba(1,190,158,.38));
  }
  .p70-rotor.spinning {
    animation: p70-spin var(--p70-duration) linear infinite;
    transform-origin: 50% 50%;
  }
  .p70-product {
    position: absolute;
    z-index: 2;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: contain;
    pointer-events: none;
    user-select: none;
    opacity: .72;
    filter: grayscale(.18) drop-shadow(0 7px 9px rgba(0,0,0,.15));
    transition: opacity .25s ease, filter .25s ease;
  }
  .p70-product-stage.on .p70-product {
    opacity: 1;
    filter: drop-shadow(0 7px 10px rgba(0,0,0,.17)) drop-shadow(0 0 6px rgba(1,190,158,.11));
  }
  .p70-percent {
    position: absolute;
    z-index: 3;
    right: -15px;
    bottom: 15px;
    min-width: 29px;
    height: 18px;
    padding: 0 5px;
    box-sizing: border-box;
    border-radius: 999px;
    display: grid;
    place-items: center;
    background: color-mix(in srgb, var(--card-background-color) 88%, transparent);
    border: 1px solid rgba(127,127,127,.25);
    box-shadow: 0 2px 8px rgba(0,0,0,.10);
    backdrop-filter: blur(4px);
    font-size: 12px;
    color: var(--secondary-text-color);
  }
  .p70-product-stage.on .p70-percent {
    border-color: #44739E;
    color: #44739E;
  }
  .p70-meta {
    margin-top: -1px;
    display: flex;
    gap: 3px;
    flex-wrap: nowrap;
    justify-content: center;
  }
  .p70-chip {
    padding: 2px 5px;
    border-radius: 999px;
    font-size: 12px;
    line-height: 10px;
    color: #44739E;
    background: rgba(127,127,127,.08);
    border: 1px solid rgba(127,127,127,.12);
  }
  /* Curseur 1–100 % intégré : aucun module JS externe */
  .p70-speed-native {
    --p70-accent: #44739E;
    box-sizing: border-box;
    width: 100%;
    padding: 4px 5px;
    border-radius: 12px;
    border: 1px solid rgba(127, 127, 127, 0.16);
    background: rgba(127, 127, 127, 0.07);
    box-shadow: none;
    -webkit-tap-highlight-color: transparent;
  }
  .p70-speed-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    margin-bottom: 0;
  }
  .p70-speed-caption {
    display: flex;
    align-items: center;
    gap: 7px;
    min-width: 0;
    min-height: 18px;
    color: var(--secondary-text-color);
    font-size: 15px;
    line-height: 18px;
    font-weight: 600;
  }
  .p70-speed-caption ha-icon {
    display: block;
    width: 24px;
    height: 24px;
    flex: 0 0 24px;
    color: var(--secondary-text-color);
    transform: translateY(0);
  }
  .p70-speed-caption span {
    display: flex;
    align-items: center;
    min-height: 18px;
    line-height: 18px;
    white-space: nowrap;
  }
  .p70-speed-value {
    flex: 0 0 40px;
    min-width: 40px;
    padding: 2px;
    border-radius: 999px;
    box-sizing: border-box;
    text-align: center;
    color: var(--secondary-text-color);
    background: rgba(127, 127, 127, 0.10);
    border: 1px solid rgba(127, 127, 127, 0.18);
    font-size: 13px;
    line-height: 16px;
    font-weight: 700;
  }
  .p70-speed-native.on .p70-speed-value,
  .p70-speed-native.on .p70-speed-caption ha-icon {
    color: var(--p70-accent);
  }
  .p70-speed-native.on .p70-speed-value {
    border-color: var(--p70-accent);
    background: color-mix(in srgb, var(--p70-accent) 12%, transparent);
  }
  .p70-speed-row {
    display: grid;
    grid-template-columns: min-content 1fr min-content;
    align-items: center;
    gap: 8px;
  }
  .p70-speed-bound {
    color: var(--secondary-text-color);
    font-size: 12px;
    line-height: 12px;
    font-weight: 600;
    opacity: .82;
  }
  .p70-speed-range {
    --fill: 0%;
    width: 100%;
    height: 22px;
    margin: 0;
    padding: 0;
    border: 0;
    outline: none;
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    background: transparent;
    touch-action: pan-y;
  }
  .p70-speed-range::-webkit-slider-runnable-track {
    height: 5px;
    border-radius: 999px;
    background: linear-gradient(
      to right,
      var(--p70-accent) 0%,
      var(--p70-accent) var(--fill),
      rgba(127, 127, 127, 0.20) var(--fill),
      rgba(127, 127, 127, 0.20) 100%
    );
  }
  .p70-speed-range::-webkit-slider-thumb {
    width: 14px;
    height: 14px;
    margin-top: -4.5px;
    border: 2px solid var(--card-background-color, #fff);
    border-radius: 50%;
    appearance: none;
    -webkit-appearance: none;
    background: var(--p70-accent);
    box-shadow: 0 1px 5px rgba(0, 0, 0, .24);
  }
  .p70-speed-range::-moz-range-track {
    height: 5px;
    border-radius: 999px;
    background: rgba(127, 127, 127, 0.20);
  }
  .p70-speed-range::-moz-range-progress {
    height: 5px;
    border-radius: 999px;
    background: var(--p70-accent);
  }
  .p70-speed-range::-moz-range-thumb {
    width: 12px;
    height: 12px;
    border: 2px solid var(--card-background-color, #fff);
    border-radius: 50%;
    background: var(--p70-accent);
    box-shadow: 0 1px 5px rgba(0, 0, 0, .24);
  }
  .p70-speed-range:disabled {
    cursor: not-allowed;
    opacity: .5;
  }
styles:
  card:
    - padding: 8px 8px 2px 8px
    - border-radius: 20px
    - overflow: hidden
  grid:
    - grid-template-areas: >-
        "n power" "status power" "visual visual" "speeds speeds" "directions
        directions" "modes modes" "cycle cycle" 
    - grid-template-columns: 1fr 38px
    - grid-template-rows: >-
        min-content min-content 142px min-content min-content min-content
        min-content
    - column-gap: 6px
    - row-gap: 6px
  name:
    - justify-self: start
    - align-self: end
    - font-size: 18px
    - line-height: 17px
    - font-weight: 700
  custom_fields:
    power:
      - align-self: center
      - justify-self: end
    status:
      - justify-self: start
      - align-self: start
    visual:
      - width: 100%
    speeds:
      - width: 100%
    modes:
      - width: 100%
      - "--grid-card-gap": 6px
    directions:
      - width: 100%
      - "--grid-card-gap": 4px
custom_fields:
  power:
    card:
      type: custom:button-card
      entity: fan.mi_smart_standing_fan_2
      icon: mdi:power
      show_name: false
      show_state: false
      tap_action:
        action: toggle
        haptic: medium
      styles:
        card:
          - width: 36px
          - height: 36px
          - border-radius: 50%
          - padding: 0
          - box-shadow: none
          - border: 1px solid rgba(127, 127, 127, 0.18)
          - background: rgba(127, 127, 127, 0.07)
        icon:
          - width: 19px
          - color: var(--secondary-text-color)
      state:
        - value: "on"
          styles:
            card:
              - background: rgba(68, 115, 158, 0.16)
              - border: 1px solid
            icon:
              - color: "#44739E"
  status: |
    [[[
      const on = entity.state === 'on';
      const pct = Number(entity.attributes.percentage ?? entity.attributes.raw_speed ?? 0);
      const mode = entity.attributes.mode === 'nature' ? 'Naturel' : 'Direct';
      return `<div class="p70-status">
        <span class="p70-dot ${on ? 'on' : ''}"></span>
        <span>${on ? `${pct}% · ${mode}` : 'Éteint'}</span>
      </div>`;
    ]]]
  visual: |
    [[[
      const on = entity.state === 'on';
      const pct = Number(entity.attributes.percentage ?? entity.attributes.raw_speed ?? 0);
      const horizontal = Boolean(entity.attributes.oscillating ?? entity.attributes.oscillate ?? false);
      const vertical = Boolean(entity.attributes.vertical_oscillate ?? false);
      const angleH = entity.attributes.angle ?? '—';
      const angleV = entity.attributes.vertical_angle ?? '—';
      const preset = entity.attributes.preset_mode ?? '—';
      const duration = Math.max(0.55, 4.6 - pct * 0.037).toFixed(2);
      const imageUrl = '/local/images/fan_p18.png';
      return `<div class="p70-visual">
        <div class="p70-h ${on && horizontal ? 'active' : ''}">
          <div class="p70-v ${on && vertical ? 'active' : ''}">
            <div class="p70-product-stage ${on ? 'on' : ''}">
              <ha-icon class="p70-rotor ${on ? 'spinning' : ''}" style="--p70-duration:${duration}s" icon="mdi:fan"></ha-icon>
              <img class="p70-product" src="${imageUrl}" alt="Mi Smart Standing Fan 2" loading="eager">
              <div class="p70-percent">${on ? pct : 0}%</div>
            </div>
          </div>
        </div>
        <div class="p70-meta">
          <span class="p70-chip">${preset}</span>
          <span class="p70-chip">H ${angleH}°</span>
        </div>
      </div>`;
    ]]]
  speeds: |
    [[[
      const fan = entity;
      if (!fan) {
        return `<div class="p70-speed-native">
          <div class="p70-speed-header">
            <div class="p70-speed-caption">
              <ha-icon icon="mdi:fan"></ha-icon>
              <span>Vitesse</span>
            </div>
            <span class="p70-speed-value">Indisponible</span>
          </div>
        </div>`;
      }

      const min = 1;
      const max = 100;
      const raw = Number(fan.attributes.percentage ?? fan.attributes.raw_speed ?? min);
      const pct = Math.max(min, Math.min(max, Number.isFinite(raw) ? Math.round(raw) : min));
      const fill = ((pct - min) / (max - min)) * 100;
      const on = fan.state === 'on';
      const unavailable = fan.state === 'unavailable' || fan.state === 'unknown';
      const valueText = unavailable ? 'Indisponible' : (on ? `${pct} %` : 'Éteint');
      const entityId = 'fan.mi_smart_standing_fan_2';

      return `<div class="p70-speed-native ${on ? 'on' : ''}">
        <div class="p70-speed-header">
          <div class="p70-speed-caption">
            <ha-icon icon="mdi:fan"></ha-icon>
            <span>Vitesse</span>
          </div>
          <span class="p70-speed-value">${valueText}</span>
        </div>
        <div class="p70-speed-row">
          <span class="p70-speed-bound">1 %</span>
          <input
            class="p70-speed-range"
            type="range"
            min="1"
            max="100"
            step="1"
            value="${pct}"
            style="--fill:${fill}%;"
            aria-label="Vitesse du ventilateur"
            ${unavailable ? 'disabled' : ''}
            onpointerdown="event.stopPropagation()"
            onpointerup="event.stopPropagation()"
            ontouchstart="event.stopPropagation()"
            ontouchend="event.stopPropagation()"
            onclick="event.stopPropagation()"
            ondblclick="event.stopPropagation()"
            onkeydown="event.stopPropagation()"
            oninput="event.stopPropagation();const v=Math.max(1,Math.min(100,Math.round(Number(this.value))));this.style.setProperty('--fill',(((v-1)/99)*100)+'%');const box=this.closest('.p70-speed-native');if(box){box.classList.add('on');const value=box.querySelector('.p70-speed-value');if(value)value.textContent=v+' %';}"
            onchange="event.stopPropagation();const v=Math.max(1,Math.min(100,Math.round(Number(this.value))));const root=this.getRootNode();const card=root&&root.host;const app=document.querySelector('home-assistant');const ha=(card&&card.hass)||(app&&app.hass);if(ha){ha.callService('fan','set_percentage',{entity_id:'${entityId}',percentage:v});}"
          >
          <span class="p70-speed-bound">100 %</span>
        </div>
      </div>`;
    ]]]
  directions:
    card:
      type: grid
      columns: 2
      square: false
      cards:
        - type: custom:button-card
          template: p70_move_compact
          entity: fan.mi_smart_standing_fan_2
          icon: mdi:chevron-left
          show_icon: true
          show_name: false
          show_state: false
          tap_action:
            action: call-service
            service: fan.set_direction
            target:
              entity_id: "[[[ return entity.entity_id; ]]]"
            data:
              direction: left
            haptic: light
          hold_action:
            action: call-service
            service: fan.set_direction
            target:
              entity_id: "[[[ return entity.entity_id; ]]]"
            data:
              direction: left
            repeat: 350
            haptic: selection
          styles:
            card:
              - min-height: 34px
              - padding: 0
              - border-radius: 10px
              - box-shadow: none
              - border: 1px solid rgba(127, 127, 127, 0.16)
              - background: rgba(127, 127, 127, 0.06)
            icon:
              - width: 24px
              - color: "#44739E"
        - type: custom:button-card
          template: p70_move_compact
          entity: fan.mi_smart_standing_fan_2
          icon: mdi:chevron-right
          show_icon: true
          show_name: false
          show_state: false
          tap_action:
            action: call-service
            service: fan.set_direction
            target:
              entity_id: "[[[ return entity.entity_id; ]]]"
            data:
              direction: right
            haptic: light
          hold_action:
            action: call-service
            service: fan.set_direction
            target:
              entity_id: "[[[ return entity.entity_id; ]]]"
            data:
              direction: right
            repeat: 350
            haptic: selection
          styles:
            card:
              - min-height: 34px
              - padding: 0
              - border-radius: 10px
              - box-shadow: none
              - border: 1px solid rgba(127, 127, 127, 0.16)
              - background: rgba(127, 127, 127, 0.06)
            icon:
              - width: 24px
              - color: "#44739E"
  modes:
    card:
      type: grid
      columns: 3
      square: false
      cards:
        - type: custom:button-card
          template: p70_toggle_compact
          entity: fan.mi_smart_standing_fan_2
          variables:
            active: "[[[ return entity.attributes.mode === \"nature\"; ]]]"
            service_on: xiaomi_miio_fan.fan_set_natural_mode_on
            service_off: xiaomi_miio_fan.fan_set_natural_mode_off
          icon: mdi:leaf
          name: Nature
          label: >-
            [[[ return entity.attributes.mode === "nature" ? "Activé" :
            "Désactivé"; ]]]
        - type: custom:button-card
          template: p70_small_compact
          entity: fan.mi_smart_standing_fan_2
          icon: mdi:swap-horizontal-bold
          name: Horizontal
          label: >
            [[[ return Boolean(entity.attributes.oscillating ??
            entity.attributes.oscillate ?? false) ? 'Activée' : 'Arrêtée'; ]]]
          tap_action:
            action: call-service
            service: fan.oscillate
            target:
              entity_id: fan.mi_smart_standing_fan_2
            data:
              oscillating: >
                [[[ return !Boolean(entity.attributes.oscillating ??
                entity.attributes.oscillate ?? false); ]]]
            haptic: light
          state:
            - operator: template
              value: >-
                [[[ return Boolean(entity.attributes.oscillating ??
                entity.attributes.oscillate ?? false); ]]]
              styles:
                card:
                  - background: rgba(68, 115, 158, 0.15)
                icon:
                  - color: "#44739E"
                name:
                  - color: "#44739E"
                label:
                  - color: "#44739E"
        - type: custom:button-card
          template: p70_small_compact
          entity: fan.mi_smart_standing_fan_2
          icon: mdi:angle-acute
          name: Angle H
          label: "[[[ return `${entity.attributes.angle ?? \"—\"}°`; ]]]"
          tap_action:
            action: call-service
            service: xiaomi_miio_fan.fan_set_oscillation_angle
            target:
              entity_id: fan.mi_smart_standing_fan_2
            data:
              angle: |
                [[[
                  const values = [30, 60, 90, 120, 140];
                  const current = Number(entity.attributes.angle ?? 0);
                  return values.find(v => v > current) ?? values[0];
                ]]]
            haptic: light
        - type: custom:button-card
          template: p70_small_compact
          entity: fan.mi_smart_standing_fan_2
          icon: mdi:led-on
          name: Voyant
          label: "[[[ return entity.attributes.led ? \"Allumé\" : \"Éteint\"; ]]]"
          tap_action:
            action: call-service
            service: xiaomi_miio_fan.fan_set_led_brightness
            target:
              entity_id: fan.mi_smart_standing_fan_2
            data:
              brightness: "[[[ return entity.attributes.led ? 2 : 0; ]]]"
            haptic: light
          state:
            - operator: template
              value: "[[[ return Boolean(entity.attributes.led); ]]]"
              styles:
                card:
                  - background: rgba(68, 115, 158, 0.15)
                icon:
                  - color: "#44739E"
                name:
                  - color: "#44739E"
                label:
                  - color: "#44739E"
        - type: custom:button-card
          template: p70_toggle_compact
          entity: fan.mi_smart_standing_fan_2
          variables:
            active: "[[[ return Boolean(entity.attributes.buzzer); ]]]"
            service_on: xiaomi_miio_fan.fan_set_buzzer_on
            service_off: xiaomi_miio_fan.fan_set_buzzer_off
          icon: mdi:volume-high
          name: Bip
          label: "[[[ return entity.attributes.buzzer ? \"Activé\" : \"Muet\"; ]]]"
        - type: custom:button-card
          template: p70_small_compact
          entity: fan.mi_smart_standing_fan_2
          icon: mdi:timer-outline
          name: Minuterie
          label: |
            [[[
              const minutes = Number(entity.attributes.delay_off_countdown ?? 0);
              return minutes > 0 ? `${minutes} min` : 'Arrêtée';
            ]]]
          tap_action:
            action: call-service
            service: xiaomi_miio_fan.fan_set_delay_off
            target:
              entity_id: fan.mi_smart_standing_fan_2
            data:
              delay_off_countdown: |
                [[[
                  const values = [0, 60, 120, 240, 480];
                  const current = Number(entity.attributes.delay_off_countdown ?? 0);
                  return values.find(v => v > current) ?? values[0];
                ]]]
            haptic: light

l'image :

Merci tout bien pour le code. Après, il est vrai que j'ai survolé l'article...:roll_eyes: