type: custom:button-card variables: thermostatversion: 2.0.0 var_url_params: |- [[[ return new URLSearchParams(window.location.search); ]]] var_climate_visuals: |- [[[ /** * Get visual styling based on HVAC mode and action * @param {Object} state - Climate entity state object * @returns {Object} Object with gradient and icon properties */ const getClimateVisuals = (state) => { if (!state) { return { gradient: 'linear-gradient(135deg, #1c1c1c 0%, #2d2d2d 100%)', icon: 'mdi:thermostat' }; } const mode = state.state; const action = state.attributes?.hvac_action; // Priority 1: Use action if available for most accurate representation if (action) { switch(action) { case 'heating': return { gradient: 'linear-gradient(135deg, #ff6b6b 0%, #ff8e53 100%)', icon: 'mdi:fire' }; case 'cooling': return { gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', icon: 'mdi:snowflake' }; case 'drying': return { gradient: 'linear-gradient(135deg, #f39c12 0%, #f1c40f 100%)', icon: 'mdi:water-percent' }; case 'fan': return { gradient: 'linear-gradient(135deg, #3498db 0%, #2980b9 100%)', icon: 'mdi:fan' }; case 'idle': // Idle state - show mode intention with muted colors switch(mode) { case 'heat': return { gradient: 'linear-gradient(135deg, #c46b6b 0%, #c47e53 100%)', icon: 'mdi:fire-circle' }; case 'cool': return { gradient: 'linear-gradient(135deg, #5a9cc4 0%, #4090c4 100%)', icon: 'mdi:snowflake-variant' }; case 'heat_cool': return { gradient: 'linear-gradient(135deg, #c46b6b 0%, #5a9cc4 100%)', icon: 'mdi:sync-circle' }; default: return { gradient: 'linear-gradient(135deg, #434343 0%, #5a5a5a 100%)', icon: 'mdi:power-standby' }; } case 'off': return { gradient: 'linear-gradient(135deg, #1c1c1c 0%, #2d2d2d 100%)', icon: 'mdi:power' }; } } // Priority 2: Fall back to mode if no action available switch(mode) { case 'heat': return { gradient: 'linear-gradient(135deg, #ff6b6b 0%, #ff8e53 100%)', icon: 'mdi:fire' }; case 'cool': return { gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', icon: 'mdi:snowflake' }; case 'heat_cool': return { gradient: 'linear-gradient(135deg, #ff6b6b 0%, #4facfe 100%)', icon: 'mdi:autorenew' }; case 'dry': return { gradient: 'linear-gradient(135deg, #f39c12 0%, #f1c40f 100%)', icon: 'mdi:water-percent' }; case 'fan_only': return { gradient: 'linear-gradient(135deg, #3498db 0%, #2980b9 100%)', icon: 'mdi:fan' }; case 'off': return { gradient: 'linear-gradient(135deg, #1c1c1c 0%, #2d2d2d 100%)', icon: 'mdi:power' }; } // Priority 3: Infer from temperature difference const currentTemp = state.attributes?.current_temperature; const targetTemp = state.attributes?.temperature; if (currentTemp != null && targetTemp != null) { const tempDiff = targetTemp - currentTemp; if (tempDiff > 0.5) { return { gradient: 'linear-gradient(135deg, #ff6b6b 0%, #ff8e53 100%)', icon: 'mdi:fire' }; } if (tempDiff < -0.5) { return { gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', icon: 'mdi:snowflake' }; } } // Final fallback: Neutral gradient return { gradient: 'linear-gradient(135deg, #434343 0%, #5a5a5a 100%)', icon: 'mdi:thermostat' }; }; // Return visuals for the current climate device if (!variables.var_climate_device) { return getClimateVisuals(null); } const state = hass.states[variables.var_climate_device]; return getClimateVisuals(state); ]]] var_climate_device: |- [[[ /** * Get the area/floor ID for a given device * @param {string} deviceId - Device ID to look up * @returns {Object} Object with areaId and floorId properties */ const getDeviceLocation = (deviceId) => { if (!deviceId) return { areaId: null, floorId: null }; const device = Object.values(hass.devices || {}).find(d => d.id === deviceId); if (!device) return { areaId: null, floorId: null }; const areaId = device.area_id || null; let floorId = null; if (areaId && hass.areas) { const area = Object.values(hass.areas).find(a => a.area_id === areaId); if (area) { floorId = area.floor_id || null; } } return { areaId, floorId }; }; /** * Get the device ID for a climate entity * @param {string} entityId - Climate entity ID * @returns {string|null} Device ID or null */ const getClimateDeviceId = (entityId) => { if (!hass.entities || !hass.entities[entityId]) return null; return hass.entities[entityId].device_id || null; }; /** * Get the mic device ID from View Assist sensor entity * @returns {string|null} Mic device ID or null */ const getViewAssistDeviceId = () => { const vaEntity = variables.var_assistsat_entity; if (!vaEntity || !hass.states[vaEntity]) return null; const micDeviceId = hass.states[vaEntity].attributes.mic_device_id; return micDeviceId || null; }; const urlParams = variables.var_url_params; const urlClimate = urlParams.get('climate'); // If URL specifies a climate device, use it if (urlClimate) { const entity = hass.states[urlClimate]; if (entity && entity.state !== 'unavailable' && entity.state !== 'unknown') { return urlClimate; } } // Get View Assist device location for intelligent matching const vaDeviceId = getViewAssistDeviceId(); const vaLocation = getDeviceLocation(vaDeviceId); // Get all available climate devices const climateKeys = Object.keys(hass.states).filter(id => id.startsWith('climate.')); const availableClimate = []; const maxIterations = Math.min(climateKeys.length, 15); // Realistic upper bound for (let i = 0; i < maxIterations; i++) { const entityId = climateKeys[i]; const state = hass.states[entityId].state; if (state !== 'unavailable' && state !== 'unknown') { const deviceId = getClimateDeviceId(entityId); const location = getDeviceLocation(deviceId); availableClimate.push({ entityId, deviceId, areaId: location.areaId, floorId: location.floorId }); } } if (availableClimate.length === 0) { return ""; } // Priority 1: Same area as View Assist device if (vaLocation.areaId) { const sameAreaClimate = availableClimate.find(c => c.areaId === vaLocation.areaId); if (sameAreaClimate) { return sameAreaClimate.entityId; } } // Priority 2: Same floor as View Assist device if (vaLocation.floorId) { const sameFloorClimate = availableClimate.find(c => c.floorId === vaLocation.floorId); if (sameFloorClimate) { return sameFloorClimate.entityId; } } // Priority 3: First available climate device return availableClimate[0].entityId; ]]] var_all_climate_devices: |- [[[ /** * Format climate entity name for display * @param {string} entityId - Climate entity ID * @returns {string} Formatted name */ const formatClimateName = (entityId) => { return entityId.replace('climate.', '').replace(/_/g, ' '); }; /** * Check if climate entity is available * @param {string} state - Entity state * @returns {boolean} True if available */ const isClimateAvailable = (state) => { return state !== 'unavailable' && state !== 'unknown'; }; /** * Get visual styling based on HVAC mode and action * @param {Object} entity - Entity object * @returns {Object} Object with gradient and icon properties */ const getClimateVisuals = (entity) => { const mode = entity.state; const action = entity.attributes?.hvac_action; // Priority 1: Use action if available if (action) { switch(action) { case 'heating': return { icon: 'mdi:fire' }; case 'cooling': return { icon: 'mdi:snowflake' }; case 'drying': return { icon: 'mdi:water-percent' }; case 'fan': return { icon: 'mdi:fan' }; case 'idle': // Idle state - show mode intention with variant icons switch(mode) { case 'heat': return { icon: 'mdi:fire-circle' }; case 'cool': return { icon: 'mdi:snowflake-variant' }; case 'heat_cool': return { icon: 'mdi:sync-circle' }; default: return { icon: 'mdi:power-standby' }; } case 'off': return { icon: 'mdi:power' }; } } // Priority 2: Fall back to mode switch(mode) { case 'heat': return { icon: 'mdi:fire' }; case 'cool': return { icon: 'mdi:snowflake' }; case 'heat_cool': return { icon: 'mdi:autorenew' }; case 'dry': return { icon: 'mdi:water-percent' }; case 'fan_only': return { icon: 'mdi:fan' }; case 'off': return { icon: 'mdi:power' }; default: return { icon: entity.attributes.icon || 'mdi:thermostat' }; } }; const urlParams = variables.var_url_params; const showType = urlParams.get('show'); const configuredClimate = urlParams.get('climate_devices'); // If specific devices are configured via URL if (showType === 'configured' && configuredClimate) { const devices = []; const climateEntityList = decodeURIComponent(configuredClimate).split(','); const maxIterations = Math.min(climateEntityList.length, 10); // Realistic upper bound for (let i = 0; i < maxIterations; i++) { const entityId = climateEntityList[i].trim(); const entity = hass.states[entityId]; if (entity && isClimateAvailable(entity.state)) { const visuals = getClimateVisuals(entity); devices.push({ entity_id: entityId, name: entity.attributes.friendly_name || formatClimateName(entityId), hvac_mode: entity.state, hvac_action: entity.attributes?.hvac_action || null, icon: visuals.icon }); } } return devices; } // Get all available climate devices const devices = []; const climateKeys = Object.keys(hass.states).filter(id => id.startsWith('climate.')); const maxIterations = Math.min(climateKeys.length, 15); // Realistic upper bound for (let i = 0; i < maxIterations; i++) { const entityId = climateKeys[i]; const entity = hass.states[entityId]; if (isClimateAvailable(entity.state)) { const visuals = getClimateVisuals(entity); devices.push({ entity_id: entityId, name: entity.attributes.friendly_name || formatClimateName(entityId), hvac_mode: entity.state, hvac_action: entity.attributes?.hvac_action || null, icon: visuals.icon }); } } // Sort alphabetically for consistent display devices.sort((a, b) => a.name.localeCompare(b.name)); return devices; ]]] var_has_multiple_climate: "[[[ return variables.var_all_climate_devices.length > 1; ]]]" template: - body_template styles: grid: - grid-template-areas: |- [[[ const isPortrait = window.viewAssistResponsive?.orientation === "portrait"; if (!variables.var_climate_device) { return isPortrait ? `"title_card status" "no_climate no_climate" "no_climate no_climate"` : `"title_card status status" "no_climate no_climate no_climate" "no_climate no_climate no_climate"`; } if (variables.var_has_multiple_climate) { return isPortrait ? `"title_card status" "thermostat thermostat" "thermostat thermostat" "thermostat thermostat" "thermostat thermostat" "controls controls"` : `"title_card status status status" "thermostat thermostat thermostat controls" "thermostat thermostat thermostat controls" "thermostat thermostat thermostat controls"`; } return isPortrait ? `"title_card status" "thermostat thermostat" "thermostat thermostat" "thermostat thermostat" "thermostat thermostat"` : `"title_card status status" "thermostat thermostat thermostat" "thermostat thermostat thermostat" "thermostat thermostat thermostat"`; ]]] - grid-template-columns: |- [[[ const isPortrait = window.viewAssistResponsive?.orientation === "portrait"; if (!variables.var_climate_device) { return isPortrait ? "min-content 1fr" : "min-content 1fr 1fr"; } if (variables.var_has_multiple_climate && !isPortrait) { return "min-content 1fr 1fr min-content"; } return isPortrait ? "min-content 1fr" : "min-content 1fr 1fr"; ]]] - grid-template-rows: |- [[[ if (!variables.var_climate_device) { return "min-content 1fr 1fr"; } if (variables.var_has_multiple_climate && window.viewAssistResponsive?.orientation === "portrait") { return "min-content 1fr 1fr 1fr 1fr min-content"; } if (variables.var_has_multiple_climate) { return "min-content 1fr 1fr 1fr"; } return window.viewAssistResponsive?.orientation === "portrait" ? "min-content 1fr 1fr 1fr 1fr" : "min-content 1fr 1fr 1fr"; ]]] card: - background: "[[[ return variables.var_climate_visuals.gradient; ]]]" - border-radius: 0px - font-family: >- [[[ return `"${variables.var_assistsat_entity_font_style}", sans-serif`; ]]] custom_fields: title_card: - position: absolute - justify-self: start - align-self: start - z-index: 1 - width: max-content - margin-left: 2% - margin-top: "-4%" thermostat: - display: "[[[ return variables.var_climate_device ? 'flex' : 'none' ]]]" - justify-content: center - align-items: center - height: 100% - width: 100% - padding-top: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "8vh" : "1vh"; ]]] - padding-left: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "0" : "1vw"; ]]] - padding-right: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "0" : "1vw"; ]]] controls: - display: "[[[ return variables.var_has_multiple_climate ? 'flex' : 'none' ]]]" - justify-self: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "center" : "end"; ]]] - align-self: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "end" : "center"; ]]] - justify-content: flex-start - align-items: |- [[[ // In landscape, align to start so cards begin at top // In portrait, keep centered return window.viewAssistResponsive?.orientation === "portrait" ? "center" : "flex-start"; ]]] - padding: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "1vh 0" : "0 2vw"; ]]] - overflow-y: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "hidden" : "auto"; ]]] - overflow-x: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "auto" : "hidden"; ]]] - max-height: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "25vh" : "84vh"; ]]] - max-width: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "93vw" : "18vw"; ]]] - margin-top: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "0" : "9vh"; ]]] - "-webkit-overflow-scrolling": touch - scroll-behavior: smooth no_climate: - display: "[[[ return variables.var_climate_device ? 'none' : 'flex' ]]]" - justify-content: center - align-items: center - color: white - text-align: center custom_fields: title_card: card: type: markdown content: |- [[[ if (!variables.var_climate_device) { return "## Climate Control"; } const state = hass.states[variables.var_climate_device]; const name = state?.attributes?.friendly_name || variables.var_climate_device.replace('climate.', ''); return `## ${name}`; ]]] card_mod: style: |- [[[ const isPortrait = window.viewAssistResponsive?.orientation === "portrait"; const fontSize = isPortrait ? "320%" : "280%"; return ` ha-card { background: transparent !important; box-shadow: none !important; border: none !important; padding: 0 !important; margin: 0 !important; } ha-markdown { color: white !important; text-align: left !important; text-shadow: 0 2px 4px rgba(0,0,0,0.7) !important; } ha-markdown h2 { margin: 0 !important; font-size: ${fontSize} !important; font-weight: 400 !important; letter-spacing: 0.05em !important; } `; ]]] thermostat: card: type: thermostat entity: "[[[ return variables.var_climate_device ]]]" card_mod: style: ha-state-control-climate-temperature$: | .container { --primary-text-color: white !important; --secondary-text-color: white !important; } p.label, p.label.secondary { color: white !important; } .: |- [[[ const isPortrait = window.viewAssistResponsive?.orientation === "portrait"; const zoomLevel = isPortrait ? "1.7" : "1.6"; return ` ha-card { background: transparent !important; height: 100% !important; zoom: ${zoomLevel}; --mdc-ripple-hover-opacity: 0 !important; --mdc-ripple-focus-opacity: 0 !important; --mdc-ripple-press-opacity: 0 !important; } .title { display: none !important; } .more-info { color: white !important; ${isPortrait ? 'margin-top: 7vh !important;' : ''} } ha-state-control-climate-temperature { --secondary-text-color: rgba(255, 255, 255, 0.9) !important; --control-circular-slider-color: white !important; --control-circular-slider-background: rgba(255, 255, 255, 0.3) !important; } `; ]]] controls: card: type: |- [[[ return window.viewAssistResponsive?.orientation === "portrait" ? "horizontal-stack" : "vertical-stack"; ]]] cards: |- [[[ if (!variables.var_has_multiple_climate) return []; const devices = variables.var_all_climate_devices; const maxDevices = Math.min(devices.length, 10); const currentDevice = variables.var_climate_device; const isPortrait = window.viewAssistResponsive?.orientation === "portrait"; const cards = []; for (let i = 0; i < maxDevices; i++) { const device = devices[i]; const isActive = device.entity_id === currentDevice; cards.push({ type: "custom:button-card", icon: device.icon, name: device.name, show_name: true, show_icon: true, tap_action: { action: "call-service", service: "view_assist.navigate", service_data: { device: variables.var_assistsat_entity, path: (variables.var_dashboard || '/view-assist') + "/thermostat?climate=" + device.entity_id } }, styles: { card: [ { "background": isActive ? "rgba(255,255,255,0.4)" : "rgba(255,255,255,0.2)" }, { "border": isActive ? "3px solid white" : "2px solid rgba(255,255,255,0.5)" }, { "border-radius": "1rem" }, { "padding": "1.5rem" }, { "width": isPortrait ? "29vw" : "16vw" }, { "height": isPortrait ? "29vw" : "16vw" }, { "backdrop-filter": "blur(15px)" }, { "box-shadow": "0 4px 20px rgba(0,0,0,0.3)" }, { "margin": isPortrait ? "0 0.5vw" : "0.75vh 0" }, { "flex-shrink": "0" } ], icon: [ { "width": isPortrait ? "15vw" : "8vw" }, { "height": isPortrait ? "15vw" : "8vw" }, { "color": "white" }, { "filter": "drop-shadow(0 2px 4px rgba(0,0,0,0.5))" } ], name: [ { "font-size": isPortrait ? "3.7vw" : "1.9vw" }, { "color": "white" }, { "margin-top": "0.75rem" }, { "font-weight": "700" }, { "text-shadow": "0 2px 9px rgba(0,0,0,0.8)" }, { "line-height": "1.2" }, { "letter-spacing": "0.02em" }, { "overflow": "hidden" }, { "text-overflow": "ellipsis" }, { "white-space": "nowrap" }, { "max-width": "100%" } ], grid: [ { "grid-template-areas": '"i" "n"' }, { "grid-template-rows": "1fr auto" }, { "gap": "0.5rem" } ] } }); } return cards; ]]] no_climate: card: type: markdown content: | ## No Climate Devices Available No thermostats found in your Home Assistant setup. card_mod: style: | ha-card { background: rgba(255,255,255,0.1); color: white; padding: 2rem; border-radius: 1rem; text-align: center; backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2); }