Thermostat Card Read Only

I like the Thermostat card but I want to make it read only and not allow adjustment of temperature. I want to use it on a tablet dashboard where I have created preset temp tiles that will control the temperature. I don’t want users to be able to adjust temp on the Thermostat tile though.

Any thoughts on how to accomlish this?

I would create an automation that sets the temp to the temp you want any time it is changed. Then let them change it, you will change it right back before the machine even reacts.

With the help of Claude AI (Original on right):

/**
 * readonly-thermostat-card.js
 *
 * A pixel-perfect read-only thermostat card for Home Assistant.
 * Uses the exact same internal HA component as the built-in thermostat card
 * (ha-state-control-climate-temperature) with a ResizeController that mirrors
 * HA's own sizing logic, then layers a transparent overlay to block all input.
 *
 * INSTALLATION:
 *   1. Copy to /config/www/readonly-thermostat-card.js
 *   2. HA → Settings → Dashboards → Resources → Add:
 *        URL:  /local/readonly-thermostat-card.js
 *        Type: JavaScript Module
 *   3. Hard-refresh browser (Ctrl+Shift+R)
 *
 * YAML:
 *   type: custom:readonly-thermostat-card
 *   entity: climate.living_room
 *   name: Living Room               # optional
 *   show_current_as_primary: false  # optional
 *   theme: default                  # optional
 *   features: []                    # optional (card features, also read-only)
 */

class ReadonlyThermostatCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._config = null;
    this._hass = null;
    this._resizeObserver = null;
  }

  // ── Lovelace API ────────────────────────────────────────────────────────────

  setConfig(config) {
    if (!config.entity) {
      throw new Error('readonly-thermostat-card: "entity" is required.');
    }
    this._config = config;
    this._buildDOM();
  }

  set hass(hass) {
    this._hass = hass;
    if (!this._config || !this._controlEl) return;

    const stateObj = hass.states[this._config.entity] ?? null;

    this._controlEl.hass = hass;
    this._controlEl.stateObj = stateObj;

    if (this._featuresEl) {
      this._featuresEl.hass = hass;
    }

    // Update title
    if (this._titleEl) {
      const name = this._config.name
        ?? (stateObj
            ? (hass.formatEntityName?.(stateObj, undefined) ?? stateObj.attributes.friendly_name)
            : this._config.entity);
      this._titleEl.textContent = name;
    }
  }

  getCardSize() { return 7; }

  getGridOptions() {
    let rows = 5, minRows = 2;
    if (this._config?.features?.length) {
      const h = Math.ceil((this._config.features.length * 2) / 3);
      rows += h; minRows += h;
    }
    return { columns: 12, rows, min_columns: 6, min_rows: minRows };
  }

  static getStubConfig() {
    return { entity: 'climate.living_room' };
  }

  disconnectedCallback() {
    this._resizeObserver?.disconnect();
  }

  // ── DOM construction ────────────────────────────────────────────────────────

  _buildDOM() {
    const config = this._config;

    // ── Styles — copied verbatim from hui-thermostat-card.ts ──
    const style = document.createElement('style');
    style.textContent = `
      :host {
        position: relative;
        display: block;
        height: 100%;
      }

      ha-card {
        position: relative;
        height: 100%;
        width: 100%;
        padding: 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: space-between;
        overflow: hidden;
      }

      /* Identical to HA's .title */
      p.title {
        width: 100%;
        font-size: var(--ha-font-size-l, 1.125rem);
        line-height: var(--ha-line-height-expanded, 1.5);
        padding: 8px 30px;
        margin: 0;
        text-align: center;
        box-sizing: border-box;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        flex: none;
        color: var(--primary-text-color);
      }

      /* Identical to HA's .container */
      .container {
        display: flex;
        align-items: center;
        justify-content: center;
        position: relative;
        overflow: hidden;
        max-width: 100%;
        box-sizing: border-box;
        flex: 1;
        width: 100%;
      }

      /* The padding-top:100% trick HA uses for the square aspect ratio */
      .container::before {
        content: "";
        display: block;
        padding-top: 100%;
      }

      /* Identical to HA's .container > * */
      .container > ha-state-control-climate-temperature {
        padding: 8px;
        /* maxWidth is set inline via JS (ResizeController equivalent) */
      }

      /* Read-only overlay — transparent, absorbs all pointer events */
      .readonly-overlay {
        position: absolute;
        inset: 0;
        z-index: 999;
        cursor: default;
        touch-action: none;
        pointer-events: all;
        background: transparent;
      }

      /* Card features — no interaction */
      hui-card-features {
        width: 100%;
        flex: none;
        padding: 0 12px 12px 12px;
        pointer-events: none;
        user-select: none;
      }
    `;

    // ── ha-card ──────────────────────────────────────────────
    const card = document.createElement('ha-card');

    // Title
    const title = document.createElement('p');
    title.className = 'title';
    title.textContent = config.name ?? config.entity;
    this._titleEl = title;
    card.appendChild(title);

    // Container
    const container = document.createElement('div');
    container.className = 'container';
    this._containerEl = container;

    // The real HA climate control — same element the built-in card uses
    const control = document.createElement('ha-state-control-climate-temperature');
    control.setAttribute('prevent-interaction-on-scroll', '');
    control.setAttribute('show-secondary', '');
    if (config.show_current_as_primary) {
      control.showCurrentAsPrimary = true;
    }
    this._controlEl = control;
    container.appendChild(control);

    // Read-only overlay
    const overlay = document.createElement('div');
    overlay.className = 'readonly-overlay';
    const absorb = (e) => { e.stopPropagation(); e.preventDefault(); };
    [
      'click', 'dblclick',
      'pointerdown', 'pointerup', 'pointermove', 'pointercancel',
      'touchstart', 'touchend', 'touchmove', 'touchcancel',
      'mousedown', 'mouseup', 'mousemove',
      'wheel', 'scroll',
      'keydown', 'keyup', 'keypress',
      'contextmenu',
    ].forEach(evt =>
      overlay.addEventListener(evt, absorb, { passive: false, capture: true })
    );
    container.appendChild(overlay);

    card.appendChild(container);

    // Optional card features
    if (config.features?.length) {
      const features = document.createElement('hui-card-features');
      features.context = { entity_id: config.entity };
      features.features = config.features;
      this._featuresEl = features;
      card.appendChild(features);
    }

    // ── Mount ────────────────────────────────────────────────
    this.shadowRoot.innerHTML = '';
    this.shadowRoot.appendChild(style);
    this.shadowRoot.appendChild(card);

    // ── ResizeController equivalent ──────────────────────────
    // The real HA card uses a ResizeController that watches the container
    // height and sets maxWidth on the control equal to that height.
    // Without this the arc gets clipped when the card is wider than tall.
    this._resizeObserver?.disconnect();
    this._resizeObserver = new ResizeObserver(() => {
      const h = container.clientHeight;
      if (h > 0) {
        control.style.maxWidth = `${h}px`;
      }
    });
    this._resizeObserver.observe(container);

    // Propagate hass if already available (re-config scenario)
    if (this._hass) {
      this.hass = this._hass;
    }
  }
}

customElements.define('readonly-thermostat-card', ReadonlyThermostatCard);

window.customCards = window.customCards || [];
window.customCards.push({
  type:        'readonly-thermostat-card',
  name:        'Read-Only Thermostat Card',
  description: 'Identical to the built-in thermostat card but fully read-only. Uses ha-state-control-climate-temperature with a ResizeObserver-based maxWidth constraint and a transparent overlay to block all input.',
  preview:     false,
});

/* ─────────────────────────────────────────────────────────────────────────────
   YAML REFERENCE
   ─────────────────────────────────────────────────────────────────────────────

   Minimal:
     type: custom:readonly-thermostat-card
     entity: climate.living_room

   All options:
     type: custom:readonly-thermostat-card
     entity: climate.living_room
     name: "Living Room"             # overrides friendly_name
     show_current_as_primary: false  # show current temp as large number
     theme: default                  # HA theme name
     features:                       # card features (rendered but non-interactive)
       - type: climate-hvac-modes

   ─────────────────────────────────────────────────────────────────────────────
   KEY FIX vs PREVIOUS VERSION
   ─────────────────────────────────────────────────────────────────────────────

   The original HA thermostat card uses a Lit ResizeController that watches
   the .container element's clientHeight and passes it as maxWidth into the
   ha-state-control-climate-temperature element via styleMap. This constrains
   the circular slider to fit within a square, preventing it from being clipped
   when the card is wider than it is tall (the common case on dashboards).

   This card replicates that with a standard ResizeObserver on the container,
   setting control.style.maxWidth = `${containerHeight}px` on every resize.

   ─────────────────────────────────────────────────────────────────────────────
*/

Thank you for this! I’ll give it a try!