# Pinewood dashboard v1.1 - 2026-10-13
# Requires custom:button-card. Map the entity IDs to your installation.
# Card displays Battery Voltage (V); firmware also exposes Battery Level (%).
# Battery Level example: sensor.pine_battery_level; no extra hardware pin.
type: custom:button-card
entity: binary_sensor.pine_car_level
triggers_update:
  - sensor.pine_front_back_tilt
  - sensor.pine_left_right_tilt
  - sensor.pinewood_battery_voltage
  - sensor.pine_mpu_temperature
  - binary_sensor.pine_car_level
  - binary_sensor.pine_car_active
  - sensor.pine_accel_z
show_name: false
show_icon: false
show_state: false
show_label: false
tap_action:
  action: none
styles:
  card:
    - padding: 0
    - border-radius: 24px
    - overflow: hidden
    - min-height: 470px
    - background: '#020817'
    - border: 1px solid rgba(65,125,220,.42)
    - box-shadow: 0 10px 32px rgba(0,0,0,.38)
  grid:
    - grid-template-areas: '"scene"'
    - grid-template-columns: 1fr
    - grid-template-rows: 1fr
  custom_fields:
    scene:
      - width: 100%
      - height: 100%
custom_fields:
  scene: |
    [[[
      /* =========================================================
         ENTITIES
         ========================================================= */

      const fbEnt =
        states['sensor.pine_front_back_tilt'];

      const lrEnt =
        states['sensor.pine_left_right_tilt'];

      const batteryEnt =
        states['sensor.pinewood_battery_voltage'];

      const tempEnt =
        states['sensor.pine_mpu_temperature'];

      const levelEnt =
        states['binary_sensor.pine_car_level'];

      const activeEnt =
        states['binary_sensor.pine_car_active'];

      /*
       * OPTIONAL UPSIDE-DOWN SENSOR
       *
       * If this entity exists, the animation can detect when
       * the physical car is actually upside down.
       */
      const accelZEnt =
        states['sensor.pine_accel_z'];

      /* =========================================================
         IMAGE FILES
         ========================================================= */

      const carImage =
        '/local/pinewood/pinewood-police-car.svg?v=2';

      const badGuysImage =
        '/local/pinewood/pinewood-city.svg?v=2';

      /* =========================================================
         HELPER
         ========================================================= */

      const numberValue = entity => {

        if (!entity)
          return null;

        const n =
          parseFloat(entity.state);

        return Number.isFinite(n)
          ? n
          : null;
      };

      /* =========================================================
         SENSOR VALUES
         ========================================================= */

      let fb =
        numberValue(fbEnt);

      let lr =
        numberValue(lrEnt);

      const battery =
        numberValue(batteryEnt);

      const temp =
        numberValue(tempEnt);

      const accelZ =
        numberValue(accelZEnt);

      const tiltAvailable = fb !== null && lr !== null;

      if (fb === null)
        fb = 0;

      if (lr === null)
        lr = 0;

      /* =========================================================
         REAL ACTIVE / SLEEP STATE

         NO TIMER GUESSING ANYMORE.

         ESPHome explicitly tells us whether the car
         is awake.
         ========================================================= */

      const active =
        activeEnt?.state === 'on';

      /* =========================================================
         LEVEL STATE
         ========================================================= */

      const flat =
        Math.abs(fb) <= 2 &&
        Math.abs(lr) <= 2;

      const maxTilt =
        Math.max(
          Math.abs(fb),
          Math.abs(lr)
        );

      let statusText;
      let statusClass;

      /*
       * When sleeping, do not keep displaying a stale
       * LEVEL/TILTED condition as though it is live.
       */

      if (!active) {

        statusText =
          'SLEEPING';

        statusClass =
          'sleeping';

      } else if (!tiltAvailable) {
        statusText = "UNAVAILABLE";
        statusClass = "sleeping";
      } else if (flat) {

        statusText =
          'LEVEL';

        statusClass =
          'level';

      } else if (maxTilt <= 5) {

        statusText =
          'SLIGHT TILT';

        statusClass =
          'warning';

      } else {

        statusText =
          'TILTED';

        statusClass =
          'danger';
      }

      /* =========================================================
         ANGLE HELPERS
         ========================================================= */

      const clamp = (
        value,
        min,
        max
      ) =>
        Math.min(
          max,
          Math.max(
            min,
            value
          )
        );

      /*
       * Keep sensor degrees equal to animation degrees.
       *
       * 15° sensor reading = 15° visual movement.
       */

      const frontBackAngle =
        clamp(
          fb,
          -75,
          75
        );

      const leftRightAngle =
        clamp(
          lr,
          -75,
          75
        );

      /* =========================================================
         FRONT / BACK

         Your corrected image faces RIGHT.

         Front of car = right side of image.

         Positive FB should lift the right/front side.

         CSS positive rotateZ goes clockwise,
         so front/back remains NEGATIVE.
         ========================================================= */

      const pitchCSS =
        -frontBackAngle;

      /* =========================================================
         LEFT / RIGHT

         THIS IS THE CHANGE YOU ASKED FOR.

         Previous:
           rollCSS = leftRightAngle

         New:
           rollCSS = -leftRightAngle

         This reverses the visual roll so physical driver-side
         and passenger-side tilts follow the real car.
         ========================================================= */

      const rollCSS =
        -leftRightAngle;

      /* =========================================================
         UPSIDE DOWN
         ========================================================= */

      const upsideDown =
        accelZ !== null &&
        accelZ > 4.0;

      const inversion =
        upsideDown
          ? 180
          : 0;

      const totalRoll =
        rollCSS +
        inversion;

      /* =========================================================
         SMALL 3D POSITION COMPENSATION
         ========================================================= */

      const rollLift =
        Math.min(
          8,
          Math.abs(lr) * .10
        );

      /* =========================================================
         SHADOW

         Reverse this along with roll so the shadow appears
         to move opposite the body lean.
         ========================================================= */

      const shadowShift =
        clamp(
          lr * .30,
          -10,
          10
        );

      const shadowWidth =
        1 -
        Math.min(
          Math.abs(lr),
          60
        ) * .003;

      const shadowOpacity =
        Math.max(
          .25,
          .55 -
          Math.abs(lr) * .004
        );

      /* =========================================================
         TEXT
         ========================================================= */

      const batteryText =
        battery !== null
          ? `${battery.toFixed(2)} V`
          : '--';

      const tempText =
        temp !== null
          ? `${temp.toFixed(1)}°C`
          : '--';

      const fbArrow =
        fb > .3
          ? '▲'
          : fb < -.3
            ? '▼'
            : '●';

      const lrArrow =
        lr > .3
          ? '▶'
          : lr < -.3
            ? '◀'
            : '●';

      return `

      <style>

        /* =====================================================
           MAIN CARD
           ===================================================== */

        .pine-card {

          position: relative;

          width: 100%;
          height: 470px;

          overflow: hidden;

          border-radius: 24px;

          color: white;

          background:
            #020817;

          font-family:
            Roboto,
            Arial,
            sans-serif;
        }

        /* =====================================================
           BAD GUY BACKGROUND
           ===================================================== */

        .chase-scene {

          position: absolute;

          left: 0;
          right: 0;

          top: 57px;
          bottom: 82px;

          z-index: 1;

          overflow: hidden;

          background:
            #06152e;
        }

        .bad-guys {

          position: absolute;

          inset: 0;

          width: 100%;
          height: 100%;

          object-fit: cover;

          object-position:
            center;

          filter:
            saturate(1.18)
            contrast(1.05)
            brightness(.88);

          transform:
            scale(1.04);

          animation:
            chase-background
            9s
            ease-in-out
            infinite
            alternate;

          pointer-events:
            none;

          user-select:
            none;
        }

        @keyframes chase-background {

          0% {

            transform:
              scale(1.04)
              translateX(-3px);
          }

          50% {

            transform:
              scale(1.07)
              translateX(3px);
          }

          100% {

            transform:
              scale(1.04)
              translateX(-2px);
          }
        }

        /* =====================================================
           SPEED LINES
           ===================================================== */

        .speed-lines {

          position: absolute;

          inset: -100% -50%;

          z-index: 2;

          opacity:
            ${active ? '.11' : '.035'};

          pointer-events:
            none;

          background:

            repeating-linear-gradient(
              103deg,

              transparent 0,
              transparent 50px,

              rgba(
                80,
                160,
                255,
                .75
              ) 52px,

              transparent 55px
            );

          animation:
            ${active
              ? 'speed-motion 2.2s linear infinite'
              : 'none'};
        }

        @keyframes speed-motion {

          from {
            transform:
              translateX(-80px);
          }

          to {
            transform:
              translateX(80px);
          }
        }

        /* =====================================================
           VIGNETTE
           ===================================================== */

        .vignette {

          position: absolute;

          inset: 0;

          z-index: 3;

          pointer-events: none;

          background:

            linear-gradient(
              180deg,

              rgba(2,8,25,.30),

              transparent 24%,

              transparent 65%,

              rgba(2,7,20,.74)
            ),

            linear-gradient(
              90deg,

              rgba(0,0,0,.18),

              transparent 20%,

              transparent 80%,

              rgba(0,0,0,.18)
            );
        }

        /* =====================================================
           SLEEP OVERLAY
           ===================================================== */

        .sleep-overlay {

          position: absolute;

          inset: 0;

          z-index: 18;

          pointer-events: none;

          background:
            rgba(0,8,24,.18);

          opacity:
            ${active ? 0 : 1};

          transition:
            opacity .35s ease;
        }

        /* =====================================================
           HEADER
           ===================================================== */

        .pine-header {

          position: absolute;

          z-index: 50;

          top: 14px;
          left: 15px;
          right: 15px;

          display: flex;

          align-items:
            flex-start;

          justify-content:
            space-between;
        }

        .pine-title {

          font-size: 20px;

          line-height: 1;

          font-weight: 900;

          letter-spacing:
            -.35px;

          text-shadow:
            0 3px 7px
            rgba(0,0,0,.80);
        }

        .pine-subtitle {

          margin-top: 5px;

          font-size: 8px;

          font-weight: 800;

          letter-spacing:
            .8px;

          color:
            rgba(
              220,
              235,
              255,
              .70
            );

          text-transform:
            uppercase;
        }

        /* =====================================================
           STATUS BADGE
           ===================================================== */

        .status {

          padding:
            6px 10px;

          border-radius:
            999px;

          font-size:
            8px;

          font-weight:
            900;

          letter-spacing:
            .8px;

          backdrop-filter:
            blur(8px);

          transition:
            all .25s ease;
        }

        .status.level {

          color:
            #9cffad;

          background:
            rgba(20,110,48,.55);

          border:
            1px solid
            rgba(75,255,110,.52);

          box-shadow:
            0 0 15px
            rgba(40,255,90,.20);
        }

        .status.warning {

          color:
            #ffdc78;

          background:
            rgba(145,92,0,.60);

          border:
            1px solid
            rgba(255,190,55,.55);
        }

        .status.danger {

          color:
            #ffaaaa;

          background:
            rgba(145,24,30,.60);

          border:
            1px solid
            rgba(255,65,70,.55);

          box-shadow:
            0 0 15px
            rgba(255,30,40,.18);
        }

        .status.sleeping {

          color:
            #bcc7d6;

          background:
            rgba(45,55,72,.58);

          border:
            1px solid
            rgba(160,175,195,.30);

          box-shadow:
            none;
        }

        /* =====================================================
           ACTIVE BADGE
           ===================================================== */

        .active {

          position: absolute;

          z-index: 55;

          top: 58px;
          left: 15px;

          display: flex;

          align-items:
            center;

          gap: 5px;

          padding:
            4px 8px;

          border-radius:
            999px;

          background:
            rgba(0,0,0,.55);

          border:
            1px solid
            rgba(255,255,255,.14);

          font-size: 7px;

          font-weight: 900;

          letter-spacing:
            .5px;

          backdrop-filter:
            blur(8px);

          transition:
            all .25s ease;
        }

        .active-dot {

          width: 7px;
          height: 7px;

          border-radius:
            50%;

          background:
            ${active
              ? '#45ff76'
              : '#758092'};

          box-shadow:
            ${active
              ? '0 0 8px #45ff76'
              : 'none'};

          transition:
            all .25s ease;
        }

        /* =====================================================
           3D CAR STAGE
           ===================================================== */

        .car-stage {

          position: absolute;

          z-index: 20;

          left: 0;
          right: 0;

          top: 84px;
          bottom: 88px;

          perspective:
            850px;

          perspective-origin:
            50% 50%;

          pointer-events:
            none;
        }

        /* =====================================================
           GROUND SHADOW
           ===================================================== */

        .ground-shadow {

          position: absolute;

          z-index: 1;

          left: 50%;
          top: 65%;

          width: 62%;
          height: 17%;

          border-radius:
            50%;

          background:
            rgba(
              0,
              0,
              0,
              ${shadowOpacity}
            );

          filter:
            blur(13px);

          transform:

            translate(
              calc(-50% + ${shadowShift}px),
              14px
            )

            scaleX(
              ${shadowWidth}
            );

          transition:
            transform 170ms linear;
        }

        /* =====================================================
           FRONT / BACK

           SIDE-ON car:
             Front/back is rotateZ.

           Image faces RIGHT.
           ===================================================== */

        .pitch-layer {

          position: absolute;

          z-index: 10;

          left: 50%;
          top: 50%;

          width:
            min(110%, 720px);

          transform:

            translate(
              -50%,
              calc(-50% - ${rollLift}px)
            )

            rotateZ(
              ${pitchCSS}deg
            );

          transform-origin:
            50% 66%;

          transition:
            transform
            170ms
            linear;

          will-change:
            transform;

          transform-style:
            preserve-3d;
        }

        /* =====================================================
           DRIVER / PASSENGER SIDE ROLL

           IMPORTANT:
           rollCSS is now NEGATED.

           Driver/passenger movement is reversed from the
           previous card.
           ===================================================== */

        .roll-layer {

          position: relative;

          width: 100%;

          transform:

            rotateX(
              ${totalRoll}deg
            );

          transform-origin:
            50% 68%;

          transform-style:
            preserve-3d;

          transition:
            transform
            170ms
            linear;

          will-change:
            transform;
        }

        /* =====================================================
           CAR ART

           NO MIRRORING.
           NO SCALEX(-1).
           ===================================================== */

        .car-art {

          position: relative;

          width: 100%;

          transform-style:
            preserve-3d;
        }

        .car-image {

          position: relative;

          z-index: 5;

          display: block;

          width: 100%;
          height: auto;

          pointer-events:
            none;

          user-select:
            none;

          backface-
          filter:

            drop-shadow(
              0 7px 5px
              rgba(0,0,0,.38)
            )

            drop-shadow(
              0 16px 13px
              rgba(0,0,0,.38)
            );
        }

        /* =====================================================
           RED ROOF FLASH

           Positioned over the upper red part of your
           vertical lightbar.
           ===================================================== */

        .roof-red {

          position: absolute;

          z-index: 20;

          left: 50%;
          top: 10.5%;

          width: 10%;
          height: 10%;

          transform:
            translate(-50%, -50%);

          border-radius:
            50%;

          pointer-events:
            none;

          mix-blend-mode:
            screen;

          background:

            radial-gradient(
              circle,

              rgba(255,255,255,1) 0%,

              rgba(255,110,90,1) 10%,

              rgba(255,20,15,.92) 28%,

              rgba(255,0,0,.38) 50%,

              transparent 72%
            );

          opacity:
            ${active ? 1 : 0};

          animation:

            ${active
              ? 'red-flash .72s steps(1) infinite'
              : 'none'};
        }

        /* =====================================================
           BLUE ROOF FLASH
           ===================================================== */

        .roof-blue {

          position: absolute;

          z-index: 20;

          left: 50%;
          top: 16.5%;

          width: 10%;
          height: 10%;

          transform:
            translate(-50%, -50%);

          border-radius:
            50%;

          pointer-events:
            none;

          mix-blend-mode:
            screen;

          background:

            radial-gradient(
              circle,

              rgba(255,255,255,1) 0%,

              rgba(110,205,255,1) 10%,

              rgba(0,105,255,.92) 28%,

              rgba(0,70,255,.38) 50%,

              transparent 72%
            );

          opacity:
            ${active ? 1 : 0};

          animation:

            ${active
              ? 'blue-flash .72s steps(1) infinite'
              : 'none'};
        }

        @keyframes red-flash {

          0%,42% {

            opacity: 1;

            filter:

              brightness(2)

              drop-shadow(
                0 0 14px #ff2020
              );
          }

          43%,100% {

            opacity: .04;
          }
        }

        @keyframes blue-flash {

          0%,42% {

            opacity: .04;
          }

          43%,100% {

            opacity: 1;

            filter:

              brightness(2)

              drop-shadow(
                0 0 14px #008cff
              );
          }
        }

        /* =====================================================
           SCENE POLICE LIGHT REFLECTIONS
           ===================================================== */

        .red-scene {

          position: absolute;

          z-index: 15;

          inset: 0;

          pointer-events:
            none;

          mix-blend-mode:
            screen;

          opacity:
            ${active ? 1 : 0};

          background:

            radial-gradient(
              circle at 65% 47%,

              rgba(
                255,
                20,
                20,
                .21
              ),

              transparent 43%
            );

          animation:

            ${active
              ? 'scene-red .72s steps(1) infinite'
              : 'none'};
        }

        .blue-scene {

          position: absolute;

          z-index: 15;

          inset: 0;

          pointer-events:
            none;

          mix-blend-mode:
            screen;

          opacity:
            ${active ? 1 : 0};

          background:

            radial-gradient(
              circle at 35% 47%,

              rgba(
                0,
                110,
                255,
                .21
              ),

              transparent 43%
            );

          animation:

            ${active
              ? 'scene-blue .72s steps(1) infinite'
              : 'none'};
        }

        @keyframes scene-red {

          0%,42% {
            opacity: 1;
          }

          43%,100% {
            opacity: .08;
          }
        }

        @keyframes scene-blue {

          0%,42% {
            opacity: .08;
          }

          43%,100% {
            opacity: 1;
          }
        }

        /* =====================================================
           UNIT LABEL
           ===================================================== */

        .unit-label {

          position: absolute;

          z-index: 40;

          left: 50%;
          bottom: 78px;

          transform:
            translateX(-50%);

          white-space:
            nowrap;

          font-size: 7px;

          font-weight: 900;

          letter-spacing:
            1.4px;

          color:
            rgba(
              225,
              237,
              255,
              .72
            );

          text-shadow:
            0 2px 5px #000;
        }

        /* =====================================================
           SENSOR READOUTS
           ===================================================== */

        .readouts {

          position: absolute;

          z-index: 60;

          left: 10px;
          right: 10px;
          bottom: 10px;

          display: grid;

          grid-template-columns:
            repeat(4, 1fr);

          gap: 6px;
        }

        .reading {

          min-width: 0;

          padding:
            8px 7px;

          border-radius:
            13px;

          background:
            rgba(1,8,25,.86);

          border:
            1px solid
            rgba(95,155,255,.27);

          backdrop-filter:
            blur(9px);

          box-shadow:
            0 4px 14px
            rgba(0,0,0,.25);
        }

        .reading-label {

          overflow: hidden;

          white-space:
            nowrap;

          text-overflow:
            ellipsis;

          font-size: 6px;

          font-weight: 900;

          letter-spacing:
            .6px;

          text-transform:
            uppercase;

          color:
            rgba(
              200,
              220,
              250,
              .60
            );
        }

        .reading-value {

          margin-top: 3px;

          white-space:
            nowrap;

          font-size: 16px;

          font-weight: 900;

          line-height: 1;

          text-shadow:
            0 2px 4px #000;
        }

        .direction {

          margin-left: 2px;

          font-size: 9px;

          color:
            #50c8ff;
        }

        /* =====================================================
           MOBILE
           ===================================================== */

        @media (max-width: 520px) {

          .pine-card {

            height:
              455px;
          }

          .pine-title {

            font-size:
              18px;
          }

          .pitch-layer {

            width:
              113%;
          }

          .reading {

            padding:
              7px 4px;

            text-align:
              center;
          }

          .reading-value {

            font-size:
              14px;
          }

          .reading-label {

            font-size:
              5.5px;
          }
        }

        @media (prefers-reduced-motion: reduce) {
          .roof-red, .roof-blue, .red-scene, .blue-scene, .speed-lines { animation: none !important; }
          .pitch-layer, .roll-layer, .ground-shadow { transition: none !important; }
        }

      </style>

      <div class="pine-card">

        <!-- BACKGROUND -->

        <div class="chase-scene">

          <img
            class="bad-guys"
            src="${badGuysImage}"
          >

          <div class="speed-lines"></div>

          <div class="vignette"></div>

        </div>

        <!-- POLICE LIGHT REFLECTIONS -->

        <div class="red-scene"></div>

        <div class="blue-scene"></div>

        <!-- SLEEP DIMMER -->

        <div class="sleep-overlay"></div>

        <!-- HEADER -->

        <div class="pine-header">

          <div>

            <div class="pine-title">
              Pinewood Derby Police
            </div>

            <div class="pine-subtitle">
              UNIT 67 • LIFESAVER
            </div>

          </div>

          <div class="status ${statusClass}">
            ${statusText}
          </div>

        </div>

        <!-- ACTIVE STATUS -->

        <div class="active">

          <div class="active-dot"></div>

          ${
            active
              ? 'CAR ACTIVE'
              : 'CAR SLEEPING'
          }

        </div>

        <!-- 3D CAR -->

        <div class="car-stage">

          <div class="ground-shadow"></div>

          <!-- FRONT / BACK TILT -->

          <div class="pitch-layer">

            <!-- DRIVER / PASSENGER TILT -->

            <div class="roll-layer">

              <div class="car-art">

                <img
                  class="car-image"
                  src="${carImage}"
                >

                <!-- FLASHING LIGHTBAR -->

                <div class="roof-red"></div>

                <div class="roof-blue"></div>

              </div>

            </div>

          </div>

        </div>

        <!-- UNIT -->

        <div class="unit-label">
          🚓 UNIT 67 • LIFESAVER
        </div>

        <!-- LIVE READOUTS -->

        <div class="readouts">

          <div class="reading">

            <div class="reading-label">
              Front / Back
            </div>

            <div class="reading-value">

              ${active && tiltAvailable ? fb.toFixed(1) + "°" : "—"}

              <span class="direction">
                ${active && tiltAvailable ? fbArrow : ""}
              </span>

            </div>

          </div>

          <div class="reading">

            <div class="reading-label">
              Left / Right
            </div>

            <div class="reading-value">

              ${active && tiltAvailable ? lr.toFixed(1) + "°" : "—"}

              <span class="direction">
                ${active && tiltAvailable ? lrArrow : ""}
              </span>

            </div>

          </div>

          <div class="reading">

            <div class="reading-label">
              Battery Voltage
            </div>

            <div class="reading-value">
              ${active ? batteryText : "—"}
            </div>

          </div>

          <div class="reading">

            <div class="reading-label">
              MPU Temp
            </div>

            <div class="reading-value">
              ${active ? tempText : "—"}
            </div>

          </div>

        </div>

      </div>

      `;
    ]]]
