Initial Home Assistant commit
This commit is contained in:
commit
6a58b10e6c
4494 files changed
+297833
No files matched your search
@@ -0,0 +1 @@
|
||||
1.15.1
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,311 @@
|
||||
/******************************************************************************
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
***************************************************************************** */
|
||||
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
|
||||
|
||||
|
||||
function __decorate(decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
}
|
||||
|
||||
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
||||
var e = new Error(message);
|
||||
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
||||
};
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
const t$2=globalThis,e$2=t$2.ShadowRoot&&(void 0===t$2.ShadyCSS||t$2.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,s$2=Symbol(),o$4=new WeakMap;let n$3 = class n{constructor(t,e,o){if(this._$cssResult$=true,o!==s$2)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e;}get styleSheet(){let t=this.o;const s=this.t;if(e$2&&void 0===t){const e=void 0!==s&&1===s.length;e&&(t=o$4.get(s)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&o$4.set(s,t));}return t}toString(){return this.cssText}};const r$4=t=>new n$3("string"==typeof t?t:t+"",void 0,s$2),i$3=(t,...e)=>{const o=1===t.length?t[0]:e.reduce((e,s,o)=>e+(t=>{if(true===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+t[o+1],t[0]);return new n$3(o,t,s$2)},S$1=(s,o)=>{if(e$2)s.adoptedStyleSheets=o.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of o){const o=document.createElement("style"),n=t$2.litNonce;void 0!==n&&o.setAttribute("nonce",n),o.textContent=e.cssText,s.appendChild(o);}},c$2=e$2?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const s of t.cssRules)e+=s.cssText;return r$4(e)})(t):t;
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/const{is:i$2,defineProperty:e$1,getOwnPropertyDescriptor:h$1,getOwnPropertyNames:r$3,getOwnPropertySymbols:o$3,getPrototypeOf:n$2}=Object,a$1=globalThis,c$1=a$1.trustedTypes,l$1=c$1?c$1.emptyScript:"",p$1=a$1.reactiveElementPolyfillSupport,d$1=(t,s)=>t,u$1={toAttribute(t,s){switch(s){case Boolean:t=t?l$1:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t);}return t},fromAttribute(t,s){let i=t;switch(s){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t);}catch(t){i=null;}}return i}},f$1=(t,s)=>!i$2(t,s),b$1={attribute:true,type:String,converter:u$1,reflect:false,useDefault:false,hasChanged:f$1};Symbol.metadata??=Symbol("metadata"),a$1.litPropertyMetadata??=new WeakMap;let y$1 = class y extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t);}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,s=b$1){if(s.state&&(s.attribute=false),this._$Ei(),this.prototype.hasOwnProperty(t)&&((s=Object.create(s)).wrapped=true),this.elementProperties.set(t,s),!s.noAccessor){const i=Symbol(),h=this.getPropertyDescriptor(t,i,s);void 0!==h&&e$1(this.prototype,t,h);}}static getPropertyDescriptor(t,s,i){const{get:e,set:r}=h$1(this.prototype,t)??{get(){return this[s]},set(t){this[s]=t;}};return {get:e,set(s){const h=e?.call(this);r?.call(this,s),this.requestUpdate(t,h,i);},configurable:true,enumerable:true}}static getPropertyOptions(t){return this.elementProperties.get(t)??b$1}static _$Ei(){if(this.hasOwnProperty(d$1("elementProperties")))return;const t=n$2(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties);}static finalize(){if(this.hasOwnProperty(d$1("finalized")))return;if(this.finalized=true,this._$Ei(),this.hasOwnProperty(d$1("properties"))){const t=this.properties,s=[...r$3(t),...o$3(t)];for(const i of s)this.createProperty(i,t[i]);}const t=this[Symbol.metadata];if(null!==t){const s=litPropertyMetadata.get(t);if(void 0!==s)for(const[t,i]of s)this.elementProperties.set(t,i);}this._$Eh=new Map;for(const[t,s]of this.elementProperties){const i=this._$Eu(t,s);void 0!==i&&this._$Eh.set(i,t);}this.elementStyles=this.finalizeStyles(this.styles);}static finalizeStyles(s){const i=[];if(Array.isArray(s)){const e=new Set(s.flat(1/0).reverse());for(const s of e)i.unshift(c$2(s));}else void 0!==s&&i.push(c$2(s));return i}static _$Eu(t,s){const i=s.attribute;return false===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=false,this.hasUpdated=false,this._$Em=null,this._$Ev();}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this));}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.();}removeController(t){this._$EO?.delete(t);}_$E_(){const t=new Map,s=this.constructor.elementProperties;for(const i of s.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t);}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return S$1(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(true),this._$EO?.forEach(t=>t.hostConnected?.());}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.());}attributeChangedCallback(t,s,i){this._$AK(t,i);}_$ET(t,s){const i=this.constructor.elementProperties.get(t),e=this.constructor._$Eu(t,i);if(void 0!==e&&true===i.reflect){const h=(void 0!==i.converter?.toAttribute?i.converter:u$1).toAttribute(s,i.type);this._$Em=t,null==h?this.removeAttribute(e):this.setAttribute(e,h),this._$Em=null;}}_$AK(t,s){const i=this.constructor,e=i._$Eh.get(t);if(void 0!==e&&this._$Em!==e){const t=i.getPropertyOptions(e),h="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:u$1;this._$Em=e;const r=h.fromAttribute(s,t.type);this[e]=r??this._$Ej?.get(e)??r,this._$Em=null;}}requestUpdate(t,s,i,e=false,h){if(void 0!==t){const r=this.constructor;if(false===e&&(h=this[t]),i??=r.getPropertyOptions(t),!((i.hasChanged??f$1)(h,s)||i.useDefault&&i.reflect&&h===this._$Ej?.get(t)&&!this.hasAttribute(r._$Eu(t,i))))return;this.C(t,s,i);} false===this.isUpdatePending&&(this._$ES=this._$EP());}C(t,s,{useDefault:i,reflect:e,wrapped:h},r){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,r??s??this[t]),true!==h||void 0!==r)||(this._$AL.has(t)||(this.hasUpdated||i||(s=void 0),this._$AL.set(t,s)),true===e&&this._$Em!==t&&(this._$Eq??=new Set).add(t));}async _$EP(){this.isUpdatePending=true;try{await this._$ES;}catch(t){Promise.reject(t);}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)rLine truncated
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
const t$1=globalThis,i$1=t=>t,s$1=t$1.trustedTypes,e=s$1?s$1.createPolicy("lit-html",{createHTML:t=>t}):void 0,h="$lit$",o$2=`lit$${Math.random().toFixed(9).slice(2)}$`,n$1="?"+o$2,r$2=`<${n$1}>`,l=document,c=()=>l.createComment(""),a=t=>null===t||"object"!=typeof t&&"function"!=typeof t,u=Array.isArray,d=t=>u(t)||"function"==typeof t?.[Symbol.iterator],f="[ \t\n\f\r]",v=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,_=/-->/g,m=/>/g,p=RegExp(`>|${f}(?:([^\\s"'>=/]+)(${f}*=${f}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),g=/'/g,$=/"/g,y=/^(?:script|style|textarea|title)$/i,x=t=>(i,...s)=>({_$litType$:t,strings:i,values:s}),b=x(1),E=Symbol.for("lit-noChange"),A=Symbol.for("lit-nothing"),C=new WeakMap,P=l.createTreeWalker(l,129);function V(t,i){if(!u(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==e?e.createHTML(i):i}const N=(t,i)=>{const s=t.length-1,e=[];let n,l=2===i?"<svg>":3===i?"<math>":"",c=v;for(let i=0;i<s;i++){const s=t[i];let a,u,d=-1,f=0;for(;f<s.length&&(c.lastIndex=f,u=c.exec(s),null!==u);)f=c.lastIndex,c===v?"!--"===u[1]?c=_:void 0!==u[1]?c=m:void 0!==u[2]?(y.test(u[2])&&(n=RegExp("</"+u[2],"g")),c=p):void 0!==u[3]&&(c=p):c===p?">"===u[0]?(c=n??v,d=-1):void 0===u[1]?d=-2:(d=c.lastIndex-u[2].length,a=u[1],c=void 0===u[3]?p:'"'===u[3]?$:g):c===$||c===g?c=p:c===_||c===m?c=v:(c=p,n=void 0);const x=c===p&&t[i+1].startsWith("/>")?" ":"";l+=c===v?s+r$2:d>=0?(e.push(a),s.slice(0,d)+h+s.slice(d)+o$2+x):s+o$2+(-2===d?i:x);}return [V(t,l+(t[s]||"<?>")+(2===i?"</svg>":3===i?"</math>":"")),e]};class S{constructor({strings:t,_$litType$:i},e){let r;this.parts=[];let l=0,a=0;const u=t.length-1,d=this.parts,[f,v]=N(t,i);if(this.el=S.createElement(f,e),P.currentNode=this.el.content,2===i||3===i){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes);}for(;null!==(r=P.nextNode())&&d.length<u;){if(1===r.nodeType){if(r.hasAttributes())for(const t of r.getAttributeNames())if(t.endsWith(h)){const i=v[a++],s=r.getAttribute(t).split(o$2),e=/([.?@])?(.*)/.exec(i);d.push({type:1,index:l,name:e[2],strings:s,ctor:"."===e[1]?I:"?"===e[1]?L:"@"===e[1]?z:H}),r.removeAttribute(t);}else t.startsWith(o$2)&&(d.push({type:6,index:l}),r.removeAttribute(t));if(y.test(r.tagName)){const t=r.textContent.split(o$2),i=t.length-1;if(i>0){r.textContent=s$1?s$1.emptyScript:"";for(let s=0;s<i;s++)r.append(t[s],c()),P.nextNode(),d.push({type:2,index:++l});r.append(t[i],c());}}}else if(8===r.nodeType)if(r.data===n$1)d.push({type:2,index:l});else {let t=-1;for(;-1!==(t=r.data.indexOf(o$2,t+1));)d.push({type:7,index:l}),t+=o$2.length-1;}l++;}}static createElement(t,i){const s=l.createElement("template");return s.innerHTML=t,s}}function M(t,i,s=t,e){if(i===E)return i;let h=void 0!==e?s._$Co?.[e]:s._$Cl;const o=a(i)?void 0:i._$litDirective$;return h?.constructor!==o&&(h?._$AO?.(false),void 0===o?h=void 0:(h=new o(t),h._$AT(t,s,e)),void 0!==e?(s._$Co??=[])[e]=h:s._$Cl=h),void 0!==h&&(i=M(t,h._$AS(t,i.values),h,e)),i}class R{constructor(t,i){this._$AV=[],this._$AN=void 0,this._$AD=t,this._$AM=i;}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(t){const{el:{content:i},parts:s}=this._$AD,e=(t?.creationScope??l).importNode(i,true);P.currentNode=e;let h=P.nextNode(),o=0,n=0,r=s[0];for(;void 0!==r;){if(o===r.index){let i;2===r.type?i=new k(h,h.nextSibling,this,t):1===r.type?i=new r.ctor(h,r.name,r.strings,this,t):6===r.type&&(i=new Z(h,this,t)),this._$AV.push(i),r=s[++n];}o!==r?.index&&(h=P.nextNode(),o++);}return P.currentNode=l,e}p(t){let i=0;for(const s of this._$AV) void 0!==s&&(void 0!==s.strings?(s._$AI(t,s,i),i+=s.strings.length-2):s._$AI(t[i])),i++;}}class k{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(t,i,s,e){this.type=2,this._$AH=A,this._$AN=void 0,this._$AA=t,this._$AB=i,this._$AM=s,this.options=e,this._$Cv=e?.isConnected??true;}get parentNode(){let t=this._$AA.parentNode;const i=this._$AM;return void 0!==i&&11===t?.nodeType&&(t=i.parentNode),t}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(t,i=this){t=M(this,t,i),a(t)?t===A||null==t||""===t?(this._$AH!==A&&this._$AR(),this._$AH=A):t!==this._$AH&&t!==E&&this._(t):void 0!==t._$litType$?this.$(t):void 0!==t.nodeType?this.T(t):d(t)?this.k(t):this._(t);}O(t){return this._$AA.parentNode.insertBefore(t,this._$AB)}T(t){this._$AH!==t&&(this._$AR(),this._$AH=this.O(t));}_(t){this._$AH!==A&&a(this._$AH)?this._$AA.nextSibling.data=t:this.T(l.createTextNode(t)),this._$AH=t;}$(t){const{values:i,_$litType$:s}=t,e="number"==typeof s?this._$AC(t):(void 0===s.el&&(s.el=S.createElement(V(s.h,s.h[0]),this.options)),s);if(this._$AH?._$AD===e)this._$AH.p(i);else {const t=new R(e,this),s=t.u(this.options);t.p(i),this.T(s),this._$AH=t;}}_$AC(t){let i=C.get(t.strings);return void 0===i&&C.set(t.strings,i=new S(t)),i}k(t){u(this._$AH)||(this._$AH=[],this._$AR());const i=this._$AH;let s,e=0;for(const h of t)e===i.length?i.push(s=new k(this.O(c()),this.O(c()),this,this.options)):s=i[e],s._$AI(h),e+Line truncated
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/const s=globalThis;class i extends y$1{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0;}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const r=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=D(r,this.renderRoot,this.renderOptions);}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(true);}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(false);}render(){return E}}i._$litElement$=true,i["finalized"]=true,s.litElementHydrateSupport?.({LitElement:i});const o$1=s.litElementPolyfillSupport;o$1?.({LitElement:i});(s.litElementVersions??=[]).push("4.2.2");
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/
|
||||
const t=t=>(e,o)=>{ void 0!==o?o.addInitializer(()=>{customElements.define(t,e);}):customElements.define(t,e);};
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/const o={attribute:true,type:String,converter:u$1,reflect:false,hasChanged:f$1},r$1=(t=o,e,r)=>{const{kind:n,metadata:i}=r;let s=globalThis.litPropertyMetadata.get(i);if(void 0===s&&globalThis.litPropertyMetadata.set(i,s=new Map),"setter"===n&&((t=Object.create(t)).wrapped=true),s.set(r.name,t),"accessor"===n){const{name:o}=r;return {set(r){const n=e.get.call(this);e.set.call(this,r),this.requestUpdate(o,n,t,true,r);},init(e){return void 0!==e&&this.C(o,void 0,t,e),e}}}if("setter"===n){const{name:o}=r;return function(r){const n=this[o];e.call(this,r),this.requestUpdate(o,n,t,true,r);}}throw Error("Unsupported decorator location: "+n)};function n(t){return (e,o)=>"object"==typeof o?r$1(t,e,o):((t,e,o)=>{const r=e.hasOwnProperty(o);return e.constructor.createProperty(o,t),r?Object.getOwnPropertyDescriptor(e,o):void 0})(t,e,o)}
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google LLC
|
||||
* SPDX-License-Identifier: BSD-3-Clause
|
||||
*/function r(r){return n({...r,state:true,attribute:false})}
|
||||
|
||||
/**
|
||||
* Climate Scheduler Card
|
||||
*/
|
||||
// Version checking - detect if browser cache is stale
|
||||
(async function () {
|
||||
try {
|
||||
const scriptUrl = document.currentScript?.src || new URL(import.meta.url).href;
|
||||
const loadedVersion = new URL(scriptUrl).searchParams.get('v');
|
||||
// Fetch the current server version
|
||||
const response = await fetch('/climate_scheduler/static/.version');
|
||||
if (response.ok) {
|
||||
const serverVersion = (await response.text()).trim().split(',')[0];
|
||||
// Compare versions - if they don't match, user has stale cache
|
||||
if (loadedVersion && serverVersion && loadedVersion !== serverVersion) {
|
||||
console.warn('[Climate Scheduler] Version mismatch detected. Loaded:', loadedVersion, 'Server:', serverVersion);
|
||||
// Store in sessionStorage to avoid showing repeatedly
|
||||
const notificationKey = 'climate_scheduler_refresh_shown';
|
||||
const shownVersion = sessionStorage.getItem(notificationKey);
|
||||
if (shownVersion !== serverVersion) {
|
||||
// Show persistent notification
|
||||
const event = new Event('hass-notification', {
|
||||
bubbles: true,
|
||||
cancelable: false,
|
||||
composed: true
|
||||
});
|
||||
event.detail = {
|
||||
message: 'Climate Scheduler has been updated. Please refresh your browser (Ctrl+F5 or Cmd+Shift+R) to load the new version.',
|
||||
duration: 0 // Persistent notification
|
||||
};
|
||||
document.body.dispatchEvent(event);
|
||||
// Mark as shown for this session
|
||||
sessionStorage.setItem(notificationKey, serverVersion);
|
||||
console.info('[Climate Scheduler] Refresh notification displayed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[Climate Scheduler] Version check failed:', e);
|
||||
}
|
||||
})();
|
||||
let ClimateSchedulerCard = class ClimateSchedulerCard extends i {
|
||||
static getConfigElement() { return null; }
|
||||
static getStubConfig() { return {}; }
|
||||
constructor() {
|
||||
super();
|
||||
this._config = {};
|
||||
this._hasIntegration = false;
|
||||
this._errorMessage = null;
|
||||
this._panelLoaded = false;
|
||||
// Register card in picker
|
||||
this._registerCard();
|
||||
}
|
||||
_registerCard() {
|
||||
try {
|
||||
window.customCards = window.customCards || [];
|
||||
const cardType = 'climate-scheduler-card';
|
||||
const exists = window.customCards.some((c) => c.type === cardType);
|
||||
if (!exists) {
|
||||
window.customCards.push({
|
||||
type: cardType,
|
||||
name: 'Climate Scheduler Card',
|
||||
description: 'Full Climate Scheduler UI as a Lovelace card',
|
||||
preview: false,
|
||||
});
|
||||
console.info('[Climate Scheduler] Card registered in picker');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[Climate Scheduler] Failed to register card in picker:', e);
|
||||
}
|
||||
}
|
||||
setConfig(config) {
|
||||
this._config = config || {};
|
||||
}
|
||||
async firstUpdated() {
|
||||
await this._loadPanel();
|
||||
}
|
||||
updated(changedProperties) {
|
||||
if (changedProperties.has('hass') && this.hass && !this._panelLoaded) {
|
||||
this._loadPanel();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
if (this._errorMessage) {
|
||||
return b `
|
||||
<div class="wrapper">
|
||||
<div class="message error">
|
||||
${this._errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (!this.hass) {
|
||||
return b `
|
||||
<div class="wrapper">
|
||||
<div class="message warning">Waiting for Home Assistant connection...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (!this._hasIntegration) {
|
||||
return b `
|
||||
<div class="wrapper">
|
||||
<div class="message error">
|
||||
<h3>❌ Climate Scheduler Integration Not Found</h3>
|
||||
<p>This card requires the Climate Scheduler integration to be installed.</p>
|
||||
<p>Install it from HACS or visit:
|
||||
<a href="https://github.com/kneave/climate-scheduler" target="_blank">
|
||||
github.com/kneave/climate-scheduler
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return b `
|
||||
<div class="wrapper">
|
||||
<climate-scheduler-panel
|
||||
.hass=${this.hass}
|
||||
embed="1">
|
||||
</climate-scheduler-panel>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
async _loadPanel() {
|
||||
// Check if integration is installed
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
// Check if climate_scheduler domain services exist
|
||||
this._hasIntegration = this.hass.services?.climate_scheduler !== undefined;
|
||||
if (!this._hasIntegration) {
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
// Load panel module
|
||||
try {
|
||||
// Determine base path - try integration-hosted static path first, fallback to standalone
|
||||
const scriptUrl = import.meta.url;
|
||||
// Integration registers frontend at /<domain>/static
|
||||
const basePath = '/climate_scheduler/static';
|
||||
// Only accept the integration-hosted static path; do not fallback.
|
||||
const testResponse = await fetch(`${basePath}/.version`);
|
||||
if (!testResponse.ok) {
|
||||
throw new Error('Integration frontend not found at /climate_scheduler/static');
|
||||
}
|
||||
const hacstag = new URL(scriptUrl).searchParams.get('hacstag');
|
||||
const versionParam = new URL(scriptUrl).searchParams.get('v');
|
||||
let versionString = null;
|
||||
// Check .version file first - if it has a timestamp, prioritize it over hacstag
|
||||
try {
|
||||
const response = await fetch(`${basePath}/.version`);
|
||||
if (response.ok) {
|
||||
const versionText = (await response.text()).trim();
|
||||
if (versionText.includes(',')) {
|
||||
// Has timestamp - this is a dev deployment, use it instead of hacstag
|
||||
versionString = versionText;
|
||||
}
|
||||
else if (!hacstag && !versionParam) {
|
||||
// No timestamp and no hacstag - production release via script
|
||||
versionString = versionText;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to load .version file:', e);
|
||||
}
|
||||
// Prefer v= param (bundled integration), then hacstag (HACS standalone)
|
||||
if (!versionString && versionParam) {
|
||||
versionString = versionParam;
|
||||
}
|
||||
else if (!versionString && hacstag) {
|
||||
versionString = hacstag;
|
||||
}
|
||||
if (!customElements.get('climate-scheduler-panel')) {
|
||||
await import(`${basePath}/panel.js?v=${versionString}`);
|
||||
}
|
||||
this._panelLoaded = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
catch (e) {
|
||||
this._errorMessage = b `
|
||||
Failed to load panel module: ${e.message}<br>
|
||||
Make sure the Climate Scheduler integration is installed and the frontend is available at /climate_scheduler/static
|
||||
`;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
getCardSize() {
|
||||
return 10;
|
||||
}
|
||||
};
|
||||
ClimateSchedulerCard.styles = i$3 `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
.message {
|
||||
padding: 16px;
|
||||
}
|
||||
.error {
|
||||
color: var(--error-color, red);
|
||||
border: 1px solid var(--error-color, red);
|
||||
border-radius: 4px;
|
||||
margin: 16px;
|
||||
}
|
||||
.warning {
|
||||
color: var(--warning-color, orange);
|
||||
}
|
||||
.error a {
|
||||
color: var(--primary-color, #03a9f4);
|
||||
}
|
||||
`;
|
||||
__decorate([
|
||||
n({ type: Object })
|
||||
], ClimateSchedulerCard.prototype, "hass", void 0);
|
||||
__decorate([
|
||||
r()
|
||||
], ClimateSchedulerCard.prototype, "_config", void 0);
|
||||
__decorate([
|
||||
r()
|
||||
], ClimateSchedulerCard.prototype, "_hasIntegration", void 0);
|
||||
__decorate([
|
||||
r()
|
||||
], ClimateSchedulerCard.prototype, "_errorMessage", void 0);
|
||||
__decorate([
|
||||
r()
|
||||
], ClimateSchedulerCard.prototype, "_panelLoaded", void 0);
|
||||
ClimateSchedulerCard = __decorate([
|
||||
t('climate-scheduler-card')
|
||||
], ClimateSchedulerCard);
|
||||
|
||||
export { ClimateSchedulerCard };
|
||||
//# sourceMappingURL=climate-scheduler-card.js.map
|
||||
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Home Assistant API Integration
|
||||
* Handles communication with Home Assistant via WebSocket API
|
||||
* Supports both custom panel mode (using hass object) and iframe mode (WebSocket)
|
||||
*/
|
||||
|
||||
class HomeAssistantAPI {
|
||||
constructor() {
|
||||
this.connection = null;
|
||||
this.messageId = 1;
|
||||
this.pendingRequests = new Map();
|
||||
this.stateUpdateCallbacks = [];
|
||||
this.hass = null; // For custom panel mode
|
||||
this.usingHassObject = false; // Track which mode we're in
|
||||
}
|
||||
|
||||
/**
|
||||
* Set hass object (custom panel mode)
|
||||
*/
|
||||
setHassObject(hass) {
|
||||
this.hass = hass;
|
||||
this.usingHassObject = true;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
// If we already have a hass object, we're in custom panel mode
|
||||
if (this.hass && this.usingHassObject) {
|
||||
console.log('Using existing hass connection from custom panel');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
// Get auth token - works in both browser and mobile app
|
||||
const authToken = await this.getAuthToken();
|
||||
|
||||
// Connect to Home Assistant WebSocket API
|
||||
// Use relative path for mobile app compatibility
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/api/websocket`;
|
||||
|
||||
this.connection = new WebSocket(wsUrl);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.connection.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
this.connection.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
this.handleMessage(message, authToken, resolve, reject);
|
||||
};
|
||||
|
||||
this.connection.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.connection.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(message, authToken, resolveConnection, rejectConnection) {
|
||||
if (message.type === 'auth_required') {
|
||||
// Send authentication
|
||||
this.send({
|
||||
type: 'auth',
|
||||
access_token: authToken
|
||||
});
|
||||
} else if (message.type === 'auth_ok') {
|
||||
console.log('Authenticated successfully');
|
||||
resolveConnection();
|
||||
} else if (message.type === 'auth_invalid') {
|
||||
console.error('Authentication failed');
|
||||
rejectConnection(new Error('Authentication failed'));
|
||||
} else if (message.type === 'result') {
|
||||
// Handle response to our request
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (pending) {
|
||||
if (message.success) {
|
||||
pending.resolve(message.result);
|
||||
} else {
|
||||
pending.reject(message.error);
|
||||
}
|
||||
this.pendingRequests.delete(message.id);
|
||||
}
|
||||
} else if (message.type === 'event') {
|
||||
// Handle state change events
|
||||
if (message.event && message.event.event_type === 'state_changed') {
|
||||
this.notifyStateUpdate(message.event.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthToken() {
|
||||
// Method 1: Check if running in Home Assistant panel context (works in mobile app)
|
||||
if (window.hassConnection) {
|
||||
try {
|
||||
const auth = await window.hassConnection;
|
||||
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
|
||||
return auth.auth.data.access_token;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not get token from hassConnection:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Try to get from parent window context
|
||||
if (window.parent && window.parent.hassConnection && window.parent !== window) {
|
||||
try {
|
||||
const auth = await window.parent.hassConnection;
|
||||
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
|
||||
return auth.auth.data.access_token;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not get token from parent.hassConnection:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Fallback to localStorage (browser only)
|
||||
try {
|
||||
const token = localStorage.getItem('hassTokens');
|
||||
if (token) {
|
||||
const tokens = JSON.parse(token);
|
||||
if (tokens.access_token) {
|
||||
return tokens.access_token;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not get token from localStorage:', e);
|
||||
}
|
||||
|
||||
throw new Error('No authentication token found. Please ensure the panel is loaded within Home Assistant.');
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
|
||||
this.connection.send(JSON.stringify(message));
|
||||
} else {
|
||||
throw new Error('WebSocket not connected');
|
||||
}
|
||||
}
|
||||
|
||||
async sendRequest(message) {
|
||||
// If using hass object (custom panel mode), use hass.callWS
|
||||
if (this.hass && this.usingHassObject) {
|
||||
try {
|
||||
return await this.hass.callWS(message);
|
||||
} catch (error) {
|
||||
console.error('Error calling hass.callWS:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to WebSocket mode
|
||||
const id = this.messageId++;
|
||||
const request = { id, ...message };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingRequests.set(id, { resolve, reject });
|
||||
this.send(request);
|
||||
|
||||
// Timeout after 30 seconds
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(id)) {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error('Request timeout'));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
async getStates() {
|
||||
// Custom panel mode
|
||||
if (this.hass && this.usingHassObject) {
|
||||
return Object.values(this.hass.states);
|
||||
}
|
||||
// WebSocket mode
|
||||
return await this.sendRequest({ type: 'get_states' });
|
||||
}
|
||||
|
||||
async getConfig() {
|
||||
// Custom panel mode
|
||||
if (this.hass && this.usingHassObject) {
|
||||
return this.hass.config;
|
||||
}
|
||||
// WebSocket mode
|
||||
return await this.sendRequest({ type: 'get_config' });
|
||||
}
|
||||
|
||||
async getClimateEntities() {
|
||||
const states = await this.getStates();
|
||||
return states.filter(state =>
|
||||
state.entity_id.startsWith('climate.') &&
|
||||
!state.entity_id.startsWith('climate.climate_scheduler_')
|
||||
);
|
||||
}
|
||||
|
||||
async callService(domain, service, serviceData, returnResponse = false) {
|
||||
// Custom panel mode
|
||||
if (this.hass && this.usingHassObject) {
|
||||
// Use callWS for return_response to avoid triggering haptic feedback
|
||||
if (returnResponse) {
|
||||
const result = await this.hass.callWS({
|
||||
type: 'call_service',
|
||||
domain,
|
||||
service,
|
||||
service_data: serviceData,
|
||||
return_response: true
|
||||
});
|
||||
return result?.response;
|
||||
} else {
|
||||
// Use callService for non-return-response calls
|
||||
return await this.hass.callService(domain, service, serviceData);
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket mode
|
||||
const requestData = {
|
||||
type: 'call_service',
|
||||
domain,
|
||||
service,
|
||||
service_data: serviceData
|
||||
};
|
||||
|
||||
if (returnResponse) {
|
||||
requestData.return_response = true;
|
||||
}
|
||||
|
||||
return await this.sendRequest(requestData);
|
||||
}
|
||||
|
||||
async setLogLevel(level = 'debug') {
|
||||
return await this.callService('logger', 'set_level', {
|
||||
'custom_components.climate_scheduler': level
|
||||
});
|
||||
}
|
||||
|
||||
async getSchedule(entityId, day = null) {
|
||||
try {
|
||||
const serviceData = {
|
||||
schedule_id: entityId
|
||||
};
|
||||
|
||||
if (day) {
|
||||
serviceData.day = day;
|
||||
}
|
||||
|
||||
// Call our custom service to get schedule with return_response
|
||||
const result = await this.callService('climate_scheduler', 'get_schedule',
|
||||
serviceData, true); // Pass true to enable return_response
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get schedule:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setSchedule(entityId, nodes, day = null, scheduleMode = null) {
|
||||
const serviceData = {
|
||||
schedule_id: entityId,
|
||||
nodes: nodes
|
||||
};
|
||||
|
||||
if (day) {
|
||||
serviceData.day = day;
|
||||
}
|
||||
if (scheduleMode) {
|
||||
serviceData.schedule_mode = scheduleMode;
|
||||
}
|
||||
|
||||
return await this.callService('climate_scheduler', 'set_schedule', serviceData);
|
||||
}
|
||||
|
||||
async enableSchedule(entityId) {
|
||||
return await this.callService('climate_scheduler', 'enable_schedule', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async disableSchedule(entityId) {
|
||||
return await this.callService('climate_scheduler', 'disable_schedule', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async advanceSchedule(entityId) {
|
||||
return await this.callService('climate_scheduler', 'advance_schedule', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async advanceGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'advance_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async cancelAdvance(entityId) {
|
||||
return await this.callService('climate_scheduler', 'cancel_advance', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async getAdvanceStatus(entityId) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_advance_status', {
|
||||
schedule_id: entityId
|
||||
}, true);
|
||||
// Normalize across modes:
|
||||
// - hass.callWS path returns `result.response`
|
||||
// - websocket path may return `{response: ...}`
|
||||
return result?.response ?? result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get advance status:', error);
|
||||
return { is_active: false, history: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async clearAdvanceHistory(entityId) {
|
||||
return await this.callService('climate_scheduler', 'clear_advance_history', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async testFireEvent(groupName, node, day) {
|
||||
return await this.callService('climate_scheduler', 'test_fire_event', {
|
||||
schedule_id: groupName,
|
||||
node: JSON.stringify(node),
|
||||
day: day
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async getOverrideStatus(entityId) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_override_status', {
|
||||
schedule_id: entityId
|
||||
}, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get override status:', error);
|
||||
return { has_override: false };
|
||||
}
|
||||
}
|
||||
|
||||
async clearSchedule(entityId) {
|
||||
return await this.callService('climate_scheduler', 'clear_schedule', {
|
||||
schedule_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async setIgnored(entityId, ignored) {
|
||||
return await this.callService('climate_scheduler', 'set_ignored', {
|
||||
schedule_id: entityId,
|
||||
ignored: ignored
|
||||
});
|
||||
}
|
||||
|
||||
// Group management methods
|
||||
async createGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'create_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'delete_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async renameGroup(oldName, newName) {
|
||||
return await this.callService('climate_scheduler', 'rename_group', {
|
||||
old_name: oldName,
|
||||
new_name: newName
|
||||
});
|
||||
}
|
||||
|
||||
async addToGroup(groupName, entityId) {
|
||||
return await this.callService('climate_scheduler', 'add_to_group', {
|
||||
schedule_id: groupName,
|
||||
entity_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromGroup(groupName, entityId) {
|
||||
return await this.callService('climate_scheduler', 'remove_from_group', {
|
||||
schedule_id: groupName,
|
||||
entity_id: entityId
|
||||
});
|
||||
}
|
||||
|
||||
async getGroups() {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_groups', {}, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get groups:', error);
|
||||
return { groups: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async setGroupSchedule(groupName, nodes, day = null, scheduleMode = null, profileName = null) {
|
||||
const callStartTime = performance.now();
|
||||
console.debug('[HA-API] setGroupSchedule called', {
|
||||
timestamp: new Date().toISOString(),
|
||||
groupName,
|
||||
nodeCount: nodes?.length,
|
||||
day,
|
||||
scheduleMode,
|
||||
profileName,
|
||||
usingHassObject: this.usingHassObject
|
||||
});
|
||||
|
||||
// Guard: ensure a valid groupName is provided before calling HA service.
|
||||
if (!groupName) {
|
||||
const msg = 'setGroupSchedule called without a valid groupName';
|
||||
console.error('[HA-API]', msg, groupName, nodes, day, scheduleMode);
|
||||
// Throw so callers can handle the error rather than sending null to HA
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const serviceData = {
|
||||
schedule_id: groupName,
|
||||
nodes: nodes
|
||||
};
|
||||
|
||||
if (day) {
|
||||
serviceData.day = day;
|
||||
}
|
||||
if (scheduleMode) {
|
||||
serviceData.schedule_mode = scheduleMode;
|
||||
}
|
||||
if (profileName) {
|
||||
serviceData.profile_name = profileName;
|
||||
}
|
||||
|
||||
console.debug('[HA-API] Calling climate_scheduler.set_group_schedule', {
|
||||
serviceData: { ...serviceData, nodes: `[${nodes?.length} nodes]` },
|
||||
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'set_group_schedule', serviceData);
|
||||
console.debug('[HA-API] set_group_schedule succeeded', {
|
||||
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
|
||||
result
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[HA-API] setGroupSchedule failed:', {
|
||||
error,
|
||||
errorCode: error?.code,
|
||||
errorMessage: error?.message,
|
||||
translationKey: error?.translation_key,
|
||||
translationPlaceholders: error?.translation_placeholders,
|
||||
groupName,
|
||||
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
|
||||
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async enableGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'enable_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async disableGroup(groupName) {
|
||||
return await this.callService('climate_scheduler', 'disable_group', {
|
||||
schedule_id: groupName
|
||||
});
|
||||
}
|
||||
|
||||
async getHistory(entityId, startTime, endTime) {
|
||||
try {
|
||||
// Format times as ISO strings
|
||||
const start = startTime.toISOString();
|
||||
const end = endTime ? endTime.toISOString() : new Date().toISOString();
|
||||
|
||||
// Use recorder history API
|
||||
const result = await this.sendRequest({
|
||||
type: 'history/history_during_period',
|
||||
start_time: start,
|
||||
end_time: end,
|
||||
entity_ids: [entityId],
|
||||
minimal_response: false,
|
||||
no_attributes: false
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get history:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeToStateChanges() {
|
||||
// Custom panel mode - hass object handles state updates automatically
|
||||
if (this.hass && this.usingHassObject) {
|
||||
// Set up listener for hass state changes
|
||||
// The panel will call updateHassConnection when hass changes
|
||||
console.log('Using hass object state updates');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// WebSocket mode
|
||||
return await this.sendRequest({
|
||||
type: 'subscribe_events',
|
||||
event_type: 'state_changed'
|
||||
});
|
||||
}
|
||||
|
||||
async getSettings() {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_settings', {}, true);
|
||||
|
||||
// Normalize across execution modes:
|
||||
// - In custom panel mode, callService(..., true) returns the service response directly.
|
||||
// - In raw websocket mode, callService may return { response: <service_response> }.
|
||||
const payload = result?.response ?? result ?? {};
|
||||
|
||||
// Service response includes version metadata, but app.js expects the raw settings dict.
|
||||
// If the response shape is { settings: {...}, version: {...} }, return settings.
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
payload.version &&
|
||||
payload.settings &&
|
||||
typeof payload.settings === 'object'
|
||||
) {
|
||||
return payload.settings;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('Failed to get settings:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(settings) {
|
||||
try {
|
||||
await this.callService('climate_scheduler', 'save_settings', { settings: JSON.stringify(settings) });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupDerivativeSensors(confirmDeleteAll = false) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'cleanup_derivative_sensors', {
|
||||
confirm_delete_all: confirmDeleteAll
|
||||
}, true);
|
||||
return result?.response || result || {};
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup derivative sensors:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupOrphanedClimateEntities(deleteEntities = false) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'cleanup_orphaned_climate_entities', {
|
||||
delete: deleteEntities
|
||||
}, true);
|
||||
return result?.response || result || {};
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup orphaned climate entities:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupUnmonitoredStorage(deleteEntries = false) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'cleanup_unmonitored_storage', {
|
||||
delete: deleteEntries
|
||||
}, true);
|
||||
return result?.response || result || {};
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup unmonitored storage:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Profile management methods
|
||||
async createProfile(scheduleId, profileName) {
|
||||
return await this.callService('climate_scheduler', 'create_profile', {
|
||||
schedule_id: scheduleId,
|
||||
profile_name: profileName
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProfile(scheduleId, profileName) {
|
||||
return await this.callService('climate_scheduler', 'delete_profile', {
|
||||
schedule_id: scheduleId,
|
||||
profile_name: profileName
|
||||
});
|
||||
}
|
||||
|
||||
async renameProfile(scheduleId, oldName, newName) {
|
||||
return await this.callService('climate_scheduler', 'rename_profile', {
|
||||
schedule_id: scheduleId,
|
||||
old_name: oldName,
|
||||
new_name: newName
|
||||
});
|
||||
}
|
||||
|
||||
async setActiveProfile(scheduleId, profileName) {
|
||||
return await this.callService('climate_scheduler', 'set_active_profile', {
|
||||
schedule_id: scheduleId,
|
||||
profile_name: profileName
|
||||
});
|
||||
}
|
||||
|
||||
async getProfiles(scheduleId) {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'get_profiles', {
|
||||
schedule_id: scheduleId
|
||||
}, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get profiles:', error);
|
||||
return { profiles: {}, active_profile: null };
|
||||
}
|
||||
}
|
||||
|
||||
async runDiagnostics() {
|
||||
try {
|
||||
const result = await this.callService('climate_scheduler', 'diagnostics', {}, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to run diagnostics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
onStateUpdate(callback) {
|
||||
this.stateUpdateCallbacks.push(callback);
|
||||
}
|
||||
|
||||
notifyStateUpdate(data) {
|
||||
this.stateUpdateCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Error in state update callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async subscribeToEvents(eventType, callback) {
|
||||
if (this.usingHassObject && this.hass) {
|
||||
// Use hass connection for event subscription
|
||||
const conn = this.hass.connection;
|
||||
if (conn && conn.subscribeEvents) {
|
||||
return await conn.subscribeEvents(callback, eventType);
|
||||
}
|
||||
}
|
||||
console.warn('Event subscription not available');
|
||||
return null;
|
||||
}}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Climate Scheduler Custom Panel
|
||||
* Modern Home Assistant custom panel implementation (replaces legacy iframe approach)
|
||||
*/
|
||||
// Version checking - detect if browser cache is stale
|
||||
(async function () {
|
||||
try {
|
||||
const scriptUrl = document.currentScript?.src || new URL(import.meta.url).href;
|
||||
const loadedVersion = new URL(scriptUrl).searchParams.get('v');
|
||||
// Fetch the current server version
|
||||
const response = await fetch('/climate_scheduler/static/.version');
|
||||
if (response.ok) {
|
||||
const serverVersion = (await response.text()).trim().split(',')[0];
|
||||
// Compare versions - check the aren't None and if they don't match, user has stale cache
|
||||
if ((loadedVersion && serverVersion) && loadedVersion !== serverVersion) {
|
||||
console.warn('[Climate Scheduler] Version mismatch detected. Loaded:', loadedVersion, 'Server:', serverVersion);
|
||||
// Store in sessionStorage to avoid showing repeatedly
|
||||
const notificationKey = 'climate_scheduler_refresh_shown';
|
||||
const shownVersion = sessionStorage.getItem(notificationKey);
|
||||
if (shownVersion !== serverVersion) {
|
||||
// Show persistent notification
|
||||
const event = new CustomEvent('hass-notification', {
|
||||
bubbles: true,
|
||||
cancelable: false,
|
||||
composed: true,
|
||||
detail: {
|
||||
message: 'Climate Scheduler has been updated. Please refresh your browser (Ctrl+F5 or Cmd+Shift+R) to load the new version.',
|
||||
duration: 0 // Persistent notification
|
||||
}
|
||||
});
|
||||
document.body.dispatchEvent(event);
|
||||
// Mark as shown for this session
|
||||
sessionStorage.setItem(notificationKey, serverVersion);
|
||||
console.info('[Climate Scheduler] Refresh notification displayed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[Climate Scheduler] Version check failed:', e);
|
||||
}
|
||||
})();
|
||||
// Load other JavaScript files
|
||||
const loadScript = (src) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
};
|
||||
// Track if scripts are loaded
|
||||
let scriptsLoaded = false;
|
||||
const getVersion = () => {
|
||||
const scriptUrl = import.meta.url;
|
||||
const version = new URL(scriptUrl).searchParams.get('v');
|
||||
if (!version)
|
||||
return null;
|
||||
// If version has comma (dev: "tag,timestamp"), use timestamp for cache busting
|
||||
if (version.includes(',')) {
|
||||
const parts = version.split(',');
|
||||
return parts[1]; // timestamp
|
||||
}
|
||||
// Otherwise use version as-is (HACS tag or production tag)
|
||||
return version;
|
||||
};
|
||||
// Load dependencies in order
|
||||
const loadScripts = () => {
|
||||
if (scriptsLoaded)
|
||||
return Promise.resolve();
|
||||
// Determine base path from where panel.js was loaded
|
||||
const scriptUrl = import.meta.url;
|
||||
const url = new URL(scriptUrl);
|
||||
// Remove panel.js and query params to get base path
|
||||
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
|
||||
const version = getVersion();
|
||||
return Promise.all([
|
||||
loadScript(`${basePath}/utils.js?v=${version}`),
|
||||
loadScript(`${basePath}/ha-api.js?v=${version}`)
|
||||
]).then(() => {
|
||||
return loadScript(`${basePath}/app.js?v=${version}`);
|
||||
}).then(() => {
|
||||
scriptsLoaded = true;
|
||||
}).catch(error => {
|
||||
console.error('Failed to load Climate Scheduler scripts:', error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
class ClimateSchedulerPanel extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._hass = null;
|
||||
this.narrow = false;
|
||||
this.panel = null;
|
||||
}
|
||||
// Declare properties that Home Assistant looks for
|
||||
static get properties() {
|
||||
return {
|
||||
hass: { type: Object },
|
||||
narrow: { type: Boolean },
|
||||
route: { type: Object },
|
||||
panel: { type: Object }
|
||||
};
|
||||
}
|
||||
async connectedCallback() {
|
||||
this.render();
|
||||
// Store reference to this panel element globally so app.js can query within it
|
||||
window.climateSchedulerPanelRoot = this;
|
||||
// Wait for scripts to load before initializing
|
||||
try {
|
||||
await loadScripts();
|
||||
// Small delay to ensure DOM is fully rendered
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Update version info in footer
|
||||
const versionElement = this.querySelector('#version-info');
|
||||
if (versionElement) {
|
||||
try {
|
||||
const scriptUrl = import.meta.url;
|
||||
const versionParam = new URL(scriptUrl).searchParams.get('v');
|
||||
let version = '';
|
||||
if (versionParam) {
|
||||
if (versionParam.includes(',')) {
|
||||
// Has timestamp - dev deployment: "tag,timestamp"
|
||||
const parts = versionParam.split(',');
|
||||
const tag = (parts[0] || 'unknown').replace(/^v/, '');
|
||||
version = `v${tag} (dev)`;
|
||||
}
|
||||
else {
|
||||
// No timestamp - production: just tag
|
||||
const tag = versionParam.replace(/^v/, '');
|
||||
version = `v${tag}`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
version = '(manual)';
|
||||
}
|
||||
versionElement.textContent = `Climate Scheduler ${version}`;
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to determine version:', e);
|
||||
versionElement.textContent = 'Climate Scheduler';
|
||||
}
|
||||
}
|
||||
// Initialize the app when panel is loaded and scripts are ready
|
||||
if (window.initClimateSchedulerApp) {
|
||||
window.initClimateSchedulerApp(this.hass);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize Climate Scheduler:', error);
|
||||
}
|
||||
}
|
||||
set hass(value) {
|
||||
this._hass = value;
|
||||
// Apply theme based on Home Assistant theme mode
|
||||
if (value && value.themes) {
|
||||
const isDark = value.themes.darkMode;
|
||||
if (isDark) {
|
||||
// Dark mode is default, remove attribute
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
this.removeAttribute('data-theme');
|
||||
}
|
||||
else {
|
||||
// Light mode needs explicit attribute
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
this.setAttribute('data-theme', 'light');
|
||||
}
|
||||
}
|
||||
// Pass hass object to app if it's already initialized
|
||||
if (window.updateHassConnection && value) {
|
||||
window.updateHassConnection(value);
|
||||
}
|
||||
}
|
||||
get hass() {
|
||||
return this._hass;
|
||||
}
|
||||
render() {
|
||||
if (!this.querySelector('.container')) {
|
||||
// Load CSS using same base path detection as scripts
|
||||
const scriptUrl = import.meta.url;
|
||||
const url = new URL(scriptUrl);
|
||||
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
|
||||
const version = getVersion();
|
||||
const styleLink = document.createElement('link');
|
||||
styleLink.rel = 'stylesheet';
|
||||
styleLink.href = `${basePath}/styles.css?v=${version}`;
|
||||
this.appendChild(styleLink);
|
||||
// Create container div for content
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = `
|
||||
<div class="container">
|
||||
<section class="entity-selector">
|
||||
<div class="groups-section">
|
||||
<h3 class="section-title">Monitored (<span id="groups-count">0</span>)</h3>
|
||||
<div id="groups-list" class="groups-list">
|
||||
<!-- Dynamically populated with groups -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profiles-section">
|
||||
<div class="group-container collapsed" id="global-profile-container">
|
||||
<div class="group-header" id="toggle-global-profiles">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="group-toggle-icon" style="transform: rotate(-90deg);">▼</span>
|
||||
<span class="group-title">Profiles</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="global-profile-list" style="display: none; padding: 12px 16px;">
|
||||
<div class="profile-controls">
|
||||
<select id="profile-dropdown" class="profile-dropdown">
|
||||
<option value="" disabled selected>Select a profile to edit...</option>
|
||||
</select>
|
||||
<button id="new-profile-btn" class="btn-profile" title="Create new profile">+</button>
|
||||
<button id="rename-profile-btn" class="btn-profile" title="Rename profile">✎</button>
|
||||
<button id="delete-profile-btn" class="btn-profile" title="Delete profile">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ignored-section">
|
||||
<div class="group-container collapsed" id="ignored-container">
|
||||
<div class="group-header" id="toggle-ignored">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="group-toggle-icon" style="transform: rotate(-90deg);">▼</span>
|
||||
<span class="group-title">Unmonitored</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ignored-entity-list" class="entity-list ignored-list" style="display: none;">
|
||||
<div class="filter-box">
|
||||
<input type="text" id="ignored-filter" placeholder="Filter by name..." />
|
||||
</div>
|
||||
<div id="ignored-entities-container">
|
||||
<span id="ignored-count" style="display: none;">0</span>
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="confirm-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Clear Schedule?</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to clear the entire schedule for <strong id="confirm-entity-name"></strong>?</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="confirm-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="confirm-clear" class="btn-danger">Clear Schedule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="create-group-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Group</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="new-group-name">Group Name:</label>
|
||||
<input type="text" id="new-group-name" placeholder="e.g., Bedrooms" style="width: 100%; padding: 8px; margin-top: 8px;" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="create-group-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="create-group-confirm" class="btn-primary">Create Group</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="add-to-group-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Add to Group</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Add <strong id="add-entity-name"></strong> to group:</p>
|
||||
<select id="add-to-group-select" style="width: 100%; padding: 8px; margin-top: 8px; margin-bottom: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;">
|
||||
<!-- Populated dynamically -->
|
||||
</select>
|
||||
<p style="text-align: center; color: var(--text-secondary); margin: 8px 0;">or</p>
|
||||
<input type="text" id="new-group-name-inline" placeholder="Create new group..." style="width: 100%; padding: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="add-to-group-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="add-to-group-confirm" class="btn-primary">Add to Group</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="convert-temperature-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Convert All Schedules</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="margin-bottom: 16px;">This will convert all saved schedules (entities and groups) as well as the default schedule and min/max settings.</p>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Current unit (convert FROM):</label>
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-from-unit" value="°C" id="convert-from-celsius" style="cursor: pointer;">
|
||||
<span>Celsius (°C)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-from-unit" value="°F" id="convert-from-fahrenheit" style="cursor: pointer;">
|
||||
<span>Fahrenheit (°F)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Target unit (convert TO):</label>
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-to-unit" value="°C" id="convert-to-celsius" style="cursor: pointer;">
|
||||
<span>Celsius (°C)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="radio" name="convert-to-unit" value="°F" id="convert-to-fahrenheit" style="cursor: pointer;">
|
||||
<span>Fahrenheit (°F)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--warning, #ff9800); font-size: 0.9rem;"><strong>Warning:</strong> This action cannot be undone. Make sure you select the correct source and target units.</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="convert-temperature-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="convert-temperature-confirm" class="btn-primary">Convert Schedules</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-group-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Edit Group</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label for="edit-group-name">Group Name:</label>
|
||||
<input type="text" id="edit-group-name" placeholder="Group name" style="width: 100%; padding: 8px; margin-top: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="edit-group-delete" class="btn-danger">Delete Group</button>
|
||||
<div style="flex: 1;"></div>
|
||||
<button id="edit-group-cancel" class="btn-secondary">Cancel</button>
|
||||
<button id="edit-group-save" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Section (Collapsible) -->
|
||||
<div class="instructions-container">
|
||||
<div id="global-instructions-toggle" class="instructions-toggle">
|
||||
<span class="toggle-icon">▶</span>
|
||||
<span class="toggle-text">Instructions</span>
|
||||
</div>
|
||||
<div id="global-graph-instructions" class="graph-instructions collapsed" style="display: none;">
|
||||
<p>📍 <strong>Double-click or double-tap</strong> the line to add a new node</p>
|
||||
<p>👆 <strong>Drag nodes</strong> vertically to change temperature or horizontally to move their time</p>
|
||||
<p>⬌ <strong>Drag the horizontal segment</strong> between two nodes to shift that period while preserving its duration</p>
|
||||
<p>📋 <strong>Copy / Paste</strong> buttons duplicate a schedule across days or entities</p>
|
||||
<p>⚙️ <strong>Tap a node</strong> to open its settings panel for HVAC/fan/swing/preset values</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Panel -->
|
||||
<div id="settings-panel" class="settings-panel collapsed">
|
||||
<div class="settings-header" id="settings-toggle">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="collapse-indicator" style="transform: rotate(-90deg);">▼</span>
|
||||
<h3>Settings</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="settings-flex" style="display: flex; gap: 24px; align-items: flex-start;">
|
||||
<div class="settings-main" style="flex: 1; min-width: 0;">
|
||||
<div class="settings-section">
|
||||
<h4>Group Management</h4>
|
||||
<button id="create-group-btn" class="btn-secondary" style="width: 100%;">
|
||||
+ Create New Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Default Schedule</h4>
|
||||
<p class="settings-description">Set the default temperature schedule used when clearing or creating new schedules</p>
|
||||
|
||||
<div class="graph-container">
|
||||
<keyframe-timeline id="default-schedule-graph" class="temperature-graph" showHeader="false"></keyframe-timeline>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px;">
|
||||
<button id="clear-default-schedule-btn" class="btn-danger-outline">Clear Schedule</button>
|
||||
</div>
|
||||
|
||||
<div id="default-node-settings-panel" class="node-settings-panel" style="display: none;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h4>Node Settings</h4>
|
||||
<button id="default-delete-node-btn" class="btn-danger-outline" style="padding: 4px 12px; font-size: 0.9rem;">Delete Node</button>
|
||||
</div>
|
||||
<div class="node-info">
|
||||
<span>Time: <strong id="default-node-time">--:--</strong></span>
|
||||
<span>Temperature: <strong id="default-node-temp">--°C</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-hvac-mode-item">
|
||||
<label for="default-node-hvac-mode">HVAC Mode:</label>
|
||||
<select id="default-node-hvac-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-fan-mode-item">
|
||||
<label for="default-node-fan-mode">Fan Mode:</label>
|
||||
<select id="default-node-fan-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-swing-mode-item">
|
||||
<label for="default-node-swing-mode">Swing Mode:</label>
|
||||
<select id="default-node-swing-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" id="default-preset-mode-item">
|
||||
<label for="default-node-preset-mode">Preset Mode:</label>
|
||||
<select id="default-node-preset-mode"><option value="">-- No Change --</option></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Graph Options</h4>
|
||||
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="tooltip-mode">Tooltip Display:</label>
|
||||
<select id="tooltip-mode">
|
||||
<option value="history">Show Historical Temperature</option>
|
||||
<option value="cursor">Show Cursor Position</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Choose what information to display when hovering over the graph</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:12px; align-items:center;">
|
||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||
<label for="min-temp" style="font-weight:600;">Min Temp (<span id="min-unit">°C</span>)</label>
|
||||
<input id="min-temp" type="number" step="0.1" placeholder="e.g. 5.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:6px;">
|
||||
<label for="max-temp" style="font-weight:600;">Max Temp (<span id="max-unit">°C</span>)</label>
|
||||
<input id="max-temp" type="number" step="0.1" placeholder="e.g. 30.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 12px;">
|
||||
<label>
|
||||
<input type="checkbox" id="debug-panel-toggle" style="margin-right: 8px;"> Show Debug Panel
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Temperature Precision</h4>
|
||||
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="graph-snap-step">Graph Snap Step:</label>
|
||||
<select id="graph-snap-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
|
||||
<option value="0.1">0.1°</option>
|
||||
<option value="0.5">0.5°</option>
|
||||
<option value="1">1.0°</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Temperature rounding when dragging nodes on the graph</p>
|
||||
</div>
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="input-temp-step">Input Field Step:</label>
|
||||
<select id="input-temp-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
|
||||
<option value="0.1">0.1°</option>
|
||||
<option value="0.5">0.5°</option>
|
||||
<option value="1">1.0°</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for temperature input fields and up/down buttons</p>
|
||||
</div>
|
||||
<div class="setting-item" style="flex:1; min-width:280px;">
|
||||
<label for="humidity-step">Humidity Slider Step:</label>
|
||||
<select id="humidity-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
|
||||
<option value="1">1%</option>
|
||||
<option value="2">2%</option>
|
||||
<option value="5">5%</option>
|
||||
</select>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for humidity slider in node settings dialog</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Derivative Sensors</h4>
|
||||
<p class="settings-description">Automatically create sensors to track heating/cooling rates for performance analysis</p>
|
||||
<div class="setting-item" style="max-width: 100%;">
|
||||
<label>
|
||||
<input type="checkbox" id="create-derivative-sensors" style="margin-right: 8px;"> Auto-create derivative sensors
|
||||
</label>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">When enabled, creates sensor.climate_scheduler_[name]_rate for each thermostat to track temperature change rate (°C/h)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h4>Workday Integration</h4>
|
||||
<p class="settings-description">Configure which days are workdays for 5/2 mode scheduling</p>
|
||||
<div class="setting-item" style="max-width: 100%;">
|
||||
<label id="use-workday-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="use-workday-integration" style="margin-right: 8px;" disabled>
|
||||
<span>Use Workday integration for 5/2 scheduling</span>
|
||||
</label>
|
||||
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;" id="workday-help-text">Checking if Workday integration is installed...</p>
|
||||
</div>
|
||||
|
||||
<div id="workday-selector" style="margin-top: 16px; display: none;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Select Workdays:</label>
|
||||
<p class="settings-description" style="margin-bottom: 8px; font-size: 0.85rem;">Choose which days are considered workdays when Workday integration is disabled</p>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="mon" style="cursor: pointer;">
|
||||
<span>Monday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="tue" style="cursor: pointer;">
|
||||
<span>Tuesday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="wed" style="cursor: pointer;">
|
||||
<span>Wednesday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="thu" style="cursor: pointer;">
|
||||
<span>Thursday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="fri" style="cursor: pointer;">
|
||||
<span>Friday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="sat" style="cursor: pointer;">
|
||||
<span>Saturday</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" class="workday-checkbox" value="sun" style="cursor: pointer;">
|
||||
<span>Sunday</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- right column removed: min/max now inline in Graph Options -->
|
||||
</div>
|
||||
|
||||
<div class="settings-actions" style="margin-top: 12px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button id="refresh-entities-menu" class="btn-secondary">Refresh Entities</button>
|
||||
<button id="sync-all-menu" class="btn-secondary">Sync All Thermostats</button>
|
||||
<button id="reload-integration-menu" class="btn-secondary">Reload Integration</button>
|
||||
<button id="convert-temperature-btn" class="btn-secondary">Convert All Schedules...</button>
|
||||
<button id="run-diagnostics-btn" class="btn-secondary">Run Diagnostics</button>
|
||||
<button id="cleanup-derivative-sensors-btn" class="btn-secondary">Cleanup Derivative Sensors</button>
|
||||
<button id="cleanup-orphaned-climate-btn" class="btn-secondary">Cleanup Orphaned Entities</button>
|
||||
<button id="cleanup-storage-btn" class="btn-secondary">Cleanup Unmonitored Storage</button>
|
||||
<button id="reset-defaults" class="btn-secondary">Reset to Defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Panel -->
|
||||
<div id="debug-panel" class="debug-panel" style="display: none;">
|
||||
<div class="debug-header">
|
||||
<h3>Debug Console</h3>
|
||||
<button id="clear-debug" class="btn-secondary" style="padding: 4px 8px; font-size: 0.85rem;">Clear</button>
|
||||
</div>
|
||||
<div id="debug-content" class="debug-content">
|
||||
<!-- Debug messages will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<img alt="Integration Usage" src="https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.climate_scheduler.total" />
|
||||
<p><span id="version-info">Climate Scheduler</span>, created by <a href="https://neave.engineering" target="_blank" rel="noopener noreferrer" style="color: var(--primary)">Keegan Neave</a></p>
|
||||
<p><a href="https://www.buymeacoffee.com/kneave" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">☕ Buy me a coffee</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
this.appendChild(container);
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('climate-scheduler-panel', ClimateSchedulerPanel);
|
||||
//# sourceMappingURL=panel.js.map
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Shared Utility Functions
|
||||
* Common utilities used across the Climate Scheduler frontend
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// TIMEZONE UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get a Date-like object in the server's timezone
|
||||
* @param {Date} date - The UTC date to convert
|
||||
* @param {string} serverTimeZone - The server's timezone (e.g., 'America/New_York')
|
||||
* @returns {Object} Date-like object with methods that return values in server timezone
|
||||
*/
|
||||
function getServerDate(date, serverTimeZone) {
|
||||
if (!serverTimeZone) return date; // Fallback to local time if no timezone set
|
||||
|
||||
// Use Intl.DateTimeFormat to get date components in server timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: serverTimeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(date);
|
||||
const partsObj = {};
|
||||
parts.forEach(part => {
|
||||
partsObj[part.type] = part.value;
|
||||
});
|
||||
|
||||
return {
|
||||
getFullYear: () => parseInt(partsObj.year),
|
||||
getMonth: () => parseInt(partsObj.month) - 1, // JS months are 0-indexed
|
||||
getDate: () => parseInt(partsObj.day),
|
||||
getHours: () => parseInt(partsObj.hour),
|
||||
getMinutes: () => parseInt(partsObj.minute),
|
||||
getSeconds: () => parseInt(partsObj.second),
|
||||
getTime: () => date.getTime(),
|
||||
_originalDate: date
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current server time as Date-like object
|
||||
* @param {string} serverTimeZone - The server's timezone
|
||||
* @returns {Object} Date-like object with current time in server timezone
|
||||
*/
|
||||
function getServerNow(serverTimeZone) {
|
||||
return getServerDate(new Date(), serverTimeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UTC timestamp to server timezone Date-like object
|
||||
* @param {Date} utcDate - The UTC date to convert
|
||||
* @param {string} serverTimeZone - The server's timezone
|
||||
* @returns {Object} Date-like object in server timezone
|
||||
*/
|
||||
function utcToServerDate(utcDate, serverTimeZone) {
|
||||
return getServerDate(utcDate, serverTimeZone);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEMPERATURE CONVERSION UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert Celsius to Fahrenheit
|
||||
* @param {number} celsius - Temperature in Celsius
|
||||
* @returns {number} Temperature in Fahrenheit
|
||||
*/
|
||||
function celsiusToFahrenheit(celsius) {
|
||||
return (celsius * 9/5) + 32;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Fahrenheit to Celsius
|
||||
* @param {number} fahrenheit - Temperature in Fahrenheit
|
||||
* @returns {number} Temperature in Celsius
|
||||
*/
|
||||
function fahrenheitToCelsius(fahrenheit) {
|
||||
return (fahrenheit - 32) * 5/9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert temperature between units
|
||||
* @param {number} temp - Temperature value
|
||||
* @param {string} fromUnit - Source unit ('°C' or '°F')
|
||||
* @param {string} toUnit - Target unit ('°C' or '°F')
|
||||
* @returns {number} Converted temperature
|
||||
*/
|
||||
function convertTemperature(temp, fromUnit, toUnit) {
|
||||
if (fromUnit === toUnit) return temp;
|
||||
if (fromUnit === '°C' && toUnit === '°F') return celsiusToFahrenheit(temp);
|
||||
if (fromUnit === '°F' && toUnit === '°C') return fahrenheitToCelsius(temp);
|
||||
return temp;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TIME UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert time string to minutes since midnight
|
||||
* @param {string} timeStr - Time in 'HH:MM' format
|
||||
* @returns {number} Minutes since midnight
|
||||
*/
|
||||
function timeToMinutes(timeStr) {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert minutes since midnight to time string
|
||||
* @param {number} minutes - Minutes since midnight
|
||||
* @returns {string} Time in 'HH:MM' format
|
||||
*/
|
||||
function minutesToTime(minutes) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.floor(minutes % 60);
|
||||
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format hours and minutes as 'HH:MM' time string
|
||||
* @param {number} hours - Hours (0-23)
|
||||
* @param {number} minutes - Minutes (0-59)
|
||||
* @returns {string} Time in 'HH:MM' format
|
||||
*/
|
||||
function formatTimeString(hours, minutes) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust time by adding/subtracting minutes with 24-hour wraparound
|
||||
* @param {string} timeStr - Time in 'HH:MM' format
|
||||
* @param {number} minutesToAdd - Minutes to add (negative to subtract)
|
||||
* @returns {string} New time in 'HH:MM' format
|
||||
*/
|
||||
function adjustTime(timeStr, minutesToAdd) {
|
||||
let totalMinutes = timeToMinutes(timeStr) + minutesToAdd;
|
||||
|
||||
// Handle wraparound
|
||||
while (totalMinutes < 0) totalMinutes += 1440;
|
||||
while (totalMinutes >= 1440) totalMinutes -= 1440;
|
||||
|
||||
return minutesToTime(totalMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate temperature at a given time (step function - hold until next node)
|
||||
* @param {Array} nodes - Array of schedule nodes with {time, temp} properties
|
||||
* @param {string} timeStr - Time in 'HH:MM' format
|
||||
* @returns {number} Interpolated temperature
|
||||
*/
|
||||
function interpolateTemperature(nodes, timeStr) {
|
||||
if (nodes.length === 0) return 18;
|
||||
|
||||
const sorted = [...nodes].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
|
||||
const currentMinutes = timeToMinutes(timeStr);
|
||||
|
||||
// Find the most recent node before or at current time
|
||||
let activeNode = null;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const nodeMinutes = timeToMinutes(sorted[i].time);
|
||||
if (nodeMinutes <= currentMinutes) {
|
||||
activeNode = sorted[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no node found before current time, use last node (wrap around from previous day)
|
||||
if (!activeNode) {
|
||||
activeNode = sorted[sorted.length - 1];
|
||||
}
|
||||
|
||||
return activeNode.temp;
|
||||
}
|
||||
Reference in new issue
Block a user