diff --git a/custom_components/omni_pca/frontend/src/omni-panel-programs.ts b/custom_components/omni_pca/frontend/src/omni-panel-programs.ts index c5250b8..f18f1a8 100644 --- a/custom_components/omni_pca/frontend/src/omni-panel-programs.ts +++ b/custom_components/omni_pca/frontend/src/omni-panel-programs.ts @@ -59,6 +59,10 @@ export class OmniPanelPrograms extends LitElement { @state() private _detail: ProgramDetail | null = null; @state() private _detailLoading = false; @state() private _fireFeedback: string | null = null; + @state() private _writeFeedback: string | null = null; + @state() private _cloneTargetSlot: string = ""; + @state() private _showCloneInput: boolean = false; + @state() private _confirmingClear: boolean = false; private _refreshTimer: number | null = null; @@ -185,6 +189,68 @@ export class OmniPanelPrograms extends LitElement { setTimeout(() => { this._fireFeedback = null; }, 4000); } + private async _clearProgram(slot: number): Promise { + if (!this._entryId) return; + this._writeFeedback = "clearing…"; + try { + await this.hass.connection.sendMessagePromise({ + type: "omni_pca/programs/clear", + entry_id: this._entryId, + slot, + }); + this._writeFeedback = `cleared slot ${slot}`; + this._confirmingClear = false; + // Refresh the list + close the detail panel; the slot is gone. + this._selectedSlot = null; + this._detail = null; + await this._loadList(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this._writeFeedback = `error: ${message}`; + } + setTimeout(() => { this._writeFeedback = null; }, 4000); + } + + private async _cloneProgram(sourceSlot: number): Promise { + if (!this._entryId) return; + const targetRaw = this._cloneTargetSlot.trim(); + const target = parseInt(targetRaw, 10); + if (!Number.isFinite(target) || target < 1 || target > 1500) { + this._writeFeedback = "target slot must be 1..1500"; + setTimeout(() => { this._writeFeedback = null; }, 4000); + return; + } + if (target === sourceSlot) { + this._writeFeedback = "target must differ from source"; + setTimeout(() => { this._writeFeedback = null; }, 4000); + return; + } + this._writeFeedback = "cloning…"; + try { + await this.hass.connection.sendMessagePromise({ + type: "omni_pca/programs/clone", + entry_id: this._entryId, + source_slot: sourceSlot, + target_slot: target, + }); + this._writeFeedback = `cloned to slot ${target}`; + this._showCloneInput = false; + this._cloneTargetSlot = ""; + // Navigate to the new clone so the user sees the result. + this._selectedSlot = target; + await this._loadList(); + await this._loadDetail(target); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this._writeFeedback = `error: ${message}`; + } + setTimeout(() => { this._writeFeedback = null; }, 4000); + } + + private _onCloneTargetInput(e: Event): void { + this._cloneTargetSlot = (e.target as HTMLInputElement).value; + } + // -- refresh timer ---------------------------------------------------- private _startRefreshTimer(): void { @@ -357,9 +423,67 @@ export class OmniPanelPrograms extends LitElement { class="fire" @click=${() => this._fireProgram(d.slot)} >▶ Fire now + + ${this._fireFeedback ? html` ${this._fireFeedback}` : ""} + ${this._writeFeedback ? html` + ${this._writeFeedback}` : ""} + ${this._showCloneInput ? html` +
+ + + +
` : ""} + ${this._confirmingClear ? html` +
+ + Clear slot ${d.slot}? + This deletes the program from the panel. + + + +
` : ""} ${d.chain_slots && d.chain_slots.length > 1 ? html`
spans slots @@ -579,16 +703,56 @@ export class OmniPanelPrograms extends LitElement { .detail footer { display: flex; align-items: center; gap: 12px; margin-top: 14px; } - .fire { - background: var(--primary-color, #03a9f4); - color: var(--text-primary-color, #fff); + .fire, .primary, .secondary, .danger { border: none; padding: 8px 16px; font-size: 0.92rem; border-radius: 4px; cursor: pointer; + font-family: inherit; + } + .fire, .primary { + background: var(--primary-color, #03a9f4); + color: var(--text-primary-color, #fff); + } + .secondary { + background: var(--secondary-background-color, #eee); + color: var(--primary-text-color, #000); + } + .danger { + background: transparent; + color: var(--error-color, #db4437); + border: 1px solid var(--error-color, #db4437); + } + .fire:hover, .primary:hover, .secondary:hover, .danger:hover { + filter: brightness(0.9); + } + .action-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; + padding: 10px; + background: var(--secondary-background-color, #f5f5f5); + border-radius: 4px; + font-size: 0.88rem; + } + .action-row.danger-row { + background: var(--error-color, #db4437); + color: white; + } + .action-row input[type="number"] { + width: 70px; + padding: 4px 6px; + font-size: 0.9rem; + border: 1px solid var(--divider-color, #ccc); + border-radius: 3px; + margin-left: 6px; + } + .action-row button { + padding: 4px 12px; + font-size: 0.85rem; } - .fire:hover { filter: brightness(0.9); } .fire-feedback { font-size: 0.85rem; color: var(--secondary-text-color, #666); } diff --git a/custom_components/omni_pca/websocket.py b/custom_components/omni_pca/websocket.py index 8847aa5..c17c8c5 100644 --- a/custom_components/omni_pca/websocket.py +++ b/custom_components/omni_pca/websocket.py @@ -381,6 +381,128 @@ async def _ws_get_program( }) +@websocket_api.websocket_command( + { + vol.Required("type"): "omni_pca/programs/clear", + vol.Required("entry_id"): str, + vol.Required("slot"): vol.All(int, vol.Range(min=1, max=1500)), + } +) +@websocket_api.async_response +async def _ws_clear_program( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Erase a program slot by writing an all-zero 14-byte body. + + Equivalent to "delete this program". v1 panels report + ``not_supported`` because their wire protocol only allows bulk + rewrites (which would clear everything). + """ + coordinator = _coordinator_for_entry(hass, msg["entry_id"]) + if coordinator is None: + connection.send_error(msg["id"], "not_found", "panel not configured") + return + try: + client = coordinator.client + except RuntimeError as err: + connection.send_error(msg["id"], "not_connected", str(err)) + return + try: + await client.clear_program(msg["slot"]) + except NotImplementedError as err: + connection.send_error(msg["id"], "not_supported", str(err)) + return + except Exception as err: + connection.send_error(msg["id"], "clear_failed", str(err)) + return + # Drop the entry from the coordinator's in-memory view so subsequent + # ``list`` calls reflect the deletion before the next poll catches up. + if coordinator.data is not None: + coordinator.data.programs.pop(msg["slot"], None) + connection.send_result(msg["id"], {"slot": msg["slot"], "cleared": True}) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "omni_pca/programs/clone", + vol.Required("entry_id"): str, + vol.Required("source_slot"): vol.All(int, vol.Range(min=1, max=1500)), + vol.Required("target_slot"): vol.All(int, vol.Range(min=1, max=1500)), + } +) +@websocket_api.async_response +async def _ws_clone_program( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Copy ``source_slot``'s program into ``target_slot``. + + Useful for "I want a slightly different version of this program" — + user clones into an empty slot, then (eventually, when the editor + UI lands) tweaks the fields and saves. + + Refuses to clone when source and target are the same slot or when + the source slot is empty / not defined. + """ + coordinator = _coordinator_for_entry(hass, msg["entry_id"]) + if coordinator is None: + connection.send_error(msg["id"], "not_found", "panel not configured") + return + src = msg["source_slot"] + dst = msg["target_slot"] + if src == dst: + connection.send_error( + msg["id"], "invalid", "source and target slots must differ", + ) + return + programs = coordinator.data.programs if coordinator.data else {} + source_program = programs.get(src) + if source_program is None or source_program.is_empty(): + connection.send_error( + msg["id"], "not_found", f"no program at source slot {src}", + ) + return + try: + client = coordinator.client + except RuntimeError as err: + connection.send_error(msg["id"], "not_connected", str(err)) + return + # The Program dataclass carries the slot field; re-stamp it for the + # destination so the on-the-wire bytes are correctly addressed. + from omni_pca.programs import Program # local — avoid cycle + cloned = Program( + slot=dst, + prog_type=source_program.prog_type, + cond=source_program.cond, + cond2=source_program.cond2, + cmd=source_program.cmd, + par=source_program.par, + pr2=source_program.pr2, + month=source_program.month, + day=source_program.day, + days=source_program.days, + hour=source_program.hour, + minute=source_program.minute, + remark_id=source_program.remark_id, + ) + try: + await client.download_program(dst, cloned) + except NotImplementedError as err: + connection.send_error(msg["id"], "not_supported", str(err)) + return + except Exception as err: + connection.send_error(msg["id"], "clone_failed", str(err)) + return + if coordinator.data is not None: + coordinator.data.programs[dst] = cloned + connection.send_result( + msg["id"], {"source_slot": src, "target_slot": dst, "cloned": True}, + ) + + @websocket_api.websocket_command( { vol.Required("type"): "omni_pca/programs/fire", @@ -433,6 +555,8 @@ def async_register_websocket_commands(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, _ws_list_programs) websocket_api.async_register_command(hass, _ws_get_program) websocket_api.async_register_command(hass, _ws_fire_program) + websocket_api.async_register_command(hass, _ws_clear_program) + websocket_api.async_register_command(hass, _ws_clone_program) # -------------------------------------------------------------------------- diff --git a/custom_components/omni_pca/www/panel.js b/custom_components/omni_pca/www/panel.js index 2d55cd3..0667331 100644 --- a/custom_components/omni_pca/www/panel.js +++ b/custom_components/omni_pca/www/panel.js @@ -1,33 +1,33 @@ // omni_pca side panel — generated by frontend/build.mjs. Edit src/, not this file. -var xe=Object.defineProperty;var Ae=Object.getOwnPropertyDescriptor;var f=(i,e,t,r)=>{for(var s=r>1?void 0:r?Ae(e,t):e,o=i.length-1,n;o>=0;o--)(n=i[o])&&(s=(r?n(e,t,s):n(s))||s);return r&&s&&xe(e,t,s),s};var D=globalThis,I=D.ShadowRoot&&(D.ShadyCSS===void 0||D.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,V=Symbol(),ie=new WeakMap,T=class{constructor(e,t,r){if(this._$cssResult$=!0,r!==V)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(I&&e===void 0){let r=t!==void 0&&t.length===1;r&&(e=ie.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),r&&ie.set(t,e))}return e}toString(){return this.cssText}},oe=i=>new T(typeof i=="string"?i:i+"",void 0,V),B=(i,...e)=>{let t=i.length===1?i[0]:e.reduce((r,s,o)=>r+(n=>{if(n._$cssResult$===!0)return n.cssText;if(typeof n=="number")return n;throw Error("Value passed to 'css' function must be a 'css' function result: "+n+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+i[o+1],i[0]);return new T(t,i,V)},ne=(i,e)=>{if(I)i.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(let t of e){let r=document.createElement("style"),s=D.litNonce;s!==void 0&&r.setAttribute("nonce",s),r.textContent=t.cssText,i.appendChild(r)}},W=I?i=>i:i=>i instanceof CSSStyleSheet?(e=>{let t="";for(let r of e.cssRules)t+=r.cssText;return oe(t)})(i):i;var{is:Se,defineProperty:Ee,getOwnPropertyDescriptor:we,getOwnPropertyNames:ke,getOwnPropertySymbols:Te,getPrototypeOf:Ce}=Object,O=globalThis,ae=O.trustedTypes,Re=ae?ae.emptyScript:"",Me=O.reactiveElementPolyfillSupport,C=(i,e)=>i,R={toAttribute(i,e){switch(e){case Boolean:i=i?Re:null;break;case Object:case Array:i=i==null?i:JSON.stringify(i)}return i},fromAttribute(i,e){let t=i;switch(e){case Boolean:t=i!==null;break;case Number:t=i===null?null:Number(i);break;case Object:case Array:try{t=JSON.parse(i)}catch{t=null}}return t}},F=(i,e)=>!Se(i,e),le={attribute:!0,type:String,converter:R,reflect:!1,useDefault:!1,hasChanged:F};Symbol.metadata??=Symbol("metadata"),O.litPropertyMetadata??=new WeakMap;var v=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=le){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let r=Symbol(),s=this.getPropertyDescriptor(e,r,t);s!==void 0&&Ee(this.prototype,e,s)}}static getPropertyDescriptor(e,t,r){let{get:s,set:o}=we(this.prototype,e)??{get(){return this[t]},set(n){this[t]=n}};return{get:s,set(n){let l=s?.call(this);o?.call(this,n),this.requestUpdate(e,l,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??le}static _$Ei(){if(this.hasOwnProperty(C("elementProperties")))return;let e=Ce(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(C("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(C("properties"))){let t=this.properties,r=[...ke(t),...Te(t)];for(let s of r)this.createProperty(s,t[s])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[r,s]of t)this.elementProperties.set(r,s)}this._$Eh=new Map;for(let[t,r]of this.elementProperties){let s=this._$Eu(t,r);s!==void 0&&this._$Eh.set(s,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let r=new Set(e.flat(1/0).reverse());for(let s of r)t.unshift(W(s))}else e!==void 0&&t.push(W(e));return t}static _$Eu(e,t){let r=t.attribute;return r===!1?void 0:typeof r=="string"?r:typeof e=="string"?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let r of t.keys())this.hasOwnProperty(r)&&(e.set(r,this[r]),delete this[r]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return ne(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,r){this._$AK(e,r)}_$ET(e,t){let r=this.constructor.elementProperties.get(e),s=this.constructor._$Eu(e,r);if(s!==void 0&&r.reflect===!0){let o=(r.converter?.toAttribute!==void 0?r.converter:R).toAttribute(t,r.type);this._$Em=e,o==null?this.removeAttribute(s):this.setAttribute(s,o),this._$Em=null}}_$AK(e,t){let r=this.constructor,s=r._$Eh.get(e);if(s!==void 0&&this._$Em!==s){let o=r.getPropertyOptions(s),n=typeof o.converter=="function"?{fromAttribute:o.converter}:o.converter?.fromAttribute!==void 0?o.converter:R;this._$Em=s;let l=n.fromAttribute(t,o.type);this[s]=l??this._$Ej?.get(s)??l,this._$Em=null}}requestUpdate(e,t,r,s=!1,o){if(e!==void 0){let n=this.constructor;if(s===!1&&(o=this[e]),r??=n.getPropertyOptions(e),!((r.hasChanged??F)(o,t)||r.useDefault&&r.reflect&&o===this._$Ej?.get(e)&&!this.hasAttribute(n._$Eu(e,r))))return;this.C(e,t,r)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(e,t,{useDefault:r,reflect:s,wrapped:o},n){r&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,n??t??this[e]),o!==!0||n!==void 0)||(this._$AL.has(e)||(this.hasUpdated||r||(t=void 0),this._$AL.set(e,t)),s===!0&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[s,o]of this._$Ep)this[s]=o;this._$Ep=void 0}let r=this.constructor.elementProperties;if(r.size>0)for(let[s,o]of r){let{wrapped:n}=o,l=this[s];n!==!0||this._$AL.has(s)||l===void 0||this.C(s,void 0,o,l)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(r=>r.hostUpdate?.()),this.update(t)):this._$EM()}catch(r){throw e=!1,this._$EM(),r}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(e){}firstUpdated(e){}};v.elementStyles=[],v.shadowRootOptions={mode:"open"},v[C("elementProperties")]=new Map,v[C("finalized")]=new Map,Me?.({ReactiveElement:v}),(O.reactiveElementVersions??=[]).push("2.1.2");var X=globalThis,ce=i=>i,j=X.trustedTypes,de=j?j.createPolicy("lit-html",{createHTML:i=>i}):void 0,me="$lit$",$=`lit$${Math.random().toFixed(9).slice(2)}$`,_e="?"+$,Ue=`<${_e}>`,S=document,U=()=>S.createComment(""),L=i=>i===null||typeof i!="object"&&typeof i!="function",ee=Array.isArray,Le=i=>ee(i)||typeof i?.[Symbol.iterator]=="function",K=`[ +var xe=Object.defineProperty;var we=Object.getOwnPropertyDescriptor;var p=(i,e,t,r)=>{for(var s=r>1?void 0:r?we(e,t):e,o=i.length-1,n;o>=0;o--)(n=i[o])&&(s=(r?n(e,t,s):n(s))||s);return r&&s&&xe(e,t,s),s};var F=globalThis,z=F.ShadowRoot&&(F.ShadyCSS===void 0||F.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,V=Symbol(),ie=new WeakMap,T=class{constructor(e,t,r){if(this._$cssResult$=!0,r!==V)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o,t=this.t;if(z&&e===void 0){let r=t!==void 0&&t.length===1;r&&(e=ie.get(t)),e===void 0&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),r&&ie.set(t,e))}return e}toString(){return this.cssText}},oe=i=>new T(typeof i=="string"?i:i+"",void 0,V),B=(i,...e)=>{let t=i.length===1?i[0]:e.reduce((r,s,o)=>r+(n=>{if(n._$cssResult$===!0)return n.cssText;if(typeof n=="number")return n;throw Error("Value passed to 'css' function must be a 'css' function result: "+n+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+i[o+1],i[0]);return new T(t,i,V)},ne=(i,e)=>{if(z)i.adoptedStyleSheets=e.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(let t of e){let r=document.createElement("style"),s=F.litNonce;s!==void 0&&r.setAttribute("nonce",s),r.textContent=t.cssText,i.appendChild(r)}},W=z?i=>i:i=>i instanceof CSSStyleSheet?(e=>{let t="";for(let r of e.cssRules)t+=r.cssText;return oe(t)})(i):i;var{is:Ae,defineProperty:Se,getOwnPropertyDescriptor:Ee,getOwnPropertyNames:ke,getOwnPropertySymbols:Te,getPrototypeOf:Ce}=Object,D=globalThis,ae=D.trustedTypes,Re=ae?ae.emptyScript:"",Me=D.reactiveElementPolyfillSupport,C=(i,e)=>i,R={toAttribute(i,e){switch(e){case Boolean:i=i?Re:null;break;case Object:case Array:i=i==null?i:JSON.stringify(i)}return i},fromAttribute(i,e){let t=i;switch(e){case Boolean:t=i!==null;break;case Number:t=i===null?null:Number(i);break;case Object:case Array:try{t=JSON.parse(i)}catch{t=null}}return t}},O=(i,e)=>!Ae(i,e),le={attribute:!0,type:String,converter:R,reflect:!1,useDefault:!1,hasChanged:O};Symbol.metadata??=Symbol("metadata"),D.litPropertyMetadata??=new WeakMap;var v=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=le){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){let r=Symbol(),s=this.getPropertyDescriptor(e,r,t);s!==void 0&&Se(this.prototype,e,s)}}static getPropertyDescriptor(e,t,r){let{get:s,set:o}=Ee(this.prototype,e)??{get(){return this[t]},set(n){this[t]=n}};return{get:s,set(n){let c=s?.call(this);o?.call(this,n),this.requestUpdate(e,c,r)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??le}static _$Ei(){if(this.hasOwnProperty(C("elementProperties")))return;let e=Ce(this);e.finalize(),e.l!==void 0&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(C("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(C("properties"))){let t=this.properties,r=[...ke(t),...Te(t)];for(let s of r)this.createProperty(s,t[s])}let e=this[Symbol.metadata];if(e!==null){let t=litPropertyMetadata.get(e);if(t!==void 0)for(let[r,s]of t)this.elementProperties.set(r,s)}this._$Eh=new Map;for(let[t,r]of this.elementProperties){let s=this._$Eu(t,r);s!==void 0&&this._$Eh.set(s,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){let t=[];if(Array.isArray(e)){let r=new Set(e.flat(1/0).reverse());for(let s of r)t.unshift(W(s))}else e!==void 0&&t.push(W(e));return t}static _$Eu(e,t){let r=t.attribute;return r===!1?void 0:typeof r=="string"?r:typeof e=="string"?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),this.renderRoot!==void 0&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){let e=new Map,t=this.constructor.elementProperties;for(let r of t.keys())this.hasOwnProperty(r)&&(e.set(r,this[r]),delete this[r]);e.size>0&&(this._$Ep=e)}createRenderRoot(){let e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return ne(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,r){this._$AK(e,r)}_$ET(e,t){let r=this.constructor.elementProperties.get(e),s=this.constructor._$Eu(e,r);if(s!==void 0&&r.reflect===!0){let o=(r.converter?.toAttribute!==void 0?r.converter:R).toAttribute(t,r.type);this._$Em=e,o==null?this.removeAttribute(s):this.setAttribute(s,o),this._$Em=null}}_$AK(e,t){let r=this.constructor,s=r._$Eh.get(e);if(s!==void 0&&this._$Em!==s){let o=r.getPropertyOptions(s),n=typeof o.converter=="function"?{fromAttribute:o.converter}:o.converter?.fromAttribute!==void 0?o.converter:R;this._$Em=s;let c=n.fromAttribute(t,o.type);this[s]=c??this._$Ej?.get(s)??c,this._$Em=null}}requestUpdate(e,t,r,s=!1,o){if(e!==void 0){let n=this.constructor;if(s===!1&&(o=this[e]),r??=n.getPropertyOptions(e),!((r.hasChanged??O)(o,t)||r.useDefault&&r.reflect&&o===this._$Ej?.get(e)&&!this.hasAttribute(n._$Eu(e,r))))return;this.C(e,t,r)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(e,t,{useDefault:r,reflect:s,wrapped:o},n){r&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,n??t??this[e]),o!==!0||n!==void 0)||(this._$AL.has(e)||(this.hasUpdated||r||(t=void 0),this._$AL.set(e,t)),s===!0&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}let e=this.scheduleUpdate();return e!=null&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[s,o]of this._$Ep)this[s]=o;this._$Ep=void 0}let r=this.constructor.elementProperties;if(r.size>0)for(let[s,o]of r){let{wrapped:n}=o,c=this[s];n!==!0||this._$AL.has(s)||c===void 0||this.C(s,void 0,o,c)}}let e=!1,t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(r=>r.hostUpdate?.()),this.update(t)):this._$EM()}catch(r){throw e=!1,this._$EM(),r}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(e){}firstUpdated(e){}};v.elementStyles=[],v.shadowRootOptions={mode:"open"},v[C("elementProperties")]=new Map,v[C("finalized")]=new Map,Me?.({ReactiveElement:v}),(D.reactiveElementVersions??=[]).push("2.1.2");var X=globalThis,ce=i=>i,j=X.trustedTypes,de=j?j.createPolicy("lit-html",{createHTML:i=>i}):void 0,_e="$lit$",$=`lit$${Math.random().toFixed(9).slice(2)}$`,me="?"+$,Le=`<${me}>`,A=document,L=()=>A.createComment(""),U=i=>i===null||typeof i!="object"&&typeof i!="function",ee=Array.isArray,Ue=i=>ee(i)||typeof i?.[Symbol.iterator]=="function",K=`[ \f\r]`,M=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,he=/-->/g,pe=/>/g,x=RegExp(`>|${K}(?:([^\\s"'>=/]+)(${K}*=${K}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),ue=/'/g,fe=/"/g,ve=/^(?:script|style|textarea|title)$/i,te=i=>(e,...t)=>({_$litType$:i,strings:e,values:t}),c=te(1),Ke=te(2),Ye=te(3),E=Symbol.for("lit-noChange"),u=Symbol.for("lit-nothing"),ge=new WeakMap,A=S.createTreeWalker(S,129);function ye(i,e){if(!ee(i)||!i.hasOwnProperty("raw"))throw Error("invalid template strings array");return de!==void 0?de.createHTML(e):e}var Pe=(i,e)=>{let t=i.length-1,r=[],s,o=e===2?"":e===3?"":"",n=M;for(let l=0;l"?(n=s??M,d=-1):g[1]===void 0?d=-2:(d=n.lastIndex-g[2].length,p=g[1],n=g[3]===void 0?x:g[3]==='"'?fe:ue):n===fe||n===ue?n=x:n===he||n===pe?n=M:(n=x,s=void 0);let y=n===x&&i[l+1].startsWith("/>")?" ":"";o+=n===M?a+Ue:d>=0?(r.push(p),a.slice(0,d)+me+a.slice(d)+$+y):a+$+(d===-2?l:y)}return[ye(i,o+(i[t]||"")+(e===2?"":e===3?"":"")),r]},P=class i{constructor({strings:e,_$litType$:t},r){let s;this.parts=[];let o=0,n=0,l=e.length-1,a=this.parts,[p,g]=Pe(e,t);if(this.el=i.createElement(p,r),A.currentNode=this.el.content,t===2||t===3){let d=this.el.content.firstChild;d.replaceWith(...d.childNodes)}for(;(s=A.nextNode())!==null&&a.length0){s.textContent=j?j.emptyScript:"";for(let y=0;y<_;y++)s.append(d[y],U()),A.nextNode(),a.push({type:2,index:++o});s.append(d[_],U())}}}else if(s.nodeType===8)if(s.data===_e)a.push({type:2,index:o});else{let d=-1;for(;(d=s.data.indexOf($,d+1))!==-1;)a.push({type:7,index:o}),d+=$.length-1}o++}}static createElement(e,t){let r=S.createElement("template");return r.innerHTML=e,r}};function w(i,e,t=i,r){if(e===E)return e;let s=r!==void 0?t._$Co?.[r]:t._$Cl,o=L(e)?void 0:e._$litDirective$;return s?.constructor!==o&&(s?._$AO?.(!1),o===void 0?s=void 0:(s=new o(i),s._$AT(i,t,r)),r!==void 0?(t._$Co??=[])[r]=s:t._$Cl=s),s!==void 0&&(e=w(i,s._$AS(i,e.values),s,r)),e}var Y=class{constructor(e,t){this._$AV=[],this._$AN=void 0,this._$AD=e,this._$AM=t}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(e){let{el:{content:t},parts:r}=this._$AD,s=(e?.creationScope??S).importNode(t,!0);A.currentNode=s;let o=A.nextNode(),n=0,l=0,a=r[0];for(;a!==void 0;){if(n===a.index){let p;a.type===2?p=new H(o,o.nextSibling,this,e):a.type===1?p=new a.ctor(o,a.name,a.strings,this,e):a.type===6&&(p=new Q(o,this,e)),this._$AV.push(p),a=r[++l]}n!==a?.index&&(o=A.nextNode(),n++)}return A.currentNode=S,s}p(e){let t=0;for(let r of this._$AV)r!==void 0&&(r.strings!==void 0?(r._$AI(e,r,t),t+=r.strings.length-2):r._$AI(e[t])),t++}},H=class i{get _$AU(){return this._$AM?._$AU??this._$Cv}constructor(e,t,r,s){this.type=2,this._$AH=u,this._$AN=void 0,this._$AA=e,this._$AB=t,this._$AM=r,this.options=s,this._$Cv=s?.isConnected??!0}get parentNode(){let e=this._$AA.parentNode,t=this._$AM;return t!==void 0&&e?.nodeType===11&&(e=t.parentNode),e}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(e,t=this){e=w(this,e,t),L(e)?e===u||e==null||e===""?(this._$AH!==u&&this._$AR(),this._$AH=u):e!==this._$AH&&e!==E&&this._(e):e._$litType$!==void 0?this.$(e):e.nodeType!==void 0?this.T(e):Le(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==u&&L(this._$AH)?this._$AA.nextSibling.data=e:this.T(S.createTextNode(e)),this._$AH=e}$(e){let{values:t,_$litType$:r}=e,s=typeof r=="number"?this._$AC(e):(r.el===void 0&&(r.el=P.createElement(ye(r.h,r.h[0]),this.options)),r);if(this._$AH?._$AD===s)this._$AH.p(t);else{let o=new Y(s,this),n=o.u(this.options);o.p(t),this.T(n),this._$AH=o}}_$AC(e){let t=ge.get(e.strings);return t===void 0&&ge.set(e.strings,t=new P(e)),t}k(e){ee(this._$AH)||(this._$AH=[],this._$AR());let t=this._$AH,r,s=0;for(let o of e)s===t.length?t.push(r=new i(this.O(U()),this.O(U()),this,this.options)):r=t[s],r._$AI(o),s++;s2||r[0]!==""||r[1]!==""?(this._$AH=Array(r.length-1).fill(new String),this.strings=r):this._$AH=u}_$AI(e,t=this,r,s){let o=this.strings,n=!1;if(o===void 0)e=w(this,e,t,0),n=!L(e)||e!==this._$AH&&e!==E,n&&(this._$AH=e);else{let l=e,a,p;for(e=o[0],a=0;a{let r=t?.renderBefore??e,s=r._$litPart$;if(s===void 0){let o=t?.renderBefore??null;r._$litPart$=s=new H(e.insertBefore(U(),o),o,void 0,t??{})}return s._$AI(i),s};var re=globalThis,b=class extends v{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){let t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=$e(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return E}};b._$litElement$=!0,b.finalized=!0,re.litElementHydrateSupport?.({LitElement:b});var Ne=re.litElementPolyfillSupport;Ne?.({LitElement:b});(re.litElementVersions??=[]).push("4.2.2");var be=i=>(e,t)=>{t!==void 0?t.addInitializer(()=>{customElements.define(i,e)}):customElements.define(i,e)};var ze={attribute:!0,type:String,converter:R,reflect:!1,hasChanged:F},De=(i=ze,e,t)=>{let{kind:r,metadata:s}=t,o=globalThis.litPropertyMetadata.get(s);if(o===void 0&&globalThis.litPropertyMetadata.set(s,o=new Map),r==="setter"&&((i=Object.create(i)).wrapped=!0),o.set(t.name,i),r==="accessor"){let{name:n}=t;return{set(l){let a=e.get.call(this);e.set.call(this,l),this.requestUpdate(n,a,i,!0,l)},init(l){return l!==void 0&&this.C(n,void 0,i,l),l}}}if(r==="setter"){let{name:n}=t;return function(l){let a=this[n];e.call(this,l),this.requestUpdate(n,a,i,!0,l)}}throw Error("Unsupported decorator location: "+r)};function N(i){return(e,t)=>typeof t=="object"?De(i,e,t):((r,s,o)=>{let n=s.hasOwnProperty(o);return s.constructor.createProperty(o,r),n?Object.getOwnPropertyDescriptor(s,o):void 0})(i,e,t)}function m(i){return N({...i,state:!0,attribute:!1})}function se(i,e){return c`${i.map(t=>Ie(t,e))}`}function Ie(i,e){switch(i.k){case"newline":return c`
`;case"indent":return c`${i.t}`;case"keyword":return c`${i.t}`;case"operator":return c`${i.t}`;case"value":return c`${i.t}`;case"ref":{let t=e&&i.ek&&typeof i.ei=="number"?()=>e(i.ek,i.ei):void 0;return c``}default:return c`${i.t}`}}var Oe=["TIMED","EVENT","YEARLY","WHEN","AT","EVERY","REMARK"],Fe=5e3,h=class extends b{constructor(){super(...arguments);this.narrow=!1;this._entryId=null;this._rows=[];this._total=0;this._filteredTotal=0;this._loading=!1;this._error=null;this._activeTriggerTypes=new Set;this._referenceFilter=null;this._searchTerm="";this._selectedSlot=null;this._detail=null;this._detailLoading=!1;this._fireFeedback=null;this._refreshTimer=null}connectedCallback(){super.connectedCallback(),this._discoverEntry(),this._entryId&&(this._loadList(),this._startRefreshTimer())}disconnectedCallback(){super.disconnectedCallback(),this._stopRefreshTimer()}updated(t){t.has("hass")&&this._entryId===null&&(this._discoverEntry(),this._entryId&&(this._loadList(),this._startRefreshTimer()))}_discoverEntry(){this.hass?.connection&&this._discoverViaList()}async _discoverViaList(){try{let r=(await this.hass.connection.sendMessagePromise({type:"config_entries/get"})).filter(s=>s.domain==="omni_pca");if(r.length===0){this._error="No Omni panel configured. Add one via Settings \u2192 Devices & Services.";return}this._entryId=r[0].entry_id,this._error=null}catch(t){this._error=`Could not discover panels: ${t instanceof Error?t.message:String(t)}`}}async _loadList(){if(this._entryId){this._loading=!0,this._error=null;try{let t={type:"omni_pca/programs/list",entry_id:this._entryId};this._activeTriggerTypes.size>0&&(t.trigger_types=[...this._activeTriggerTypes]),this._referenceFilter&&(t.references_entity=this._referenceFilter),this._searchTerm&&(t.search=this._searchTerm);let r=await this.hass.connection.sendMessagePromise(t);this._rows=r.programs,this._total=r.total,this._filteredTotal=r.filtered_total}catch(t){this._error=t instanceof Error?t.message:String(t)}finally{this._loading=!1}}}async _loadDetail(t){if(this._entryId){this._detailLoading=!0,this._detail=null;try{this._detail=await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/get",entry_id:this._entryId,slot:t})}catch(r){this._error=r instanceof Error?r.message:String(r)}finally{this._detailLoading=!1}}}async _fireProgram(t){if(this._entryId){this._fireFeedback="firing\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/fire",entry_id:this._entryId,slot:t}),this._fireFeedback=`fired slot ${t}`}catch(r){this._fireFeedback=`error: ${r instanceof Error?r.message:r}`}setTimeout(()=>{this._fireFeedback=null},4e3)}}_startRefreshTimer(){this._refreshTimer===null&&(this._refreshTimer=window.setInterval(()=>{this._loadList(),this._selectedSlot!==null&&this._loadDetail(this._selectedSlot)},Fe))}_stopRefreshTimer(){this._refreshTimer!==null&&(window.clearInterval(this._refreshTimer),this._refreshTimer=null)}_toggleTriggerFilter(t){let r=new Set(this._activeTriggerTypes);r.has(t)?r.delete(t):r.add(t),this._activeTriggerTypes=r,this._loadList()}_onSearchInput(t){this._searchTerm=t.target.value,this._loadList()}_clearReferenceFilter(){this._referenceFilter=null,this._loadList()}_onRowClick(t){this._selectedSlot=t,this._loadDetail(t)}_onRefClick(t,r){this._referenceFilter=`${t}:${r}`,this._selectedSlot=null,this._detail=null,this._loadList()}_closeDetail(){this._selectedSlot=null,this._detail=null}render(){return c` + ${i.s?l`${i.s}`:""} + `}default:return l`${i.t}`}}var De=["TIMED","EVENT","YEARLY","WHEN","AT","EVERY","REMARK"],Oe=5e3,h=class extends b{constructor(){super(...arguments);this.narrow=!1;this._entryId=null;this._rows=[];this._total=0;this._filteredTotal=0;this._loading=!1;this._error=null;this._activeTriggerTypes=new Set;this._referenceFilter=null;this._searchTerm="";this._selectedSlot=null;this._detail=null;this._detailLoading=!1;this._fireFeedback=null;this._writeFeedback=null;this._cloneTargetSlot="";this._showCloneInput=!1;this._confirmingClear=!1;this._refreshTimer=null}connectedCallback(){super.connectedCallback(),this._discoverEntry(),this._entryId&&(this._loadList(),this._startRefreshTimer())}disconnectedCallback(){super.disconnectedCallback(),this._stopRefreshTimer()}updated(t){t.has("hass")&&this._entryId===null&&(this._discoverEntry(),this._entryId&&(this._loadList(),this._startRefreshTimer()))}_discoverEntry(){this.hass?.connection&&this._discoverViaList()}async _discoverViaList(){try{let r=(await this.hass.connection.sendMessagePromise({type:"config_entries/get"})).filter(s=>s.domain==="omni_pca");if(r.length===0){this._error="No Omni panel configured. Add one via Settings \u2192 Devices & Services.";return}this._entryId=r[0].entry_id,this._error=null}catch(t){this._error=`Could not discover panels: ${t instanceof Error?t.message:String(t)}`}}async _loadList(){if(this._entryId){this._loading=!0,this._error=null;try{let t={type:"omni_pca/programs/list",entry_id:this._entryId};this._activeTriggerTypes.size>0&&(t.trigger_types=[...this._activeTriggerTypes]),this._referenceFilter&&(t.references_entity=this._referenceFilter),this._searchTerm&&(t.search=this._searchTerm);let r=await this.hass.connection.sendMessagePromise(t);this._rows=r.programs,this._total=r.total,this._filteredTotal=r.filtered_total}catch(t){this._error=t instanceof Error?t.message:String(t)}finally{this._loading=!1}}}async _loadDetail(t){if(this._entryId){this._detailLoading=!0,this._detail=null;try{this._detail=await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/get",entry_id:this._entryId,slot:t})}catch(r){this._error=r instanceof Error?r.message:String(r)}finally{this._detailLoading=!1}}}async _fireProgram(t){if(this._entryId){this._fireFeedback="firing\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/fire",entry_id:this._entryId,slot:t}),this._fireFeedback=`fired slot ${t}`}catch(r){this._fireFeedback=`error: ${r instanceof Error?r.message:r}`}setTimeout(()=>{this._fireFeedback=null},4e3)}}async _clearProgram(t){if(this._entryId){this._writeFeedback="clearing\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/clear",entry_id:this._entryId,slot:t}),this._writeFeedback=`cleared slot ${t}`,this._confirmingClear=!1,this._selectedSlot=null,this._detail=null,await this._loadList()}catch(r){let s=r instanceof Error?r.message:String(r);this._writeFeedback=`error: ${s}`}setTimeout(()=>{this._writeFeedback=null},4e3)}}async _cloneProgram(t){if(!this._entryId)return;let r=this._cloneTargetSlot.trim(),s=parseInt(r,10);if(!Number.isFinite(s)||s<1||s>1500){this._writeFeedback="target slot must be 1..1500",setTimeout(()=>{this._writeFeedback=null},4e3);return}if(s===t){this._writeFeedback="target must differ from source",setTimeout(()=>{this._writeFeedback=null},4e3);return}this._writeFeedback="cloning\u2026";try{await this.hass.connection.sendMessagePromise({type:"omni_pca/programs/clone",entry_id:this._entryId,source_slot:t,target_slot:s}),this._writeFeedback=`cloned to slot ${s}`,this._showCloneInput=!1,this._cloneTargetSlot="",this._selectedSlot=s,await this._loadList(),await this._loadDetail(s)}catch(o){let n=o instanceof Error?o.message:String(o);this._writeFeedback=`error: ${n}`}setTimeout(()=>{this._writeFeedback=null},4e3)}_onCloneTargetInput(t){this._cloneTargetSlot=t.target.value}_startRefreshTimer(){this._refreshTimer===null&&(this._refreshTimer=window.setInterval(()=>{this._loadList(),this._selectedSlot!==null&&this._loadDetail(this._selectedSlot)},Oe))}_stopRefreshTimer(){this._refreshTimer!==null&&(window.clearInterval(this._refreshTimer),this._refreshTimer=null)}_toggleTriggerFilter(t){let r=new Set(this._activeTriggerTypes);r.has(t)?r.delete(t):r.add(t),this._activeTriggerTypes=r,this._loadList()}_onSearchInput(t){this._searchTerm=t.target.value,this._loadList()}_clearReferenceFilter(){this._referenceFilter=null,this._loadList()}_onRowClick(t){this._selectedSlot=t,this._loadDetail(t)}_onRefClick(t,r){this._referenceFilter=`${t}:${r}`,this._selectedSlot=null,this._detail=null,this._loadList()}_closeDetail(){this._selectedSlot=null,this._detail=null}render(){return l`
Omni Programs - ${this._total>0?c` + ${this._total>0?l` ${this._filteredTotal===this._total?`${this._total} programs`:`${this._filteredTotal} of ${this._total} shown`} `:""}
- ${this._error?c` + ${this._error?l`
${this._error}
`:""} ${this._renderFilters()}
${this._renderList()} ${this._selectedSlot!==null?this._renderDetail():""}
- `}_renderFilters(){return c` + `}_renderFilters(){return l`
- ${Oe.map(t=>c` + ${De.map(t=>l` `)}
- ${this._referenceFilter?c` + ${this._referenceFilter?l`
filtering on ${this._referenceFilter}
`:""}
- `}_renderList(){return this._loading&&this._rows.length===0?c`
loading…
`:this._rows.length===0?c`
No programs match the current filters.
`:c` + `}_renderList(){return this._loading&&this._rows.length===0?l`
loading…
`:this._rows.length===0?l`
No programs match the current filters.
`:l`
- ${this._rows.map(t=>c` + ${this._rows.map(t=>l`
this._onRowClick(t.slot)} @@ -66,15 +66,15 @@ var xe=Object.defineProperty;var Ae=Object.getOwnPropertyDescriptor;var f=(i,e,t ${t.trigger_type} - ${t.condition_count>0?c` + ${t.condition_count>0?l` ${t.condition_count} cond`:""} - ${t.action_count>1?c` + ${t.action_count>1?l` ${t.action_count} actions`:""}
`)}
- `}_renderDetail(){if(this._detailLoading)return c``;if(this._detail===null)return c``;let t=this._detail;return c` + `}_renderDetail(){if(this._detailLoading)return l``;if(this._detail===null)return l``;let t=this._detail;return l` @@ -309,16 +359,56 @@ var xe=Object.defineProperty;var Ae=Object.getOwnPropertyDescriptor;var f=(i,e,t .detail footer { display: flex; align-items: center; gap: 12px; margin-top: 14px; } - .fire { - background: var(--primary-color, #03a9f4); - color: var(--text-primary-color, #fff); + .fire, .primary, .secondary, .danger { border: none; padding: 8px 16px; font-size: 0.92rem; border-radius: 4px; cursor: pointer; + font-family: inherit; + } + .fire, .primary { + background: var(--primary-color, #03a9f4); + color: var(--text-primary-color, #fff); + } + .secondary { + background: var(--secondary-background-color, #eee); + color: var(--primary-text-color, #000); + } + .danger { + background: transparent; + color: var(--error-color, #db4437); + border: 1px solid var(--error-color, #db4437); + } + .fire:hover, .primary:hover, .secondary:hover, .danger:hover { + filter: brightness(0.9); + } + .action-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; + padding: 10px; + background: var(--secondary-background-color, #f5f5f5); + border-radius: 4px; + font-size: 0.88rem; + } + .action-row.danger-row { + background: var(--error-color, #db4437); + color: white; + } + .action-row input[type="number"] { + width: 70px; + padding: 4px 6px; + font-size: 0.9rem; + border: 1px solid var(--divider-color, #ccc); + border-radius: 3px; + margin-left: 6px; + } + .action-row button { + padding: 4px 12px; + font-size: 0.85rem; } - .fire:hover { filter: brightness(0.9); } .fire-feedback { font-size: 0.85rem; color: var(--secondary-text-color, #666); } @@ -333,7 +423,7 @@ var xe=Object.defineProperty;var Ae=Object.getOwnPropertyDescriptor;var f=(i,e,t text-align: center; color: var(--secondary-text-color, #888); } - `,f([N({attribute:!1})],h.prototype,"hass",2),f([N({attribute:!1})],h.prototype,"narrow",2),f([m()],h.prototype,"_entryId",2),f([m()],h.prototype,"_rows",2),f([m()],h.prototype,"_total",2),f([m()],h.prototype,"_filteredTotal",2),f([m()],h.prototype,"_loading",2),f([m()],h.prototype,"_error",2),f([m()],h.prototype,"_activeTriggerTypes",2),f([m()],h.prototype,"_referenceFilter",2),f([m()],h.prototype,"_searchTerm",2),f([m()],h.prototype,"_selectedSlot",2),f([m()],h.prototype,"_detail",2),f([m()],h.prototype,"_detailLoading",2),f([m()],h.prototype,"_fireFeedback",2),h=f([be("omni-panel-programs")],h);export{h as OmniPanelPrograms}; + `,p([H({attribute:!1})],h.prototype,"hass",2),p([H({attribute:!1})],h.prototype,"narrow",2),p([g()],h.prototype,"_entryId",2),p([g()],h.prototype,"_rows",2),p([g()],h.prototype,"_total",2),p([g()],h.prototype,"_filteredTotal",2),p([g()],h.prototype,"_loading",2),p([g()],h.prototype,"_error",2),p([g()],h.prototype,"_activeTriggerTypes",2),p([g()],h.prototype,"_referenceFilter",2),p([g()],h.prototype,"_searchTerm",2),p([g()],h.prototype,"_selectedSlot",2),p([g()],h.prototype,"_detail",2),p([g()],h.prototype,"_detailLoading",2),p([g()],h.prototype,"_fireFeedback",2),p([g()],h.prototype,"_writeFeedback",2),p([g()],h.prototype,"_cloneTargetSlot",2),p([g()],h.prototype,"_showCloneInput",2),p([g()],h.prototype,"_confirmingClear",2),h=p([be("omni-panel-programs")],h);export{h as OmniPanelPrograms}; /*! Bundled license information: @lit/reactive-element/css-tag.js: diff --git a/src/omni_pca/client.py b/src/omni_pca/client.py index 9ab435e..31425f0 100644 --- a/src/omni_pca/client.py +++ b/src/omni_pca/client.py @@ -646,6 +646,53 @@ class OmniClient: slot = (reply.payload[0] << 8) | reply.payload[1] yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot) + async def download_program(self, slot: int, program: "Program") -> None: + """Write ``program`` into the panel at the given 1-based ``slot``. + + Wire opcode: 8 (DownloadProgram) per clsOLMsg2DownloadProgram + (clsHAC.cs:1133-1140). Payload is the same 2-byte BE slot + number + 14-byte wire body the UploadProgram reply uses, so + ``Program.encode_wire_bytes`` produces the right thing. + + The panel responds with ``Ack`` on success; we raise + :class:`CommandFailedError` on ``Nak`` and + :class:`OmniConnectionError` for any other opcode. + + Writing an all-zero body clears the slot (treats the slot as + ``ProgramType.FREE``) — matches the panel's behaviour for an + empty record. + """ + if not 1 <= slot <= 1500: + raise ValueError(f"program slot {slot} out of range 1..1500") + body = program.encode_wire_bytes() + if len(body) != 14: + raise ValueError( + f"encoded program body must be 14 bytes, got {len(body)}" + ) + payload = bytes([(slot >> 8) & 0xFF, slot & 0xFF]) + body + reply = await self._conn.request( + OmniLink2MessageType.DownloadProgram, payload + ) + if reply.opcode == int(OmniLink2MessageType.Nak): + raise CommandFailedError( + f"panel NAK'd DownloadProgram for slot {slot}" + ) + if reply.opcode != int(OmniLink2MessageType.Ack): + raise OmniConnectionError( + f"unexpected opcode {reply.opcode} after DownloadProgram " + f"(expected {int(OmniLink2MessageType.Ack)})" + ) + + async def clear_program(self, slot: int) -> None: + """Convenience: clear a program slot by writing an all-zero body. + + On the panel this marks the slot as :class:`ProgramType.FREE`, + same as ``DownloadProgram(slot, all-zero)``. + """ + from .programs import Program, ProgramType + empty = Program(slot=slot, prog_type=int(ProgramType.FREE)) + await self.download_program(slot, empty) + # ---- helpers (status) ----------------------------------------------- async def _fetch_status_range( diff --git a/src/omni_pca/mock_panel.py b/src/omni_pca/mock_panel.py index d97c6d5..3d42dea 100644 --- a/src/omni_pca/mock_panel.py +++ b/src/omni_pca/mock_panel.py @@ -849,8 +849,32 @@ class MockPanel: return _build_ack(), () if opcode == OmniLink2MessageType.UploadProgram: return self._reply_program_data(payload), () + if opcode == OmniLink2MessageType.DownloadProgram: + return self._handle_download_program(payload), () return _build_nak(opcode), () + def _handle_download_program(self, payload: bytes) -> Message: + """Write the 14-byte program body at ``payload[2:16]`` to slot + ``payload[0..1]`` (BE u16). Acks on success, NAKs on bad shape. + + Mirrors :meth:`_reply_program_data` in reverse — same wire + framing as the UploadProgram reply, just inbound. Writing an + all-zero body removes the slot from ``state.programs`` so + subsequent UploadProgram requests treat it as undefined + (matches real-panel behaviour for cleared slots). + """ + if len(payload) < 2 + 14: + return _build_nak(OmniLink2MessageType.DownloadProgram) + number = (payload[0] << 8) | payload[1] + if not 1 <= number <= 1500: + return _build_nak(OmniLink2MessageType.DownloadProgram) + body = bytes(payload[2 : 2 + 14]) + if body == b"\x00" * 14: + self.state.programs.pop(number, None) + else: + self.state.programs[number] = body + return _build_ack() + def _reply_program_data(self, payload: bytes) -> Message: """v2 program read — single-slot OR iterator. diff --git a/src/omni_pca/v1/adapter.py b/src/omni_pca/v1/adapter.py index 5fc3135..317743a 100644 --- a/src/omni_pca/v1/adapter.py +++ b/src/omni_pca/v1/adapter.py @@ -182,6 +182,13 @@ class OmniClientV1Adapter: """ return self._client.iter_programs() + async def download_program(self, slot: int, program) -> None: + """v1 forwarder — raises NotImplementedError. See client.py.""" + await self._client.download_program(slot, program) + + async def clear_program(self, slot: int) -> None: + await self._client.clear_program(slot) + # ---- properties synthesis ------------------------------------------ async def get_object_properties( diff --git a/src/omni_pca/v1/client.py b/src/omni_pca/v1/client.py index 5ac8451..cabfd17 100644 --- a/src/omni_pca/v1/client.py +++ b/src/omni_pca/v1/client.py @@ -241,6 +241,28 @@ class OmniClientV1: slot = (reply.payload[0] << 8) | reply.payload[1] yield Program.from_wire_bytes(reply.payload[2 : 2 + 14], slot=slot) + async def download_program(self, slot: int, program) -> None: + """v1 does not expose a single-slot DownloadProgram opcode. + + On v1 the only way to change programs is the bulk + ``DownloadPrograms`` flow (clsHAC.cs:171, clsOLMsgDownloadPrograms), + which clears the panel's entire program table and re-streams + every record. That's destructive for HA's "edit one program" + use case, so we surface a structured error instead of silently + falling back. Use a v2-capable panel for editing. + """ + raise NotImplementedError( + "v1 panels don't support single-slot program writes; " + "the DownloadPrograms flow clears all programs before " + "rewriting. Use a TCP-mode (v2) connection for editing." + ) + + async def clear_program(self, slot: int) -> None: + raise NotImplementedError( + "v1 panels don't support single-slot program clears; " + "see download_program for details." + ) + # ---- write methods (Command + ExecuteSecurityCommand) ---------------- # # The Command and ExecuteSecurityCommand payloads are byte-identical diff --git a/tests/ha_integration/test_program_websocket.py b/tests/ha_integration/test_program_websocket.py index 1fd321b..6f5b842 100644 --- a/tests/ha_integration/test_program_websocket.py +++ b/tests/ha_integration/test_program_websocket.py @@ -241,6 +241,90 @@ async def test_ws_fire_program_executes_command( assert response["result"] == {"slot": 42, "fired": True} +async def test_ws_clear_program_writes_zero_body( + hass: HomeAssistant, configured_panel, hass_ws_client +) -> None: + """Clear erases a slot end-to-end: ws command → DownloadProgram on + the wire → mock state loses the slot → coordinator drops it from + its in-memory map.""" + coordinator = hass.data[DOMAIN][configured_panel.entry_id] + assert 42 in coordinator.data.programs + client = await hass_ws_client(hass) + await client.send_json_auto_id({ + "type": "omni_pca/programs/clear", + "entry_id": configured_panel.entry_id, + "slot": 42, + }) + response = await client.receive_json() + assert response["success"] is True + assert response["result"] == {"slot": 42, "cleared": True} + # The coordinator's view drops the slot immediately so a follow-up + # list reflects the deletion without waiting for the next poll. + assert 42 not in coordinator.data.programs + + +async def test_ws_clone_program_copies_to_empty_slot( + hass: HomeAssistant, configured_panel, hass_ws_client +) -> None: + """Cloning slot 12 to slot 500 lands a copy at the target with the + right fields and leaves the source untouched.""" + coordinator = hass.data[DOMAIN][configured_panel.entry_id] + assert 12 in coordinator.data.programs + assert 500 not in coordinator.data.programs + source = coordinator.data.programs[12] + client = await hass_ws_client(hass) + await client.send_json_auto_id({ + "type": "omni_pca/programs/clone", + "entry_id": configured_panel.entry_id, + "source_slot": 12, + "target_slot": 500, + }) + response = await client.receive_json() + assert response["success"] is True + assert response["result"] == { + "source_slot": 12, "target_slot": 500, "cloned": True, + } + # New program landed at the target with re-stamped slot. + cloned = coordinator.data.programs[500] + assert cloned.slot == 500 + assert cloned.prog_type == source.prog_type + assert cloned.cmd == source.cmd + assert cloned.pr2 == source.pr2 + # Source remains. + assert 12 in coordinator.data.programs + + +async def test_ws_clone_program_rejects_same_slot( + hass: HomeAssistant, configured_panel, hass_ws_client +) -> None: + client = await hass_ws_client(hass) + await client.send_json_auto_id({ + "type": "omni_pca/programs/clone", + "entry_id": configured_panel.entry_id, + "source_slot": 12, + "target_slot": 12, + }) + response = await client.receive_json() + assert response["success"] is False + assert response["error"]["code"] == "invalid" + + +async def test_ws_clone_program_rejects_missing_source( + hass: HomeAssistant, configured_panel, hass_ws_client +) -> None: + """Cloning from a slot that has no program is a structured error.""" + client = await hass_ws_client(hass) + await client.send_json_auto_id({ + "type": "omni_pca/programs/clone", + "entry_id": configured_panel.entry_id, + "source_slot": 999, # not seeded + "target_slot": 100, + }) + response = await client.receive_json() + assert response["success"] is False + assert response["error"]["code"] == "not_found" + + async def test_ws_list_programs_live_state_overlay_zone( hass: HomeAssistant, configured_panel, hass_ws_client ) -> None: diff --git a/tests/test_e2e_program_echo.py b/tests/test_e2e_program_echo.py index b35c1e5..52a9cc3 100644 --- a/tests/test_e2e_program_echo.py +++ b/tests/test_e2e_program_echo.py @@ -511,6 +511,131 @@ async def test_mockstate_from_pca_serves_real_panel_programs() -> None: assert p.prog_type in {1, 2, 3} # TIMED / EVENT / YEARLY from this fixture +# ---- DownloadProgram writeback ------------------------------------------ + + +@pytest.mark.asyncio +async def test_v2_download_program_writes_slot() -> None: + """Writing a Program via DownloadProgram lands it in MockState; a + subsequent UploadProgram returns the same bytes — proving the + full read-then-write-then-read loop works against the mock.""" + from omni_pca.client import OmniClient + from omni_pca.commands import Command + + target = Program( + slot=42, prog_type=int(ProgramType.TIMED), + cmd=int(Command.UNIT_ON), pr2=7, + hour=22, minute=30, + days=int(Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY), + ) + panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState()) + async with panel.serve(transport="tcp") as (host, port): + async with OmniClient( + host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0, + ) as c: + # Slot 42 starts empty. + assert 42 not in panel.state.programs + await c.download_program(42, target) + # Now the mock's state should carry the wire bytes. + assert 42 in panel.state.programs + assert panel.state.programs[42] == target.encode_wire_bytes() + # And a read-back via iter_programs should yield the same program. + programs = [p async for p in c.iter_programs()] + assert len(programs) == 1 + p = programs[0] + assert p.slot == 42 + assert p.prog_type == int(ProgramType.TIMED) + assert p.cmd == int(Command.UNIT_ON) + assert p.pr2 == 7 + assert p.hour == 22 and p.minute == 30 + assert p.days == int(Days.MONDAY | Days.WEDNESDAY | Days.FRIDAY) + + +@pytest.mark.asyncio +async def test_v2_download_program_overwrites_existing_slot() -> None: + """Writing to a slot that already has a program replaces it.""" + from omni_pca.client import OmniClient + from omni_pca.commands import Command + + original = Program( + slot=10, prog_type=int(ProgramType.TIMED), + cmd=int(Command.UNIT_OFF), pr2=1, + hour=6, minute=0, days=int(Days.MONDAY), + ) + replacement = Program( + slot=10, prog_type=int(ProgramType.TIMED), + cmd=int(Command.UNIT_ON), pr2=99, + hour=22, minute=0, days=int(Days.SUNDAY), + ) + panel = MockPanel( + controller_key=CONTROLLER_KEY, + state=MockState(programs={10: original.encode_wire_bytes()}), + ) + async with panel.serve(transport="tcp") as (host, port): + async with OmniClient( + host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0, + ) as c: + await c.download_program(10, replacement) + assert panel.state.programs[10] == replacement.encode_wire_bytes() + + +@pytest.mark.asyncio +async def test_v2_clear_program_removes_slot() -> None: + """``clear_program`` writes an all-zero body, which the mock treats + as deletion — subsequent reads see the slot as undefined.""" + from omni_pca.client import OmniClient + from omni_pca.commands import Command + + seed = Program( + slot=5, prog_type=int(ProgramType.TIMED), + cmd=int(Command.UNIT_ON), pr2=1, + hour=6, minute=0, days=int(Days.MONDAY), + ) + panel = MockPanel( + controller_key=CONTROLLER_KEY, + state=MockState(programs={5: seed.encode_wire_bytes()}), + ) + async with panel.serve(transport="tcp") as (host, port): + async with OmniClient( + host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0, + ) as c: + await c.clear_program(5) + assert 5 not in panel.state.programs + + +@pytest.mark.asyncio +async def test_v2_download_program_rejects_out_of_range_slot() -> None: + """Client-side range check catches bad slot before sending.""" + from omni_pca.client import OmniClient + + p = Program(slot=1, prog_type=int(ProgramType.TIMED)) + panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState()) + async with panel.serve(transport="tcp") as (host, port): + async with OmniClient( + host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0, + ) as c: + with pytest.raises(ValueError, match="out of range"): + await c.download_program(0, p) + with pytest.raises(ValueError, match="out of range"): + await c.download_program(1501, p) + + +@pytest.mark.asyncio +async def test_v1_download_program_raises_not_implemented() -> None: + """v1 has no single-slot write; the client raises a structured + NotImplementedError so HA can surface the limitation.""" + from omni_pca.v1 import OmniClientV1 + + p = Program(slot=1, prog_type=int(ProgramType.TIMED)) + panel = MockPanel(controller_key=CONTROLLER_KEY, state=MockState()) + async with panel.serve(transport="udp") as (host, port): + async with OmniClientV1( + host=host, port=port, controller_key=CONTROLLER_KEY, timeout=2.0, + ) as c: + with pytest.raises(NotImplementedError, match="v1 panels"): + await c.download_program(1, p) + + @pytest.mark.asyncio async def test_v1_client_iter_programs_enumerates_all_seeded() -> None: seeded = {