diff --git a/AGENTS.md b/AGENTS.md index 7082f85..cefff4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -705,11 +705,15 @@ Read and modify YAML files to understand and change Home Assistant's defined beh Query and interact with the running Home Assistant instance: - `get_states`, `search_entities`, `get_home_context` - Current entity states and compact area/domain/entity context - `call_service` - Control devices (with confirmation), and read from services that answer with data (`recorder.get_statistics`, `weather.get_forecasts`, `calendar.get_events`, `todo.get_items`) — the response comes back automatically -- `get_history`, `get_logbook` - Historical data +- `get_history`, `get_logbook` - Historical data; supplied timestamps must include `Z` or a UTC offset +- `get_calendar_events` - Calendar events; supplied start/end timestamps must include `Z` or a UTC offset - `get_devices`, `get_areas` - Device and area registry info - `write_config_safe` - **Safe config writing with automatic validation, content protection, and backup** - `validate_config` - Check configuration validity - `get_error_log` - System errors and warnings +- `get_supervisor_health`, `get_supervisor_resolution` - Read-only Supervisor, host, connectivity, and repair evidence +- `get_backup_posture`, `get_store_audit`, `get_supervisor_metrics` - Bounded backup, software-source, and resource evidence +- `get_support_logs` - Bounded, credential-redacted Core, Supervisor, host, or app logs - `diagnose_entity` - Comprehensive entity troubleshooting - `get_agent_capabilities` - OpenCode MCP capabilities and native HA `llm` / MCP readiness - `get_ha_llm_development_guide` - Upstream references and starter template for native `/llm.py` providers @@ -727,7 +731,7 @@ Query and interact with the running Home Assistant instance: | Control devices | N/A | `call_service` | `hab action call` | N/A | | Read from a service that answers with data | N/A | `call_service` (automatic) | `hab action call --return-response` | N/A | | Add new integrations | Primary | N/A | N/A | N/A | -| Troubleshoot issues | Review configs | `diagnose_entity`, `get_error_log` | `hab system health` | N/A | +| Troubleshoot issues | Review configs | `diagnose_entity`, `get_error_log`, `get_supervisor_health`, `get_supervisor_resolution` | `hab system health` | N/A | | Check agent/LLM readiness | N/A | `get_agent_capabilities` | N/A | N/A | | Develop native HA LLM tools | `custom_components/*/llm.py` | `get_ha_llm_development_guide` | N/A | N/A | | Find entities | Grep YAML files | `search_entities` | `hab entity list --domain` | N/A | @@ -736,7 +740,7 @@ Query and interact with the running Home Assistant instance: | **Verify UI changes** | N/A | **`screenshot_url`** | N/A | N/A | | **Manage areas/floors** | N/A | `get_areas` (read-only) | **`hab area/floor` (CRUD)** | N/A | | **Manage helpers** | N/A | N/A | **`hab helper` (primary)** | N/A | -| **Backups** | N/A | N/A | **`hab backup` (primary)** | N/A | +| **Backups** | N/A | `get_backup_posture` | **`hab backup` (primary)** | N/A | | **Blueprints** | N/A | N/A | **`hab blueprint` (primary)** | N/A | | **Update firmware** | N/A | **`watch_firmware_update`** | N/A | N/A | | **Check for updates** | N/A | `get_available_updates` | N/A | N/A | diff --git a/custom_components/ingress/www/entrypoint.js b/custom_components/ingress/www/entrypoint.js index 8df96a6..9a9d163 100644 --- a/custom_components/ingress/www/entrypoint.js +++ b/custom_components/ingress/www/entrypoint.js @@ -1,6 +1,6 @@ (function($){typeof define=="function"&&define.amd?define($):$()})(function(){"use strict";const $=(r,t)=>{document.createElement("ha-panel-custom").navigate(r,t)},X=async(r,t)=>{if(!customElements.get(r)){const e=[{url_path:"tmp",component_name:t}],n=document.createElement("partial-panel-resolver");await(n._getRoutes||n.getRoutes).call(n,e).routes.tmp.load()}},Se=async(r,t)=>{let e=null;try{e=await r.callWS({type:"supervisor/api",endpoint:`/addons/${t}/info`,method:"get"})}catch{}return e},Z=r=>(document.cookie=`ingress_session=${r};path=/api/hassio_ingress/;SameSite=Strict${location.protocol==="https:"?";Secure":""}`,r),K=async r=>{const t=await r.callWS({type:"supervisor/api",endpoint:"/ingress/session",method:"post"});return Z(t.session)},_e=async(r,t)=>{await r.callWS({type:"supervisor/api",endpoint:"/ingress/validate_session",method:"post",data:{session:t}}),Z(t)};let g=window.__ingressSession;if(!g){const r=()=>({session:"",refCount:0});let t=r();g=window.__ingressSession={get session(){return t.session},get refCount(){return t.refCount},async init(e){if(!e)return!1;if(t.hass=e,t.timer===void 0){try{t.session=await K(e)}catch{return!1}t.timer===void 0&&(t.timer=setInterval(async()=>{const n=t.hass;try{await _e(n,t.session)}catch{t.session=await K(n)}},6e4),t.refCount=0)}return++t.refCount,!0},fini(){t.timer!==void 0&&(--t.refCount,clearTimeout(t.finiTimer),t.finiTimer=setTimeout(()=>{delete t.finiTimer,t.refCount<=0&&t.timer!==void 0&&(clearInterval(t.timer),t=r())},6e4))}}}const N=r=>(document.cookie=`ha_ingress_session=${r};path=/api/ingress/;SameSite=Strict${location.protocol==="https:"?";Secure":""}`,r),ee=async r=>{const t=await r.callWS({type:"ha-ingress/session"});return N(t.session)},Ee=async(r,t)=>{await r.callWS({type:"ha-ingress/validate_session",session:t}),N(t)};let R=window.__haIngressSession;if(!R){let t={session:""};R=window.__haIngressSession={get session(){return t.session},async init(e){if(!e)return!1;if(t.hass=e,t.timer===void 0){try{t.session=await ee(e)}catch{return!1}t.timer===void 0&&(t.timer=setInterval(async()=>{const n=t.hass;try{await Ee(n,t.session)}catch{t.session=await ee(n)}},6e4))}return!0}}}var F=function(r,t){return F=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,n){e.__proto__=n}||function(e,n){for(var i in n)Object.prototype.hasOwnProperty.call(n,i)&&(e[i]=n[i])},F(r,t)};function x(r,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");F(r,t);function e(){this.constructor=r}r.prototype=t===null?Object.create(t):(e.prototype=t.prototype,new e)}function xe(r,t,e,n){function i(o){return o instanceof e?o:new e(function(s){s(o)})}return new(e||(e=Promise))(function(o,s){function a(f){try{u(n.next(f))}catch(d){s(d)}}function c(f){try{u(n.throw(f))}catch(d){s(d)}}function u(f){f.done?o(f.value):i(f.value).then(a,c)}u((n=n.apply(r,t||[])).next())})}function te(r,t){var e={label:0,sent:function(){if(o[0]&1)throw o[1];return o[1]},trys:[],ops:[]},n,i,o,s;return s={next:a(0),throw:a(1),return:a(2)},typeof Symbol=="function"&&(s[Symbol.iterator]=function(){return this}),s;function a(u){return function(f){return c([u,f])}}function c(u){if(n)throw new TypeError("Generator is already executing.");for(;s&&(s=0,u[0]&&(e=0)),e;)try{if(n=1,i&&(o=u[0]&2?i.return:u[0]?i.throw||((o=i.return)&&o.call(i),0):i.next)&&!(o=o.call(i,u[1])).done)return o;switch(i=0,o&&(u=[u[0]&2,o.value]),u[0]){case 0:case 1:o=u;break;case 4:return e.label++,{value:u[1],done:!1};case 5:e.label++,i=u[1],u=[0];continue;case 7:u=e.ops.pop(),e.trys.pop();continue;default:if(o=e.trys,!(o=o.length>0&&o[o.length-1])&&(u[0]===6||u[0]===2)){e=0;continue}if(u[0]===3&&(!o||u[1]>o[0]&&u[1]=r.length&&(r=void 0),{value:r&&r[n++],done:!r}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function T(r,t){var e=typeof Symbol=="function"&&r[Symbol.iterator];if(!e)return r;var n=e.call(r),i,o=[],s;try{for(;(t===void 0||t-- >0)&&!(i=n.next()).done;)o.push(i.value)}catch(a){s={error:a}}finally{try{i&&!i.done&&(e=n.return)&&e.call(n)}finally{if(s)throw s.error}}return o}function M(r,t,e){if(e||arguments.length===2)for(var n=0,i=t.length,o;n1||c(l,p)})},h&&(i[l]=h(i[l])))}function c(l,h){try{u(n[l](h))}catch(p){v(o[0][3],p)}}function u(l){l.value instanceof I?Promise.resolve(l.value.v).then(f,d):v(o[0][2],l)}function f(l){c("next",l)}function d(l){c("throw",l)}function v(l,h){l(h),o.shift(),o.length&&c(o[0][0],o[0][1])}}function Pe(r){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=r[Symbol.asyncIterator],e;return t?t.call(r):(r=typeof S=="function"?S(r):r[Symbol.iterator](),e={},n("next"),n("throw"),n("return"),e[Symbol.asyncIterator]=function(){return this},e);function n(o){e[o]=r[o]&&function(s){return new Promise(function(a,c){s=r[o](s),i(a,c,s.done,s.value)})}}function i(o,s,a,c){Promise.resolve(c).then(function(u){o({value:u,done:a})},s)}}typeof SuppressedError=="function"&&SuppressedError;function y(r){return typeof r=="function"}function re(r){var t=function(n){Error.call(n),n.stack=new Error().stack},e=r(t);return e.prototype=Object.create(Error.prototype),e.prototype.constructor=e,e}var B=re(function(r){return function(e){r(this),this.message=e?e.length+` errors occurred during unsubscription: `+e.map(function(n,i){return i+1+") "+n.toString()}).join(` - `):"",this.name="UnsubscriptionError",this.errors=e}});function Y(r,t){if(r){var e=r.indexOf(t);0<=e&&r.splice(e,1)}}var j=function(){function r(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return r.prototype.unsubscribe=function(){var t,e,n,i,o;if(!this.closed){this.closed=!0;var s=this._parentage;if(s)if(this._parentage=null,Array.isArray(s))try{for(var a=S(s),c=a.next();!c.done;c=a.next()){var u=c.value;u.remove(this)}}catch(p){t={error:p}}finally{try{c&&!c.done&&(e=a.return)&&e.call(a)}finally{if(t)throw t.error}}else s.remove(this);var f=this.initialTeardown;if(y(f))try{f()}catch(p){o=p instanceof B?p.errors:[p]}var d=this._finalizers;if(d){this._finalizers=null;try{for(var v=S(d),l=v.next();!l.done;l=v.next()){var h=l.value;try{oe(h)}catch(p){o=o??[],p instanceof B?o=M(M([],T(o)),T(p.errors)):o.push(p)}}}catch(p){n={error:p}}finally{try{l&&!l.done&&(i=v.return)&&i.call(v)}finally{if(n)throw n.error}}}if(o)throw new B(o)}},r.prototype.add=function(t){var e;if(t&&t!==this)if(this.closed)oe(t);else{if(t instanceof r){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(e=this._finalizers)!==null&&e!==void 0?e:[]).push(t)}},r.prototype._hasParent=function(t){var e=this._parentage;return e===t||Array.isArray(e)&&e.includes(t)},r.prototype._addParent=function(t){var e=this._parentage;this._parentage=Array.isArray(e)?(e.push(t),e):e?[e,t]:t},r.prototype._removeParent=function(t){var e=this._parentage;e===t?this._parentage=null:Array.isArray(e)&&Y(e,t)},r.prototype.remove=function(t){var e=this._finalizers;e&&Y(e,t),t instanceof r&&t._removeParent(this)},r.EMPTY=function(){var t=new r;return t.closed=!0,t}(),r}(),ne=j.EMPTY;function ie(r){return r instanceof j||r&&"closed"in r&&y(r.remove)&&y(r.add)&&y(r.unsubscribe)}function oe(r){y(r)?r():r.unsubscribe()}var se={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1},ae={setTimeout:function(r,t){for(var e=[],n=2;n0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(e){return this._throwIfClosed(),r.prototype._trySubscribe.call(this,e)},t.prototype._subscribe=function(e){return this._throwIfClosed(),this._checkFinalizedStatuses(e),this._innerSubscribe(e)},t.prototype._innerSubscribe=function(e){var n=this,i=this,o=i.hasError,s=i.isStopped,a=i.observers;return o||s?ne:(this.currentObservers=null,a.push(e),new j(function(){n.currentObservers=null,Y(a,e)}))},t.prototype._checkFinalizedStatuses=function(e){var n=this,i=n.hasError,o=n.thrownError,s=n.isStopped;i?e.error(o):s&&e.complete()},t.prototype.asObservable=function(){var e=new m;return e.source=this,e},t.create=function(e,n){return new he(e,n)},t}(m),he=function(r){x(t,r);function t(e,n){var i=r.call(this)||this;return i.destination=e,i.source=n,i}return t.prototype.next=function(e){var n,i;(i=(n=this.destination)===null||n===void 0?void 0:n.next)===null||i===void 0||i.call(n,e)},t.prototype.error=function(e){var n,i;(i=(n=this.destination)===null||n===void 0?void 0:n.error)===null||i===void 0||i.call(n,e)},t.prototype.complete=function(){var e,n;(n=(e=this.destination)===null||e===void 0?void 0:e.complete)===null||n===void 0||n.call(e)},t.prototype._subscribe=function(e){var n,i;return(i=(n=this.source)===null||n===void 0?void 0:n.subscribe(e))!==null&&i!==void 0?i:ne},t}(fe),Ue=function(r){x(t,r);function t(e){var n=r.call(this)||this;return n._value=e,n}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(e){var n=r.prototype._subscribe.call(this,e);return!n.closed&&e.next(this._value),n},t.prototype.getValue=function(){var e=this,n=e.hasError,i=e.thrownError,o=e._value;if(n)throw i;return this._throwIfClosed(),o},t.prototype.next=function(e){r.prototype.next.call(this,this._value=e)},t}(fe),de=new m(function(r){return r.complete()}),pe=function(r){return r&&typeof r.length=="number"&&typeof r!="function"};function Ve(r){return y(r==null?void 0:r.then)}function Re(r){return y(r[G])}function Fe(r){return Symbol.asyncIterator&&y(r==null?void 0:r[Symbol.asyncIterator])}function Be(r){return new TypeError("You provided "+(r!==null&&typeof r=="object"?"an invalid object":"'"+r+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function Ye(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var qe=Ye();function We(r){return y(r==null?void 0:r[qe])}function ze(r){return Ie(this,arguments,function(){var e,n,i,o;return te(this,function(s){switch(s.label){case 0:e=r.getReader(),s.label=1;case 1:s.trys.push([1,,9,10]),s.label=2;case 2:return[4,I(e.read())];case 3:return n=s.sent(),i=n.value,o=n.done,o?[4,I(void 0)]:[3,5];case 4:return[2,s.sent()];case 5:return[4,I(i)];case 6:return[4,s.sent()];case 7:return s.sent(),[3,2];case 8:return[3,10];case 9:return e.releaseLock(),[7];case 10:return[2]}})})}function De(r){return y(r==null?void 0:r.getReader)}function O(r){if(r instanceof m)return r;if(r!=null){if(Re(r))return Ge(r);if(pe(r))return Qe(r);if(Ve(r))return Je(r);if(Fe(r))return ve(r);if(We(r))return Xe(r);if(De(r))return Ze(r)}throw Be(r)}function Ge(r){return new m(function(t){var e=r[G]();if(y(e.subscribe))return e.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function Qe(r){return new m(function(t){for(var e=0;e>>");let n=t;return e.find((i,o)=>(o===0?n=t.querySelector(e[o]):n instanceof Element&&(n=((n==null?void 0:n.shadowRoot)??n).querySelector(e[o])),n===null)),n===t?null:n}function J(r){return r>1?r:window.innerWidth*r}const ft=(r,t)=>t.length&&r.composedPath().some(e=>e instanceof Element&&e.matches(t.join())),we={haPanelEnergy:{onLock:()=>{const r=C("home-assistant >>> home-assistant-main >>> ha-drawer ha-panel-energy >>> ha-top-app-bar-fixed >>> header");r&&(r.style.top="0px")}}};function ht(){Object.assign(document.body.style,{position:"fixed",inset:0,top:`-${document.documentElement.scrollTop}px`}),Object.values(we).forEach(({onLock:r})=>r==null?void 0:r())}function dt(){const r=Math.abs(parseInt(document.body.style.top))||document.documentElement.scrollTop;Object.assign(document.body.style,{position:"",inset:""}),window.scrollTo({top:r,left:0,behavior:"auto"}),Object.values(we).forEach(({onUnlock:t})=>t==null?void 0:t())}const pt=({startThreshold:r,endThreshold:t,preventOthers:e,lockVerticalScroll:n,exclusions:i})=>_(document,"touchstart",{capture:e}).pipe(A(o=>!ft(o,i)),A(({touches:[{clientX:o}]})=>o{n&&ht(),e&&o.stopPropagation()}),Q(({touches:[{clientY:o}]})=>_(document,"touchmove",{capture:e}).pipe(A(({touches:s})=>s.length<2),V(s=>e&&(s==null?void 0:s.stopPropagation())),lt(_(document,"touchend",{capture:e}).pipe(V(s=>{n&&dt(),e&&(s==null||s.stopPropagation())}),be(1))),ct(1),k(({changedTouches:[{clientX:s,clientY:a}]})=>({x:s,y:a})),A(({x:s,y:a})=>s>Math.abs(a-o)&&s>J(t))))),vt=({preventOthers:r,threshold:t})=>_(document,"touchstart").pipe(V(e=>r&&e.stopPropagation()),Q(({touches:[{clientX:e}]})=>_(document,"touchend").pipe(A(({changedTouches:[{clientX:n}]})=>e-n>J(t)),be(1))));let ge=!1;const yt=r=>{var v,l;if(ge)return!0;const t=C("home-assistant >>> home-assistant-main >>> ha-drawer"),e=C("ha-sidebar",t);if(!e||getComputedStyle(e).display==="none")return!1;if(!r){const h=C("ha-panel-lovelace",t);r=((l=(v=h==null?void 0:h.lovelace)==null?void 0:v.config)==null?void 0:l.sidebar_swipe)||{}}const{start_threshold:n=.1,end_threshold:i=.13,back_threshold:o=50,prevent_others:s=!0,lock_vertical_scroll:a=!0,exclusions:c=[]}=r,u=new Ue(!1),f=vt({threshold:o,preventOthers:s}),d=pt({startThreshold:n,endThreshold:i,preventOthers:s,lockVerticalScroll:a,exclusions:c});return t&&new MutationObserver(h=>{u.next(h[0].oldValue===null)}).observe(t,{attributes:!0,attributeOldValue:!0,attributeFilter:["open"]}),u.pipe(Q(h=>h?f.pipe(k(()=>!1)):d.pipe(k(()=>!0)))).subscribe(h=>{var p;(p=C("home-assistant >>> home-assistant-main"))==null||p.dispatchEvent(new CustomEvent("hass-toggle-menu",{detail:{open:h}}))}),ge=!0,!0};var mt="M2,21V19H20V21H2M20,8V5H18V8H20M20,3A2,2 0 0,1 22,5V8A2,2 0 0,1 20,10H18V13A4,4 0 0,1 14,17H8A4,4 0 0,1 4,13V3H20M16,5H6V13A2,2 0 0,0 8,15H14A2,2 0 0,0 16,13V5Z";const bt=async(r,t,e,n)=>{const i=a=>{n||((r.shadowRoot||r).innerHTML=`
${a}
`)},o=await Se(t,e);if(!o)return i(`Unable to fetch add-on info of '${e}'`);if(!o.ingress_url)return i(`Add-on '${e}' does not support Ingress`);const s=o.ingress_url.replace(/\/+$/,"");return!n&&!await g.init(t)?i("Unable to create an Ingress session"):s};class wt extends HTMLElement{constructor(){super(),this._panelPath="",this.attachShadow({mode:"open"})}connectedCallback(){this._isHassio&&g.init(this._isHassio)}disconnectedCallback(){this._isHassio&&g.fini()}async setProperties(t){this._setProperties&&this._setProperties(t);const e=this._getPanelConfig(t);if(!e)return;const n=await this._getTargetUrl(e,t);n&&await this._createIngressView(n,e,t)}_getPanelConfig(t){var o,s,a;if(!t.panel&&t.route&&(t.panel=(s=(o=t.hass)==null?void 0:o.panels)==null?void 0:s[t.route.prefix.slice(1)]),!t.panel)return;const e=t.panel;let{config:n,url_path:i}=e;if(n.title=e.title,n.icon=e.icon,n.children){const c=(((a=t.route)==null?void 0:a.path)||"").split("/",2)[1];c&&c in n.children&&(n=n.children[c],i=`${i}/${c}`)}if(this._panelPath!==i)return this._panelPath=i,delete this._setProperties,n}async _getTargetUrl(t,e,n){let i="";const o=new URLSearchParams(window.location.search),{url:s,addon:a}=t,c=s===void 0;if(typeof s=="object"){const{match:d,replace:v}=s;if(i=s.default,d){const l=window.location.origin,h=l.replace(new RegExp(`^${d}$`,"i"),v);l!==h&&(i=h)}}else if(c){const d=t.token.value;if(a){const v=await bt(this,e.hass,a,n);if(!v)return;i=v}else await R.init(e.hass),i=`/api/ingress/${d}`}else i=s;n||(this._isHassio&&(g.fini(),delete this._isHassio),a&&(this._isHassio=e.hass,this.isConnected||g.fini()));let{index:u,ui_mode:f}=t;if(u!==void 0){const d=o.get("index");!n&&d&&!/(^|\/)\.\.\//.test(d)&&(u=d.replace(/^\/+/,"")),i=`${i}/${u}`}if(t.token&&!c){const d=new URL(i);d.searchParams.set(t.field,t.token.value),i=d.href}if(n||f==="custom")return i;if(o.has("replace"))a&&(i=`/files/ingress/iframe.html?ingress=${encodeURIComponent(i)}`),window.location.href=i;else if(f==="replace")i.indexOf("://")!==-1&&(window.location.href=i),$(i,{replace:!0});else return i}async _createIngressView(t,e,n){const i=(v,l)=>"setProperties"in v?h=>v.setProperties(h):h=>{for(const p of l)p in h&&(v[p]=h[p])},o=this.shadowRoot,{ui_mode:s,title:a,icon:c}=e;if(s==="custom"){const v={};for(const[l,h]of Object.entries(e.children||{})){const p=await this._getTargetUrl(h,n,!0);if(p){const{title:P,icon:E,ui_mode:H}=h;v[l]={url:p,title:P,icon:E,ui_mode:H}}}try{const{default:l}=await import(t),h=await l({url:t,title:a,icon:c,children:v,ensureHaElem:X});if(!(h instanceof HTMLElement))throw new Error("custom should export default async(config)=>HTMLElement");o.innerHTML="",o.appendChild(h),this._setProperties=i(h,["hass","narrow","route"]),this._setProperties(n)}catch(l){o.innerHTML=`
custom url[${t}] is invalid:
+  `):"",this.name="UnsubscriptionError",this.errors=e}});function Y(r,t){if(r){var e=r.indexOf(t);0<=e&&r.splice(e,1)}}var j=function(){function r(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return r.prototype.unsubscribe=function(){var t,e,n,i,o;if(!this.closed){this.closed=!0;var s=this._parentage;if(s)if(this._parentage=null,Array.isArray(s))try{for(var a=S(s),c=a.next();!c.done;c=a.next()){var u=c.value;u.remove(this)}}catch(p){t={error:p}}finally{try{c&&!c.done&&(e=a.return)&&e.call(a)}finally{if(t)throw t.error}}else s.remove(this);var f=this.initialTeardown;if(y(f))try{f()}catch(p){o=p instanceof B?p.errors:[p]}var d=this._finalizers;if(d){this._finalizers=null;try{for(var v=S(d),l=v.next();!l.done;l=v.next()){var h=l.value;try{oe(h)}catch(p){o=o??[],p instanceof B?o=M(M([],T(o)),T(p.errors)):o.push(p)}}}catch(p){n={error:p}}finally{try{l&&!l.done&&(i=v.return)&&i.call(v)}finally{if(n)throw n.error}}}if(o)throw new B(o)}},r.prototype.add=function(t){var e;if(t&&t!==this)if(this.closed)oe(t);else{if(t instanceof r){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(e=this._finalizers)!==null&&e!==void 0?e:[]).push(t)}},r.prototype._hasParent=function(t){var e=this._parentage;return e===t||Array.isArray(e)&&e.includes(t)},r.prototype._addParent=function(t){var e=this._parentage;this._parentage=Array.isArray(e)?(e.push(t),e):e?[e,t]:t},r.prototype._removeParent=function(t){var e=this._parentage;e===t?this._parentage=null:Array.isArray(e)&&Y(e,t)},r.prototype.remove=function(t){var e=this._finalizers;e&&Y(e,t),t instanceof r&&t._removeParent(this)},r.EMPTY=function(){var t=new r;return t.closed=!0,t}(),r}(),ne=j.EMPTY;function ie(r){return r instanceof j||r&&"closed"in r&&y(r.remove)&&y(r.add)&&y(r.unsubscribe)}function oe(r){y(r)?r():r.unsubscribe()}var se={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1},ae={setTimeout:function(r,t){for(var e=[],n=2;n0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(e){return this._throwIfClosed(),r.prototype._trySubscribe.call(this,e)},t.prototype._subscribe=function(e){return this._throwIfClosed(),this._checkFinalizedStatuses(e),this._innerSubscribe(e)},t.prototype._innerSubscribe=function(e){var n=this,i=this,o=i.hasError,s=i.isStopped,a=i.observers;return o||s?ne:(this.currentObservers=null,a.push(e),new j(function(){n.currentObservers=null,Y(a,e)}))},t.prototype._checkFinalizedStatuses=function(e){var n=this,i=n.hasError,o=n.thrownError,s=n.isStopped;i?e.error(o):s&&e.complete()},t.prototype.asObservable=function(){var e=new m;return e.source=this,e},t.create=function(e,n){return new he(e,n)},t}(m),he=function(r){x(t,r);function t(e,n){var i=r.call(this)||this;return i.destination=e,i.source=n,i}return t.prototype.next=function(e){var n,i;(i=(n=this.destination)===null||n===void 0?void 0:n.next)===null||i===void 0||i.call(n,e)},t.prototype.error=function(e){var n,i;(i=(n=this.destination)===null||n===void 0?void 0:n.error)===null||i===void 0||i.call(n,e)},t.prototype.complete=function(){var e,n;(n=(e=this.destination)===null||e===void 0?void 0:e.complete)===null||n===void 0||n.call(e)},t.prototype._subscribe=function(e){var n,i;return(i=(n=this.source)===null||n===void 0?void 0:n.subscribe(e))!==null&&i!==void 0?i:ne},t}(fe),Ue=function(r){x(t,r);function t(e){var n=r.call(this)||this;return n._value=e,n}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(e){var n=r.prototype._subscribe.call(this,e);return!n.closed&&e.next(this._value),n},t.prototype.getValue=function(){var e=this,n=e.hasError,i=e.thrownError,o=e._value;if(n)throw i;return this._throwIfClosed(),o},t.prototype.next=function(e){r.prototype.next.call(this,this._value=e)},t}(fe),de=new m(function(r){return r.complete()}),pe=function(r){return r&&typeof r.length=="number"&&typeof r!="function"};function Ve(r){return y(r==null?void 0:r.then)}function Re(r){return y(r[G])}function Fe(r){return Symbol.asyncIterator&&y(r==null?void 0:r[Symbol.asyncIterator])}function Be(r){return new TypeError("You provided "+(r!==null&&typeof r=="object"?"an invalid object":"'"+r+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function Ye(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var qe=Ye();function We(r){return y(r==null?void 0:r[qe])}function ze(r){return Ie(this,arguments,function(){var e,n,i,o;return te(this,function(s){switch(s.label){case 0:e=r.getReader(),s.label=1;case 1:s.trys.push([1,,9,10]),s.label=2;case 2:return[4,I(e.read())];case 3:return n=s.sent(),i=n.value,o=n.done,o?[4,I(void 0)]:[3,5];case 4:return[2,s.sent()];case 5:return[4,I(i)];case 6:return[4,s.sent()];case 7:return s.sent(),[3,2];case 8:return[3,10];case 9:return e.releaseLock(),[7];case 10:return[2]}})})}function De(r){return y(r==null?void 0:r.getReader)}function k(r){if(r instanceof m)return r;if(r!=null){if(Re(r))return Ge(r);if(pe(r))return Qe(r);if(Ve(r))return Je(r);if(Fe(r))return ve(r);if(We(r))return Xe(r);if(De(r))return Ze(r)}throw Be(r)}function Ge(r){return new m(function(t){var e=r[G]();if(y(e.subscribe))return e.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function Qe(r){return new m(function(t){for(var e=0;e>>");let n=t;return e.find((i,o)=>(o===0?n=t.querySelector(e[o]):n instanceof Element&&(n=((n==null?void 0:n.shadowRoot)??n).querySelector(e[o])),n===null)),n===t?null:n}function J(r){return r>1?r:window.innerWidth*r}const ft=(r,t)=>t.length&&r.composedPath().some(e=>e instanceof Element&&e.matches(t.join())),we={haPanelEnergy:{onLock:()=>{const r=C("home-assistant >>> home-assistant-main >>> ha-drawer ha-panel-energy >>> ha-top-app-bar-fixed >>> header");r&&(r.style.top="0px")}}};function ht(){Object.assign(document.body.style,{position:"fixed",inset:0,top:`-${document.documentElement.scrollTop}px`}),Object.values(we).forEach(({onLock:r})=>r==null?void 0:r())}function dt(){const r=Math.abs(parseInt(document.body.style.top))||document.documentElement.scrollTop;Object.assign(document.body.style,{position:"",inset:""}),window.scrollTo({top:r,left:0,behavior:"auto"}),Object.values(we).forEach(({onUnlock:t})=>t==null?void 0:t())}const pt=({startThreshold:r,endThreshold:t,preventOthers:e,lockVerticalScroll:n,exclusions:i})=>_(document,"touchstart",{capture:e}).pipe(A(o=>!ft(o,i)),A(({touches:[{clientX:o}]})=>o{n&&ht(),e&&o.stopPropagation()}),Q(({touches:[{clientY:o}]})=>_(document,"touchmove",{capture:e}).pipe(A(({touches:s})=>s.length<2),V(s=>e&&(s==null?void 0:s.stopPropagation())),lt(_(document,"touchend",{capture:e}).pipe(V(s=>{n&&dt(),e&&(s==null||s.stopPropagation())}),be(1))),ct(1),O(({changedTouches:[{clientX:s,clientY:a}]})=>({x:s,y:a})),A(({x:s,y:a})=>s>Math.abs(a-o)&&s>J(t))))),vt=({preventOthers:r,threshold:t})=>_(document,"touchstart").pipe(V(e=>r&&e.stopPropagation()),Q(({touches:[{clientX:e}]})=>_(document,"touchend").pipe(A(({changedTouches:[{clientX:n}]})=>e-n>J(t)),be(1))));let ge=!1;const yt=r=>{var v,l;if(ge)return!0;const t=C("home-assistant >>> home-assistant-main >>> ha-drawer"),e=C("ha-sidebar",t);if(!e||getComputedStyle(e).display==="none")return!1;if(!r){const h=C("ha-panel-lovelace",t);r=((l=(v=h==null?void 0:h.lovelace)==null?void 0:v.config)==null?void 0:l.sidebar_swipe)||{}}const{start_threshold:n=.1,end_threshold:i=.13,back_threshold:o=50,prevent_others:s=!0,lock_vertical_scroll:a=!0,exclusions:c=[]}=r,u=new Ue(!1),f=vt({threshold:o,preventOthers:s}),d=pt({startThreshold:n,endThreshold:i,preventOthers:s,lockVerticalScroll:a,exclusions:c});return t&&new MutationObserver(h=>{u.next(h[0].oldValue===null)}).observe(t,{attributes:!0,attributeOldValue:!0,attributeFilter:["open"]}),u.pipe(Q(h=>h?f.pipe(O(()=>!1)):d.pipe(O(()=>!0)))).subscribe(h=>{var p;(p=C("home-assistant >>> home-assistant-main"))==null||p.dispatchEvent(new CustomEvent("hass-toggle-menu",{detail:{open:h}}))}),ge=!0,!0};var mt="M2,21V19H20V21H2M20,8V5H18V8H20M20,3A2,2 0 0,1 22,5V8A2,2 0 0,1 20,10H18V13A4,4 0 0,1 14,17H8A4,4 0 0,1 4,13V3H20M16,5H6V13A2,2 0 0,0 8,15H14A2,2 0 0,0 16,13V5Z";const bt=async(r,t,e,n)=>{const i=a=>{n||((r.shadowRoot||r).innerHTML=`
${a}
`)},o=await Se(t,e);if(!o)return i(`Unable to fetch add-on info of '${e}'`);if(!o.ingress_url)return i(`Add-on '${e}' does not support Ingress`);const s=o.ingress_url.replace(/\/+$/,"");return!n&&!await g.init(t)?i("Unable to create an Ingress session"):s};class wt extends HTMLElement{constructor(){super(),this._panelPath="",this.attachShadow({mode:"open"})}connectedCallback(){this.style.display="block",this.style.height="100vh",this.style.height="100dvh",this._isHassio&&g.init(this._isHassio)}disconnectedCallback(){this._isHassio&&g.fini()}async setProperties(t){this._setProperties&&this._setProperties(t);const e=this._getPanelConfig(t);if(!e)return;const n=await this._getTargetUrl(e,t);n&&await this._createIngressView(n,e,t)}_getPanelConfig(t){var o,s,a;if(!t.panel&&t.route&&(t.panel=(s=(o=t.hass)==null?void 0:o.panels)==null?void 0:s[t.route.prefix.slice(1)]),!t.panel)return;const e=t.panel;let{config:n,url_path:i}=e;if(n.title=e.title,n.icon=e.icon,n.children){const c=(((a=t.route)==null?void 0:a.path)||"").split("/",2)[1];c&&c in n.children&&(n=n.children[c],i=`${i}/${c}`)}if(this._panelPath!==i)return this._panelPath=i,delete this._setProperties,n}async _getTargetUrl(t,e,n){let i="";const o=new URLSearchParams(window.location.search),{url:s,addon:a}=t,c=s===void 0;if(typeof s=="object"){const{match:d,replace:v}=s;if(i=s.default,d){const l=window.location.origin,h=l.replace(new RegExp(`^${d}$`,"i"),v);l!==h&&(i=h)}}else if(c){const d=t.token.value;if(a){const v=await bt(this,e.hass,a,n);if(!v)return;i=v}else await R.init(e.hass),i=`/api/ingress/${d}`}else i=s;n||(this._isHassio&&(g.fini(),delete this._isHassio),a&&(this._isHassio=e.hass,this.isConnected||g.fini()));let{index:u,ui_mode:f}=t;if(u!==void 0){const d=o.get("index");!n&&d&&!/(^|\/)\.\.\//.test(d)&&(u=d.replace(/^\/+/,"")),i=`${i}/${u}`}if(t.token&&!c){const d=new URL(i);d.searchParams.set(t.field,t.token.value),i=d.href}if(n||f==="custom")return i;if(o.has("replace"))a&&(i=`/files/ingress/iframe.html?ingress=${encodeURIComponent(i)}`),window.location.href=i;else if(f==="replace")i.indexOf("://")!==-1&&(window.location.href=i),$(i,{replace:!0});else return i}async _createIngressView(t,e,n){const i=(v,l)=>"setProperties"in v?h=>v.setProperties(h):h=>{for(const p of l)p in h&&(v[p]=h[p])},o=this.shadowRoot,{ui_mode:s,title:a,icon:c}=e;if(s==="custom"){const v={};for(const[l,h]of Object.entries(e.children||{})){const p=await this._getTargetUrl(h,n,!0);if(p){const{title:P,icon:E,ui_mode:H}=h;v[l]={url:p,title:P,icon:E,ui_mode:H}}}try{const{default:l}=await import(t),h=await l({url:t,title:a,icon:c,children:v,ensureHaElem:X});if(!(h instanceof HTMLElement))throw new Error("custom should export default async(config)=>HTMLElement");o.innerHTML="",o.appendChild(h),this._setProperties=i(h,["hass","narrow","route"]),this._setProperties(n)}catch(l){o.innerHTML=`
custom url[${t}] is invalid:
 ${l}
`}return}let u=` `,f=` diff --git a/custom_components/powercalc/__init__.py b/custom_components/powercalc/__init__.py index 06c20af..c52d591 100644 --- a/custom_components/powercalc/__init__.py +++ b/custom_components/powercalc/__init__.py @@ -212,7 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: global_config = get_global_configuration(hass, config) - discovery_manager = await create_discovery_manager_instance(hass, config, global_config) + discovery_manager = create_discovery_manager_instance(hass, config, global_config) hass.data[DOMAIN] = { DATA_DISCOVERY_MANAGER: discovery_manager, DOMAIN_CONFIG: global_config, @@ -225,6 +225,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DATA_ANALYTICS: {}, } + await discovery_manager.setup() + register_services(hass) await async_load_platform(hass, Platform.SELECT, DOMAIN, {}, config) @@ -235,8 +237,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: await repair_none_config_entries_issue(hass) - except Exception as e: # pragma: no cover - _LOGGER.error("problem while cleaning up None entities", exc_info=e) # pragma: no cover + except Exception: # pragma: no cover + _LOGGER.exception("problem while cleaning up None entities") # pragma: no cover await init_analytics(hass) @@ -272,7 +274,7 @@ async def init_analytics(hass: HomeAssistant) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) -async def create_discovery_manager_instance( +def create_discovery_manager_instance( hass: HomeAssistant, ha_config: ConfigType, global_powercalc_config: ConfigType, @@ -284,15 +286,13 @@ async def create_discovery_manager_instance( exclude_self_usage = discovery_config.get(CONF_EXCLUDE_SELF_USAGE, False) enable_autodiscovery = discovery_config.get(CONF_ENABLED, True) - manager = DiscoveryManager( + return DiscoveryManager( hass, ha_config, exclude_device_types=exclude_device_types, exclude_self_usage_profiles=exclude_self_usage, enabled=enable_autodiscovery, ) - await manager.setup() - return manager def register_services(hass: HomeAssistant) -> None: @@ -579,8 +579,8 @@ async def repair_none_config_entries_issue(hass: HomeAssistant) -> None: object.__setattr__(entry, "unique_id", unique_id) hass.config_entries._entries._index_entry(entry) # noqa: SLF001 await hass.config_entries.async_remove(entry.entry_id) - except Exception as e: # pragma: no cover - _LOGGER.error("problem while cleaning up None entities", exc_info=e) # pragma: no cover + except Exception: # pragma: no cover + _LOGGER.exception("problem while cleaning up None entities") # pragma: no cover def _notify_message( diff --git a/custom_components/powercalc/config_flow.py b/custom_components/powercalc/config_flow.py index e37b8f2..3b020e2 100644 --- a/custom_components/powercalc/config_flow.py +++ b/custom_components/powercalc/config_flow.py @@ -38,6 +38,7 @@ from .const import ( CONF_POWER, CONF_SENSOR_TYPE, CONF_STANDBY_POWER, + DISCOVERY_INTEGRATION_NAME, DISCOVERY_POWER_PROFILES, DISCOVERY_SOURCE_ENTITY, DOMAIN, @@ -400,6 +401,7 @@ class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN): self.source_entity_id = self.source_entity.entity_id self.name = self.source_entity.name + integration_name = discovery_info.pop(DISCOVERY_INTEGRATION_NAME, None) power_profiles: list[PowerProfile] = [] if DISCOVERY_POWER_PROFILES in discovery_info: @@ -410,7 +412,7 @@ class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN): self.sensor_config = discovery_info.copy() self.context["title_placeholders"] = { - "name": self.name or "", + "name": f"{self.name} - {integration_name}" if integration_name else self.name or "", "manufacturer": str(self.sensor_config.get(CONF_MANUFACTURER)), "model": str(self.sensor_config.get(CONF_MODEL)), } @@ -549,12 +551,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow): menu = [Step.BASIC_OPTIONS] if self.selected_sensor_type == SensorType.VIRTUAL_POWER: - if self.strategy and self.should_add_strategy_option_to_menu(): - strategy_step = STRATEGY_STEP_MAPPING[self.strategy] - menu.append(strategy_step) - if self.selected_profile: - menu.append(Step.LIBRARY_OPTIONS) - menu.append(Step.ADVANCED_OPTIONS) + menu.extend(self.build_virtual_power_menu()) if self.selected_sensor_type == SensorType.DAILY_ENERGY: menu.append(Step.DAILY_ENERGY) if self.selected_sensor_type == SensorType.REAL_POWER: @@ -570,6 +567,32 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow): return menu + def build_virtual_power_menu(self) -> list[Step]: + """Build the options menu entries specific to virtual power sensors.""" + menu: list[Step] = [] + if self.strategy and self.should_add_strategy_option_to_menu(): + menu.append(STRATEGY_STEP_MAPPING[self.strategy]) + if self.selected_profile: + menu.append(Step.LIBRARY_OPTIONS) + if self.should_add_select_device_to_menu(): + menu.append(Step.SELECT_DEVICE) + menu.append(Step.ADVANCED_OPTIONS) + return menu + + def should_add_select_device_to_menu(self) -> bool: + """Check whether the device selection should be added to the menu.""" + if not self.selected_profile or self.selected_profile.discovery_by not in [ + DiscoveryBy.DEVICE, + DiscoveryBy.CONFIG_ENTRY, + ]: + return False + + if self.selected_profile.discovery_by == DiscoveryBy.CONFIG_ENTRY: + # Only devices of the source config entry are selectable, so there must be something to choose from. + return len(self.flow_handlers[FlowType.LIBRARY].get_selectable_devices()) > 1 + + return True + def should_add_strategy_option_to_menu(self) -> bool: """Check whether the strategy option should be added to the menu.""" if not self.strategy or self.strategy not in STRATEGY_STEP_MAPPING: diff --git a/custom_components/powercalc/const.py b/custom_components/powercalc/const.py index 5c83ab3..474f82f 100644 --- a/custom_components/powercalc/const.py +++ b/custom_components/powercalc/const.py @@ -240,7 +240,9 @@ DEFAULT_UTILITY_METER_TYPES = [DAILY, WEEKLY, MONTHLY] DISCOVERY_SOURCE_ENTITY = "source_entity" DISCOVERY_POWER_PROFILES = "power_profiles" +DISCOVERY_INTEGRATION_NAME = "integration_name" DISCOVERY_TYPE = "discovery_type" +LIBRARY_DISCOVERY_IGNORED_DOMAINS = "discovery_ignored_domains" LIBRARY_URL = "https://library.powercalc.nl" API_URL = "https://api.powercalc.nl" diff --git a/custom_components/powercalc/device_binding.py b/custom_components/powercalc/device_binding.py index 8896453..a6e135a 100644 --- a/custom_components/powercalc/device_binding.py +++ b/custom_components/powercalc/device_binding.py @@ -31,6 +31,54 @@ def is_composite_device_id(hass: HomeAssistant, device_id: str) -> bool: return bool(is_composite(device_id)) +def get_related_device_ids(hass: HomeAssistant, device_id: str) -> set[str]: + """ + Return the IDs of all devices representing the same physical device, including `device_id` itself. + Devices are related when they were split off from the same legacy composite device, or when they + share any identifier or connection. Both happen when a single physical device is provided by more + than one config entry. + `device_id` may be the ID of a registered device, or of a legacy composite device. + """ + device_reg = device_registry.async_get(hass) + related = {device_id} + related.update(device.id for device in _get_composite_split_devices(device_reg, device_id)) + + device = device_reg.async_get(device_id) + if device is None: + return related + + sibling_devices = _get_composite_split_devices(device_reg, getattr(device, "composite_device_id", None)) + related.update(sibling_device.id for sibling_device in sibling_devices) + + # Only available in HA >=2026.8. On older versions devices sharing an identifier or connection + # were merged into a single device, so there is nothing to relate. + get_devices = getattr(device_reg, "async_get_devices", None) + if callable(get_devices): + related.update( + linked_device.id + for linked_device in get_devices(identifiers=device.identifiers, connections=device.connections) + ) + + return related + + +def _get_composite_split_devices( + device_reg: device_registry.DeviceRegistry, + composite_device_id: str | None, +) -> list[DeviceEntry]: + """ + Return the devices a legacy composite device was split into. + Returns an empty list when the ID does not identify a composite device, or when running on + HA <2026.8, which does not split composite devices at all. + """ + if not composite_device_id: + return [] + get_split_devices = getattr(device_reg, "async_get_devices_for_composite_device_id", None) + if not callable(get_split_devices): + return [] # pragma: no cover + return list(get_split_devices(composite_device_id)) + + def get_config_entry_ids(device: DeviceEntry) -> set[str]: """ Return the config entry IDs a device belongs to. @@ -39,7 +87,7 @@ def get_config_entry_ids(device: DeviceEntry) -> set[str]: """ if _HAS_SINGLE_CONFIG_ENTRY: return {device.config_entry_id} - return set(getattr(device, "config_entries", set())) + return set(getattr(device, "config_entries", set())) # pragma: no cover def get_first_device_for_config_entry(hass: HomeAssistant, config_entry_id: str) -> DeviceEntry | None: @@ -56,6 +104,18 @@ def get_devices_for_config_entry(hass: HomeAssistant, config_entry_id: str) -> l ] +def get_related_devices(hass: HomeAssistant, device_id: str) -> list[DeviceEntry]: + """Return all non-composite devices belonging to the same config entry as the given device.""" + device = device_registry.async_get(hass).async_get(device_id) + if device is None: + return [] + + devices: dict[str, DeviceEntry] = {} + for config_entry_id in get_config_entry_ids(device): + devices.update({related.id: related for related in get_devices_for_config_entry(hass, config_entry_id)}) + return list(devices.values()) + + def attach_configured_device_entry( hass: HomeAssistant, sensor_config: ConfigType, diff --git a/custom_components/powercalc/diagnostics.py b/custom_components/powercalc/diagnostics.py index e30e4e4..46039ab 100644 --- a/custom_components/powercalc/diagnostics.py +++ b/custom_components/powercalc/diagnostics.py @@ -49,6 +49,6 @@ async def get_yaml_configuration(hass: HomeAssistant) -> ConfigType: try: yaml_config = await async_integration_yaml_config(hass, DOMAIN) return yaml_config.get(DOMAIN, {}) # type: ignore - except Exception as err: # noqa: BLE001 # pragma: nocover - _LOGGER.error("Could not retrieve YAML config: %s", err) + except Exception: # pragma: nocover + _LOGGER.exception("Could not retrieve YAML config") return {} diff --git a/custom_components/powercalc/discovery.py b/custom_components/powercalc/discovery.py index e2ba32a..6ee382d 100644 --- a/custom_components/powercalc/discovery.py +++ b/custom_components/powercalc/discovery.py @@ -1,4 +1,4 @@ -from collections.abc import Awaitable, Callable +from collections.abc import Callable from datetime import datetime, timedelta from enum import StrEnum import logging @@ -8,14 +8,15 @@ from typing import Any, TypeVar from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER, ConfigEntry -from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_UNIQUE_ID -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.const import CONF_DEVICE, CONF_ENTITY_ID, CONF_PLATFORM, CONF_UNIQUE_ID +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import discovery_flow import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import EntityCategory import homeassistant.helpers.entity_registry as er from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import IntegrationNotFound, async_get_integration from .common import SourceEntity, create_source_entity from .const import ( @@ -24,6 +25,7 @@ from .const import ( CONF_MODEL, CONF_SENSORS, DATA_DISCOVERY_MANAGER, + DISCOVERY_INTEGRATION_NAME, DISCOVERY_POWER_PROFILES, DISCOVERY_SOURCE_ENTITY, DOMAIN, @@ -31,7 +33,12 @@ from .const import ( MANUFACTURER_WLED, CalculationStrategy, ) -from .device_binding import get_config_entry_ids, get_first_device_for_config_entry, is_composite_device_id +from .device_binding import ( + get_config_entry_ids, + get_first_device_for_config_entry, + get_related_device_ids, + is_composite_device_id, +) from .group_include.filter import ( CategoryFilter, CompositeFilter, @@ -180,6 +187,8 @@ class DiscoveryManager: continue # pragma: no cover self.initialized_flows.add(entry.unique_id) + self._initialize_configured_device(entry) + entity_id = entry.data.get(CONF_ENTITY_ID) if not entity_id or entity_id == DUMMY_ENTITY_ID: continue @@ -189,6 +198,23 @@ class DiscoveryManager: self.initialized_flows.add(f"pc_{entity.device_entry.id}") self.initialized_flows.add(entity_id) + def _initialize_configured_device(self, entry: ConfigEntry) -> None: + """Mark the device a config entry was setup for as already setup. + + A single physical device can be represented by several device registry entries. HA >=2026.8 + splits devices belonging to multiple config entries into one device per entry, so the entry + may hold the composite device ID, which no longer resolves to a registered device, or one of + the split devices after the user resolved the composite device repair. Devices can also be + registered by several integrations, in which case they share identifiers or connections. + All of them are the device the user already configured, so none should be discovered again. + """ + device_id = entry.data.get(CONF_DEVICE) + if not device_id: + return + + for related_device_id in get_related_device_ids(self.hass, str(device_id)): + self.initialized_flows.add(f"pc_{related_device_id}") + def remove_initialized_flow(self, entry: ConfigEntry) -> None: """Remove a flow from the initialized flows.""" if entry.unique_id: @@ -199,17 +225,22 @@ class DiscoveryManager: async def perform_discovery( self, - source_provider: Callable[[], Awaitable[list[_DiscoverySourceT]]], - source_creator: Callable[[_DiscoverySourceT], Awaitable[SourceEntity]], + source_provider: Callable[[], list[_DiscoverySourceT]], + source_creator: Callable[[_DiscoverySourceT], SourceEntity], discovery_type: DiscoveryBy, ) -> None: """Generalized discovery procedure for entities and devices.""" - for source in await source_provider(): + library = await self._get_library() + ignored_domains = library.discovery_ignored_domains + for source in source_provider(): log_identifier = str( getattr(source, "entity_id", getattr(source, "id", getattr(source, "entry_id", "unknown"))), ) try: - source_entity = await source_creator(source) + if self._is_domain_ignored(source, ignored_domains): + _LOGGER.debug("%s: Integration domain is ignored, skipping discovery", log_identifier) + continue + source_entity = source_creator(source) model_info = await self.extract_model_info_from_device_info( source_entity.entity_entry or source_entity.device_entry, ) @@ -235,13 +266,19 @@ class DiscoveryManager: ) continue - self._init_entity_discovery(model_info, unique_id, source_entity, log_identifier, power_profiles, {}) - except Exception as err: # noqa: BLE001 - _LOGGER.error( - "%s: Error during %s discovery: %s", + await self._init_entity_discovery( + model_info, + unique_id, + source_entity, + log_identifier, + power_profiles, + {}, + ) + except Exception: + _LOGGER.exception( + "%s: Error during %s discovery", log_identifier, discovery_type, - err, ) async def discover_entity( @@ -256,12 +293,12 @@ class DiscoveryManager: return await self.find_power_profiles(model_info, source_entity, discovery_type) - async def create_entity_source(self, entity_entry: er.RegistryEntry) -> SourceEntity: + def create_entity_source(self, entity_entry: er.RegistryEntry) -> SourceEntity: """Create SourceEntity for an entity.""" return create_source_entity(entity_entry.entity_id, self.hass) @staticmethod - async def create_device_source(device_entry: dr.DeviceEntry) -> SourceEntity: + def create_device_source(device_entry: dr.DeviceEntry) -> SourceEntity: """Create SourceEntity for a device.""" return SourceEntity( object_id=device_entry.name_by_user or device_entry.name or "", @@ -271,7 +308,7 @@ class DiscoveryManager: device_entry=device_entry, ) - async def create_config_entry_source(self, config_entry: ConfigEntry) -> SourceEntity: + def create_config_entry_source(self, config_entry: ConfigEntry) -> SourceEntity: """Create a source representing all devices belonging to a config entry.""" device_entry = get_first_device_for_config_entry(self.hass, config_entry.entry_id) return SourceEntity( @@ -341,6 +378,23 @@ class DiscoveryManager: return power_profiles + def _is_domain_ignored(self, source: _DiscoverySourceT, ignored_domains: set[str]) -> bool: + """Return whether a discovery source belongs to a globally ignored integration domain.""" + if not ignored_domains: + return False + + if isinstance(source, er.RegistryEntry): + return source.platform in ignored_domains + if isinstance(source, ConfigEntry): + return source.domain in ignored_domains + + config_entry_id = next(iter(source.config_entries), None) + if config_entry_id is None: # pragma: no cover + return False + + config_entry = self.hass.config_entries.async_get_entry(config_entry_id) + return config_entry is not None and config_entry.domain in ignored_domains + async def init_wled_flow(self, model_info: ModelInfo, source_entity: SourceEntity) -> None: """Initialize the discovery flow for a WLED light.""" if DeviceType.LIGHT in self._exclude_device_types: @@ -358,7 +412,7 @@ class DiscoveryManager: ) return - self._init_entity_discovery( + await self._init_entity_discovery( model_info, unique_id, source_entity, @@ -379,7 +433,7 @@ class DiscoveryManager: and not re.search("master|segment", str(entity_entry.entity_id), flags=re.IGNORECASE) ) - async def get_entities(self) -> list[er.RegistryEntry]: + def get_entities(self) -> list[er.RegistryEntry]: """Get all entities from entity registry which qualifies for discovery.""" def _check_already_configured(entity: er.RegistryEntry) -> bool: @@ -409,7 +463,7 @@ class DiscoveryManager: ) return get_filtered_entity_list(self.hass, NotFilter(entity_filter)) - async def get_devices(self) -> list[dr.DeviceEntry]: + def get_devices(self) -> list[dr.DeviceEntry]: """Fetch device entries.""" return [ device @@ -417,7 +471,7 @@ class DiscoveryManager: if not is_composite_device_id(self.hass, device.id) ] - async def get_config_entries(self) -> list[ConfigEntry]: + def get_config_entries(self) -> list[ConfigEntry]: """Fetch config entries which have at least one non-composite device.""" config_entry_ids = { config_entry_id @@ -518,8 +572,7 @@ class DiscoveryManager: return await self.get_model_information_from_device(device_entry) - @callback - def _init_entity_discovery( + async def _init_entity_discovery( self, model_info: ModelInfo, unique_id: str, @@ -532,6 +585,7 @@ class DiscoveryManager: discovery_data: dict[str, Any] = { CONF_ENTITY_ID: source_entity.entity_id, + DISCOVERY_INTEGRATION_NAME: await self._get_integration_name(source_entity), DISCOVERY_SOURCE_ENTITY: source_entity, CONF_UNIQUE_ID: unique_id, } @@ -564,6 +618,27 @@ class DiscoveryManager: data=discovery_data, ) + async def _get_integration_name(self, source_entity: SourceEntity) -> str | None: + """Return the display name of the integration which owns the discovery source.""" + config_entry_id = source_entity.config_entry_id + if config_entry_id is None and source_entity.entity_entry: + config_entry_id = source_entity.entity_entry.config_entry_id + if config_entry_id is None and source_entity.device_entry: + config_entry_id = next(iter(get_config_entry_ids(source_entity.device_entry)), None) + if config_entry_id is None: + return None + + config_entry = self.hass.config_entries.async_get_entry(config_entry_id) + if config_entry is None: + return None + + try: + integration = await async_get_integration(self.hass, config_entry.domain) + except IntegrationNotFound: + _LOGGER.debug("Unable to resolve integration name for domain %s", config_entry.domain) + return None + return integration.name + @property def status(self) -> DiscoveryStatus: """Get the discovery status""" diff --git a/custom_components/powercalc/flow_helper/flows/library.py b/custom_components/powercalc/flow_helper/flows/library.py index 5fb56a2..5f3795a 100644 --- a/custom_components/powercalc/flow_helper/flows/library.py +++ b/custom_components/powercalc/flow_helper/flows/library.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_DEVICE from homeassistant.helpers import selector, translation +from homeassistant.helpers.device_registry import DeviceEntry import voluptuous as vol from custom_components.powercalc.const import ( @@ -20,7 +21,7 @@ from custom_components.powercalc.const import ( LIBRARY_URL, CalculationStrategy, ) -from custom_components.powercalc.device_binding import get_devices_for_config_entry +from custom_components.powercalc.device_binding import get_devices_for_config_entry, get_related_devices from custom_components.powercalc.discovery import ( get_power_profile_by_source_device, get_power_profile_by_source_entity, @@ -225,33 +226,51 @@ class LibraryFlow: async def async_step_select_device(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Ask which device should receive the entities created for a config-entry profile.""" - assert self.flow.source_entity is not None - assert self.flow.source_entity.config_entry_id is not None - devices = get_devices_for_config_entry(self.flow.hass, self.flow.source_entity.config_entry_id) return await self.flow.handle_form_step( PowercalcFormStep( step=Step.SELECT_DEVICE, - schema=vol.Schema( - { - vol.Required(CONF_DEVICE): selector.SelectSelector( - selector.SelectSelectorConfig( - options=[ - selector.SelectOptionDict( - value=device.id, - label=device.name_by_user or device.name or device.id, - ) - for device in devices - ], - mode=selector.SelectSelectorMode.DROPDOWN, - ), - ), - }, - ), + schema=self.build_select_device_schema(), next_step=Step.POST_LIBRARY, ), user_input, ) + def build_select_device_schema(self) -> vol.Schema: + """Build the schema to select the device the Powercalc entities should be linked to.""" + discovery_by = self.flow.selected_profile.discovery_by if self.flow.selected_profile else DiscoveryBy.ENTITY + + if discovery_by == DiscoveryBy.CONFIG_ENTRY: + # The entities can only be linked to one of the devices of the source config entry. + return vol.Schema( + { + vol.Required(CONF_DEVICE): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict( + value=device.id, + label=device.name_by_user or device.name or device.id, + ) + for device in self.get_selectable_devices() + ], + mode=selector.SelectSelectorMode.DROPDOWN, + ), + ), + }, + ) + + return vol.Schema({vol.Required(CONF_DEVICE): selector.DeviceSelector()}) + + def get_selectable_devices(self) -> list[DeviceEntry]: + """Return the devices belonging to the same config entry as the source of the profile.""" + config_entry_id = self.flow.source_entity.config_entry_id if self.flow.source_entity else None + if config_entry_id: + return get_devices_for_config_entry(self.flow.hass, config_entry_id) + + # The options flow has no reference to the source config entry anymore, + # so resolve the candidates from the currently configured device instead. + device_id: str | None = self.flow.sensor_config.get(CONF_DEVICE) + return get_related_devices(self.flow.hass, device_id) if device_id else [] + async def _async_next_strategy_step(self, profile: PowerProfile) -> ConfigFlowResult | None: """Return the next step needed to configure the calculation strategy, or None when nothing is left to ask.""" handled_steps = self.flow.handled_steps @@ -641,6 +660,14 @@ class LibraryOptionsFlow(LibraryFlow): super().__init__(flow) self.flow: PowercalcOptionsFlow = flow + async def async_step_select_device(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + """Change the device the Powercalc entities are linked to.""" + return await self.flow.async_handle_options_step( + user_input, + self.build_select_device_schema(), + Step.SELECT_DEVICE, + ) + async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the basic options flow.""" self.flow.is_library_flow = True diff --git a/custom_components/powercalc/manifest.json b/custom_components/powercalc/manifest.json index ef7c1ee..8276bca 100644 --- a/custom_components/powercalc/manifest.json +++ b/custom_components/powercalc/manifest.json @@ -22,5 +22,5 @@ "requirements": [ "numpy>=1.21.1" ], - "version": "v1.24.0" + "version": "v1.24.1" } \ No newline at end of file diff --git a/custom_components/powercalc/power_profile/library.py b/custom_components/powercalc/power_profile/library.py index d566aab..53e9c27 100644 --- a/custom_components/powercalc/power_profile/library.py +++ b/custom_components/powercalc/power_profile/library.py @@ -52,6 +52,11 @@ class ProfileLibrary: async def initialize(self) -> None: await self._loader.initialize() + @property + def discovery_ignored_domains(self) -> set[str]: + """Get integration domains globally excluded from discovery.""" + return self._loader.get_discovery_ignored_domains() + @staticmethod @singleton("powercalc_library") async def factory(hass: HomeAssistant) -> ProfileLibrary: diff --git a/custom_components/powercalc/power_profile/loader/composite.py b/custom_components/powercalc/power_profile/loader/composite.py index 059b537..2a6c6d0 100644 --- a/custom_components/powercalc/power_profile/loader/composite.py +++ b/custom_components/powercalc/power_profile/loader/composite.py @@ -15,6 +15,10 @@ class CompositeLoader(Loader): for loader in self.loaders: await loader.initialize() + def get_discovery_ignored_domains(self) -> set[str]: + """Get all integration domains excluded by the combined libraries.""" + return {domain for loader in self.loaders for domain in loader.get_discovery_ignored_domains()} + async def get_manufacturer_listing( self, device_types: set[DeviceType] | None, diff --git a/custom_components/powercalc/power_profile/loader/local.py b/custom_components/powercalc/power_profile/loader/local.py index f91a739..0597090 100644 --- a/custom_components/powercalc/power_profile/loader/local.py +++ b/custom_components/powercalc/power_profile/loader/local.py @@ -25,6 +25,10 @@ class LocalLoader(Loader): if not self._is_custom_directory: await self._hass.async_add_executor_job(self._load_custom_library) + def get_discovery_ignored_domains(self) -> set[str]: + """Local profile directories do not provide global library metadata.""" + return set() + async def get_manufacturer_listing( self, device_types: set[DeviceType] | None, diff --git a/custom_components/powercalc/power_profile/loader/protocol.py b/custom_components/powercalc/power_profile/loader/protocol.py index c4fa071..77ae7f2 100644 --- a/custom_components/powercalc/power_profile/loader/protocol.py +++ b/custom_components/powercalc/power_profile/loader/protocol.py @@ -7,6 +7,9 @@ class Loader(Protocol): async def initialize(self) -> None: """Initialize the loader.""" + def get_discovery_ignored_domains(self) -> set[str]: + """Get integration domains excluded from discovery.""" + async def get_manufacturer_listing( self, device_types: set[DeviceType] | None, diff --git a/custom_components/powercalc/power_profile/loader/remote.py b/custom_components/powercalc/power_profile/loader/remote.py index bd0cf69..df3c95d 100644 --- a/custom_components/powercalc/power_profile/loader/remote.py +++ b/custom_components/powercalc/power_profile/loader/remote.py @@ -16,7 +16,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.loader import async_get_integration -from custom_components.powercalc.const import API_URL, BUILT_IN_LIBRARY_DIR, DOMAIN +from custom_components.powercalc.const import ( + API_URL, + BUILT_IN_LIBRARY_DIR, + DOMAIN, + LIBRARY_DISCOVERY_IGNORED_DOMAINS, +) from custom_components.powercalc.helpers import async_cache, clear_async_cache from custom_components.powercalc.power_profile.error import LibraryLoadingError, ProfileDownloadError from custom_components.powercalc.power_profile.loader.protocol import Loader @@ -80,6 +85,10 @@ class RemoteLoader(Loader): for manufacturer in manufacturers: self._index_manufacturer(manufacturer, powercalc_version) + def get_discovery_ignored_domains(self) -> set[str]: + """Get integration domains excluded from discovery by library metadata.""" + return set(self.library_contents.get(LIBRARY_DISCOVERY_IGNORED_DOMAINS, [])) + def _index_manufacturer(self, manufacturer: LibraryManufacturer, powercalc_version: AwesomeVersion) -> None: """Register a manufacturer, its aliases and all of its supported models in the lookup tables.""" manufacturer_name = str(manufacturer.get("dir_name")) diff --git a/custom_components/powercalc/sensor.py b/custom_components/powercalc/sensor.py index 63c2c17..09bfbce 100644 --- a/custom_components/powercalc/sensor.py +++ b/custom_components/powercalc/sensor.py @@ -538,11 +538,8 @@ async def handle_nested_entity( ), ) entities_to_add.extend_items(child_entities) - except SensorConfigurationError as exception: - _LOGGER.error( - "Group state might be misbehaving because there was an error with an entity", - exc_info=exception, - ) + except SensorConfigurationError: + _LOGGER.exception("Group state might be misbehaving because there was an error with an entity") async def add_discovered_entities( diff --git a/custom_components/powercalc/sensors/group/custom.py b/custom_components/powercalc/sensors/group/custom.py index 3919fc4..3338094 100644 --- a/custom_components/powercalc/sensors/group/custom.py +++ b/custom_components/powercalc/sensors/group/custom.py @@ -938,8 +938,8 @@ class PreviousStateStore: instance.states[group] = { entity_id: State.from_dict(json_state) for (entity_id, json_state) in entities.items() } - except HomeAssistantError as exc: # pragma: no cover - _LOGGER.error("Error loading previous energy sensor states", exc_info=exc) + except HomeAssistantError: # pragma: no cover + _LOGGER.exception("Error loading previous energy sensor states") instance.async_setup_dump() @@ -983,8 +983,8 @@ class PreviousStateStore: """Save the current states to storage.""" try: await self.store.async_save(self.states) - except HomeAssistantError as exc: # pragma: no cover - _LOGGER.error("Error saving current states", exc_info=exc) + except HomeAssistantError: # pragma: no cover + _LOGGER.exception("Error saving current states") @callback def async_setup_dump(self) -> None: diff --git a/custom_components/powercalc/translations/cs.json b/custom_components/powercalc/translations/cs.json index abe0855..6aa607a 100644 --- a/custom_components/powercalc/translations/cs.json +++ b/custom_components/powercalc/translations/cs.json @@ -971,6 +971,7 @@ "playbook": "Možnosti playbooku", "multi_switch": "Možnosti vícenásobného spínače", "real_power": "Možnosti skutečného výkonu", + "select_device": "Zařízení", "utility_meter_options": "Možnosti měřičů spotřeby", "wled": "Možnosti WLED", "cost_options": "Možnosti nákladů" @@ -980,6 +981,16 @@ "title": "Možnosti knihovny", "description": "Aktuálně je vybrán následující profil knihovny: \n výrobce: {manufacturer}\n model: {model}\n\nPokud chcete profil změnit, klikněte na Další." }, + "select_device": { + "data": { + "device": "Zařízení" + }, + "data_description": { + "device": "Zařízení, ke kterému mají být entity Powercalc připojeny" + }, + "description": "Vyberte zařízení, ke kterému má být senzor Powercalc připojen.", + "title": "Vyberte zařízení" + }, "linear": { "title": "Lineární možnosti", "data": { diff --git a/custom_components/powercalc/translations/da.json b/custom_components/powercalc/translations/da.json index 3f3b3e3..ae55b2c 100644 --- a/custom_components/powercalc/translations/da.json +++ b/custom_components/powercalc/translations/da.json @@ -971,6 +971,7 @@ "playbook": "Playbook-indstillinger", "multi_switch": "Indstillinger for multikontakt", "real_power": "Indstillinger for fysisk effekt", + "select_device": "Enhed", "utility_meter_options": "Indstillinger for forbrugsmålere", "wled": "WLED-indstillinger", "cost_options": "Omkostningsindstillinger" @@ -980,6 +981,16 @@ "title": "Biblioteksindstillinger", "description": "I øjeblikket er følgende biblioteksprofil valgt: \n producent: {manufacturer}\n model: {model}\n\nHvis du vil ændre profilen, skal du klikke på næste." }, + "select_device": { + "data": { + "device": "Enhed" + }, + "data_description": { + "device": "Den enhed, som Powercalc-entiteterne skal knyttes til" + }, + "description": "Vælg den enhed, som Powercalc-sensoren skal knyttes til.", + "title": "Vælg enhed" + }, "linear": { "title": "Lineære indstillinger", "data": { diff --git a/custom_components/powercalc/translations/de.json b/custom_components/powercalc/translations/de.json index d255051..b48653b 100644 --- a/custom_components/powercalc/translations/de.json +++ b/custom_components/powercalc/translations/de.json @@ -971,6 +971,7 @@ "playbook": "Playbook-Optionen", "multi_switch": "Mehrfachschalter-Optionen", "real_power": "Optionen für reale Leistung", + "select_device": "Gerät", "utility_meter_options": "Verbrauchszähler-Optionen", "wled": "WLED Einstellung", "cost_options": "Kostenoptionen" @@ -980,6 +981,16 @@ "title": "Bibliotheksoptionen", "description": "Derzeit ist folgendes Bibliotheksprofil ausgewählt: \n Hersteller: {manufacturer}\n Modell: {model}\n\nWenn du das Profil ändern möchtest, klicke auf Weiter." }, + "select_device": { + "data": { + "device": "Gerät" + }, + "data_description": { + "device": "Das Gerät, mit dem die Powercalc-Entitäten verknüpft werden sollen" + }, + "description": "Wählen Sie das Gerät aus, mit dem der Powercalc-Sensor verknüpft werden soll.", + "title": "Gerät auswählen" + }, "linear": { "title": "Lineare Optionen", "data": { diff --git a/custom_components/powercalc/translations/en.json b/custom_components/powercalc/translations/en.json index d7bf814..1fd3b21 100644 --- a/custom_components/powercalc/translations/en.json +++ b/custom_components/powercalc/translations/en.json @@ -971,6 +971,7 @@ "playbook": "Playbook options", "multi_switch": "Multi switch options", "real_power": "Real power options", + "select_device": "Device", "utility_meter_options": "Utility meter options", "wled": "WLED options", "cost_options": "Cost options" @@ -980,6 +981,16 @@ "title": "Library options", "description": "Currently the following library profile is selected: \n manufacturer: {manufacturer}\n model: {model}\n\nIf you want to change the profile, click next." }, + "select_device": { + "data": { + "device": "Device" + }, + "data_description": { + "device": "The device the Powercalc entities should be linked to" + }, + "description": "Select the device to which the Powercalc sensor should be linked.", + "title": "Select device" + }, "linear": { "title": "Linear options", "data": { diff --git a/custom_components/powercalc/translations/es.json b/custom_components/powercalc/translations/es.json index 57eed79..a863c3f 100644 --- a/custom_components/powercalc/translations/es.json +++ b/custom_components/powercalc/translations/es.json @@ -971,6 +971,7 @@ "playbook": "Opciones de Reproducciones", "multi_switch": "Opciones multi-interruptor", "real_power": "Opciones de potencia real", + "select_device": "Dispositivo", "utility_meter_options": "Opciones de contador eléctrico", "wled": "Opciones de WLED", "cost_options": "Opciones de costos" @@ -980,6 +981,16 @@ "title": "Opciones de biblioteca", "description": "Actualmente se ha seleccionado el siguiente perfil de biblioteca: \n fabricante: {manufacturer}\n modelo: {model}\n\nSi quieres cambiar el perfil, haz clic a continuación." }, + "select_device": { + "data": { + "device": "Dispositivo" + }, + "data_description": { + "device": "El dispositivo al que se deben vincular las entidades de Powercalc" + }, + "description": "Seleccione el dispositivo al que se debe vincular el sensor de Powercalc.", + "title": "Seleccionar dispositivo" + }, "linear": { "title": "Opciones lineales", "data": { diff --git a/custom_components/powercalc/translations/fi.json b/custom_components/powercalc/translations/fi.json index 7d45a49..928d311 100644 --- a/custom_components/powercalc/translations/fi.json +++ b/custom_components/powercalc/translations/fi.json @@ -971,6 +971,7 @@ "playbook": "Playbookin vaihtoehdot", "multi_switch": "Useita kytkinvaihtoehtoja", "real_power": "Todelliset tehovaihtoehdot", + "select_device": "Laite", "utility_meter_options": "Kulutusmittarin asetukset", "wled": "WLED vaihtoehdot", "cost_options": "Kustannusvaihtoehdot" @@ -980,6 +981,16 @@ "title": "Kirjaston vaihtoehdot", "description": "Tällä hetkellä on valittuna seuraava kirjastoprofiili: \n valmistaja: {manufacturer}\n malli: {model}\n\nJos haluat vaihtaa profiilia, napsauta Seuraava." }, + "select_device": { + "data": { + "device": "Laite" + }, + "data_description": { + "device": "Laite, johon Powercalc-entiteetit liitetään" + }, + "description": "Valitse laite, johon Powercalc-anturi liitetään.", + "title": "Valitse laite" + }, "linear": { "title": "Lineaariset vaihtoehdot", "data": { diff --git a/custom_components/powercalc/translations/fr.json b/custom_components/powercalc/translations/fr.json index 0b587b4..524e254 100644 --- a/custom_components/powercalc/translations/fr.json +++ b/custom_components/powercalc/translations/fr.json @@ -971,6 +971,7 @@ "playbook": "Options du playbook", "multi_switch": "Options du multi-interrupteur", "real_power": "Options de puissance réelle", + "select_device": "Appareil", "utility_meter_options": "Options des compteurs de services publics", "wled": "Options WLED", "cost_options": "Options de coût" @@ -980,6 +981,16 @@ "title": "Options de bibliothèque", "description": "Le profil de bibliothèque suivant est actuellement sélectionné : \n fabricant : {manufacturer}\n modèle : {model}\n\nSi vous souhaitez changer de profil, cliquez sur Suivant." }, + "select_device": { + "data": { + "device": "Appareil" + }, + "data_description": { + "device": "L’appareil auquel les entités Powercalc doivent être associées" + }, + "description": "Sélectionnez l’appareil auquel le capteur Powercalc doit être associé.", + "title": "Sélectionner un appareil" + }, "linear": { "title": "Options linéaires", "data": { diff --git a/custom_components/powercalc/translations/hu.json b/custom_components/powercalc/translations/hu.json index 317b524..f2f6284 100644 --- a/custom_components/powercalc/translations/hu.json +++ b/custom_components/powercalc/translations/hu.json @@ -971,6 +971,7 @@ "playbook": "Playbook beállításai", "multi_switch": "Többkapcsolós beállítások", "real_power": "Valós teljesítmény beállításai", + "select_device": "Eszköz", "utility_meter_options": "Közüzemi mérő beállításai", "wled": "WLED beállításai", "cost_options": "Költségbeállítások" @@ -980,6 +981,16 @@ "title": "Könyvtári beállítások", "description": "Jelenleg a következő könyvtári profil van kiválasztva: \n gyártó: {manufacturer}\n modell: {model}\n\nHa módosítani szeretné a profilt, kattintson a Tovább gombra." }, + "select_device": { + "data": { + "device": "Eszköz" + }, + "data_description": { + "device": "Az eszköz, amelyhez a Powercalc-entitásokat hozzá kell rendelni" + }, + "description": "Válassza ki azt az eszközt, amelyhez a Powercalc-érzékelőt hozzá kell rendelni.", + "title": "Eszköz kiválasztása" + }, "linear": { "title": "Lineáris beállítások", "data": { diff --git a/custom_components/powercalc/translations/it.json b/custom_components/powercalc/translations/it.json index 74cb3fc..0e5c2d6 100644 --- a/custom_components/powercalc/translations/it.json +++ b/custom_components/powercalc/translations/it.json @@ -971,6 +971,7 @@ "playbook": "Opzioni del playbook", "multi_switch": "Opzioni multiinterruttore", "real_power": "Opzioni di potenza reale", + "select_device": "Dispositivo", "utility_meter_options": "Opzioni del contatore di utilità", "wled": "Opzioni WLED", "cost_options": "Opzioni di costo" @@ -980,6 +981,16 @@ "title": "Opzioni della libreria", "description": "Attualmente è selezionato il seguente profilo libreria: \n produttore: {manufacturer}\n modello: {model}\n\nSe desideri modificare il profilo, fai clic su Avanti." }, + "select_device": { + "data": { + "device": "Dispositivo" + }, + "data_description": { + "device": "Il dispositivo a cui devono essere collegate le entità Powercalc" + }, + "description": "Seleziona il dispositivo a cui deve essere collegato il sensore Powercalc.", + "title": "Seleziona dispositivo" + }, "linear": { "title": "Opzioni lineari", "data": { diff --git a/custom_components/powercalc/translations/nb.json b/custom_components/powercalc/translations/nb.json index 7bf28fd..aa92c52 100644 --- a/custom_components/powercalc/translations/nb.json +++ b/custom_components/powercalc/translations/nb.json @@ -971,6 +971,7 @@ "playbook": "Playbook-alternativer", "multi_switch": "Alternativer for flere brytere", "real_power": "Innstillinger for målt effekt", + "select_device": "Enhet", "utility_meter_options": "Innstillinger for forbruksmåler", "wled": "WLED-innstillinger", "cost_options": "Kostnadsalternativer" @@ -980,6 +981,16 @@ "title": "Alternativer for bibliotek", "description": "For øyeblikket er følgende bibliotekprofil valgt: \n produsent: {manufacturer}\n modell: {model}\n\nHvis du vil endre profilen, klikker du på neste." }, + "select_device": { + "data": { + "device": "Enhet" + }, + "data_description": { + "device": "Enheten Powercalc-entitetene skal knyttes til" + }, + "description": "Velg enheten som Powercalc-sensoren skal knyttes til.", + "title": "Velg enhet" + }, "linear": { "title": "Lineære alternativer", "data": { diff --git a/custom_components/powercalc/translations/nl.json b/custom_components/powercalc/translations/nl.json index 2f32992..8a75aa8 100644 --- a/custom_components/powercalc/translations/nl.json +++ b/custom_components/powercalc/translations/nl.json @@ -10,7 +10,7 @@ "already_configured": "Sensor is reeds geconfigureerd, specifieer een uniek ID" }, "error": { - "cost_price_mandatory": "U moet een energieprijs of een energieprijssensor aanleveren", + "cost_price_mandatory": "U moet een energieprijs of een sensor van de energieprijs leveren", "daily_energy_mandatory": "Je moet minimaal waarde of waarde template opgeven", "entity_mandatory": "Je moet verplicht een entiteit opgeven voor iedere andere strategie dan playbook", "fixed_mandatory": "Je dient minimaal een van de volgende velden te definiëren: Vermogen, Vermogen template of Vermogen per status", @@ -971,6 +971,7 @@ "playbook": "Playbook opties", "multi_switch": "Multi switch opties", "real_power": "Opties voor werkelijk vermogen", + "select_device": "Apparaat", "utility_meter_options": "Nutsmeter opties", "wled": "WLED opties", "cost_options": "Kosten opties" @@ -980,6 +981,16 @@ "title": "Bibliotheek opties", "description": "Momenteel is het huidige profiel geselecteerd: \nfabrikant: {manufacturer}\nmodel: {model}\n\nAls u het profiel wilt wijzigen, klikt u op volgende." }, + "select_device": { + "data": { + "device": "Apparaat" + }, + "data_description": { + "device": "Het apparaat waaraan de Powercalc-entiteiten moeten worden gekoppeld" + }, + "description": "Selecteer het apparaat waaraan de Powercalc-sensor moet worden gekoppeld.", + "title": "Apparaat selecteren" + }, "linear": { "title": "Lineaire opties", "data": { diff --git a/custom_components/powercalc/translations/pl.json b/custom_components/powercalc/translations/pl.json index 24aca35..5be6c7a 100644 --- a/custom_components/powercalc/translations/pl.json +++ b/custom_components/powercalc/translations/pl.json @@ -971,6 +971,7 @@ "playbook": "Opcje playbook", "multi_switch": "Opcje wieloprzełącznika", "real_power": "Opcje rzeczywistej mocy", + "select_device": "Urządzenie", "utility_meter_options": "Opcje licznika mediów", "wled": "Opcje WLED", "cost_options": "Opcje kosztów" @@ -980,6 +981,16 @@ "title": "Opcje biblioteki", "description": "Obecnie wybrany jest następujący profil biblioteki: \n producent: {manufacturer}\n model: {model}\n\nJeśli chcesz zmienić profil, kliknij dalej." }, + "select_device": { + "data": { + "device": "Urządzenie" + }, + "data_description": { + "device": "Urządzenie, z którym mają zostać powiązane encje Powercalc" + }, + "description": "Wybierz urządzenie, z którym ma zostać powiązany czujnik Powercalc.", + "title": "Wybierz urządzenie" + }, "linear": { "title": "Opcje liniowe", "data": { diff --git a/custom_components/powercalc/translations/pt-BR.json b/custom_components/powercalc/translations/pt-BR.json index 0574e82..ace07d1 100644 --- a/custom_components/powercalc/translations/pt-BR.json +++ b/custom_components/powercalc/translations/pt-BR.json @@ -971,6 +971,7 @@ "playbook": "Opções de roteiro", "multi_switch": "Opções de multi-interruptor", "real_power": "Opções de potência real", + "select_device": "Dispositivo", "utility_meter_options": "Opções do medidor de utilidade", "wled": "Opções de WLED", "cost_options": "Opções de custo" @@ -980,6 +981,16 @@ "title": "Opções da biblioteca", "description": "Atualmente o seguinte perfil de biblioteca esta selecionado: \n fabricante {manufacturer}\n modelo: {model}\n\nSe você deseja alterar o perfil, clique em próximo." }, + "select_device": { + "data": { + "device": "Dispositivo" + }, + "data_description": { + "device": "O dispositivo ao qual as entidades do Powercalc devem ser vinculadas" + }, + "description": "Selecione o dispositivo ao qual o sensor do Powercalc deve ser vinculado.", + "title": "Selecionar dispositivo" + }, "linear": { "title": "Opções linear", "data": { diff --git a/custom_components/powercalc/translations/pt.json b/custom_components/powercalc/translations/pt.json index 936bdb1..39f943e 100644 --- a/custom_components/powercalc/translations/pt.json +++ b/custom_components/powercalc/translations/pt.json @@ -971,6 +971,7 @@ "playbook": "Opções de sequência", "multi_switch": "Opções de interruptor múltiplo", "real_power": "Opções de potência real", + "select_device": "Dispositivo", "utility_meter_options": "Opções de contador de serviços", "wled": "Opções WLED", "cost_options": "Opções de custo" @@ -980,6 +981,16 @@ "title": "Opções da biblioteca", "description": "Atualmente está selecionado o seguinte perfil da biblioteca: \n fabricante: {manufacturer}\n modelo: {model}\n\nSe quiser alterar o perfil, clique em seguinte." }, + "select_device": { + "data": { + "device": "Dispositivo" + }, + "data_description": { + "device": "O dispositivo ao qual as entidades do Powercalc devem ser associadas" + }, + "description": "Selecione o dispositivo ao qual o sensor do Powercalc deve ser associado.", + "title": "Selecionar dispositivo" + }, "linear": { "title": "Opções lineares", "data": { diff --git a/custom_components/powercalc/translations/ro.json b/custom_components/powercalc/translations/ro.json index 85ad84d..d22943a 100644 --- a/custom_components/powercalc/translations/ro.json +++ b/custom_components/powercalc/translations/ro.json @@ -971,6 +971,7 @@ "playbook": "Opțiuni de playbook", "multi_switch": "Opțiuni de comutare multiplă", "real_power": "Opțiuni de putere reală", + "select_device": "Dispozitiv", "utility_meter_options": "Opțiuni de contor de utilitate", "wled": "Opțiuni WLED", "cost_options": "Opțiuni de cost" @@ -980,6 +981,16 @@ "title": "Opțiuni de bibliotecă", "description": "În prezent este selectat următorul profil de bibliotecă: \n producator: {manufacturer}\n model: {model}\n\nDacă doriți să schimbați profilul, faceți clic pe următorul." }, + "select_device": { + "data": { + "device": "Dispozitiv" + }, + "data_description": { + "device": "Dispozitivul la care trebuie asociate entitățile Powercalc" + }, + "description": "Selectați dispozitivul la care trebuie asociat senzorul Powercalc.", + "title": "Selectați dispozitivul" + }, "linear": { "title": "Opțiuni liniare", "data": { diff --git a/custom_components/powercalc/translations/ru.json b/custom_components/powercalc/translations/ru.json index 468f4a2..5e50e4d 100644 --- a/custom_components/powercalc/translations/ru.json +++ b/custom_components/powercalc/translations/ru.json @@ -971,6 +971,7 @@ "playbook": "Параметры сценариев", "multi_switch": "Параметры мультивыключателя", "real_power": "Параметры реальной мощности", + "select_device": "Устройство", "utility_meter_options": "Параметры счётчиков", "wled": "Параметры WLED", "cost_options": "Варианты стоимости" @@ -980,6 +981,16 @@ "title": "Параметры библиотеки", "description": "В настоящее время выбран следующий профиль библиотеки: \n производитель: {manufacturer}\n модель: {model}\n\nЕсли хотите изменить профиль, нажмите далее." }, + "select_device": { + "data": { + "device": "Устройство" + }, + "data_description": { + "device": "Устройство, к которому следует привязать сущности Powercalc" + }, + "description": "Выберите устройство, к которому следует привязать датчик Powercalc.", + "title": "Выберите устройство" + }, "linear": { "title": "Линейные параметры", "data": { diff --git a/custom_components/powercalc/translations/sk.json b/custom_components/powercalc/translations/sk.json index 952cfdf..fe15042 100644 --- a/custom_components/powercalc/translations/sk.json +++ b/custom_components/powercalc/translations/sk.json @@ -971,6 +971,7 @@ "playbook": "Možnosti playbooku", "multi_switch": "Možnosti viacerých prepínačov", "real_power": "Skutočné možnosti napájania", + "select_device": "Zariadenie", "utility_meter_options": "Možnosti elektromera", "wled": "WLED možnosti", "cost_options": "Možnosti nákladov" @@ -980,6 +981,16 @@ "title": "Možnosti knižnice", "description": "Momentálne je vybratý nasledujúci profil knižnice: \n výrobca: {manufacturer}\n model: {model}\n\nAk chcete profil zmeniť, kliknite na tlačidlo Ďalej." }, + "select_device": { + "data": { + "device": "Zariadenie" + }, + "data_description": { + "device": "Zariadenie, ku ktorému majú byť priradené entity Powercalc" + }, + "description": "Vyberte zariadenie, ku ktorému má byť priradený senzor Powercalc.", + "title": "Vyberte zariadenie" + }, "linear": { "title": "lineárne možnosti", "data": { diff --git a/custom_components/powercalc/translations/sv.json b/custom_components/powercalc/translations/sv.json index 2922082..524625e 100644 --- a/custom_components/powercalc/translations/sv.json +++ b/custom_components/powercalc/translations/sv.json @@ -971,6 +971,7 @@ "playbook": "Playbook-alternativ", "multi_switch": "Alternativ för flera switchar", "real_power": "Verkliga kraftalternativ", + "select_device": "Enhet", "utility_meter_options": "Alternativ för verktygsmätare", "wled": "WLED alternativ", "cost_options": "Kostnadsalternativ" @@ -980,6 +981,16 @@ "title": "Biblioteksalternativ", "description": "För närvarande är följande biblioteksprofil vald: \n tillverkare: {manufacturer}\n modell: {model}\n\nOm du vill ändra profilen klickar du på nästa." }, + "select_device": { + "data": { + "device": "Enhet" + }, + "data_description": { + "device": "Enheten som Powercalc-entiteterna ska länkas till" + }, + "description": "Välj enheten som Powercalc-sensorn ska länkas till.", + "title": "Välj enhet" + }, "linear": { "title": "Linjära alternativ", "data": { diff --git a/custom_components/powercalc/translations/zh-Hans.json b/custom_components/powercalc/translations/zh-Hans.json index a49f1a9..356ca24 100644 --- a/custom_components/powercalc/translations/zh-Hans.json +++ b/custom_components/powercalc/translations/zh-Hans.json @@ -971,6 +971,7 @@ "playbook": "运行方案选项", "multi_switch": "多路开关选项", "real_power": "实际功率选项", + "select_device": "设备", "utility_meter_options": "公用事业计量表选项", "wled": "WLED 选项", "cost_options": "费用选项" @@ -980,6 +981,16 @@ "title": "配置文件库选项", "description": "当前选择了以下配置文件库中的配置文件:\n 制造商:{manufacturer}\n 型号:{model}\n\n如需更改配置文件,请点击“下一步”。" }, + "select_device": { + "data": { + "device": "设备" + }, + "data_description": { + "device": "Powercalc 实体应关联到的设备" + }, + "description": "选择 Powercalc 传感器应关联到的设备。", + "title": "选择设备" + }, "linear": { "title": "线性选项", "data": { diff --git a/custom_components/wyzeapi/__init__.py b/custom_components/wyzeapi/__init__.py index 49d2dae..319f4b0 100644 --- a/custom_components/wyzeapi/__init__.py +++ b/custom_components/wyzeapi/__init__.py @@ -34,6 +34,7 @@ from .token_manager import TokenManager PLATFORMS = [ "light", "switch", + "fan", "lock", "climate", "alarm_control_panel", diff --git a/custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index e190c53..0000000 Binary files a/custom_components/wyzeapi/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc deleted file mode 100644 index d0c7831..0000000 Binary files a/custom_components/wyzeapi/__pycache__/alarm_control_panel.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/button.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/button.cpython-314.pyc deleted file mode 100644 index 0ec4e92..0000000 Binary files a/custom_components/wyzeapi/__pycache__/button.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc deleted file mode 100644 index e403abb..0000000 Binary files a/custom_components/wyzeapi/__pycache__/camera.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc deleted file mode 100644 index 17d2c28..0000000 Binary files a/custom_components/wyzeapi/__pycache__/climate.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc deleted file mode 100644 index 99e426a..0000000 Binary files a/custom_components/wyzeapi/__pycache__/config_flow.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/const.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/const.cpython-314.pyc deleted file mode 100644 index 6978ea0..0000000 Binary files a/custom_components/wyzeapi/__pycache__/const.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc deleted file mode 100644 index 8a02b84..0000000 Binary files a/custom_components/wyzeapi/__pycache__/coordinator.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc deleted file mode 100644 index 6c8f433..0000000 Binary files a/custom_components/wyzeapi/__pycache__/cover.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/light.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/light.cpython-314.pyc deleted file mode 100644 index 3feb024..0000000 Binary files a/custom_components/wyzeapi/__pycache__/light.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc deleted file mode 100644 index 37d8b4b..0000000 Binary files a/custom_components/wyzeapi/__pycache__/lock.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/number.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/number.cpython-314.pyc deleted file mode 100644 index 00ea593..0000000 Binary files a/custom_components/wyzeapi/__pycache__/number.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc deleted file mode 100644 index 1522129..0000000 Binary files a/custom_components/wyzeapi/__pycache__/sensor.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc deleted file mode 100644 index 9d3db2e..0000000 Binary files a/custom_components/wyzeapi/__pycache__/siren.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc deleted file mode 100644 index e00ff43..0000000 Binary files a/custom_components/wyzeapi/__pycache__/switch.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc deleted file mode 100644 index b31b46a..0000000 Binary files a/custom_components/wyzeapi/__pycache__/token_manager.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc b/custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc deleted file mode 100644 index f961fbe..0000000 Binary files a/custom_components/wyzeapi/__pycache__/ydble_utils.cpython-314.pyc and /dev/null differ diff --git a/custom_components/wyzeapi/const.py b/custom_components/wyzeapi/const.py index 84bd7a7..041c7c3 100644 --- a/custom_components/wyzeapi/const.py +++ b/custom_components/wyzeapi/const.py @@ -15,6 +15,7 @@ LOCK_UPDATED = f"{DOMAIN}.lock_updated" CAMERA_UPDATED = f"{DOMAIN}.camera_updated" LIGHT_UPDATED = f"{DOMAIN}.light_updated" COVER_UPDATED = f"{DOMAIN}.cover_updated" +AIR_PURIFIER_UPDATED = f"{DOMAIN}.air_purifier_updated" RESET_BUTTON_PRESSED = f"{DOMAIN}.reset_button_pressed" # EVENT NAMES WYZE_CAMERA_EVENT = "wyze_camera_event" diff --git a/custom_components/wyzeapi/manifest.json b/custom_components/wyzeapi/manifest.json index 7a158ab..99e4752 100644 --- a/custom_components/wyzeapi/manifest.json +++ b/custom_components/wyzeapi/manifest.json @@ -11,8 +11,8 @@ "issue_tracker": "https://github.com/SecKatie/ha-wyzeapi/issues", "loggers": ["custom_components.wyzeapi"], "requirements": [ - "wyzeapy>=0.5.33,<0.6", + "wyzeapy>=0.6.1,<0.7", "websockets" ], - "version": "0.1.38" + "version": "0.1.39" } diff --git a/custom_components/wyzeapi/sensor.py b/custom_components/wyzeapi/sensor.py index bb954e9..42b9b35 100644 --- a/custom_components/wyzeapi/sensor.py +++ b/custom_components/wyzeapi/sensor.py @@ -7,6 +7,7 @@ import logging from typing import Any from wyzeapy import Wyzeapy +from wyzeapy.services.air_purifier_service import AirPurifier from wyzeapy.services.camera_service import Camera from wyzeapy.services.irrigation_service import Irrigation, IrrigationService from wyzeapy.services.lock_service import Lock @@ -36,6 +37,7 @@ from homeassistant.helpers.event import ( ) from .const import ( + AIR_PURIFIER_UPDATED, CAMERA_UPDATED, CONF_CLIENT, DOMAIN, @@ -71,6 +73,7 @@ async def async_setup_entry( camera_service = await client.camera_service switch_usage_service = await client.switch_usage_service irrigation_service = await client.irrigation_service + air_purifier_service = await client.air_purifier_service locks = await lock_service.get_locks() sensors = [] @@ -95,6 +98,11 @@ async def async_setup_entry( sensors.append(WyzePlugEnergySensor(plug, switch_usage_service)) sensors.append(WyzePlugDailyEnergySensor(plug)) + air_purifiers = await air_purifier_service.get_air_purifiers() + for air_purifier in air_purifiers: + sensors.append(WyzeAirPurifierAQISensor(air_purifier)) + sensors.append(WyzeAirPurifierHourlyMaxAQISensor(air_purifier)) + # Get all irrigation devices irrigation_devices = await irrigation_service.get_irrigations() @@ -624,3 +632,140 @@ class WyzeIrrigationSSID(WyzeIrrigationBaseSensor): def native_value(self) -> str: """Return the SSID.""" return self._device.ssid + + +class WyzeAirPurifierAirQualitySensor(SensorEntity): + """Base class for Wyze Air Purifier air quality sensors.""" + + _attr_attribution = ATTRIBUTION + _attr_device_class = SensorDeviceClass.AQI + _attr_has_entity_name = True + _attr_should_poll = False + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_suggested_display_precision = 0 + + def __init__( + self, + air_purifier: AirPurifier, + ) -> None: + """Initialize the AQI sensor.""" + self._air_purifier = air_purifier + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this entity.""" + device_info = DeviceInfo( + identifiers={(DOMAIN, self._air_purifier.mac)}, + name=self._air_purifier.nickname, + manufacturer="WyzeLabs", + model=self._air_purifier.product_model, + ) + if self._air_purifier.app_version: + device_info["sw_version"] = self._air_purifier.app_version + if self._air_purifier.sn: + device_info["serial_number"] = self._air_purifier.sn + if self._air_purifier.wifi_mac: + device_info["connections"] = { + (dr.CONNECTION_NETWORK_MAC, self._air_purifier.wifi_mac) + } + return device_info + + @property + def available(self) -> bool: + """Return the connection status of this sensor.""" + return self._air_purifier.available + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device attributes of the entity.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + "device model": self._air_purifier.product_model, + } + + @callback + def handle_air_purifier_update(self, air_purifier: AirPurifier) -> None: + """Handle air purifier updates.""" + self._air_purifier = air_purifier + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Add listener on startup.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{AIR_PURIFIER_UPDATED}-{self._air_purifier.mac}", + self.handle_air_purifier_update, + ) + ) + + +class WyzeAirPurifierAQISensor(WyzeAirPurifierAirQualitySensor): + """Representation of a Wyze Air Purifier current AQI sensor.""" + + _attr_name = "Current AQI" + + def __init__( + self, + air_purifier: AirPurifier, + ) -> None: + """Initialize the current AQI sensor.""" + super().__init__(air_purifier) + self._attr_unique_id = f"{self._air_purifier.mac}-aqi" + + @property + def native_value(self) -> int | None: + """Return the current AQI value.""" + return self._air_purifier.aqi + + +class WyzeAirPurifierHourlyMaxAQISensor(WyzeAirPurifierAirQualitySensor): + """Representation of a Wyze Air Purifier hourly max AQI sensor.""" + + _attr_name = "Hourly Max AQI" + + def __init__( + self, + air_purifier: AirPurifier, + ) -> None: + """Initialize the hourly max AQI sensor.""" + super().__init__(air_purifier) + self._attr_unique_id = f"{self._air_purifier.mac}-hourly-max-aqi" + + @property + def native_value(self) -> int | None: + """Return the hourly max AQI value.""" + return self._air_purifier.max_hourly_aqi + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device attributes of the entity.""" + attributes = super().extra_state_attributes + attributes.update( + { + "hour_start": self._timestamp_attribute( + self._air_purifier.max_hourly_aqi_start_time + ), + "hour_end": self._timestamp_attribute( + self._air_purifier.max_hourly_aqi_start_time, + offset=datetime.timedelta(hours=1), + ), + "sampled_until": self._timestamp_attribute( + self._air_purifier.max_hourly_aqi_end_time + ), + } + ) + return attributes + + @staticmethod + def _timestamp_attribute( + timestamp: int | None, offset: datetime.timedelta | None = None + ) -> str | None: + """Return an ISO formatted timestamp attribute.""" + if timestamp is None: + return None + + value = datetime.datetime.fromtimestamp(timestamp, datetime.UTC) + if offset is not None: + value += offset + return value.isoformat() diff --git a/zigbee2mqtt/state.json b/zigbee2mqtt/state.json index 158ad06..214436c 100644 --- a/zigbee2mqtt/state.json +++ b/zigbee2mqtt/state.json @@ -4,7 +4,7 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 120.6, + "voltage": 120.2, "countdown_to_turn_on": 0, "ac_frequency": 60, "power_factor": 0.11, @@ -22,15 +22,15 @@ }, "0xffffb40e0607af27": { "state": "ON", - "voltage": 120.2, + "voltage": 119.7, "ac_frequency": 60, "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "power": 3.9, - "current": 0.13, - "energy": 26.89, - "power_factor": 0.25, + "power": 96.8, + "current": 2.53, + "energy": 26.92, + "power_factor": 0.32, "update": { "state": "idle", "installed_version": 268513381, @@ -45,10 +45,10 @@ "state": "ON", "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 120.2, + "voltage": 119.8, "countdown_to_turn_on": 0, - "energy": 50.32, - "power_factor": 0.89, + "energy": 50.34, + "power_factor": 0.2, "ac_frequency": 60, "update": { "state": "idle", @@ -58,8 +58,8 @@ "latest_release_notes": null }, "linkquality": 134, - "power": 92.1, - "current": 0.88, + "power": 0.2, + "current": 0.01, "power_on_behavior": "on" }, "0xb40e060fffe031e3": { @@ -74,13 +74,13 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 119.3, + "voltage": 118.8, "state": "ON", "ac_frequency": 60, - "energy": 102.51, - "power": 102.3, - "current": 0.92, - "power_factor": 0.95, + "energy": 102.54, + "power": 97.2, + "current": 0.87, + "power_factor": 0.94, "update": { "state": "idle", "installed_version": 268513381, @@ -95,11 +95,11 @@ "led_brightness": 100, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, - "voltage": 120.1, - "energy": 43.84, + "voltage": 119.5, + "energy": 43.87, "state": "ON", - "power": 79.1, - "current": 0.83, + "power": 84.2, + "current": 0.91, "ac_frequency": 60, "power_factor": 0.76, "update": { @@ -114,12 +114,12 @@ }, "0xffffb40e060895b3": { "state": "ON", - "voltage": 120.6, + "voltage": 120.2, "ac_frequency": 60, "energy": 6.33, "current": 0.01, "power": 0.2, - "power_factor": 0.13, + "power_factor": 0.2, "linkquality": 123, "update": { "state": "idle", @@ -136,14 +136,14 @@ "0xffffb40e0608864e": { "led_brightness": 100, "countdown_to_turn_off": 0, - "voltage": 120.8, + "voltage": 120.7, "energy": 17.34, "countdown_to_turn_on": 0, "state": "ON", "current": 0.02, "ac_frequency": 60, - "power": 0.3, - "power_factor": 0.2, + "power": 0.4, + "power_factor": 0.14, "update": { "state": "idle", "installed_version": 268513381, @@ -172,13 +172,13 @@ "0xffffb40e060893d8": { "state": "ON", "led_brightness": 100, - "voltage": 121.3, + "voltage": 120.7, "countdown_to_turn_off": 0, "countdown_to_turn_on": 0, "energy": 3.04, "power_on_behavior": "on", "linkquality": 87, - "current": 0.03, + "current": 0.01, "ac_frequency": 60, "update": { "state": "idle", @@ -187,12 +187,12 @@ "latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota", "latest_release_notes": null }, - "power_factor": 0.08, - "power": 0.1 + "power_factor": 0.27, + "power": 0.4 }, "0xa4c1380d0679ffff": { "battery": 100, - "temperature": 27.5, + "temperature": 27.6, "temperature_units": "celsius", "temperature_calibration": 0, "update": {