1&&r&&r.length>1){var a=Bi(r)/Bi(o);!isFinite(a)&&(a=1),e.pinchScale=a;var s=[((i=r)[0][0]+i[1][0])/2,(i[0][1]+i[1][1])/2];return e.pinchX=s[0],e.pinchY=s[1],{type:"pinch",target:t[0].target,event:e}}}}};function Wi(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t}function Ui(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4],t[5]=e[5],t}function Gi(t,e,n){var i=e[0]*n[0]+e[2]*n[1],r=e[1]*n[0]+e[3]*n[1],o=e[0]*n[2]+e[2]*n[3],a=e[1]*n[2]+e[3]*n[3],s=e[0]*n[4]+e[2]*n[5]+e[4],l=e[1]*n[4]+e[3]*n[5]+e[5];return t[0]=i,t[1]=r,t[2]=o,t[3]=a,t[4]=s,t[5]=l,t}function qi(t,e,n){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t[4]=e[4]+n[0],t[5]=e[5]+n[1],t}function ji(t,e,n,i){void 0===i&&(i=[0,0]);var r=e[0],o=e[2],a=e[4],s=e[1],l=e[3],c=e[5],u=Math.sin(n),h=Math.cos(n);return t[0]=r*h+s*u,t[1]=-r*u+s*h,t[2]=o*h+l*u,t[3]=-o*u+h*l,t[4]=h*(a-i[0])+u*(c-i[1])+i[0],t[5]=h*(c-i[1])-u*(a-i[0])+i[1],t}function Xi(t,e){var n=e[0],i=e[2],r=e[4],o=e[1],a=e[3],s=e[5],l=n*a-o*i;return l?(l=1/l,t[0]=a*l,t[1]=-o*l,t[2]=-i*l,t[3]=n*l,t[4]=(i*s-a*r)*l,t[5]=(o*r-n*s)*l,t):null}var Yi=function(){function t(t,e){this.x=t||0,this.y=e||0}return t.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},t.prototype.clone=function(){return new t(this.x,this.y)},t.prototype.set=function(t,e){return this.x=t,this.y=e,this},t.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},t.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},t.prototype.scale=function(t){this.x*=t,this.y*=t},t.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},t.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},t.prototype.dot=function(t){return this.x*t.x+this.y*t.y},t.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},t.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},t.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},t.prototype.distance=function(t){var e=this.x-t.x,n=this.y-t.y;return Math.sqrt(e*e+n*n)},t.prototype.distanceSquare=function(t){var e=this.x-t.x,n=this.y-t.y;return e*e+n*n},t.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},t.prototype.transform=function(t){if(t){var e=this.x,n=this.y;return this.x=t[0]*e+t[2]*n+t[4],this.y=t[1]*e+t[3]*n+t[5],this}},t.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},t.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},t.set=function(t,e,n){t.x=e,t.y=n},t.copy=function(t,e){t.x=e.x,t.y=e.y},t.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},t.lenSquare=function(t){return t.x*t.x+t.y*t.y},t.dot=function(t,e){return t.x*e.x+t.y*e.y},t.add=function(t,e,n){t.x=e.x+n.x,t.y=e.y+n.y},t.sub=function(t,e,n){t.x=e.x-n.x,t.y=e.y-n.y},t.scale=function(t,e,n){t.x=e.x*n,t.y=e.y*n},t.scaleAndAdd=function(t,e,n,i){t.x=e.x+n.x*i,t.y=e.y+n.y*i},t.lerp=function(t,e,n,i){var r=1-i;t.x=r*e.x+i*n.x,t.y=r*e.y+i*n.y},t}(),Zi=Math.min,Ki=Math.max,Qi=Math.abs,Ji=["x","y"],tr=["width","height"],er=new Yi,nr=new Yi,ir=new Yi,rr=new Yi,or=pr(),ar=or.minTv,sr=or.maxTv,lr=[0,0],cr=function(){function t(e,n,i,r){t.set(this,e,n,i,r)}return t.set=function(t,e,n,i,r){return i<0&&(e+=i,i=-i),r<0&&(n+=r,r=-r),t.x=e,t.y=n,t.width=i,t.height=r,t},t.prototype.union=function(t){var e=Zi(t.x,this.x),n=Zi(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=Ki(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=Ki(t.y+t.height,this.y+this.height)-n:this.height=t.height,this.x=e,this.y=n},t.prototype.applyTransform=function(e){t.applyTransform(this,this,e)},t.prototype.calculateTransform=function(t){var e=this,n=t.width/e.width,i=t.height/e.height,r=[1,0,0,1,0,0];return qi(r,r,[-e.x,-e.y]),function(t,e,n){var i=n[0],r=n[1];t[0]=e[0]*i,t[1]=e[1]*r,t[2]=e[2]*i,t[3]=e[3]*r,t[4]=e[4]*i,t[5]=e[5]*r}(r,r,[n,i]),qi(r,r,[t.x,t.y]),r},t.prototype.intersect=function(e,n,i){return t.intersect(this,e,n,i)},t.intersect=function(e,n,i,r){i&&Yi.set(i,0,0);var o=r&&r.outIntersectRect||null,a=r&&r.clamp;if(o&&(o.x=o.y=o.width=o.height=NaN),!e||!n)return!1;e instanceof t||(e=t.set(ur,e.x,e.y,e.width,e.height)),n instanceof t||(n=t.set(hr,n.x,n.y,n.width,n.height));var s=!!i;or.reset(r,s);var l=or.touchThreshold,c=e.x+l,u=e.x+e.width-l,h=e.y+l,d=e.y+e.height-l,p=n.x+l,f=n.x+n.width-l,g=n.y+l,v=n.y+n.height-l;if(c>u||h>d||p>f||g>v)return!1;var m=!(u=t.x&&e<=t.x+t.width&&n>=t.y&&n<=t.y+t.height},t.prototype.contain=function(e,n){return t.contain(this,e,n)},t.prototype.clone=function(){return new t(this.x,this.y,this.width,this.height)},t.prototype.copy=function(e){t.copy(this,e)},t.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},t.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},t.prototype.isZero=function(){return 0===this.width||0===this.height},t.create=function(e){return new t(e.x,e.y,e.width,e.height)},t.copy=function(t,e){return t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height,t},t.applyTransform=function(e,n,i){if(i){if(i[1]<1e-5&&i[1]>-1e-5&&i[2]<1e-5&&i[2]>-1e-5){var r=i[0],o=i[3],a=i[4],s=i[5];return e.x=n.x*r+a,e.y=n.y*o+s,e.width=n.width*r,e.height=n.height*o,e.width<0&&(e.x+=e.width,e.width=-e.width),void(e.height<0&&(e.y+=e.height,e.height=-e.height))}er.x=ir.x=n.x,er.y=rr.y=n.y,nr.x=rr.x=n.x+n.width,nr.y=ir.y=n.y+n.height,er.transform(i),rr.transform(i),nr.transform(i),ir.transform(i),e.x=Zi(er.x,nr.x,ir.x,rr.x),e.y=Zi(er.y,nr.y,ir.y,rr.y);var l=Ki(er.x,nr.x,ir.x,rr.x),c=Ki(er.y,nr.y,ir.y,rr.y);e.width=l-e.x,e.height=c-e.y}else e!==n&&t.copy(e,n)},t}(),ur=new cr(0,0,0,0),hr=new cr(0,0,0,0);function dr(t,e,n,i,r,o,a,s){var l=Qi(e-n),c=Qi(i-t),u=Zi(l,c),h=Ji[r],d=Ji[1-r],p=tr[r];e=c||!or.bidirectional)&&(ar[h]=-c,ar[d]=0,or.useDir&&or.calcDirMTV())))}function pr(){var t=0,e=new Yi,n=new Yi,i={minTv:new Yi,maxTv:new Yi,useDir:!1,dirMinTv:new Yi,touchThreshold:0,bidirectional:!0,negativeSize:!1,reset:function(r,o){i.touchThreshold=0,r&&null!=r.touchThreshold&&(i.touchThreshold=Ki(0,r.touchThreshold)),i.negativeSize=!1,o&&(i.minTv.set(1/0,1/0),i.maxTv.set(0,0),i.useDir=!1,r&&null!=r.direction&&(i.useDir=!0,i.dirMinTv.copy(i.minTv),n.copy(i.minTv),t=r.direction,i.bidirectional=null==r.bidirectional||!!r.bidirectional,i.bidirectional||e.set(Math.cos(t),Math.sin(t))))},calcDirMTV:function(){var o=i.minTv,a=i.dirMinTv,s=o.y*o.y+o.x*o.x,l=Math.sin(t),c=Math.cos(t),u=l*o.y+c*o.x;r(u)?r(o.x)&&r(o.y)&&a.set(0,0):(n.x=s*c/u,n.y=s*l/u,r(n.x)&&r(n.y)?a.set(0,0):(i.bidirectional||e.dot(n)>0)&&n.len()=0;c--){var u=i[c];u===n||u.ignore||u.ignoreCoarsePointer||u.parent&&u.parent.ignoreCoarsePointer||(_r.copy(u.getBoundingRect()),u.transform&&_r.applyTransform(u.transform),_r.intersect(l)&&o.push(u))}if(o.length)for(var h=Math.PI/12,d=2*Math.PI,p=0;p=0;o--){var a=t[o],s=void 0;if(a!==r&&!a.ignore&&(s=xr(a,n,i))&&(!e.topTarget&&(e.topTarget=a),s!==fr)){e.target=a;break}}}function Sr(t,e,n){var i=t.painter;return e<0||e>i.getWidth()||n<0||n>i.getHeight()}Cn(["click","mousedown","mouseup","mousewheel","dblclick","contextmenu"],function(t){br.prototype[t]=function(e){var n,i,r=e.zrX,o=e.zrY,a=Sr(this,r,o);if("mouseup"===t&&a||(i=(n=this.findHover(r,o)).target),"mousedown"===t)this._downEl=i,this._downPoint=[e.zrX,e.zrY],this._upEl=i;else if("mouseup"===t)this._upEl=i;else if("click"===t){if(this._downEl!==this._upEl||!this._downPoint||pi(this._downPoint,[e.zrX,e.zrY])>4)return;this._downPoint=null}this.dispatchToElement(n,t,e)}});function Cr(t,e,n,i){var r=e+1;if(r===n)return 1;if(i(t[r++],t[e])<0){for(;r=0;)r++;return r-e}function kr(t,e,n,i,r){for(i===e&&i++;i>>1])<0?l=o:s=o+1;var c=i-s;switch(c){case 3:t[s+3]=t[s+2];case 2:t[s+2]=t[s+1];case 1:t[s+1]=t[s];break;default:for(;c>0;)t[s+c]=t[s+c-1],c--}t[s]=a}}function Mr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])>0){for(s=i-r;l0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}else{for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}for(a++;a>>1);o(t,e[n+u])>0?a=u+1:l=u}return l}function Tr(t,e,n,i,r,o){var a=0,s=0,l=1;if(o(t,e[n+r])<0){for(s=r+1;ls&&(l=s);var c=a;a=r-l,l=r-c}else{for(s=i-r;l=0;)a=l,(l=1+(l<<1))<=0&&(l=s);l>s&&(l=s),a+=r,l+=r}for(a++;a>>1);o(t,e[n+u])<0?l=u:a=u+1}return l}function Ir(t,e){var n,i,r=7,o=0,a=[];function s(s){var l=n[s],c=i[s],u=n[s+1],h=i[s+1];i[s]=c+h,s===o-3&&(n[s+1]=n[s+2],i[s+1]=i[s+2]),o--;var d=Tr(t[u],t,l,c,0,e);l+=d,0!==(c-=d)&&0!==(h=Mr(t[l+c-1],t,u,h,h-1,e))&&(c<=h?function(n,i,o,s){var l=0;for(l=0;l=7||p>=7);if(f)break;g<0&&(g=0),g+=2}if((r=g)<1&&(r=1),1===i){for(l=0;l=0;l--)t[p+l]=t[d+l];return void(t[h]=a[u])}var f=r;for(;;){var g=0,v=0,m=!1;do{if(e(a[u],t[c])<0){if(t[h--]=t[c--],g++,v=0,0===--i){m=!0;break}}else if(t[h--]=a[u--],v++,g=0,1===--s){m=!0;break}}while((g|v)=0;l--)t[p+l]=t[d+l];if(0===i){m=!0;break}}if(t[h--]=a[u--],1===--s){m=!0;break}if(0!==(v=s-Mr(t[c],a,0,s,s-1,e))){for(s-=v,p=(h-=v)+1,d=(u-=v)+1,l=0;l=7||v>=7);if(m)break;f<0&&(f=0),f+=2}(r=f)<1&&(r=1);if(1===s){for(p=(h-=i)+1,d=(c-=i)+1,l=i-1;l>=0;l--)t[p+l]=t[d+l];t[h]=a[u]}else{if(0===s)throw new Error;for(d=h-(s-1),l=0;l1;){var t=o-2;if(t>=1&&i[t-1]<=i[t]+i[t+1]||t>=2&&i[t-2]<=i[t]+i[t-1])i[t-1]i[t+1])break;s(t)}},forceMergeRuns:function(){for(;o>1;){var t=o-2;t>0&&i[t-1]=32;)e|=1&t,t>>=1;return t+e}(r);do{if((o=Cr(t,n,i,e))s&&(l=s),kr(t,n,n+l,n+o,e),o=l}a.pushRun(n,o),a.mergeRuns(),r-=o,n+=o}while(0!==r);a.forceMergeRuns()}}}var Ar=!1;function Pr(){Ar||(Ar=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function Lr(t,e){return t.zlevel===e.zlevel?t.z===e.z?t.z2-e.z2:t.z-e.z:t.zlevel-e.zlevel}var Er,zr=function(){function t(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=Lr}return t.prototype.traverse=function(t,e){for(var n=0;n=0&&this._roots.splice(i,1)}},t.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},t.prototype.getRoots=function(){return this._roots},t.prototype.dispose=function(){this._displayList=null,this._roots=null},t}();Er=Ye.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(t){return setTimeout(t,16)};var Nr={linear:function(t){return t},quadraticIn:function(t){return t*t},quadraticOut:function(t){return t*(2-t)},quadraticInOut:function(t){return(t*=2)<1?.5*t*t:-.5*(--t*(t-2)-1)},cubicIn:function(t){return t*t*t},cubicOut:function(t){return--t*t*t+1},cubicInOut:function(t){return(t*=2)<1?.5*t*t*t:.5*((t-=2)*t*t+2)},quarticIn:function(t){return t*t*t*t},quarticOut:function(t){return 1- --t*t*t*t},quarticInOut:function(t){return(t*=2)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2)},quinticIn:function(t){return t*t*t*t*t},quinticOut:function(t){return--t*t*t*t*t+1},quinticInOut:function(t){return(t*=2)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2)},sinusoidalIn:function(t){return 1-Math.cos(t*Math.PI/2)},sinusoidalOut:function(t){return Math.sin(t*Math.PI/2)},sinusoidalInOut:function(t){return.5*(1-Math.cos(Math.PI*t))},exponentialIn:function(t){return 0===t?0:Math.pow(1024,t-1)},exponentialOut:function(t){return 1===t?1:1-Math.pow(2,-10*t)},exponentialInOut:function(t){return 0===t?0:1===t?1:(t*=2)<1?.5*Math.pow(1024,t-1):.5*(2-Math.pow(2,-10*(t-1)))},circularIn:function(t){return 1-Math.sqrt(1-t*t)},circularOut:function(t){return Math.sqrt(1- --t*t)},circularInOut:function(t){return(t*=2)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},elasticIn:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),-n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/.4))},elasticOut:function(t){var e,n=.1;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=.4*Math.asin(1/n)/(2*Math.PI),n*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/.4)+1)},elasticInOut:function(t){var e,n=.1,i=.4;return 0===t?0:1===t?1:(!n||n<1?(n=1,e=.1):e=i*Math.asin(1/n)/(2*Math.PI),(t*=2)<1?n*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*-.5:n*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/i)*.5+1)},backIn:function(t){var e=1.70158;return t*t*((e+1)*t-e)},backOut:function(t){var e=1.70158;return--t*t*((e+1)*t+e)+1},backInOut:function(t){var e=2.5949095;return(t*=2)<1?t*t*((e+1)*t-e)*.5:.5*((t-=2)*t*((e+1)*t+e)+2)},bounceIn:function(t){return 1-Nr.bounceOut(1-t)},bounceOut:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},bounceInOut:function(t){return t<.5?.5*Nr.bounceIn(2*t):.5*Nr.bounceOut(2*t-1)+.5}},Or=Math.pow,Rr=Math.sqrt,$r=1e-8,Hr=1e-4,Fr=Rr(3),Br=1/3,Vr=ai(),Wr=ai(),Ur=ai();function Gr(t){return t>-1e-8&&t<$r}function qr(t){return t>$r||t<-1e-8}function jr(t,e,n,i,r){var o=1-r;return o*o*(o*t+3*r*e)+r*r*(r*i+3*o*n)}function Xr(t,e,n,i,r){var o=1-r;return 3*(((e-t)*o+2*(n-e)*r)*o+(i-n)*r*r)}function Yr(t,e,n,i,r,o){var a=i+3*(e-n)-t,s=3*(n-2*e+t),l=3*(e-t),c=t-r,u=s*s-3*a*l,h=s*l-9*a*c,d=l*l-3*s*c,p=0;if(Gr(u)&&Gr(h)){if(Gr(s))o[0]=0;else(C=-l/s)>=0&&C<=1&&(o[p++]=C)}else{var f=h*h-4*u*d;if(Gr(f)){var g=h/u,v=-g/2;(C=-s/a+g)>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v)}else if(f>0){var m=Rr(f),y=u*s+1.5*a*(-h+m),_=u*s+1.5*a*(-h-m);(C=(-s-((y=y<0?-Or(-y,Br):Or(y,Br))+(_=_<0?-Or(-_,Br):Or(_,Br))))/(3*a))>=0&&C<=1&&(o[p++]=C)}else{var b=(2*u*s-3*a*h)/(2*Rr(u*u*u)),x=Math.acos(b)/3,w=Rr(u),S=Math.cos(x),C=(-s-2*w*S)/(3*a),k=(v=(-s+w*(S+Fr*Math.sin(x)))/(3*a),(-s+w*(S-Fr*Math.sin(x)))/(3*a));C>=0&&C<=1&&(o[p++]=C),v>=0&&v<=1&&(o[p++]=v),k>=0&&k<=1&&(o[p++]=k)}}return p}function Zr(t,e,n,i,r){var o=6*n-12*e+6*t,a=9*e+3*i-3*t-9*n,s=3*e-3*t,l=0;if(Gr(a)){if(qr(o))(u=-s/o)>=0&&u<=1&&(r[l++]=u)}else{var c=o*o-4*a*s;if(Gr(c))r[0]=-o/(2*a);else if(c>0){var u,h=Rr(c),d=(-o-h)/(2*a);(u=(-o+h)/(2*a))>=0&&u<=1&&(r[l++]=u),d>=0&&d<=1&&(r[l++]=d)}}return l}function Kr(t,e,n,i,r,o){var a=(e-t)*r+t,s=(n-e)*r+e,l=(i-n)*r+n,c=(s-a)*r+a,u=(l-s)*r+s,h=(u-c)*r+c;o[0]=t,o[1]=a,o[2]=c,o[3]=h,o[4]=h,o[5]=u,o[6]=l,o[7]=i}function Qr(t,e,n,i,r,o,a,s,l){for(var c=t,u=e,h=0,d=1/l,p=1;p<=l;p++){var f=p*d,g=jr(t,n,r,a,f),v=jr(e,i,o,s,f),m=g-c,y=v-u;h+=Math.sqrt(m*m+y*y),c=g,u=v}return h}function Jr(t,e,n,i){var r=1-i;return r*(r*t+2*i*e)+i*i*n}function to(t,e,n,i){return 2*((1-i)*(e-t)+i*(n-e))}function eo(t,e,n){var i=t+n-2*e;return 0===i?.5:(t-e)/i}function no(t,e,n,i,r){var o=(e-t)*i+t,a=(n-e)*i+e,s=(a-o)*i+o;r[0]=t,r[1]=o,r[2]=s,r[3]=s,r[4]=a,r[5]=n}function io(t,e,n,i,r,o,a){for(var s=t,l=e,c=0,u=1/a,h=1;h<=a;h++){var d=h*u,p=Jr(t,n,r,d),f=Jr(e,i,o,d),g=p-s,v=f-l;c+=Math.sqrt(g*g+v*v),s=p,l=f}return c}var ro=/cubic-bezier\(([0-9,\.e ]+)\)/;function oo(t){var e=t&&ro.exec(t);if(e){var n=e[1].split(","),i=+Xn(n[0]),r=+Xn(n[1]),o=+Xn(n[2]),a=+Xn(n[3]);if(isNaN(i+r+o+a))return;var s=[];return function(t){return t<=0?0:t>=1?1:Yr(0,i,o,1,t,s)&&jr(0,r,a,1,s[0])}}}var ao=function(){function t(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||ri,this.ondestroy=t.ondestroy||ri,this.onrestart=t.onrestart||ri,t.easing&&this.setEasing(t.easing)}return t.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),!this._paused){var n=this._life,i=t-this._startTime-this._pausedTime,r=i/n;r<0&&(r=0),r=Math.min(r,1);var o=this.easingFunc,a=o?o(r):r;if(this.onframe(a),1===r){if(!this.loop)return!0;var s=i%n;this._startTime=t-s,this._pausedTime=0,this.onrestart()}return!1}this._pausedTime+=e},t.prototype.pause=function(){this._paused=!0},t.prototype.resume=function(){this._paused=!1},t.prototype.setEasing=function(t){this.easing=t,this.easingFunc=Ln(t)?t:Nr[t]||oo(t)},t}(),so=function(t){this.value=t},lo=function(){function t(){this._len=0}return t.prototype.insert=function(t){var e=new so(t);return this.insertEntry(e),e},t.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},t.prototype.remove=function(t){var e=t.prev,n=t.next;e?e.next=n:this.head=n,n?n.prev=e:this.tail=e,t.next=t.prev=null,this._len--},t.prototype.len=function(){return this._len},t.prototype.clear=function(){this.head=this.tail=null,this._len=0},t}(),co=function(){function t(t){this._list=new lo,this._maxSize=10,this._map={},this._maxSize=t}return t.prototype.put=function(t,e){var n=this._list,i=this._map,r=null;if(null==i[t]){var o=n.len(),a=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var s=n.head;n.remove(s),delete i[s.key],r=s.value,this._lastRemovedEntry=s}a?a.value=e:a=new so(e),a.key=t,n.insertEntry(a),i[t]=a}return r},t.prototype.get=function(t){var e=this._map[t],n=this._list;if(null!=e)return e!==n.tail&&(n.remove(e),n.insertEntry(e)),e.value},t.prototype.clear=function(){this._list.clear(),this._map={}},t.prototype.len=function(){return this._list.len()},t}(),uo={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function ho(t){return(t=Math.round(t))<0?0:t>255?255:t}function po(t){return t<0?0:t>1?1:t}function fo(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?ho(parseFloat(e)/100*255):ho(parseInt(e,10))}function go(t){var e=t;return e.length&&"%"===e.charAt(e.length-1)?po(parseFloat(e)/100):po(parseFloat(e))}function vo(t,e,n){return n<0?n+=1:n>1&&(n-=1),6*n<1?t+(e-t)*n*6:2*n<1?e:3*n<2?t+(e-t)*(2/3-n)*6:t}function mo(t,e,n){return t+(e-t)*n}function yo(t,e,n,i,r){return t[0]=e,t[1]=n,t[2]=i,t[3]=r,t}function _o(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t[3]=e[3],t}var bo=new co(20),xo=null;function wo(t,e){xo&&_o(xo,e),xo=bo.put(t,xo||e.slice())}function So(t,e){if(t){e=e||[];var n=bo.get(t);if(n)return _o(e,n);var i=(t+="").replace(/ /g,"").toLowerCase();if(i in uo)return _o(e,uo[i]),wo(t,e),e;var r,o=i.length;if("#"===i.charAt(0))return 4===o||5===o?(r=parseInt(i.slice(1,4),16))>=0&&r<=4095?(yo(e,(3840&r)>>4|(3840&r)>>8,240&r|(240&r)>>4,15&r|(15&r)<<4,5===o?parseInt(i.slice(4),16)/15:1),wo(t,e),e):void yo(e,0,0,0,1):7===o||9===o?(r=parseInt(i.slice(1,7),16))>=0&&r<=16777215?(yo(e,(16711680&r)>>16,(65280&r)>>8,255&r,9===o?parseInt(i.slice(7),16)/255:1),wo(t,e),e):void yo(e,0,0,0,1):void 0;var a=i.indexOf("("),s=i.indexOf(")");if(-1!==a&&s+1===o){var l=i.substr(0,a),c=i.substr(a+1,s-(a+1)).split(","),u=1;switch(l){case"rgba":if(4!==c.length)return 3===c.length?yo(e,+c[0],+c[1],+c[2],1):yo(e,0,0,0,1);u=go(c.pop());case"rgb":return c.length>=3?(yo(e,fo(c[0]),fo(c[1]),fo(c[2]),3===c.length?u:go(c[3])),wo(t,e),e):void yo(e,0,0,0,1);case"hsla":return 4!==c.length?void yo(e,0,0,0,1):(c[3]=go(c[3]),Co(c,e),wo(t,e),e);case"hsl":return 3!==c.length?void yo(e,0,0,0,1):(Co(c,e),wo(t,e),e);default:return}}yo(e,0,0,0,1)}}function Co(t,e){var n=(parseFloat(t[0])%360+360)%360/360,i=go(t[1]),r=go(t[2]),o=r<=.5?r*(i+1):r+i-r*i,a=2*r-o;return yo(e=e||[],ho(255*vo(a,o,n+1/3)),ho(255*vo(a,o,n)),ho(255*vo(a,o,n-1/3)),1),4===t.length&&(e[3]=t[3]),e}function ko(t,e){var n=So(t);if(n){for(var i=0;i<3;i++)n[i]=n[i]*(1-e)|0,n[i]>255?n[i]=255:n[i]<0&&(n[i]=0);return To(n,4===n.length?"rgba":"rgb")}}function Mo(t,e,n,i){var r=So(t);if(t)return r=function(t){if(t){var e,n,i=t[0]/255,r=t[1]/255,o=t[2]/255,a=Math.min(i,r,o),s=Math.max(i,r,o),l=s-a,c=(s+a)/2;if(0===l)e=0,n=0;else{n=c<.5?l/(s+a):l/(2-s-a);var u=((s-i)/6+l/2)/l,h=((s-r)/6+l/2)/l,d=((s-o)/6+l/2)/l;i===s?e=d-h:r===s?e=1/3+u-d:o===s&&(e=2/3+h-u),e<0&&(e+=1),e>1&&(e-=1)}var p=[360*e,n,c];return null!=t[3]&&p.push(t[3]),p}}(r),null!=n&&(r[1]=go(Ln(n)?n(r[1]):n)),null!=i&&(r[2]=go(Ln(i)?i(r[2]):i)),To(Co(r),"rgba")}function To(t,e){if(t&&t.length){var n=t[0]+","+t[1]+","+t[2];return"rgba"!==e&&"hsva"!==e&&"hsla"!==e||(n+=","+t[3]),e+"("+n+")"}}function Io(t,e){var n=So(t);return n?(.299*n[0]+.587*n[1]+.114*n[2])*n[3]/255+(1-n[3])*e:0}var Do=new co(100);function Ao(t){if(En(t)){var e=Do.get(t);return e||(e=ko(t,-.1),Do.put(t,e)),e}if(Fn(t)){var n=_n({},t);return n.colorStops=kn(t.colorStops,function(t){return{offset:t.offset,color:ko(t.color,-.1)}}),n}return t}var Po=Math.round;function Lo(t){var e;if(t&&"transparent"!==t){if("string"==typeof t&&t.indexOf("rgba")>-1){var n=So(t);n&&(t="rgb("+n[0]+","+n[1]+","+n[2]+")",e=n[3])}}else t="none";return{color:t,opacity:null==e?1:e}}var Eo=1e-4;function zo(t){return t-1e-4}function No(t){return Po(1e3*t)/1e3}function Oo(t){return Po(1e4*t)/1e4}var Ro={left:"start",right:"end",center:"middle",middle:"middle"};function $o(t){return t&&!!t.image}function Ho(t){return $o(t)||function(t){return t&&!!t.svgElement}(t)}function Fo(t){return"linear"===t.type}function Bo(t){return"radial"===t.type}function Vo(t){return t&&("linear"===t.type||"radial"===t.type)}function Wo(t){return"url(#"+t+")"}function Uo(t){var e=t.getGlobalScale(),n=Math.max(e[0],e[1]);return Math.max(Math.ceil(Math.log(n)/Math.log(10)),1)}function Go(t){var e=t.x||0,n=t.y||0,i=(t.rotation||0)*oi,r=Wn(t.scaleX,1),o=Wn(t.scaleY,1),a=t.skewX||0,s=t.skewY||0,l=[];return(e||n)&&l.push("translate("+e+"px,"+n+"px)"),i&&l.push("rotate("+i+")"),1===r&&1===o||l.push("scale("+r+","+o+")"),(a||s)&&l.push("skew("+Po(a*oi)+"deg, "+Po(s*oi)+"deg)"),l.join(" ")}var qo=Ye.hasGlobalWindow&&Ln(window.btoa)?function(t){return window.btoa(unescape(encodeURIComponent(t)))}:"undefined"!=typeof Buffer?function(t){return Buffer.from(t).toString("base64")}:function(t){return null},jo=Array.prototype.slice;function Xo(t,e,n){return(e-t)*n+t}function Yo(t,e,n,i){for(var r=e.length,o=0;oi?e:t,o=Math.min(n,i),a=r[o-1]||{color:[0,0,0,0],offset:0},s=o;sa)i.length=a;else for(var s=o;s=1},t.prototype.getAdditiveTrack=function(){return this._additiveTrack},t.prototype.addKeyframe=function(t,e,n){this._needsSort=!0;var i=this.keyframes,r=i.length,o=!1,a=6,s=e;if(Sn(e)){var l=function(t){return Sn(t&&t[0])?2:1}(e);a=l,(1===l&&!Nn(e[0])||2===l&&!Nn(e[0][0]))&&(o=!0)}else if(Nn(e)&&!Bn(e))a=0;else if(En(e))if(isNaN(+e)){var c=So(e);c&&(s=c,a=3)}else a=0;else if(Fn(e)){var u=_n({},s);u.colorStops=kn(e.colorStops,function(t){return{offset:t.offset,color:So(t.color)}}),Fo(e)?a=4:Bo(e)&&(a=5),s=u}0===r?this.valType=a:a===this.valType&&6!==a||(o=!0),this.discrete=this.discrete||o;var h={time:t,value:s,rawValue:e,percent:0};return n&&(h.easing=n,h.easingFunc=Ln(n)?n:Nr[n]||oo(n)),i.push(h),h},t.prototype.prepare=function(t,e){var n=this.keyframes;this._needsSort&&n.sort(function(t,e){return t.time-e.time});for(var i=this.valType,r=n.length,o=n[r-1],a=this.discrete,s=ia(i),l=na(i),c=0;c=0&&!(l[n].percent<=e);n--);n=p(n,c-2)}else{for(n=d;ne);n++);n=p(n-1,c-2)}r=l[n+1],i=l[n]}if(i&&r){this._lastFr=n,this._lastFrP=e;var f=r.percent-i.percent,g=0===f?1:p((e-i.percent)/f,1);r.easingFunc&&(g=r.easingFunc(g));var v=o?this._additiveValue:h?ra:t[u];if(!ia(s)&&!h||v||(v=this._additiveValue=[]),this.discrete)t[u]=g<1?i.rawValue:r.rawValue;else if(ia(s))1===s?Yo(v,i[a],r[a],g):function(t,e,n,i){for(var r=e.length,o=r&&e[0].length,a=0;a0&&s.addKeyframe(0,ta(l),i),this._trackKeys.push(a)}s.addKeyframe(t,ta(e[a]),i)}return this._maxTime=Math.max(this._maxTime,t),this},t.prototype.pause=function(){this._clip.pause(),this._paused=!0},t.prototype.resume=function(){this._clip.resume(),this._paused=!1},t.prototype.isPaused=function(){return!!this._paused},t.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},t.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,n=0;n0)){this._started=1;for(var e=this,n=[],i=this._maxTime||0,r=0;r1){var a=o.pop();r.addKeyframe(a.time,t[i]),r.prepare(this._maxTime,r.getAdditiveTrack())}}}},t}();function sa(){return(new Date).getTime()}var la,ca,ua=function(t){function e(e){var n=t.call(this)||this;return n._running=!1,n._time=0,n._pausedTime=0,n._pauseStart=0,n._paused=!1,e=e||{},n.stage=e.stage||{},n}return y(e,t),e.prototype.addClip=function(t){t.animation&&this.removeClip(t),this._head?(this._tail.next=t,t.prev=this._tail,t.next=null,this._tail=t):this._head=this._tail=t,t.animation=this},e.prototype.addAnimator=function(t){t.animation=this;var e=t.getClip();e&&this.addClip(e)},e.prototype.removeClip=function(t){if(t.animation){var e=t.prev,n=t.next;e?e.next=n:this._head=n,n?n.prev=e:this._tail=e,t.next=t.prev=t.animation=null}},e.prototype.removeAnimator=function(t){var e=t.getClip();e&&this.removeClip(e),t.animation=null},e.prototype.update=function(t){for(var e=sa()-this._pausedTime,n=e-this._time,i=this._head;i;){var r=i.next;i.step(e,n)?(i.ondestroy(),this.removeClip(i),i=r):i=r}this._time=e,t||(this.trigger("frame",n),this.stage.update&&this.stage.update())},e.prototype._startLoop=function(){var t=this;this._running=!0,Er(function e(){t._running&&(Er(e),!t._paused&&t.update())})},e.prototype.start=function(){this._running||(this._time=sa(),this._pausedTime=0,this._startLoop())},e.prototype.stop=function(){this._running=!1},e.prototype.pause=function(){this._paused||(this._pauseStart=sa(),this._paused=!0)},e.prototype.resume=function(){this._paused&&(this._pausedTime+=sa()-this._pauseStart,this._paused=!1)},e.prototype.clear=function(){for(var t=this._head;t;){var e=t.next;t.prev=t.next=t.animation=null,t=e}this._head=this._tail=null},e.prototype.isFinished=function(){return null==this._head},e.prototype.animate=function(t,e){e=e||{},this.start();var n=new aa(t,e.loop);return this.addAnimator(n),n},e}(bi),ha=Ye.domSupported,da=(ca={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},{mouse:la=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],touch:["touchstart","touchend","touchmove"],pointer:kn(la,function(t){var e=t.replace("mouse","pointer");return ca.hasOwnProperty(e)?e:t})}),pa=["mousemove","mouseup"],fa=["pointermove","pointerup"],ga=!1;function va(t){var e=t.pointerType;return"pen"===e||"touch"===e}function ma(t){t&&(t.zrByTouch=!0)}function ya(t,e){for(var n=e,i=!1;n&&9!==n.nodeType&&!(i=n.domBelongToZr||n!==e&&n===t.painterRoot);)n=n.parentNode;return i}var _a=function(t,e){this.stopPropagation=ri,this.stopImmediatePropagation=ri,this.preventDefault=ri,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY},ba={mousedown:function(t){t=Ri(this.dom,t),this.__mayPointerCapture=[t.zrX,t.zrY],this.trigger("mousedown",t)},mousemove:function(t){t=Ri(this.dom,t);var e=this.__mayPointerCapture;!e||t.zrX===e[0]&&t.zrY===e[1]||this.__togglePointerCapture(!0),this.trigger("mousemove",t)},mouseup:function(t){t=Ri(this.dom,t),this.__togglePointerCapture(!1),this.trigger("mouseup",t)},mouseout:function(t){ya(this,(t=Ri(this.dom,t)).toElement||t.relatedTarget)||(this.__pointerCapturing&&(t.zrEventControl="no_globalout"),this.trigger("mouseout",t))},wheel:function(t){ga=!0,t=Ri(this.dom,t),this.trigger("mousewheel",t)},mousewheel:function(t){ga||(t=Ri(this.dom,t),this.trigger("mousewheel",t))},touchstart:function(t){ma(t=Ri(this.dom,t)),this.__lastTouchMoment=new Date,this.handler.processGesture(t,"start"),ba.mousemove.call(this,t),ba.mousedown.call(this,t)},touchmove:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"change"),ba.mousemove.call(this,t)},touchend:function(t){ma(t=Ri(this.dom,t)),this.handler.processGesture(t,"end"),ba.mouseup.call(this,t),+new Date-+this.__lastTouchMoment<300&&ba.click.call(this,t)},pointerdown:function(t){ba.mousedown.call(this,t)},pointermove:function(t){va(t)||ba.mousemove.call(this,t)},pointerup:function(t){ba.mouseup.call(this,t)},pointerout:function(t){va(t)||ba.mouseout.call(this,t)}};Cn(["click","dblclick","contextmenu"],function(t){ba[t]=function(e){e=Ri(this.dom,e),this.trigger(t,e)}});var xa={pointermove:function(t){va(t)||xa.mousemove.call(this,t)},pointerup:function(t){xa.mouseup.call(this,t)},mousemove:function(t){this.trigger("mousemove",t)},mouseup:function(t){var e=this.__pointerCapturing;this.__togglePointerCapture(!1),this.trigger("mouseup",t),e&&(t.zrEventControl="only_globalout",this.trigger("mouseout",t))}};function wa(t,e){var n=e.domHandlers;Ye.pointerEventsSupported?Cn(da.pointer,function(i){Ca(e,i,function(e){n[i].call(t,e)})}):(Ye.touchEventsSupported&&Cn(da.touch,function(i){Ca(e,i,function(r){n[i].call(t,r),function(t){t.touching=!0,null!=t.touchTimer&&(clearTimeout(t.touchTimer),t.touchTimer=null),t.touchTimer=setTimeout(function(){t.touching=!1,t.touchTimer=null},700)}(e)})}),Cn(da.mouse,function(i){Ca(e,i,function(r){r=Oi(r),e.touching||n[i].call(t,r)})}))}function Sa(t,e){function n(n){Ca(e,n,function(i){i=Oi(i),ya(t,i.target)||(i=function(t,e){return Ri(t.dom,new _a(t,e),!0)}(t,i),e.domHandlers[n].call(t,i))},{capture:!0})}Ye.pointerEventsSupported?Cn(fa,n):Ye.touchEventsSupported||Cn(pa,n)}function Ca(t,e,n,i){t.mounted[e]=n,t.listenerOpts[e]=i,function(t,e,n,i){t.addEventListener(e,n,i)}(t.domTarget,e,n,i)}function ka(t){var e=t.mounted;for(var n in e)e.hasOwnProperty(n)&&$i(t.domTarget,n,e[n],t.listenerOpts[n]);t.mounted={}}var Ma=function(t,e){this.mounted={},this.listenerOpts={},this.touching=!1,this.domTarget=t,this.domHandlers=e},Ta=function(t){function e(e,n){var i=t.call(this)||this;return i.__pointerCapturing=!1,i.dom=e,i.painterRoot=n,i._localHandlerScope=new Ma(e,ba),ha&&(i._globalHandlerScope=new Ma(document,xa)),wa(i,i._localHandlerScope),i}return y(e,t),e.prototype.dispose=function(){ka(this._localHandlerScope),ha&&ka(this._globalHandlerScope)},e.prototype.setCursor=function(t){this.dom.style&&(this.dom.style.cursor=t||"default")},e.prototype.__togglePointerCapture=function(t){if(this.__mayPointerCapture=null,ha&&+this.__pointerCapturing^+t){this.__pointerCapturing=t;var e=this._globalHandlerScope;t?Sa(this,e):ka(e)}},e}(bi),Ia=1;Ye.hasGlobalWindow&&(Ia=Math.max(window.devicePixelRatio||window.screen&&window.screen.deviceXDPI/window.screen.logicalXDPI||1,1));var Da=Ia,Aa="#333",Pa="#ccc",La=Wi,Ea=5e-5;function za(t){return t>Ea||t<-5e-5}var Na,Oa=[],Ra=[],$a=[1,0,0,1,0,0],Ha=Math.abs,Fa=function(){function t(){}var e;return t.prototype.getLocalTransform=function(e){return t.getLocalTransform(this,e)},t.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},t.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},t.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},t.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},t.prototype.needLocalTransform=function(){return za(this.rotation)||za(this.x)||za(this.y)||za(this.scaleX-1)||za(this.scaleY-1)||za(this.skewX)||za(this.skewY)},t.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),n=this.transform;e||t?(n=n||[1,0,0,1,0,0],e?this.getLocalTransform(n):La(n),t&&(e?Gi(n,t,n):Ui(n,t)),this.transform=n,this._resolveGlobalScaleRatio(n)):n&&(La(n),this.invTransform=null)},t.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(null!=e&&1!==e){this.getGlobalScale(Oa);var n=Oa[0]<0?-1:1,i=Oa[1]<0?-1:1,r=((Oa[0]-n)*e+n)/Oa[0]||0,o=((Oa[1]-i)*e+i)/Oa[1]||0;t[0]*=r,t[1]*=r,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||[1,0,0,1,0,0],Xi(this.invTransform,t)},t.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},t.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],n=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),r=Math.PI/2+i-Math.atan2(t[3],t[2]);n=Math.sqrt(n)*Math.cos(r),e=Math.sqrt(e),this.skewX=r,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=n,this.originX=0,this.originY=0}},t.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||[1,0,0,1,0,0],Gi(Ra,t.invTransform,e),e=Ra);var n=this.originX,i=this.originY;(n||i)&&($a[4]=n,$a[5]=i,Gi(Ra,e,$a),Ra[4]-=n,Ra[5]-=i,e=Ra),this.setLocalTransform(e)}},t.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},t.prototype.transformCoordToLocal=function(t,e){var n=[t,e],i=this.invTransform;return i&&gi(n,n,i),n},t.prototype.transformCoordToGlobal=function(t,e){var n=[t,e],i=this.transform;return i&&gi(n,n,i),n},t.prototype.getLineScale=function(){var t=this.transform;return t&&Ha(t[0]-1)>1e-10&&Ha(t[3]-1)>1e-10?Math.sqrt(Ha(t[0]*t[3]-t[2]*t[1])):1},t.prototype.copyTransform=function(t){Va(this,t)},t.getLocalTransform=function(t,e){e=e||[];var n=t.originX||0,i=t.originY||0,r=t.scaleX,o=t.scaleY,a=t.anchorX,s=t.anchorY,l=t.rotation||0,c=t.x,u=t.y,h=t.skewX?Math.tan(t.skewX):0,d=t.skewY?Math.tan(-t.skewY):0;if(n||i||a||s){var p=n+a,f=i+s;e[4]=-p*r-h*f*o,e[5]=-f*o-d*p*r}else e[4]=e[5]=0;return e[0]=r,e[3]=o,e[1]=d*r,e[2]=h*o,l&&ji(e,e,l),e[4]+=n+c,e[5]+=i+u,e},t.initDefaultProps=((e=t.prototype).scaleX=e.scaleY=e.globalScaleRatio=1,void(e.x=e.y=e.originX=e.originY=e.skewX=e.skewY=e.rotation=e.anchorX=e.anchorY=0)),t}(),Ba=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function Va(t,e){for(var n=0;n=Ga)){t=t||Ke;for(var e=[],n=+new Date,i=0;i<=127;i++)e[i]=en.measureText(String.fromCharCode(i),t).width;var r=+new Date-n;return r>16?Ua=Ga:r>2&&Ua++,e}}(t.font),t.asciiWidthMapTried=!0),0<=e&&e<=127?null!=t.asciiWidthMap?t.asciiWidthMap[e]:t.asciiCharWidth:t.stWideCharWidth}function ja(t,e){var n=t.strWidthCache,i=n.get(e);return null==i&&(i=en.measureText(e,t.font).width,n.put(e,i)),i}function Xa(t,e,n,i){var r=ja(Wa(e),t),o=Qa(e),a=Za(0,r,n),s=Ka(0,o,i);return new cr(a,s,r,o)}function Ya(t,e,n,i){var r=((t||"")+"").split("\n");if(1===r.length)return Xa(r[0],e,n,i);for(var o=new cr(0,0,0,0),a=0;a=0?parseFloat(t)/100*e:parseFloat(t):t}function ts(t,e,n){var i=e.position||"inside",r=null!=e.distance?e.distance:5,o=n.height,a=n.width,s=o/2,l=n.x,c=n.y,u="left",h="top";if(i instanceof Array)l+=Ja(i[0],n.width),c+=Ja(i[1],n.height),u=null,h=null;else switch(i){case"left":l-=r,c+=s,u="right",h="middle";break;case"right":l+=r+a,c+=s,h="middle";break;case"top":l+=a/2,c-=r,u="center",h="bottom";break;case"bottom":l+=a/2,c+=o+r,u="center";break;case"inside":l+=a/2,c+=s,u="center",h="middle";break;case"insideLeft":l+=r,c+=s,h="middle";break;case"insideRight":l+=a-r,c+=s,u="right",h="middle";break;case"insideTop":l+=a/2,c+=r,u="center";break;case"insideBottom":l+=a/2,c+=o-r,u="center",h="bottom";break;case"insideTopLeft":l+=r,c+=r;break;case"insideTopRight":l+=a-r,c+=r,u="right";break;case"insideBottomLeft":l+=r,c+=o-r,h="bottom";break;case"insideBottomRight":l+=a-r,c+=o-r,u="right",h="bottom"}return(t=t||{}).x=l,t.y=c,t.align=u,t.verticalAlign=h,t}var es="__zr_normal__",ns=Ba.concat(["ignore"]),is=Mn(Ba,function(t,e){return t[e]=!0,t},{ignore:!1}),rs={},os=new cr(0,0,0,0),as=[],ss=function(){function t(t){this.id=gn(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return t.prototype._init=function(t){this.attr(t)},t.prototype.drift=function(t,e,n){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},t.prototype.beforeUpdate=function(){},t.prototype.afterUpdate=function(){},t.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},t.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var n=this.textConfig,i=n.local,r=e.innerTransformable,o=void 0,a=void 0,s=!1;r.parent=i?this:null;var l=!1;r.copyTransform(e);var c=null!=n.position,u=n.autoOverflowArea,h=void 0;if((u||c)&&(h=os,n.layoutRect?h.copy(n.layoutRect):h.copy(this.getBoundingRect()),i||h.applyTransform(this.transform)),c){this.calculateTextPosition?this.calculateTextPosition(rs,n,h):ts(rs,n,h),r.x=rs.x,r.y=rs.y,o=rs.align,a=rs.verticalAlign;var d=n.origin;if(d&&null!=n.rotation){var p=void 0,f=void 0;"center"===d?(p=.5*h.width,f=.5*h.height):(p=Ja(d[0],h.width),f=Ja(d[1],h.height)),l=!0,r.originX=-r.x+p+(i?0:h.x),r.originY=-r.y+f+(i?0:h.y)}}null!=n.rotation&&(r.rotation=n.rotation);var g=n.offset;g&&(r.x+=g[0],r.y+=g[1],l||(r.originX=-g[0],r.originY=-g[1]));var v=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={});if(u){var m=v.overflowRect=v.overflowRect||new cr(0,0,0,0);r.getLocalTransform(as),Xi(as,as),cr.copy(m,h),m.applyTransform(as)}else v.overflowRect=null;var y=void 0,_=void 0,b=void 0;(null==n.inside?"string"==typeof n.position&&n.position.indexOf("inside")>=0:n.inside)&&this.canBeInsideText()?(y=n.insideFill,_=n.insideStroke,null!=y&&"auto"!==y||(y=this.getInsideTextFill()),null!=_&&"auto"!==_||(_=this.getInsideTextStroke(y),b=!0)):(y=n.outsideFill,_=n.outsideStroke,null!=y&&"auto"!==y||(y=this.getOutsideFill()),null!=_&&"auto"!==_||(_=this.getOutsideStroke(y),b=!0)),(y=y||"#000")===v.fill&&_===v.stroke&&b===v.autoStroke&&o===v.align&&a===v.verticalAlign||(s=!0,v.fill=y,v.stroke=_,v.autoStroke=b,v.align=o,v.verticalAlign=a,e.setDefaultTextStyle(v)),e.__dirty|=1,s&&e.dirtyStyle(!0)}},t.prototype.canBeInsideText=function(){return!0},t.prototype.getInsideTextFill=function(){return"#fff"},t.prototype.getInsideTextStroke=function(t){return"#000"},t.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?Pa:Aa},t.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),n="string"==typeof e&&So(e);n||(n=[255,255,255,1]);for(var i=n[3],r=this.__zr.isDarkMode(),o=0;o<3;o++)n[o]=n[o]*i+(r?0:255)*(1-i);return n[3]=1,To(n,"rgba")},t.prototype.traverse=function(t,e){},t.prototype.attrKV=function(t,e){"textConfig"===t?this.setTextConfig(e):"textContent"===t?this.setTextContent(e):"clipPath"===t?this.setClipPath(e):"extra"===t?(this.extra=this.extra||{},_n(this.extra,e)):this[t]=e},t.prototype.hide=function(){this.ignore=!0,this.markRedraw()},t.prototype.show=function(){this.ignore=!1,this.markRedraw()},t.prototype.attr=function(t,e){if("string"==typeof t)this.attrKV(t,e);else if(On(t))for(var n=In(t),i=0;i0},t.prototype.getState=function(t){return this.states[t]},t.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},t.prototype.clearStates=function(t){this.useState(es,!1,t)},t.prototype.useState=function(t,e,n,i){var r=t===es;if(this.hasState()||!r){var o=this.currentStates,a=this.stateTransition;if(!(xn(o,t)>=0)||!e&&1!==o.length){var s;if(this.stateProxy&&!r&&(s=this.stateProxy(t)),s||(s=this.states&&this.states[t]),s||r){r||this.saveCurrentToNormalState(s);var l=!!(s&&s.hoverLayer||i);l&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,s,this._normalState,e,!n&&!this.__inHover&&a&&a.duration>0,a);var c=this._textContent,u=this._textGuide;return c&&c.useState(t,e,n,l),u&&u.useState(t,e,n,l),r?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!l&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2),s}vn("State "+t+" not exists.")}}},t.prototype.useStates=function(t,e,n){if(t.length){var i=[],r=this.currentStates,o=t.length,a=o===r.length;if(a)for(var s=0;s0,p);var f=this._textContent,g=this._textGuide;f&&f.useStates(t,e,h),g&&g.useStates(t,e,h),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!h&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=-2)}else this.clearStates()},t.prototype.isSilent=function(){for(var t=this;t;){if(t.silent)return!0;var e=t.__hostTarget;t=e?t.ignoreHostSilent?null:e:t.parent}return!1},t.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var n=this.currentStates.slice();n.splice(e,1),this.useStates(n)}},t.prototype.replaceState=function(t,e,n){var i=this.currentStates.slice(),r=xn(i,t),o=xn(i,e)>=0;r>=0?o?i.splice(r,1):i[r]=e:n&&!o&&i.push(e),this.useStates(i)},t.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},t.prototype._mergeStates=function(t){for(var e,n={},i=0;i=0&&e.splice(n,1)}),this.animators.push(t),n&&n.animation.addAnimator(t),n&&n.wakeUp()},t.prototype.updateDuringAnimation=function(t){this.markRedraw()},t.prototype.stopAnimation=function(t,e){for(var n=this.animators,i=n.length,r=[],o=0;o0&&n.during&&o[0].during(function(t,e){n.during(e)});for(var d=0;d0||r.force&&!a.length){var w,S=void 0,C=void 0,k=void 0;if(s){C={},d&&(S={});for(b=0;b<_;b++){C[m=g[b]]=n[m],d?S[m]=i[m]:n[m]=i[m]}}else if(d){k={};for(b=0;b<_;b++){k[m=g[b]]=ta(n[m]),us(n,i,m)}}(w=new aa(n,!1,!1,h?Tn(f,function(t){return t.targetName===e}):null)).targetName=e,r.scope&&(w.scope=r.scope),d&&S&&w.whenWithKeys(0,S,g),k&&w.whenWithKeys(0,k,g),w.whenWithKeys(null==c?500:c,s?C:i,g).delay(u||0),t.addAnimator(w,e),a.push(w)}}wn(ss,bi),wn(ss,Fa);var ds=function(t){function e(e){var n=t.call(this)||this;return n.isGroup=!0,n._children=[],n.attr(e),n}return y(e,t),e.prototype.childrenRef=function(){return this._children},e.prototype.children=function(){return this._children.slice()},e.prototype.childAt=function(t){return this._children[t]},e.prototype.childOfName=function(t){for(var e=this._children,n=0;n=0&&(n.splice(i,0,t),this._doAdd(t))}return this},e.prototype.replace=function(t,e){var n=xn(this._children,t);return n>=0&&this.replaceAt(e,n),this},e.prototype.replaceAt=function(t,e){var n=this._children,i=n[e];if(t&&t!==this&&t.parent!==this&&t!==i){n[e]=t,i.parent=null;var r=this.__zr;r&&i.removeSelfFromZr(r),this._doAdd(t)}return this},e.prototype._doAdd=function(t){t.parent&&t.parent.remove(t),t.parent=this;var e=this.__zr;e&&e!==t.__zr&&t.addSelfToZr(e),e&&e.refresh()},e.prototype.remove=function(t){var e=this.__zr,n=this._children,i=xn(n,t);return i<0||(n.splice(i,1),t.parent=null,e&&t.removeSelfFromZr(e),e&&e.refresh()),this},e.prototype.removeAll=function(){for(var t=this._children,e=this.__zr,n=0;n str | None:
+ """Return the kind of sub-device an identifier names, or None if it names none.
+
+ None rather than an "unknown" string: the caller knows whether it is looking
+ at something that must be a sub-device, and a sentinel that reads like a kind
+ is what let an unclassified device render as a device with no type.
+ """
+ if identifier.endswith(f"_{SUB_DEVICE_BESS}"):
+ return SUB_DEVICE_BESS
+ if identifier.endswith(f"_{SUB_DEVICE_MID}"):
+ return SUB_DEVICE_MID
+ # Infix, not suffix: the node id follows, and it is what distinguishes one
+ # charger from another on the same panel.
+ if f"_{SUB_DEVICE_EVSE}_" in identifier:
+ return SUB_DEVICE_EVSE
+ return None
+
def snapshot_to_device_info(
snapshot: SpanPanelSnapshot,
@@ -57,7 +88,7 @@ def bess_device_info(
"""
name = f"{panel_name} Battery"
return DeviceInfo(
- identifiers={(DOMAIN, f"{panel_identifier}_bess")},
+ identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_BESS}")},
name=name,
manufacturer=battery.vendor_name or "Unknown",
# `model` is the human designation on both schemas now: v1.0 publishes it as
@@ -92,7 +123,7 @@ def mid_device_info(
would put the MID one level deeper than its siblings for no reader's benefit.
"""
return DeviceInfo(
- identifiers={(DOMAIN, f"{panel_identifier}_mid")},
+ identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_MID}")},
name=f"{panel_name} Microgrid Interconnect",
manufacturer=mid.vendor_name or "Unknown",
model=mid.model or "Microgrid Interconnect Device",
@@ -114,7 +145,7 @@ def evse_device_info(
name = f"{base_name} ({display_suffix})" if display_suffix else base_name
name = f"{panel_name} {name}"
return DeviceInfo(
- identifiers={(DOMAIN, f"{panel_identifier}_evse_{evse.node_id}")},
+ identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_EVSE}_{evse.node_id}")},
name=name,
manufacturer=evse.vendor_name or "SPAN",
model=evse.model or "SPAN Drive",
diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py
index aae65dbb..f2969a60 100644
--- a/custom_components/span_panel/websocket.py
+++ b/custom_components/span_panel/websocket.py
@@ -13,6 +13,7 @@
from .const import DOMAIN
from .helpers import build_panel_unique_id
from .id_builder import build_binary_sensor_unique_id
+from .util import classify_sub_device_identifier
if TYPE_CHECKING:
from . import SpanPanelRuntimeData
@@ -224,12 +225,19 @@ def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None:
def _classify_sub_device(device_entry: dr.DeviceEntry) -> str:
- """Classify a sub-device as 'bess' or 'evse' based on its identifiers."""
+ """Classify a sub-device by its identifiers: 'bess', 'mid', 'evse' or 'unknown'.
+
+ The grammar lives with the builders that write it, in `util`, rather than
+ being restated here. Restating it is how the MID went out as 'unknown' for a
+ release — it was added to the writing end and not to this one — and how a
+ consumer ended up rendering a device with a name and no type.
+
+ 'unknown' is still returned rather than propagated as None, because it is the
+ documented wire value for this field and a consumer distinguishes on it.
+ """
for _, identifier in device_entry.identifiers:
- if "_bess" in identifier:
- return "bess"
- if "_evse_" in identifier:
- return "evse"
+ if (kind := classify_sub_device_identifier(identifier)) is not None:
+ return kind
return "unknown"
diff --git a/tests/test_device_links.py b/tests/test_device_links.py
index 0a4b04e5..de91e06c 100644
--- a/tests/test_device_links.py
+++ b/tests/test_device_links.py
@@ -27,6 +27,7 @@
from custom_components.span_panel.const import DOMAIN
from custom_components.span_panel.util import (
bess_device_info,
+ classify_sub_device_identifier,
evse_device_info,
mid_device_info,
)
@@ -88,6 +89,35 @@ def _builders() -> list[tuple[str, Any]]:
]
+@pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "")
+def test_every_builders_identifier_classifies_as_its_own_kind(label: str, build: Any) -> None:
+ """The writing end and the reading end must agree, for every kind.
+
+ They did not: the topology command restated the identifier grammar instead of
+ reading it back, so when the MID was added it classified as `unknown` and a
+ card rendered it as a device with a name and no type. Parametrised over the
+ same builder list as the link test, so a fourth sub-device added to `util`
+ without teaching `classify_sub_device_identifier` fails here rather than in
+ someone's dashboard.
+ """
+ info = build()
+ identifiers = info.get("identifiers") or set()
+ kinds = {classify_sub_device_identifier(identifier) for _domain, identifier in identifiers}
+
+ assert kinds == {label}, f"{label} identifier classifies as {kinds}"
+
+
+def test_the_panel_is_not_mistaken_for_a_sub_device() -> None:
+ """A bare serial names the panel, and the panel is nobody's child.
+
+ The classifier reads suffixes, so the guard worth having is that an
+ identifier carrying no kind at all comes back as None rather than matching
+ something by accident.
+ """
+ assert classify_sub_device_identifier("sp3-link-001") is None
+ assert classify_sub_device_identifier("sim-40t-001") is None
+
+
@pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "")
def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None:
"""Both halves asserted: the new key is set and the old one is gone.
diff --git a/websocket-api.md b/websocket-api.md
index 79e95fc7..4e74b8f4 100644
--- a/websocket-api.md
+++ b/websocket-api.md
@@ -6,7 +6,7 @@ AppDaemon scripts, or any WebSocket client connected to Home Assistant.
## `span_panel/panel_topology`
Returns the full physical layout of a SPAN panel in a single call — circuits with their breaker slot positions, entity IDs grouped by role (power, energy,
-switch, select), and sub-devices (BESS, EVSE) with their entities.
+switch, select), and sub-devices (BESS, MID, EVSE) with their entities.
A custom card rendering the physical panel needs to know which breaker slot each circuit occupies, which entity provides its power reading, which switch
controls its relay, and so on. Without this command, the card would need to query the device registry, entity registry, and individual entity states in separate
@@ -117,7 +117,7 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru
| `device_id` | string | HA device registry ID (echoed from request) |
| `device_name` | string | HA device display name |
| `circuits` | object | Circuit UUID keyed map (see below) |
-| `sub_devices` | object | HA device ID keyed map of BESS/EVSE (see below) |
+| `sub_devices` | object | HA device ID keyed map of BESS/MID/EVSE (see below) |
#### Circuit Object
@@ -153,7 +153,7 @@ current, `switch` is absent for always-on circuits).
| Field | Type | Description |
| --------------- | ----------- | ------------------------------------- |
| `name` | string | HA device display name |
-| `type` | string | `bess`, `evse`, or `unknown` |
+| `type` | string | `bess`, `mid`, `evse`, or `unknown` |
| `manufacturer` | string/null | Device manufacturer |
| `model` | string/null | Device model |
| `serial_number` | string/null | Device serial number |
From 987f29b0bc71f52382a5b21178fda1f9f095f9be Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 12 Aug 2026 18:21:47 -0700
Subject: [PATCH 011/116] Release 2.1.0, and say that the Home Assistant floor
moved
v2.0.8 is the last release; sixteen commits of v1.0 schema work have landed
since with no version to file them under, and `pyproject.toml` had drifted a
patch behind `manifest.json` besides. Both now read 2.1.0.
The entry leads with the Home Assistant requirement because it is the only
change here a user can be hurt by. 2026.8 replaced the two device-registry
calls this integration depends on and the replacements do not exist in
2026.5-2026.7, so no single release runs on both; anyone older stays on 2.0.8
and HACS will not offer them this. That is worth a sentence in a changelog
rather than a silent failure to update.
Everything else is additive or an improvement, and is written for someone
reading release notes rather than a diff: the MID device and its Grid State
entity, self-reload on a firmware schema change, `DSM Grid State` becoming
sensed rather than inferred, `Grid Islandable` surviving on MID presence, and
the enum sensors that used to advertise only "unknown". `battery.model`
changing value is called out, because it is the one existing entity whose
reading moves.
Emphasis in the new section uses underscores to match the file. MD049 is
first-wins, so an asterisk above the existing `_must_` lines silently made
every one of them a violation.
---
CHANGELOG.md | 38 ++++++++++++++++++++++
custom_components/span_panel/manifest.json | 2 +-
pyproject.toml | 2 +-
uv.lock | 2 +-
4 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a84ef89a..42b75d54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,44 @@
All notable changes to this project will be documented in this file.
+## [2.1.0] - 8/2026
+
+Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does.
+
+### Requires Home Assistant 2026.8.0 or newer
+
+This release raises the minimum from 2026.5.4. Home Assistant 2026.8 replaced the two device-registry calls this integration relies on — the old forms stop
+working entirely in 2027.8 — and their replacements do not exist in 2026.5 through 2026.7, so there is no version of this release that runs on both. If you are
+on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not offer you this release.
+
+### Added
+
+- **Your panel's Microgrid Interconnect Device appears as its own device** on the new data model, carrying **Grid State** — the health of the utility supply
+ itself, which the previous firmware never reported. Everything about it is additive; no existing entity moves or changes id.
+- **The integration notices a firmware upgrade and reloads itself.** A panel that becomes v1.0 while Home Assistant is running used to keep reading the tree
+ with the old parser, reporting every circuit as missing until you reloaded by hand. It now detects the change, reloads, writes a log line, and raises a
+ one-time notice explaining what changed.
+- **Grid-forming device name** as an attribute on the GFE sensor.
+
+### Changed
+
+- **`DSM Grid State` is now more trustworthy on the new data model.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the
+ battery if one was fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the
+ Microgrid Interconnect Device actually senses.
+- **`Grid Islandable` keeps working** across the upgrade. v1.0 publishes no panel-level islandable property, so the entity now reflects whether a Microgrid
+ Interconnect Device is present, which is how v1.0 says backup capability is detected.
+- **Battery model** may read differently after upgrading: the new data model separates the human-readable designation from the SKU, and this entity now shows
+ the designation. This is a library-level normalisation applied to both data models, so it happens once, at this release, rather than unpredictably during a
+ firmware update.
+
+### Fixed
+
+- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed
+ "Possible states: Unknown". The lists are now derived from the translations and checked against them by a test.
+- **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model.
+- **Sub-devices link to the panel by registry id**, replacing a form Home Assistant deprecated in 2026.8. No user-visible effect; required for the version bump
+ above.
+
## [2.0.8] - 5/2026
### Fixed
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 0e6c92a8..267b2db6 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0b3",
"span-panel-api-schema-1==0.1.0b3"
],
- "version": "2.0.8",
+ "version": "2.1.0",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/pyproject.toml b/pyproject.toml
index 7d8e974b..4cacfe27 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "span"
-version = "2.0.7"
+version = "2.1.0"
description = "Span Panel Custom Integration for Home Assistant"
authors = [{name = "SpanPanel"}]
license = {text = "MIT"}
diff --git a/uv.lock b/uv.lock
index 1c279442..8108e62c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2491,7 +2491,7 @@ wheels = [
[[package]]
name = "span"
-version = "2.0.7"
+version = "2.1.0"
source = { virtual = "." }
dependencies = [
{ name = "homeassistant" },
From 6c1bf4d6e1fba60349478005cce3016df14be563 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Mon, 17 Aug 2026 15:09:06 -0700
Subject: [PATCH 012/116] feat(mid): show the firmware and hardware revision
the MID publishes
r202633 documents model, firmware-version and hardware-version on the MID's info
node. mid_device_info could set a model and a serial and nothing else, so a user
saw a Microgrid Interconnect card with no firmware row beside a battery that had
one -- the battery's identical property having been mapped from the start. The
library now carries both (span-panel-api SpanMidSnapshot.software_version /
hardware_version); this reads them.
Passed through unguarded, exactly as bess_device_info does. DeviceInfo omits a
None field and renders an empty string as a present-but-blank row, so `or ""`
would invent a firmware row reading empty for a panel that published nothing --
worse than no row, because it asserts the panel answered and the answer was
nothing. A test pins that distinction.
No schema conditional, deliberately: flat publishes no MID at all, so has_mid
keeps every caller of this builder off a flat panel and a branch here would be
unreachable code implying a case that cannot arise. The comment saying so
originally used the DUAL-SCHEMA token and was caught by
test_every_schema_conditional_is_findable, which asserts the exact set of modules
that branch on schema -- util.py does not, and the marker is a grep target rather
than prose. Reworded rather than widening the list.
Corrects a stale comment in the existing card test. It read "the producer
publishes no MID model today", which stopped being true when panelbench started
valuing info/model; that case is the fallback path now, not the only path.
PV is deliberately not included. The integration has no PV device -- PV metadata
reaches a user as three diagnostic sensors on the panel -- so consuming
SpanPVSnapshot.software_version would mean adding a fourth, which is a new
user-visible entity and a product decision rather than a mapping change.
---
custom_components/span_panel/util.py | 11 ++++++
tests/test_mid_device.py | 50 +++++++++++++++++++++++++++-
uv.lock | 2 +-
3 files changed, 61 insertions(+), 2 deletions(-)
diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py
index 5ff7aa4b..9462180e 100644
--- a/custom_components/span_panel/util.py
+++ b/custom_components/span_panel/util.py
@@ -128,6 +128,17 @@ def mid_device_info(
manufacturer=mid.vendor_name or "Unknown",
model=mid.model or "Microgrid Interconnect Device",
serial_number=mid.serial_number,
+ # Passed through unguarded, exactly as `bess_device_info` does: `DeviceInfo`
+ # omits a `None` field and renders an empty string as a present-but-blank row,
+ # so `or ""` here would invent a row for a panel that published nothing. The
+ # library preserves that distinction for the same reason.
+ #
+ # Unguarded on schema, deliberately: r202633 documents both on the MID's `info`
+ # node, and flat publishes no MID at all, so `has_mid` keeps every caller of this
+ # builder off a flat panel. A conditional here would be unreachable code implying
+ # a case that cannot arise.
+ sw_version=mid.software_version,
+ hw_version=mid.hardware_version,
via_device_id=panel_device_id,
)
diff --git a/tests/test_mid_device.py b/tests/test_mid_device.py
index b4cbb1f3..ac203365 100644
--- a/tests/test_mid_device.py
+++ b/tests/test_mid_device.py
@@ -72,8 +72,13 @@ def test_the_mid_becomes_its_own_device_hung_off_the_panel() -> None:
assert info["manufacturer"] == "Span"
assert info["serial_number"] == "SIM-BESS-40T-001-mid"
assert info["via_device_id"] == "panel-device-id"
- # The producer publishes no MID model today; the card still needs a legible one.
+ # A panel that publishes no MID model still needs a legible card. This was written
+ # when no producer published one at all; panelbench does now, so this is the
+ # fallback path rather than the only path — `test_the_mid_card_carries_the_identity
+ # _a_producer_publishes` covers the other.
assert info["model"] == "Microgrid Interconnect Device"
+ assert "sw_version" not in info or info["sw_version"] is None
+ assert "hw_version" not in info or info["hw_version"] is None
def test_the_mid_carries_grid_state_and_nothing_already_surfaced() -> None:
@@ -148,3 +153,46 @@ def test_nothing_reads_the_mid_without_checking_it_is_there() -> None:
assert create_mid_sensors(_coordinator(), flat) == []
assert _grid_forming_device_name(flat) is None
assert "mid" not in detect_capabilities(flat)
+
+
+def test_the_mid_card_carries_the_identity_a_producer_publishes() -> None:
+ """Model, firmware and hardware revision reach the device card when published.
+
+ r202633 documents all three on the MID's `info` node. Until the library carried the
+ latter two, `mid_device_info` could set a model and a serial and nothing else, so a
+ user saw a Microgrid Interconnect card with no firmware row beside a battery that
+ had one — the battery's identical property having been mapped from the start.
+
+ Not gated on schema. Flat publishes no MID at all, so `has_mid` keeps every caller
+ of this builder off a flat panel; a guard here would be unreachable code implying a
+ case that cannot arise.
+ """
+ info = mid_device_info(
+ "sim-40t-001",
+ _mid(model="SPAN MID", software_version="sim-mid/v0.1.0", hardware_version="rev1"),
+ "SPAN Panel",
+ panel_device_id="panel-device-id",
+ )
+
+ assert info["model"] == "SPAN MID"
+ assert info["sw_version"] == "sim-mid/v0.1.0"
+ assert info["hw_version"] == "rev1"
+
+
+def test_an_unpublished_revision_omits_the_row_rather_than_blanking_it() -> None:
+ """`None` and `""` are different to a user, so the library's distinction is kept.
+
+ `DeviceInfo` omits a `None` field and renders an empty string as a present-but-blank
+ row. Defaulting with `or ""` here would invent a firmware row reading empty for a
+ panel that published nothing, which is worse than no row: it asserts the panel
+ answered and the answer was nothing.
+ """
+ info = mid_device_info(
+ "sim-40t-001",
+ _mid(software_version=None, hardware_version=None),
+ "SPAN Panel",
+ panel_device_id="panel-device-id",
+ )
+
+ assert info.get("sw_version") is None
+ assert info.get("hw_version") is None
diff --git a/uv.lock b/uv.lock
index 8108e62c..61397ab4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2580,7 +2580,7 @@ dev = [
{ name = "ruff", specifier = ">=0.15.5" },
{ name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" },
{ name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" },
- { name = "twine" },
+ { name = "twine", specifier = ">=7.0" },
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
{ name = "vulture", specifier = ">=2.14" },
]
From 19e555fee0e8f45d26d13aeeaa662f66ce9091ec Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Tue, 18 Aug 2026 22:01:26 -0700
Subject: [PATCH 013/116] test: add adapter distributions and vendored schema
fixtures
---
requirements_test.txt | 3 +
tests/adapter_fixtures.py | 74 +++
tests/fixtures/schema_one_tree.json | 225 ++++++++++
.../fixtures/schema_one_tree_batteryless.json | 214 +++++++++
tests/fixtures/schema_one_tree_no_pv.json | 218 +++++++++
tests/fixtures/schema_zero_types.json | 420 ++++++++++++++++++
6 files changed, 1154 insertions(+)
create mode 100644 tests/adapter_fixtures.py
create mode 100644 tests/fixtures/schema_one_tree.json
create mode 100644 tests/fixtures/schema_one_tree_batteryless.json
create mode 100644 tests/fixtures/schema_one_tree_no_pv.json
create mode 100644 tests/fixtures/schema_zero_types.json
diff --git a/requirements_test.txt b/requirements_test.txt
index 250967b2..0ecdb95d 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,3 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
+span-panel-api==3.0.0b4
+span-panel-api-schema-0==1.0.0b4
+span-panel-api-schema-1==0.1.0b4
diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py
new file mode 100644
index 00000000..473161ca
--- /dev/null
+++ b/tests/adapter_fixtures.py
@@ -0,0 +1,74 @@
+"""Build real adapter field metadata from vendored fixtures.
+
+The library's own harness compares wire-level deltas between schemas; it cannot
+know what this integration declares it reads. These helpers give the
+integration's tests real adapter output to check declarations against.
+
+Uses the real `ebus_sdk.DiscoveredDevice` rather than a stand-in. A
+description-only stand-in is not sufficient: `_downstream_lugs_metadata` ->
+`find_lugs` reads property *values* via `device.get_property(...)`, so a stand-in
+either raises or silently loses the five downstream/feedthrough paths. ebus-sdk
+arrives transitively with span-panel-api-schema-1, so the import is free.
+"""
+
+from __future__ import annotations
+
+import json
+import pathlib
+
+from ebus_sdk.homie import DiscoveredDevice
+from span_panel_api.models import FieldMetadata
+
+_FIXTURES = pathlib.Path(__file__).parent / "fixtures"
+
+
+def _devices(name: str) -> list[DiscoveredDevice]:
+ """Rebuild discovered devices from a retained-topic capture.
+
+ Mirrors the library's own builder (test_schema_one_devices.py:26-37):
+ `update_description` parses the JSON string, `update_property` stores each
+ non-`$` topic as a property value.
+ """
+ tree = json.loads((_FIXTURES / name).read_text())
+ devices: list[DiscoveredDevice] = []
+ for device_id, topics in tree.items():
+ device = DiscoveredDevice(device_id, "ebus")
+ device.update_description(topics["$description"])
+ device.update_state(topics.get("$state", "ready"))
+ for topic, value in topics.items():
+ if topic.startswith("$"):
+ continue
+ node, _, prop = topic.partition("/")
+ if prop:
+ device.update_property(node, prop, value)
+ devices.append(device)
+ return devices
+
+
+def schema_zero_metadata() -> dict[str, FieldMetadata]:
+ """Field metadata as schema_0 builds it from the flat REST schema."""
+ from span_panel_api_schema_0.field_metadata import build_field_metadata
+
+ raw = json.loads((_FIXTURES / "schema_zero_types.json").read_text())
+ return build_field_metadata(raw["types"])
+
+
+def schema_one_metadata() -> dict[str, FieldMetadata]:
+ """Field metadata as schema_1 builds it from a full parent/child tree."""
+ from span_panel_api_schema_1.field_metadata import build_field_metadata
+
+ return build_field_metadata(_devices("schema_one_tree.json"))
+
+
+def schema_one_metadata_batteryless() -> dict[str, FieldMetadata]:
+ """Build the same tree with the BESS removed — no battery hardware present."""
+ from span_panel_api_schema_1.field_metadata import build_field_metadata
+
+ return build_field_metadata(_devices("schema_one_tree_batteryless.json"))
+
+
+def schema_one_metadata_no_pv() -> dict[str, FieldMetadata]:
+ """Build the same tree with the PV device removed, power-flows still present."""
+ from span_panel_api_schema_1.field_metadata import build_field_metadata
+
+ return build_field_metadata(_devices("schema_one_tree_no_pv.json"))
diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json
new file mode 100644
index 00000000..f6c25916
--- /dev/null
+++ b/tests/fixtures/schema_one_tree.json
@@ -0,0 +1,225 @@
+{
+ "0ab966b95f92a6a51ec548485aa85f54": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "1",
+ "breaker/rating": "15",
+ "info/name": "Kitchen Lights",
+ "info/spaces": "1",
+ "load-shed/priority": "UNKNOWN",
+ "meter/active-power": "-121.0",
+ "meter/current": "1.0083333333333333",
+ "meter/exported-energy": "2.0166666666666666",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "1",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "573066aaddd7b75114c4563ce3af18c4": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "2",
+ "breaker/rating": "30",
+ "connection/feeds-device-id": "pv",
+ "connection/feeds-device-status": "OK",
+ "connection/feeds-device-type": "energy.ebus.device.pv",
+ "info/name": "Solar Inverter",
+ "info/spaces": "36,38",
+ "load-shed/priority": "NEVER",
+ "meter/active-power": "8500.0",
+ "meter/current": "35.416666666666664",
+ "meter/exported-energy": "0.0",
+ "meter/imported-energy": "141.66666666666666",
+ "pcs/managed": "false",
+ "pcs/priority": "5",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "false",
+ "switch/relay-requester": "NONE"
+ },
+ "62d0e03897b337b57101aae82f1e9ba2": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "2",
+ "breaker/rating": "50",
+ "connection/feeds-device-id": "evse",
+ "connection/feeds-device-status": "OK",
+ "connection/feeds-device-type": "energy.ebus.device.evse",
+ "info/name": "SPAN Drive - Garage",
+ "info/spaces": "32,34",
+ "load-shed/priority": "OFF_GRID",
+ "meter/active-power": "-2410.0",
+ "meter/current": "10.041666666666666",
+ "meter/exported-energy": "40.166666666666664",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "3",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "bess": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "info/model": "Example BESS",
+ "info/nameplate-capacity": "13.5",
+ "info/vendor-name": "Span",
+ "meter/active-power": "-3500.0",
+ "soc/soc": "50.410493827160494",
+ "soc/soe": "6.805416666666667",
+ "status/communication-state": "OK"
+ },
+ "bess-mid": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}",
+ "$state": "ready",
+ "grid/grid-forming-entity": "GRID",
+ "grid/grid-state": "UP",
+ "grid/islanding-state": "ON_GRID",
+ "info/vendor-name": "Span"
+ },
+ "d3724e0d660ba506aa79c1cafe5d1181": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "1",
+ "breaker/rating": "20",
+ "info/name": "Garage Outlet",
+ "info/spaces": "2",
+ "load-shed/priority": "UNKNOWN",
+ "meter/active-power": "-122.0",
+ "meter/current": "1.0166666666666666",
+ "meter/exported-energy": "2.033333333333333",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "2",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "evse": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "config/max-charge-current": "32",
+ "config/user-max-charge-current": "32",
+ "info/firmware-version": "example/v0.1.0",
+ "info/model": "SPAN Drive",
+ "info/part-number": "SPN-DRV-001",
+ "info/serial-number": "SIM-EVSE-example-40t-001",
+ "info/vendor-name": "SPAN",
+ "meter/advertised-current": "32.0",
+ "status/status": "CHARGING",
+ "switch/lock-state": "LOCKED"
+ },
+ "evse-2": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "config/max-charge-current": "32",
+ "config/user-max-charge-current": "32",
+ "info/firmware-version": "example/v0.1.0",
+ "info/model": "SPAN Drive",
+ "info/part-number": "SPN-DRV-001",
+ "info/serial-number": "SIM-EVSE-example-40t-001-2",
+ "info/vendor-name": "SPAN",
+ "meter/advertised-current": "32.0",
+ "status/status": "AVAILABLE",
+ "switch/lock-state": "UNLOCKED"
+ },
+ "example-40t-001": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}",
+ "$state": "ready",
+ "breaker/rating": "200",
+ "door/state": "CLOSED",
+ "info/data-model-version": "1.0",
+ "info/firmware-version": "example/v0.1.0",
+ "info/hardware-version": "rev2",
+ "info/model": "MAIN_40",
+ "info/serial-number": "example-40t-001",
+ "info/vendor-name": "Span",
+ "meter/voltage-a": "120.0",
+ "meter/voltage-b": "120.0",
+ "pcs/active": "false",
+ "pcs/binding-constraint": "NONE",
+ "pcs/enabled": "false",
+ "pcs/feed-import-limit": "0.0",
+ "pcs/feed-import-limit-active": "false",
+ "pcs/feed-import-limit-enablement": "UNCONFIGURED",
+ "pcs/import-limit": "0.0",
+ "pcs/off-grid-import-limit": "0.0",
+ "pcs/off-grid-import-limit-active": "false",
+ "pcs/off-grid-import-limit-enablement": "UNCONFIGURED",
+ "pcs/operator-import-limit": "0.0",
+ "pcs/operator-import-limit-active": "false",
+ "pcs/operator-import-limit-enablement": "UNCONFIGURED",
+ "pcs/requested-import-limit": "0.0",
+ "pcs/requested-import-limit-active": "false",
+ "pcs/requested-import-limit-enablement": "UNCONFIGURED",
+ "power-flows/battery": "-3500.0",
+ "power-flows/grid": "-2347.0",
+ "power-flows/pv": "8500.0",
+ "power-flows/site": "2653.0",
+ "shed-forecast/confidence": "HIGH",
+ "shed-forecast/full-charge-time-to-priority-shed": "3038",
+ "shed-forecast/full-charge-total-time-remaining": "4320",
+ "shed-forecast/time-to-priority-shed": "3037",
+ "shed-forecast/total-time-remaining": "4320",
+ "shed/asserted-islanding-state": "NONE",
+ "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}",
+ "status/cloud-connection": "CONNECTED",
+ "status/ethernet": "true",
+ "status/postal-code": "94103",
+ "status/relay": "CLOSED",
+ "status/time-zone": "America/Los_Angeles",
+ "status/wifi": "true"
+ },
+ "fe8b85c15bc9610c1b8b4ebc6f82488d": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "2",
+ "breaker/rating": "50",
+ "connection/feeds-device-id": "evse-2",
+ "connection/feeds-device-status": "OK",
+ "connection/feeds-device-type": "energy.ebus.device.evse",
+ "info/name": "SPAN Drive - Driveway",
+ "info/spaces": "35,37",
+ "load-shed/priority": "OFF_GRID",
+ "meter/active-power": "0.0",
+ "meter/current": "0.0",
+ "meter/exported-energy": "0.0",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "4",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "lugs-downstream": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "info/direction": "DOWNSTREAM",
+ "meter/active-power": "-5847.0",
+ "meter/current-a": "46.46666666666666",
+ "meter/current-b": "46.474999999999994",
+ "meter/exported-energy": "141.66666666666666",
+ "meter/imported-energy": "44.21666666666666"
+ },
+ "lugs-upstream": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "connection/fed-by-device-id": "bess",
+ "connection/fed-by-device-status": "OK",
+ "connection/fed-by-device-type": "energy.ebus.device.bess",
+ "info/direction": "UPSTREAM",
+ "meter/active-power": "-5847.0",
+ "meter/current-a": "46.46666666666666",
+ "meter/current-b": "46.474999999999994",
+ "meter/exported-energy": "141.66666666666666",
+ "meter/imported-energy": "44.21666666666666"
+ },
+ "pv": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "info/model": "IQ8PLUS-72-2-US",
+ "info/nominal-power": "10000.0",
+ "info/vendor-name": "Enphase"
+ }
+}
diff --git a/tests/fixtures/schema_one_tree_batteryless.json b/tests/fixtures/schema_one_tree_batteryless.json
new file mode 100644
index 00000000..6baeda2a
--- /dev/null
+++ b/tests/fixtures/schema_one_tree_batteryless.json
@@ -0,0 +1,214 @@
+{
+ "0ab966b95f92a6a51ec548485aa85f54": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "1",
+ "breaker/rating": "15",
+ "info/name": "Kitchen Lights",
+ "info/spaces": "1",
+ "load-shed/priority": "UNKNOWN",
+ "meter/active-power": "-121.0",
+ "meter/current": "1.0083333333333333",
+ "meter/exported-energy": "2.0166666666666666",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "1",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "573066aaddd7b75114c4563ce3af18c4": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "2",
+ "breaker/rating": "30",
+ "connection/feeds-device-id": "pv",
+ "connection/feeds-device-status": "OK",
+ "connection/feeds-device-type": "energy.ebus.device.pv",
+ "info/name": "Solar Inverter",
+ "info/spaces": "36,38",
+ "load-shed/priority": "NEVER",
+ "meter/active-power": "8500.0",
+ "meter/current": "35.416666666666664",
+ "meter/exported-energy": "0.0",
+ "meter/imported-energy": "141.66666666666666",
+ "pcs/managed": "false",
+ "pcs/priority": "5",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "false",
+ "switch/relay-requester": "NONE"
+ },
+ "62d0e03897b337b57101aae82f1e9ba2": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "2",
+ "breaker/rating": "50",
+ "connection/feeds-device-id": "evse",
+ "connection/feeds-device-status": "OK",
+ "connection/feeds-device-type": "energy.ebus.device.evse",
+ "info/name": "SPAN Drive - Garage",
+ "info/spaces": "32,34",
+ "load-shed/priority": "OFF_GRID",
+ "meter/active-power": "-2410.0",
+ "meter/current": "10.041666666666666",
+ "meter/exported-energy": "40.166666666666664",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "3",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "bess-mid": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}",
+ "$state": "ready",
+ "grid/grid-forming-entity": "GRID",
+ "grid/grid-state": "UP",
+ "grid/islanding-state": "ON_GRID",
+ "info/vendor-name": "Span"
+ },
+ "d3724e0d660ba506aa79c1cafe5d1181": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "1",
+ "breaker/rating": "20",
+ "info/name": "Garage Outlet",
+ "info/spaces": "2",
+ "load-shed/priority": "UNKNOWN",
+ "meter/active-power": "-122.0",
+ "meter/current": "1.0166666666666666",
+ "meter/exported-energy": "2.033333333333333",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "2",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "evse": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "config/max-charge-current": "32",
+ "config/user-max-charge-current": "32",
+ "info/firmware-version": "example/v0.1.0",
+ "info/model": "SPAN Drive",
+ "info/part-number": "SPN-DRV-001",
+ "info/serial-number": "SIM-EVSE-example-40t-001",
+ "info/vendor-name": "SPAN",
+ "meter/advertised-current": "32.0",
+ "status/status": "CHARGING",
+ "switch/lock-state": "LOCKED"
+ },
+ "evse-2": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "config/max-charge-current": "32",
+ "config/user-max-charge-current": "32",
+ "info/firmware-version": "example/v0.1.0",
+ "info/model": "SPAN Drive",
+ "info/part-number": "SPN-DRV-001",
+ "info/serial-number": "SIM-EVSE-example-40t-001-2",
+ "info/vendor-name": "SPAN",
+ "meter/advertised-current": "32.0",
+ "status/status": "AVAILABLE",
+ "switch/lock-state": "UNLOCKED"
+ },
+ "example-40t-001": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}",
+ "$state": "ready",
+ "breaker/rating": "200",
+ "door/state": "CLOSED",
+ "info/data-model-version": "1.0",
+ "info/firmware-version": "example/v0.1.0",
+ "info/hardware-version": "rev2",
+ "info/model": "MAIN_40",
+ "info/serial-number": "example-40t-001",
+ "info/vendor-name": "Span",
+ "meter/voltage-a": "120.0",
+ "meter/voltage-b": "120.0",
+ "pcs/active": "false",
+ "pcs/binding-constraint": "NONE",
+ "pcs/enabled": "false",
+ "pcs/feed-import-limit": "0.0",
+ "pcs/feed-import-limit-active": "false",
+ "pcs/feed-import-limit-enablement": "UNCONFIGURED",
+ "pcs/import-limit": "0.0",
+ "pcs/off-grid-import-limit": "0.0",
+ "pcs/off-grid-import-limit-active": "false",
+ "pcs/off-grid-import-limit-enablement": "UNCONFIGURED",
+ "pcs/operator-import-limit": "0.0",
+ "pcs/operator-import-limit-active": "false",
+ "pcs/operator-import-limit-enablement": "UNCONFIGURED",
+ "pcs/requested-import-limit": "0.0",
+ "pcs/requested-import-limit-active": "false",
+ "pcs/requested-import-limit-enablement": "UNCONFIGURED",
+ "power-flows/battery": "-3500.0",
+ "power-flows/grid": "-2347.0",
+ "power-flows/pv": "8500.0",
+ "power-flows/site": "2653.0",
+ "shed-forecast/confidence": "HIGH",
+ "shed-forecast/full-charge-time-to-priority-shed": "3038",
+ "shed-forecast/full-charge-total-time-remaining": "4320",
+ "shed-forecast/time-to-priority-shed": "3037",
+ "shed-forecast/total-time-remaining": "4320",
+ "shed/asserted-islanding-state": "NONE",
+ "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}",
+ "status/cloud-connection": "CONNECTED",
+ "status/ethernet": "true",
+ "status/postal-code": "94103",
+ "status/relay": "CLOSED",
+ "status/time-zone": "America/Los_Angeles",
+ "status/wifi": "true"
+ },
+ "fe8b85c15bc9610c1b8b4ebc6f82488d": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "2",
+ "breaker/rating": "50",
+ "connection/feeds-device-id": "evse-2",
+ "connection/feeds-device-status": "OK",
+ "connection/feeds-device-type": "energy.ebus.device.evse",
+ "info/name": "SPAN Drive - Driveway",
+ "info/spaces": "35,37",
+ "load-shed/priority": "OFF_GRID",
+ "meter/active-power": "0.0",
+ "meter/current": "0.0",
+ "meter/exported-energy": "0.0",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "4",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "lugs-downstream": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "info/direction": "DOWNSTREAM",
+ "meter/active-power": "-5847.0",
+ "meter/current-a": "46.46666666666666",
+ "meter/current-b": "46.474999999999994",
+ "meter/exported-energy": "141.66666666666666",
+ "meter/imported-energy": "44.21666666666666"
+ },
+ "lugs-upstream": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "connection/fed-by-device-id": "bess",
+ "connection/fed-by-device-status": "OK",
+ "connection/fed-by-device-type": "energy.ebus.device.bess",
+ "info/direction": "UPSTREAM",
+ "meter/active-power": "-5847.0",
+ "meter/current-a": "46.46666666666666",
+ "meter/current-b": "46.474999999999994",
+ "meter/exported-energy": "141.66666666666666",
+ "meter/imported-energy": "44.21666666666666"
+ },
+ "pv": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "info/model": "IQ8PLUS-72-2-US",
+ "info/nominal-power": "10000.0",
+ "info/vendor-name": "Enphase"
+ }
+}
diff --git a/tests/fixtures/schema_one_tree_no_pv.json b/tests/fixtures/schema_one_tree_no_pv.json
new file mode 100644
index 00000000..9b0483ab
--- /dev/null
+++ b/tests/fixtures/schema_one_tree_no_pv.json
@@ -0,0 +1,218 @@
+{
+ "0ab966b95f92a6a51ec548485aa85f54": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "1",
+ "breaker/rating": "15",
+ "info/name": "Kitchen Lights",
+ "info/spaces": "1",
+ "load-shed/priority": "UNKNOWN",
+ "meter/active-power": "-121.0",
+ "meter/current": "1.0083333333333333",
+ "meter/exported-energy": "2.0166666666666666",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "1",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "573066aaddd7b75114c4563ce3af18c4": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "2",
+ "breaker/rating": "30",
+ "connection/feeds-device-id": "pv",
+ "connection/feeds-device-status": "OK",
+ "connection/feeds-device-type": "energy.ebus.device.pv",
+ "info/name": "Solar Inverter",
+ "info/spaces": "36,38",
+ "load-shed/priority": "NEVER",
+ "meter/active-power": "8500.0",
+ "meter/current": "35.416666666666664",
+ "meter/exported-energy": "0.0",
+ "meter/imported-energy": "141.66666666666666",
+ "pcs/managed": "false",
+ "pcs/priority": "5",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "false",
+ "switch/relay-requester": "NONE"
+ },
+ "62d0e03897b337b57101aae82f1e9ba2": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "2",
+ "breaker/rating": "50",
+ "connection/feeds-device-id": "evse",
+ "connection/feeds-device-status": "OK",
+ "connection/feeds-device-type": "energy.ebus.device.evse",
+ "info/name": "SPAN Drive - Garage",
+ "info/spaces": "32,34",
+ "load-shed/priority": "OFF_GRID",
+ "meter/active-power": "-2410.0",
+ "meter/current": "10.041666666666666",
+ "meter/exported-energy": "40.166666666666664",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "3",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "bess": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "info/model": "Example BESS",
+ "info/nameplate-capacity": "13.5",
+ "info/vendor-name": "Span",
+ "meter/active-power": "-3500.0",
+ "soc/soc": "50.410493827160494",
+ "soc/soe": "6.805416666666667",
+ "status/communication-state": "OK"
+ },
+ "bess-mid": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}",
+ "$state": "ready",
+ "grid/grid-forming-entity": "GRID",
+ "grid/grid-state": "UP",
+ "grid/islanding-state": "ON_GRID",
+ "info/vendor-name": "Span"
+ },
+ "d3724e0d660ba506aa79c1cafe5d1181": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "1",
+ "breaker/rating": "20",
+ "info/name": "Garage Outlet",
+ "info/spaces": "2",
+ "load-shed/priority": "UNKNOWN",
+ "meter/active-power": "-122.0",
+ "meter/current": "1.0166666666666666",
+ "meter/exported-energy": "2.033333333333333",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "2",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "evse": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "config/max-charge-current": "32",
+ "config/user-max-charge-current": "32",
+ "info/firmware-version": "example/v0.1.0",
+ "info/model": "SPAN Drive",
+ "info/part-number": "SPN-DRV-001",
+ "info/serial-number": "SIM-EVSE-example-40t-001",
+ "info/vendor-name": "SPAN",
+ "meter/advertised-current": "32.0",
+ "status/status": "CHARGING",
+ "switch/lock-state": "LOCKED"
+ },
+ "evse-2": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "config/max-charge-current": "32",
+ "config/user-max-charge-current": "32",
+ "info/firmware-version": "example/v0.1.0",
+ "info/model": "SPAN Drive",
+ "info/part-number": "SPN-DRV-001",
+ "info/serial-number": "SIM-EVSE-example-40t-001-2",
+ "info/vendor-name": "SPAN",
+ "meter/advertised-current": "32.0",
+ "status/status": "AVAILABLE",
+ "switch/lock-state": "UNLOCKED"
+ },
+ "example-40t-001": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}",
+ "$state": "ready",
+ "breaker/rating": "200",
+ "door/state": "CLOSED",
+ "info/data-model-version": "1.0",
+ "info/firmware-version": "example/v0.1.0",
+ "info/hardware-version": "rev2",
+ "info/model": "MAIN_40",
+ "info/serial-number": "example-40t-001",
+ "info/vendor-name": "Span",
+ "meter/voltage-a": "120.0",
+ "meter/voltage-b": "120.0",
+ "pcs/active": "false",
+ "pcs/binding-constraint": "NONE",
+ "pcs/enabled": "false",
+ "pcs/feed-import-limit": "0.0",
+ "pcs/feed-import-limit-active": "false",
+ "pcs/feed-import-limit-enablement": "UNCONFIGURED",
+ "pcs/import-limit": "0.0",
+ "pcs/off-grid-import-limit": "0.0",
+ "pcs/off-grid-import-limit-active": "false",
+ "pcs/off-grid-import-limit-enablement": "UNCONFIGURED",
+ "pcs/operator-import-limit": "0.0",
+ "pcs/operator-import-limit-active": "false",
+ "pcs/operator-import-limit-enablement": "UNCONFIGURED",
+ "pcs/requested-import-limit": "0.0",
+ "pcs/requested-import-limit-active": "false",
+ "pcs/requested-import-limit-enablement": "UNCONFIGURED",
+ "power-flows/battery": "-3500.0",
+ "power-flows/grid": "-2347.0",
+ "power-flows/pv": "8500.0",
+ "power-flows/site": "2653.0",
+ "shed-forecast/confidence": "HIGH",
+ "shed-forecast/full-charge-time-to-priority-shed": "3038",
+ "shed-forecast/full-charge-total-time-remaining": "4320",
+ "shed-forecast/time-to-priority-shed": "3037",
+ "shed-forecast/total-time-remaining": "4320",
+ "shed/asserted-islanding-state": "NONE",
+ "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}",
+ "status/cloud-connection": "CONNECTED",
+ "status/ethernet": "true",
+ "status/postal-code": "94103",
+ "status/relay": "CLOSED",
+ "status/time-zone": "America/Los_Angeles",
+ "status/wifi": "true"
+ },
+ "fe8b85c15bc9610c1b8b4ebc6f82488d": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "breaker/poles": "2",
+ "breaker/rating": "50",
+ "connection/feeds-device-id": "evse-2",
+ "connection/feeds-device-status": "OK",
+ "connection/feeds-device-type": "energy.ebus.device.evse",
+ "info/name": "SPAN Drive - Driveway",
+ "info/spaces": "35,37",
+ "load-shed/priority": "OFF_GRID",
+ "meter/active-power": "0.0",
+ "meter/current": "0.0",
+ "meter/exported-energy": "0.0",
+ "meter/imported-energy": "0.0",
+ "pcs/managed": "true",
+ "pcs/priority": "4",
+ "switch/relay": "CLOSED",
+ "switch/relay-controllable": "true",
+ "switch/relay-requester": "NONE"
+ },
+ "lugs-downstream": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "info/direction": "DOWNSTREAM",
+ "meter/active-power": "-5847.0",
+ "meter/current-a": "46.46666666666666",
+ "meter/current-b": "46.474999999999994",
+ "meter/exported-energy": "141.66666666666666",
+ "meter/imported-energy": "44.21666666666666"
+ },
+ "lugs-upstream": {
+ "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
+ "$state": "ready",
+ "connection/fed-by-device-id": "bess",
+ "connection/fed-by-device-status": "OK",
+ "connection/fed-by-device-type": "energy.ebus.device.bess",
+ "info/direction": "UPSTREAM",
+ "meter/active-power": "-5847.0",
+ "meter/current-a": "46.46666666666666",
+ "meter/current-b": "46.474999999999994",
+ "meter/exported-energy": "141.66666666666666",
+ "meter/imported-energy": "44.21666666666666"
+ }
+}
diff --git a/tests/fixtures/schema_zero_types.json b/tests/fixtures/schema_zero_types.json
new file mode 100644
index 00000000..b7a338d0
--- /dev/null
+++ b/tests/fixtures/schema_zero_types.json
@@ -0,0 +1,420 @@
+{
+ "firmwareVersion": "spanos2/r202603/05",
+ "homieDomain": "ebus",
+ "homieVersion": 5,
+ "types": {
+ "energy.ebus.device.distribution-enclosure.core": {
+ "vendor-name": {
+ "name": "Vendor name",
+ "datatype": "string"
+ },
+ "serial-number": {
+ "name": "Serial number",
+ "datatype": "string"
+ },
+ "hardware-version": {
+ "name": "Hardware version",
+ "datatype": "string"
+ },
+ "software-version": {
+ "name": "Software version",
+ "datatype": "string"
+ },
+ "door": {
+ "name": "Door state",
+ "datatype": "enum",
+ "format": "UNKNOWN,OPEN,CLOSED"
+ },
+ "grid-islandable": {
+ "name": "Capable of operating with power while disconnected from the grid",
+ "datatype": "boolean"
+ },
+ "dominant-power-source": {
+ "name": "Current dominant power source, load-shedding trigger",
+ "datatype": "enum",
+ "format": "GRID,BATTERY,PV,GENERATOR,NONE,UNKNOWN",
+ "settable": true
+ },
+ "relay": {
+ "name": "Main relay",
+ "datatype": "enum",
+ "format": "UNKNOWN,OPEN,CLOSED"
+ },
+ "l1-voltage": {
+ "name": "L1 voltage",
+ "datatype": "float",
+ "unit": "V"
+ },
+ "l2-voltage": {
+ "name": "L2 voltage",
+ "datatype": "float",
+ "unit": "V"
+ },
+ "breaker-rating": {
+ "name": "Main breaker rating",
+ "datatype": "integer",
+ "unit": "A"
+ },
+ "ethernet": {
+ "name": "Is Ethernet network interface operational?",
+ "datatype": "boolean"
+ },
+ "wifi": {
+ "name": "Is Wi-Fi network interface operational?",
+ "datatype": "boolean"
+ },
+ "wifi-ssid": {
+ "name": "SSID to which Wi-Fi network interface is connected",
+ "datatype": "string"
+ },
+ "vendor-cloud": {
+ "name": "Device connected to vendor cloud?",
+ "datatype": "enum",
+ "format": "UNKNOWN,UNCONNECTED,CONNECTED"
+ },
+ "postal-code": {
+ "name": "Postal (Zip) code",
+ "datatype": "string"
+ },
+ "time-zone": {
+ "name": "Time zone",
+ "datatype": "string"
+ }
+ },
+ "energy.ebus.device.lugs": {
+ "direction": {
+ "name": "Lugs feed direction: upstream or downstream",
+ "datatype": "enum",
+ "format": "UPSTREAM,DOWNSTREAM"
+ },
+ "feed": {
+ "name": "Device the lugs are connected to, if known",
+ "datatype": "string"
+ },
+ "l1-current": {
+ "name": "L1 current",
+ "datatype": "float",
+ "unit": "A"
+ },
+ "l2-current": {
+ "name": "L2 current",
+ "datatype": "float",
+ "unit": "A"
+ },
+ "active-power": {
+ "name": "Active power",
+ "datatype": "float",
+ "unit": "W"
+ },
+ "imported-energy": {
+ "name": "Imported energy",
+ "datatype": "float",
+ "unit": "Wh"
+ },
+ "exported-energy": {
+ "name": "Exported energy",
+ "datatype": "float",
+ "unit": "Wh"
+ }
+ },
+ "energy.ebus.device.circuit": {
+ "name": {
+ "name": "Circuit name",
+ "datatype": "string"
+ },
+ "relay": {
+ "name": "Circuit relay state",
+ "datatype": "enum",
+ "format": "UNKNOWN,OPEN,CLOSED",
+ "settable": true
+ },
+ "relay-requester": {
+ "name": "Actor requesting the relay state",
+ "datatype": "enum",
+ "format": "UNKNOWN,NONE,BACKUP,USER,PCS,PCS_FAIL_SAFE,ALWAYS_ON,NEVER_BACKUP,INVERTER,FAULT"
+ },
+ "breaker-rating": {
+ "name": "Circuit breaker rating",
+ "datatype": "integer",
+ "unit": "A"
+ },
+ "current": {
+ "name": "Measured current",
+ "datatype": "float",
+ "unit": "A"
+ },
+ "active-power": {
+ "name": "Measured active power",
+ "datatype": "float",
+ "unit": "kW"
+ },
+ "imported-energy": {
+ "name": "Measured energy imported",
+ "datatype": "float",
+ "unit": "Wh"
+ },
+ "exported-energy": {
+ "name": "Measured energy exported",
+ "datatype": "float",
+ "unit": "Wh"
+ },
+ "space": {
+ "name": "Circuit breaker space number within load center",
+ "datatype": "integer",
+ "format": "1:32:1"
+ },
+ "dipole": {
+ "name": "Does circuit land on a two-pole breaker?",
+ "datatype": "boolean"
+ },
+ "shed-priority": {
+ "name": "Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)",
+ "datatype": "enum",
+ "format": "UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER",
+ "settable": true
+ },
+ "pcs-managed": {
+ "name": "Is circuit managed by PCS?",
+ "datatype": "boolean"
+ },
+ "pcs-priority": {
+ "name": "Circuit PCS priority ranking",
+ "datatype": "integer"
+ },
+ "sheddable": {
+ "name": "Is circuit configured to be sheddable?",
+ "datatype": "boolean"
+ },
+ "never-backup": {
+ "name": "Is circuit configured to be never-backup?",
+ "datatype": "boolean"
+ },
+ "always-on": {
+ "name": "Is circuit configured to be always on?",
+ "datatype": "boolean"
+ }
+ },
+ "energy.ebus.device.bess": {
+ "vendor-name": {
+ "name": "Vendor name",
+ "datatype": "string"
+ },
+ "product-name": {
+ "name": "Product name",
+ "datatype": "string"
+ },
+ "model": {
+ "name": "Model",
+ "datatype": "string"
+ },
+ "serial-number": {
+ "name": "Serial number",
+ "datatype": "string"
+ },
+ "software-version": {
+ "name": "Software version",
+ "datatype": "string"
+ },
+ "nameplate-capacity": {
+ "name": "Nameplate capacity",
+ "datatype": "float",
+ "unit": "kWh"
+ },
+ "relative-position": {
+ "name": "Relative position of the commissioned backup system WRT the distribution enclosure",
+ "datatype": "enum",
+ "format": "UPSTREAM,DOWNSTREAM,IN_PANEL"
+ },
+ "feed": {
+ "name": "Circuit ID upon which the commissioned backup system is landed",
+ "datatype": "enum"
+ },
+ "soc": {
+ "name": "State of charge",
+ "datatype": "float",
+ "unit": "%"
+ },
+ "soe": {
+ "name": "State of energy",
+ "datatype": "float",
+ "unit": "kWh"
+ },
+ "connected": {
+ "name": "Connected to backup system?",
+ "datatype": "boolean"
+ },
+ "grid-state": {
+ "name": "Grid connection state",
+ "datatype": "enum",
+ "format": "UNKNOWN,ON_GRID,OFF_GRID"
+ }
+ },
+ "energy.ebus.device.pv": {
+ "vendor-name": {
+ "name": "Vendor name",
+ "datatype": "string"
+ },
+ "product-name": {
+ "name": "Product name",
+ "datatype": "string"
+ },
+ "serial-number": {
+ "name": "Serial number",
+ "datatype": "string"
+ },
+ "software-version": {
+ "name": "Software version",
+ "datatype": "string"
+ },
+ "nameplate-capacity": {
+ "name": "Nameplate capacity",
+ "datatype": "float",
+ "unit": "W"
+ },
+ "relative-position": {
+ "name": "Relative position of the commissioned PV system WRT the distribution enclosure",
+ "datatype": "enum",
+ "format": "UPSTREAM,DOWNSTREAM,IN_PANEL"
+ },
+ "feed": {
+ "name": "Circuit ID upon which the commissioned PV system is landed",
+ "datatype": "enum"
+ }
+ },
+ "energy.ebus.device.evse": {
+ "vendor-name": {
+ "name": "Vendor name",
+ "datatype": "string"
+ },
+ "product-name": {
+ "name": "Product name",
+ "datatype": "string"
+ },
+ "part-number": {
+ "name": "Part number",
+ "datatype": "string"
+ },
+ "serial-number": {
+ "name": "Serial number",
+ "datatype": "string"
+ },
+ "software-version": {
+ "name": "Software version",
+ "datatype": "string"
+ },
+ "feed": {
+ "name": "Circuit ID upon which the commissioned EVSE is landed",
+ "datatype": "enum"
+ },
+ "lock-state": {
+ "name": "Lock state",
+ "datatype": "enum",
+ "format": "UNKNOWN,LOCKED,UNLOCKED"
+ },
+ "status": {
+ "name": "Status",
+ "datatype": "enum",
+ "format": "UNKNOWN,AVAILABLE,PREPARING,CHARGING,SUSPENDED_EV,SUSPENDED_EVSE,FINISHING,RESERVED,FAULTED,UNAVAILABLE"
+ },
+ "advertised-current": {
+ "name": "Current EVSE is advertising to the EV",
+ "datatype": "float",
+ "unit": "A"
+ }
+ },
+ "energy.ebus.device.pcs": {
+ "enabled": {
+ "name": "PCS system enabled",
+ "datatype": "boolean"
+ },
+ "active": {
+ "name": "PCS system actively controlling one (or more) loads",
+ "datatype": "boolean"
+ },
+ "import-limit": {
+ "name": "The power import limit currently being managed to",
+ "datatype": "float",
+ "unit": "A"
+ },
+ "feed-import-limit": {
+ "name": "Limit of maximum power feeding the distribution enclosure",
+ "datatype": "float",
+ "unit": "A"
+ },
+ "feed-import-limit-enablement": {
+ "name": "Enablement status of the feed-import-limit",
+ "datatype": "enum",
+ "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED"
+ },
+ "feed-import-limit-active": {
+ "name": "Is feed-import-limit currently being enforced?",
+ "datatype": "boolean"
+ },
+ "grid-import-limit": {
+ "name": "Grid limit maximum import power",
+ "datatype": "float",
+ "unit": "A"
+ },
+ "grid-import-limit-enablement": {
+ "name": "Enablement status of the grid-import-limit",
+ "datatype": "enum",
+ "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED"
+ },
+ "grid-import-limit-active": {
+ "name": "Is grid-import-limit currently being enforced?",
+ "datatype": "boolean"
+ },
+ "off-grid-import-limit": {
+ "name": "Off-Grid limit maximum import power",
+ "datatype": "float",
+ "unit": "A"
+ },
+ "off-grid-import-limit-enablement": {
+ "name": "Enablement status of the off-grid-import-limit",
+ "datatype": "enum",
+ "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED"
+ },
+ "off-grid-import-limit-active": {
+ "name": "Is off-grid-import-limit currently being enforced?",
+ "datatype": "boolean"
+ },
+ "requested-import-limit": {
+ "name": "Requested limit maximum import power",
+ "datatype": "float",
+ "unit": "A"
+ },
+ "requested-import-limit-enablement": {
+ "name": "Enablement status of the requested-import-limit",
+ "datatype": "enum",
+ "format": "UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED"
+ },
+ "requested-import-limit-active": {
+ "name": "Is requested-import-limit currently being enforced?",
+ "datatype": "boolean"
+ }
+ },
+ "energy.ebus.device.power-flows": {
+ "pv": {
+ "name": "PV power flow",
+ "datatype": "float",
+ "unit": "W"
+ },
+ "battery": {
+ "name": "Battery/BESS power flow",
+ "datatype": "float",
+ "unit": "W"
+ },
+ "grid": {
+ "name": "Grid power flow",
+ "datatype": "float",
+ "unit": "W"
+ },
+ "site": {
+ "name": "Site power flow",
+ "datatype": "float",
+ "unit": "W"
+ }
+ }
+ },
+ "typesSchemaHash": "sha256:d347556a07d98f40"
+}
From 4ff4e3d535f8bef488a6aa1ec46a01c0207fdac8 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Tue, 18 Aug 2026 22:08:09 -0700
Subject: [PATCH 014/116] chore: pin span-panel-api 3.0.0b4 and both schema
adapters
Sources point at the span-panel-api-p3 worktree so this branch develops against
the field-metadata changes without disturbing the baseline pair.
---
custom_components/span_panel/manifest.json | 6 ++---
pyproject.toml | 8 +++----
uv.lock | 26 +++++++++++-----------
3 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 267b2db6..4781e14f 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,9 +22,9 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b3",
- "span-panel-api-schema-0==1.0.0b3",
- "span-panel-api-schema-1==0.1.0b3"
+ "span-panel-api==3.0.0b4",
+ "span-panel-api-schema-0==1.0.0b4",
+ "span-panel-api-schema-1==0.1.0b4"
],
"version": "2.1.0",
"zeroconf": [
diff --git a/pyproject.toml b/pyproject.toml
index 4cacfe27..a9c87152 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,9 +47,9 @@ package = false
[tool.uv.sources]
# This branch develops against the in-progress schema-1 adapter, which lives in a
# worktree rather than the primary checkout the symlink points at.
-span-panel-api = { path = "../../span/span-panel-api-p2", editable = true }
-span-panel-api-schema-0 = { path = "../../span/span-panel-api-p2/packages/schema-0", editable = true }
-span-panel-api-schema-1 = { path = "../../span/span-panel-api-p2/packages/schema-1", editable = true }
+span-panel-api = { path = "../../span/span-panel-api-p3", editable = true }
+span-panel-api-schema-0 = { path = "../../span/span-panel-api-p3/packages/schema-0", editable = true }
+span-panel-api-schema-1 = { path = "../../span/span-panel-api-p3/packages/schema-1", editable = true }
[tool.jscpd]
path = ["custom_components/span_panel", "./*.{html,md}"]
@@ -129,7 +129,7 @@ exclude = [
]
extraPaths = [
"./custom_components",
- "../../span/span-panel-api-p2/src",
+ "../../span/span-panel-api-p3/src",
"../ha-synthetic-sensors/src"
]
pythonPlatform = "Darwin"
diff --git a/uv.lock b/uv.lock
index 61397ab4..87d23906 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2523,9 +2523,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "homeassistant", specifier = "==2026.8.0" },
- { name = "span-panel-api", editable = "../../span/span-panel-api-p2" },
- { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" },
- { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" },
+ { name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
+ { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" },
+ { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" },
]
[package.metadata.requires-dev]
@@ -2550,8 +2550,8 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b3"
-source = { editable = "../../span/span-panel-api-p2" }
+version = "3.0.0b4"
+source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
{ name = "paho-mqtt" },
@@ -2578,8 +2578,8 @@ dev = [
{ name = "pytest-cov" },
{ name = "radon" },
{ name = "ruff", specifier = ">=0.15.5" },
- { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" },
- { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" },
+ { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" },
+ { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" },
{ name = "twine", specifier = ">=7.0" },
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
{ name = "vulture", specifier = ">=2.14" },
@@ -2587,19 +2587,19 @@ dev = [
[[package]]
name = "span-panel-api-schema-0"
-version = "1.0.0b3"
-source = { editable = "../../span/span-panel-api-p2/packages/schema-0" }
+version = "1.0.0b4"
+source = { editable = "../../span/span-panel-api-p3/packages/schema-0" }
dependencies = [
{ name = "span-panel-api" },
]
[package.metadata]
-requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p2" }]
+requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" }]
[[package]]
name = "span-panel-api-schema-1"
-version = "0.1.0b3"
-source = { editable = "../../span/span-panel-api-p2/packages/schema-1" }
+version = "0.1.0b4"
+source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
{ name = "span-panel-api" },
@@ -2608,7 +2608,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "ebus-sdk", specifier = ">=0.19.0,<1.0" },
- { name = "span-panel-api", editable = "../../span/span-panel-api-p2" },
+ { name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
]
[[package]]
From 56e552c292f0aef3a61fa8ee6659ff16668219e2 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Tue, 18 Aug 2026 22:12:17 -0700
Subject: [PATCH 015/116] chore: pin b4 in project dependencies and document
fixture provenance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The earlier pin commit updated manifest.json and the uv sources but left
[project] dependencies on b3 — inert locally because the sources override it,
operative on a clean resolve. requirements_test.txt is read by nothing; uv sync
reads [project] dependencies, so that is the file that had to change.
---
pyproject.toml | 6 ++---
tests/fixtures/README.md | 58 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 61 insertions(+), 3 deletions(-)
create mode 100644 tests/fixtures/README.md
diff --git a/pyproject.toml b/pyproject.toml
index a9c87152..b042b9c5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,13 +8,13 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b3",
+ "span-panel-api==3.0.0b4",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
# and that is the list Home Assistant actually reads.
- "span-panel-api-schema-0==1.0.0b3",
- "span-panel-api-schema-1==0.1.0b3",
+ "span-panel-api-schema-0==1.0.0b4",
+ "span-panel-api-schema-1==0.1.0b4",
]
[dependency-groups]
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
new file mode 100644
index 00000000..498828d2
--- /dev/null
+++ b/tests/fixtures/README.md
@@ -0,0 +1,58 @@
+# Adapter fixtures
+
+Real schema-adapter inputs, used by the field-path conformance tests via
+`tests/adapter_fixtures.py`. They are **committed rather than generated** so the
+test suite has no cross-repo dependency and CI needs no checkout of the library.
+The cost of that choice is that they go stale silently — hence this file.
+
+## Provenance
+
+Both are byte-identical copies from the `span-panel-api` repository:
+
+| File here | Source in `span-panel-api` |
+|---|---|
+| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` |
+| `schema_one_tree.json` | `tests/fixtures/parent_child_tree.json` |
+
+Refresh by copying them again. If a copy changes shape rather than content, the
+loader in `tests/adapter_fixtures.py` is what needs updating — note that
+`schema_one_tree.json` is a **dict keyed by device id**, whose `$description`
+value is a **JSON string**, not a parsed object.
+
+## Derived variants
+
+Both are produced from `schema_one_tree.json` by dropping every device whose
+parsed `$description["type"]` contains a marker. Regenerate with:
+
+```bash
+uv run python - << 'PY'
+import json, pathlib
+
+tree = json.loads(pathlib.Path("tests/fixtures/schema_one_tree.json").read_text())
+
+def drop(marker: str, out_name: str) -> None:
+ kept = {
+ device_id: topics
+ for device_id, topics in tree.items()
+ if marker not in json.loads(topics.get("$description", "{}")).get("type", "")
+ }
+ pathlib.Path(f"tests/fixtures/{out_name}").write_text(json.dumps(kept, indent=2))
+ print(f"{out_name}: {len(tree)} -> {len(kept)} devices")
+
+drop(".bess", "schema_one_tree_batteryless.json")
+drop(".pv", "schema_one_tree_no_pv.json")
+PY
+```
+
+Each must remove **exactly one** device (13 -> 12) and must retain the panel and
+both lugs devices. A variant that removed more would make the conformance tests
+pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and
+correctly survives the `.bess` filter.
+
+## Why these exist
+
+`schema_one_tree_batteryless.json` proves a panel with no BESS produces **no**
+`battery.*` entries — hardware absence, not degradation.
+`schema_one_tree_no_pv.json` proves the same for a panel that has power-flows
+telemetry but no PV device, which is the case telemetry-based capability
+detection gets wrong.
From 253694235c8cf91a380d9bc3f3b36587dc8031b8 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Tue, 18 Aug 2026 22:28:05 -0700
Subject: [PATCH 016/116] refactor: declare snapshot field paths on entity
descriptions
Retires SENSOR_FIELD_MAP. The declaration now lives on the object that reads
the field, so the two cannot drift. Also drops the key-based dedup, which
silently discarded descriptions sharing a key across device classes.
---
custom_components/span_panel/binary_sensor.py | 19 ++-
custom_components/span_panel/field_paths.py | 105 ++++++++++++++++
.../span_panel/sensor_circuit.py | 6 +
.../span_panel/sensor_definitions.py | 118 +++++++++++++++---
tests/test_field_paths.py | 28 +++++
5 files changed, 256 insertions(+), 20 deletions(-)
create mode 100644 custom_components/span_panel/field_paths.py
create mode 100644 tests/test_field_paths.py
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 59a125b8..f4f80fb0 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -30,6 +30,7 @@
)
from .coordinator import SpanPanelCoordinator
from .entity import SpanPanelEntity
+from .field_paths import FieldPathDeclarationMixin
from .helpers import (
build_binary_sensor_unique_id_for_entry,
build_evse_unique_id_for_entry,
@@ -48,13 +49,13 @@
@dataclass(frozen=True)
-class SpanPanelRequiredKeysMixin:
+class SpanPanelRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for Span Panel binary sensors."""
value_fn: Callable[[SpanPanelSnapshot], bool | None]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanPanelBinarySensorEntityDescription(
BinarySensorEntityDescription, SpanPanelRequiredKeysMixin
):
@@ -72,6 +73,7 @@ class SpanPanelBinarySensorEntityDescription(
] = (
SpanPanelBinarySensorEntityDescription(
key=SYSTEM_DOOR_STATE,
+ field_path="panel.door_state",
translation_key="door_state",
device_class=BinarySensorDeviceClass.TAMPER,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -83,6 +85,7 @@ class SpanPanelBinarySensorEntityDescription(
),
SpanPanelBinarySensorEntityDescription(
key=SYSTEM_ETHERNET_LINK,
+ field_path="panel.eth0_link",
translation_key="ethernet_link",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -90,6 +93,7 @@ class SpanPanelBinarySensorEntityDescription(
),
SpanPanelBinarySensorEntityDescription(
key=SYSTEM_WIFI_LINK,
+ field_path="panel.wlan_link",
translation_key="wifi_link",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -97,6 +101,9 @@ class SpanPanelBinarySensorEntityDescription(
),
SpanPanelBinarySensorEntityDescription(
key=PANEL_STATUS,
+ # Reports coordinator reachability, not a snapshot field — the value_fn
+ # is a placeholder the entity class overrides.
+ derived=True,
translation_key="panel_status",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -134,6 +141,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
GRID_ISLANDABLE_SENSOR = SpanPanelBinarySensorEntityDescription(
key="grid_islandable",
+ derived=True,
translation_key="grid_islandable",
device_class=BinarySensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -142,6 +150,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
BESS_CONNECTED_SENSOR = SpanPanelBinarySensorEntityDescription(
key="bess_connected",
+ derived=True,
translation_key="bess_connected",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -278,13 +287,13 @@ def _construct_binary_sensor_unique_id(
@dataclass(frozen=True)
-class SpanEvseBinarySensorRequiredKeysMixin:
+class SpanEvseBinarySensorRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for EVSE binary sensors."""
value_fn: Callable[[SpanEvseSnapshot], bool | None]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanEvseBinarySensorEntityDescription(
BinarySensorEntityDescription, SpanEvseBinarySensorRequiredKeysMixin
):
@@ -301,12 +310,14 @@ class SpanEvseBinarySensorEntityDescription(
] = (
SpanEvseBinarySensorEntityDescription(
key="evse_charging",
+ field_path="evse.status",
translation_key="evse_charging",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
value_fn=lambda e: (e.status or "") == "CHARGING",
),
SpanEvseBinarySensorEntityDescription(
key="evse_ev_connected",
+ derived=True,
translation_key="evse_ev_connected",
device_class=BinarySensorDeviceClass.PLUG,
value_fn=lambda e: (e.status or "") in _EV_CONNECTED_STATUSES,
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
new file mode 100644
index 00000000..ea415532
--- /dev/null
+++ b/custom_components/span_panel/field_paths.py
@@ -0,0 +1,105 @@
+"""Snapshot field paths this integration reads.
+
+Most declarations live on the entity descriptions that read them, so they
+cannot drift from the reader. A few readers are in entity code rather than on a
+description; those are listed here.
+
+This module replaces `schema_expectations.SENSOR_FIELD_MAP`, a hand-maintained
+parallel dict that had already drifted once (it pointed at
+`battery.product_name` and `pv.product_name` after the library renamed those
+fields to `battery.model` / `pv.model`).
+
+Field path convention: ``{snapshot_type}.{field_name}`` — ``panel``,
+``circuit``, ``battery``, ``pv`` and ``evse``.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, kw_only=True)
+class FieldPathDeclarationMixin:
+ """Declares which snapshot field an entity description reads.
+
+ Mixed into every required-keys mixin so the declaration and the reader are
+ the same object. Defined once here rather than repeated on each mixin, so
+ the two fields cannot themselves drift apart across platforms.
+
+ The fields are keyword-only: an entity description flattens this mixin's
+ fields ahead of ``EntityDescription.key``, which has no default, so a
+ positional pair here would make every description unconstructable.
+ """
+
+ field_path: str | None = None
+ """Snapshot field this entity reads, e.g. "circuit.instant_power_w".
+
+ Declared here rather than in a parallel map so the declaration and the
+ reader are the same object. Verified against `value_fn` by the proxy test
+ in tests/test_field_path_introspection.py.
+ """
+
+ derived: bool = False
+ """True when the value is computed from several fields, or none.
+
+ Derived entities have no single source field, so they are exempt from the
+ producible check.
+ """
+
+
+RESIDUAL_FIELD_PATHS: frozenset[str] = frozenset(
+ {
+ # switch.py reads this in entity code, not via a description value_fn
+ "circuit.relay_state",
+ # select.py uses a wrapper class rather than a frozen dataclass
+ # description, so it cannot carry the field as a dataclass field
+ "circuit.priority",
+ # Consumed by entity naming and attributes rather than by any platform
+ "circuit.name",
+ "circuit.tabs",
+ }
+)
+"""Readers not carried on an entity description.
+
+Keep this small. A new entry is a hint that the reader belongs on a
+description instead.
+"""
+
+
+def declared_field_paths() -> dict[str, bool]:
+ """Every field path the integration reads, mapped to whether it is derived.
+
+ Derived paths are exempt from the producible check because they have no
+ single source field.
+ """
+ # Deferred: the platform modules import `FieldPathDeclarationMixin` from
+ # here, and `binary_sensor` reaches the package root for its config-entry
+ # type. Importing them at module scope would close both loops.
+ from .binary_sensor import ( # pylint: disable=import-outside-toplevel
+ BESS_CONNECTED_SENSOR,
+ BINARY_SENSORS,
+ EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ )
+ from .sensor_definitions import ( # pylint: disable=import-outside-toplevel
+ all_sensor_descriptions,
+ )
+
+ paths: dict[str, bool] = dict.fromkeys(RESIDUAL_FIELD_PATHS, False)
+ for description in (
+ *all_sensor_descriptions(),
+ *BINARY_SENSORS,
+ *EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ BESS_CONNECTED_SENSOR,
+ ):
+ if not isinstance(description, FieldPathDeclarationMixin):
+ # A description that cannot declare anything would be dropped
+ # silently, which is the drift this module exists to prevent.
+ raise TypeError(
+ f"entity description '{description.key}' carries no field-path declaration"
+ )
+ if description.derived or description.field_path is None:
+ continue
+ paths[description.field_path] = False
+ return paths
diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py
index 8e12c30c..af0a0d68 100644
--- a/custom_components/span_panel/sensor_circuit.py
+++ b/custom_components/span_panel/sensor_circuit.py
@@ -113,6 +113,8 @@ def __init__(
suggested_display_precision=description.suggested_display_precision,
device_class=description.device_class,
value_fn=description.value_fn,
+ field_path=description.field_path,
+ derived=description.derived,
entity_registry_enabled_default=description.entity_registry_enabled_default,
entity_registry_visible_default=description.entity_registry_visible_default,
entity_category=description.entity_category,
@@ -270,6 +272,8 @@ def __init__(
suggested_display_precision=description.suggested_display_precision,
device_class=description.device_class,
value_fn=description.value_fn,
+ field_path=description.field_path,
+ derived=description.derived,
entity_registry_enabled_default=description.entity_registry_enabled_default,
entity_registry_visible_default=description.entity_registry_visible_default,
)
@@ -445,6 +449,8 @@ def __init__(
suggested_display_precision=description.suggested_display_precision,
device_class=description.device_class,
value_fn=description.value_fn,
+ field_path=description.field_path,
+ derived=description.derived,
entity_registry_enabled_default=True,
entity_registry_visible_default=False,
)
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 286ed920..4fd70973 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -35,15 +35,17 @@
SpanPanelSnapshot,
)
+from .field_paths import FieldPathDeclarationMixin
+
@dataclass(frozen=True)
-class SpanPanelCircuitsRequiredKeysMixin:
+class SpanPanelCircuitsRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for Span Panel circuit sensors."""
value_fn: Callable[[SpanCircuitSnapshot], float | None]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanPanelCircuitsSensorEntityDescription(
SensorEntityDescription, SpanPanelCircuitsRequiredKeysMixin
):
@@ -51,25 +53,25 @@ class SpanPanelCircuitsSensorEntityDescription(
@dataclass(frozen=True)
-class SpanPanelDataRequiredKeysMixin:
+class SpanPanelDataRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for Span Panel data sensors."""
value_fn: Callable[[SpanPanelSnapshot], float | str | None]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanPanelDataSensorEntityDescription(SensorEntityDescription, SpanPanelDataRequiredKeysMixin):
"""Describes a Span Panel data sensor entity."""
@dataclass(frozen=True)
-class SpanPanelStatusRequiredKeysMixin:
+class SpanPanelStatusRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for Span Panel status sensors."""
value_fn: Callable[[SpanPanelSnapshot], str]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanPanelStatusSensorEntityDescription(
SensorEntityDescription, SpanPanelStatusRequiredKeysMixin
):
@@ -77,13 +79,13 @@ class SpanPanelStatusSensorEntityDescription(
@dataclass(frozen=True)
-class SpanPanelBatteryRequiredKeysMixin:
+class SpanPanelBatteryRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for Span Panel battery sensors."""
value_fn: Callable[[SpanBatterySnapshot], float | None]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanPanelBatterySensorEntityDescription(
SensorEntityDescription, SpanPanelBatteryRequiredKeysMixin
):
@@ -101,6 +103,7 @@ class SpanPanelBatterySensorEntityDescription(
] = (
SpanPanelDataSensorEntityDescription(
key="dsm_state",
+ derived=True,
translation_key="dsm_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -110,6 +113,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="dsm_grid_state",
+ derived=True,
translation_key="dsm_grid_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -118,6 +122,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="current_run_config",
+ derived=True,
translation_key="current_run_config",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -126,6 +131,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="main_relay_state",
+ field_path="panel.main_relay_state",
translation_key="main_relay_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -134,6 +140,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="grid_forming_entity",
+ derived=True,
translation_key="grid_forming_entity",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -142,6 +149,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="vendor_cloud",
+ field_path="panel.vendor_cloud",
translation_key="vendor_cloud",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -155,6 +163,7 @@ class SpanPanelBatterySensorEntityDescription(
STATUS_SENSORS: tuple[SpanPanelStatusSensorEntityDescription,] = (
SpanPanelStatusSensorEntityDescription(
key="software_version",
+ field_path="panel.firmware_version",
translation_key="software_version",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda s: s.firmware_version,
@@ -170,6 +179,7 @@ class SpanPanelBatterySensorEntityDescription(
] = (
SpanPanelCircuitsSensorEntityDescription(
key="instantPowerW",
+ field_path="circuit.instant_power_w",
name="Power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -181,6 +191,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="producedEnergyWh",
+ field_path="circuit.produced_energy_wh",
name="Produced Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -192,6 +203,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="consumedEnergyWh",
+ field_path="circuit.consumed_energy_wh",
name="Consumed Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -206,6 +218,7 @@ class SpanPanelBatterySensorEntityDescription(
# Battery sensor definition (conditionally created when battery data available)
BATTERY_SENSOR: SpanPanelBatterySensorEntityDescription = SpanPanelBatterySensorEntityDescription(
key="storage_battery_percentage",
+ field_path="battery.soe_percentage",
translation_key="battery_level",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
@@ -221,6 +234,7 @@ class SpanPanelBatterySensorEntityDescription(
# L1/L2 voltage sensors (v2 only, conditionally created)
L1_VOLTAGE_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
key="l1_voltage",
+ field_path="panel.l1_voltage",
translation_key="l1_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
@@ -233,6 +247,7 @@ class SpanPanelBatterySensorEntityDescription(
L2_VOLTAGE_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
key="l2_voltage",
+ field_path="panel.l2_voltage",
translation_key="l2_voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
@@ -247,6 +262,7 @@ class SpanPanelBatterySensorEntityDescription(
UPSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = (
SpanPanelDataSensorEntityDescription(
key="upstream_l1_current",
+ field_path="panel.upstream_l1_current_a",
translation_key="upstream_l1_current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
@@ -261,6 +277,7 @@ class SpanPanelBatterySensorEntityDescription(
UPSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = (
SpanPanelDataSensorEntityDescription(
key="upstream_l2_current",
+ field_path="panel.upstream_l2_current_a",
translation_key="upstream_l2_current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
@@ -275,6 +292,7 @@ class SpanPanelBatterySensorEntityDescription(
DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = (
SpanPanelDataSensorEntityDescription(
key="downstream_l1_current",
+ field_path="panel.downstream_l1_current_a",
translation_key="downstream_l1_current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
@@ -288,6 +306,7 @@ class SpanPanelBatterySensorEntityDescription(
DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = (
SpanPanelDataSensorEntityDescription(
key="downstream_l2_current",
+ field_path="panel.downstream_l2_current_a",
translation_key="downstream_l2_current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
@@ -302,6 +321,7 @@ class SpanPanelBatterySensorEntityDescription(
MAIN_BREAKER_RATING_SENSOR: SpanPanelDataSensorEntityDescription = (
SpanPanelDataSensorEntityDescription(
key="main_breaker_rating",
+ field_path="panel.main_breaker_rating_a",
translation_key="main_breaker_rating",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -318,6 +338,7 @@ class SpanPanelBatterySensorEntityDescription(
CIRCUIT_CURRENT_SENSOR: SpanPanelCircuitsSensorEntityDescription = (
SpanPanelCircuitsSensorEntityDescription(
key="circuit_current",
+ field_path="circuit.current_a",
name="Current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
@@ -331,6 +352,7 @@ class SpanPanelBatterySensorEntityDescription(
CIRCUIT_BREAKER_RATING_SENSOR: SpanPanelCircuitsSensorEntityDescription = (
SpanPanelCircuitsSensorEntityDescription(
key="circuit_breaker_rating",
+ field_path="circuit.breaker_rating_a",
name="Breaker Rating",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -345,13 +367,13 @@ class SpanPanelBatterySensorEntityDescription(
@dataclass(frozen=True)
-class SpanBessMetadataRequiredKeysMixin:
+class SpanBessMetadataRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for BESS metadata sensors."""
value_fn: Callable[[SpanBatterySnapshot], float | str | None]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanBessMetadataSensorEntityDescription(
SensorEntityDescription, SpanBessMetadataRequiredKeysMixin
):
@@ -359,13 +381,13 @@ class SpanBessMetadataSensorEntityDescription(
@dataclass(frozen=True)
-class SpanMidRequiredKeysMixin:
+class SpanMidRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for MID sensors."""
value_fn: Callable[[SpanMidSnapshot], str | None]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKeysMixin):
"""Describes a sensor on the Microgrid Interconnect Device."""
@@ -373,6 +395,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
MID_SENSORS: tuple[SpanMidSensorEntityDescription, ...] = (
SpanMidSensorEntityDescription(
key="mid_grid_state",
+ derived=True,
translation_key="mid_grid_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -403,30 +426,35 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
] = (
SpanBessMetadataSensorEntityDescription(
key="vendor",
+ field_path="battery.vendor_name",
translation_key="bess_vendor",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda b: b.vendor_name,
),
SpanBessMetadataSensorEntityDescription(
key="model",
+ field_path="battery.model",
translation_key="bess_model",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda b: b.model,
),
SpanBessMetadataSensorEntityDescription(
key="serial_number",
+ field_path="battery.serial_number",
translation_key="bess_serial_number",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda b: b.serial_number,
),
SpanBessMetadataSensorEntityDescription(
key="firmware_version",
+ field_path="battery.software_version",
translation_key="bess_firmware_version",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda b: b.software_version,
),
SpanBessMetadataSensorEntityDescription(
key="nameplate_capacity",
+ field_path="battery.nameplate_capacity_kwh",
translation_key="bess_nameplate_capacity",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -435,6 +463,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
),
SpanBessMetadataSensorEntityDescription(
key="soe_kwh",
+ field_path="battery.soe_kwh",
translation_key="bess_soe_kwh",
device_class=SensorDeviceClass.ENERGY_STORAGE,
state_class=SensorStateClass.MEASUREMENT,
@@ -451,13 +480,13 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
@dataclass(frozen=True)
-class SpanPVMetadataRequiredKeysMixin:
+class SpanPVMetadataRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for PV metadata sensors."""
value_fn: Callable[[SpanPanelSnapshot], float | str | None]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanPVMetadataSensorEntityDescription(
SensorEntityDescription, SpanPVMetadataRequiredKeysMixin
):
@@ -471,18 +500,21 @@ class SpanPVMetadataSensorEntityDescription(
] = (
SpanPVMetadataSensorEntityDescription(
key="pv_vendor",
+ field_path="pv.vendor_name",
translation_key="pv_vendor",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda s: s.pv.vendor_name,
),
SpanPVMetadataSensorEntityDescription(
key="pv_product",
+ field_path="pv.model",
translation_key="pv_product",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda s: s.pv.model,
),
SpanPVMetadataSensorEntityDescription(
key="pv_nameplate_capacity",
+ field_path="pv.nameplate_capacity_w",
translation_key="pv_nameplate_capacity",
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -499,6 +531,7 @@ class SpanPVMetadataSensorEntityDescription(
] = (
SpanPanelDataSensorEntityDescription(
key="instantGridPowerW",
+ field_path="panel.instant_grid_power_w",
translation_key="instant_grid_power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -508,6 +541,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="feedthroughPowerW",
+ field_path="panel.feedthrough_power_w",
translation_key="feedthrough_power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -520,6 +554,7 @@ class SpanPVMetadataSensorEntityDescription(
# Battery power sensor (conditionally created when BESS is commissioned)
BATTERY_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
key="batteryPowerW",
+ field_path="panel.power_flow_battery",
translation_key="battery_power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -531,6 +566,7 @@ class SpanPVMetadataSensorEntityDescription(
# PV power sensor (conditionally created when PV is commissioned)
PV_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
key="pvPowerW",
+ field_path="panel.power_flow_pv",
translation_key="pv_power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -542,6 +578,7 @@ class SpanPVMetadataSensorEntityDescription(
# Grid power flow sensor (conditionally created when power-flows data is available)
GRID_POWER_FLOW_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
key="gridPowerFlowW",
+ field_path="panel.power_flow_grid",
translation_key="grid_power_flow",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -553,6 +590,7 @@ class SpanPVMetadataSensorEntityDescription(
# Site power sensor (conditionally created when power-flows data is available)
SITE_POWER_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
key="sitePowerW",
+ field_path="panel.power_flow_site",
translation_key="site_power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -572,6 +610,7 @@ class SpanPVMetadataSensorEntityDescription(
] = (
SpanPanelDataSensorEntityDescription(
key="mainMeterEnergyProducedWh",
+ field_path="panel.main_meter_energy_produced_wh",
translation_key="main_meter_produced_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -581,6 +620,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="mainMeterEnergyConsumedWh",
+ field_path="panel.main_meter_energy_consumed_wh",
translation_key="main_meter_consumed_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -590,6 +630,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="feedthroughEnergyProducedWh",
+ field_path="panel.feedthrough_energy_produced_wh",
translation_key="feedthrough_produced_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -599,6 +640,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="feedthroughEnergyConsumedWh",
+ field_path="panel.feedthrough_energy_consumed_wh",
translation_key="feedthrough_consumed_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -608,6 +650,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="mainMeterNetEnergyWh",
+ derived=True,
translation_key="main_meter_net_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -620,6 +663,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="feedthroughNetEnergyWh",
+ derived=True,
translation_key="feedthrough_net_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -641,6 +685,7 @@ class SpanPVMetadataSensorEntityDescription(
] = (
SpanPanelCircuitsSensorEntityDescription(
key="circuit_power",
+ field_path="circuit.instant_power_w",
name="Power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -654,6 +699,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_produced",
+ field_path="circuit.produced_energy_wh",
name="Produced Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -665,6 +711,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_consumed",
+ field_path="circuit.consumed_energy_wh",
name="Consumed Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -676,6 +723,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_net",
+ derived=True,
name="Net Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -698,13 +746,13 @@ class SpanPVMetadataSensorEntityDescription(
@dataclass(frozen=True)
-class SpanEvseRequiredKeysMixin:
+class SpanEvseRequiredKeysMixin(FieldPathDeclarationMixin):
"""Required keys mixin for EVSE sensors."""
value_fn: Callable[[SpanEvseSnapshot], float | str | None]
-@dataclass(frozen=True)
+@dataclass(frozen=True, kw_only=True)
class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredKeysMixin):
"""Describes an EVSE sensor entity."""
@@ -716,6 +764,7 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK
] = (
SpanEvseSensorEntityDescription(
key="evse_status",
+ field_path="evse.status",
translation_key="evse_status",
device_class=SensorDeviceClass.ENUM,
options=[
@@ -734,6 +783,7 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK
),
SpanEvseSensorEntityDescription(
key="evse_advertised_current",
+ field_path="evse.advertised_current_a",
translation_key="evse_advertised_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
@@ -743,9 +793,45 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK
),
SpanEvseSensorEntityDescription(
key="evse_lock_state",
+ field_path="evse.lock_state",
translation_key="evse_lock_state",
device_class=SensorDeviceClass.ENUM,
options=["locked", "unlocked", "unknown"],
value_fn=lambda e: e.lock_state or "unknown",
),
)
+
+
+def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]:
+ """Every sensor description, without deduplication.
+
+ Deliberately not keyed by `description.key`: keys such as "model" and
+ "serial_number" repeat across device classes, so a dict keyed on them
+ silently drops descriptions.
+ """
+ return (
+ *PANEL_DATA_STATUS_SENSORS,
+ *STATUS_SENSORS,
+ *UNMAPPED_SENSORS,
+ *MID_SENSORS,
+ *BESS_METADATA_SENSORS,
+ *PV_METADATA_SENSORS,
+ *PANEL_POWER_SENSORS,
+ *PANEL_ENERGY_SENSORS,
+ *CIRCUIT_SENSORS,
+ *EVSE_SENSORS,
+ BATTERY_SENSOR,
+ BATTERY_POWER_SENSOR,
+ PV_POWER_SENSOR,
+ GRID_POWER_FLOW_SENSOR,
+ SITE_POWER_SENSOR,
+ L1_VOLTAGE_SENSOR,
+ L2_VOLTAGE_SENSOR,
+ UPSTREAM_L1_CURRENT_SENSOR,
+ UPSTREAM_L2_CURRENT_SENSOR,
+ DOWNSTREAM_L1_CURRENT_SENSOR,
+ DOWNSTREAM_L2_CURRENT_SENSOR,
+ MAIN_BREAKER_RATING_SENSOR,
+ CIRCUIT_CURRENT_SENSOR,
+ CIRCUIT_BREAKER_RATING_SENSOR,
+ )
diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py
new file mode 100644
index 00000000..2a1fded8
--- /dev/null
+++ b/tests/test_field_paths.py
@@ -0,0 +1,28 @@
+"""Every entity that reads one snapshot field must say which field."""
+
+from __future__ import annotations
+
+from custom_components.span_panel.field_paths import declared_field_paths
+from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS
+
+
+def test_circuit_power_declares_its_field_path() -> None:
+ power = next(d for d in CIRCUIT_SENSORS if d.key == "circuit_power")
+ assert power.field_path == "circuit.instant_power_w"
+ assert power.derived is False
+
+
+def test_derived_sensor_declares_no_path() -> None:
+ """dsm_state is a multi-signal derivation with no single source field."""
+ from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS
+
+ dsm = next(d for d in PANEL_DATA_STATUS_SENSORS if d.key == "dsm_state")
+ assert dsm.derived is True
+ assert dsm.field_path is None
+
+
+def test_declared_field_paths_includes_residuals() -> None:
+ """Readers that live in entity code rather than on a description still count."""
+ paths = declared_field_paths()
+ assert "circuit.relay_state" in paths
+ assert "circuit.priority" in paths
From 0a15c2a20414d645ff820f7ebfa70e167e3321d2 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Tue, 18 Aug 2026 22:45:34 -0700
Subject: [PATCH 017/116] refactor: return a frozenset of producible field
paths
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
declared_field_paths() returned dict[str, bool] whose value was always False —
derived descriptions are skipped before the assignment, so no key could map to
True. It is a set, so return one.
Also splits the residual readers by empirical producibility against both
schema adapters. Readers both adapters produce join RESIDUAL_FIELD_PATHS;
$target echoes, topology-derived values and schema-conditional fields go to
RESIDUAL_DERIVED_PATHS and stay out of the producible set.
Adds the enumeration test that was missing: a description inheriting the
declaration mixin but setting neither field passed silently and was dropped
from every gate.
---
custom_components/span_panel/field_paths.py | 57 ++++++++++++++++++---
tests/test_field_paths.py | 46 ++++++++++++++++-
2 files changed, 93 insertions(+), 10 deletions(-)
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index ea415532..8570eeae 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -57,20 +57,61 @@ class FieldPathDeclarationMixin:
# Consumed by entity naming and attributes rather than by any platform
"circuit.name",
"circuit.tabs",
+ # sensor_circuit.py publishes this as a circuit attribute
+ "circuit.relay_requester",
}
)
-"""Readers not carried on an entity description.
+"""Readers not carried on an entity description, and producible by both adapters.
Keep this small. A new entry is a hint that the reader belongs on a
description instead.
"""
-def declared_field_paths() -> dict[str, bool]:
- """Every field path the integration reads, mapped to whether it is derived.
+RESIDUAL_DERIVED_PATHS: frozenset[str] = frozenset(
+ {
+ # Homie `$target` values — a pending-command echo, not a schema field.
+ # Neither adapter publishes a metadata row for them.
+ "circuit.relay_state_target",
+ "circuit.priority_target",
+ # Assembled by the library from panel topology rather than read from a
+ # schema property; no metadata row in either adapter.
+ "circuit.device_type",
+ "circuit.relative_position",
+ # No metadata row in either adapter — the panel reports it outside the
+ # typed field surface.
+ "panel.panel_size",
+ # Neither adapter emits any `mid.*` metadata rows at all; util.py reads
+ # these off the MID snapshot for device_info only.
+ "mid.hardware_version",
+ "mid.software_version",
+ # Schema-conditional: schema_1 publishes it, schema_0 has no row.
+ "circuit.is_user_controllable",
+ # Schema-conditional: schema_0 publishes these, schema_1 has no row.
+ "circuit.always_on",
+ "circuit.is_sheddable",
+ "panel.wifi_ssid",
+ # Schema-conditional: schema_0 publishes it, schema_1's
+ # `_PROPERTY_FIELD_MAP` has no `connected` row — the same gap that makes
+ # the `bess_connected` binary sensor `derived=True`.
+ "battery.connected",
+ }
+)
+"""Residual readers exempt from the producible check.
+
+Deliberately **not** returned by `declared_field_paths()`. Each entry is either
+produced by no adapter at all, or by only one of the two — so requiring it to be
+producible would fail against the other schema. Recorded here so the reads are
+still enumerated somewhere rather than being invisible.
+"""
+
+
+def declared_field_paths() -> frozenset[str]:
+ """Field paths the integration reads that must be producible by an adapter.
- Derived paths are exempt from the producible check because they have no
- single source field.
+ Derived entities are excluded: they have no single source field, so there is
+ nothing for an adapter to produce. Residual readers that no adapter (or only
+ one) produces are excluded too, and are listed in `RESIDUAL_DERIVED_PATHS`.
"""
# Deferred: the platform modules import `FieldPathDeclarationMixin` from
# here, and `binary_sensor` reaches the package root for its config-entry
@@ -85,7 +126,7 @@ def declared_field_paths() -> dict[str, bool]:
all_sensor_descriptions,
)
- paths: dict[str, bool] = dict.fromkeys(RESIDUAL_FIELD_PATHS, False)
+ paths: set[str] = set(RESIDUAL_FIELD_PATHS)
for description in (
*all_sensor_descriptions(),
*BINARY_SENSORS,
@@ -101,5 +142,5 @@ def declared_field_paths() -> dict[str, bool]:
)
if description.derived or description.field_path is None:
continue
- paths[description.field_path] = False
- return paths
+ paths.add(description.field_path)
+ return frozenset(paths)
diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py
index 2a1fded8..ddc5fd28 100644
--- a/tests/test_field_paths.py
+++ b/tests/test_field_paths.py
@@ -2,8 +2,21 @@
from __future__ import annotations
-from custom_components.span_panel.field_paths import declared_field_paths
-from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS
+from custom_components.span_panel.binary_sensor import (
+ BESS_CONNECTED_SENSOR,
+ BINARY_SENSORS,
+ EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+)
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_DERIVED_PATHS,
+ RESIDUAL_FIELD_PATHS,
+ declared_field_paths,
+)
+from custom_components.span_panel.sensor_definitions import (
+ CIRCUIT_SENSORS,
+ all_sensor_descriptions,
+)
def test_circuit_power_declares_its_field_path() -> None:
@@ -26,3 +39,32 @@ def test_declared_field_paths_includes_residuals() -> None:
paths = declared_field_paths()
assert "circuit.relay_state" in paths
assert "circuit.priority" in paths
+
+
+def test_every_description_declares_exactly_one() -> None:
+ """A description inheriting the mixin but setting neither field is invisible.
+
+ The `TypeError` guard in `declared_field_paths` only catches a description
+ that lacks the mixin entirely. Every new sensor inherits it automatically,
+ so the likelier mistake is inheriting it and declaring nothing — which
+ drops the entity from every gate with no signal. This is that signal.
+
+ Must enumerate exactly what `declared_field_paths` iterates.
+ """
+ for description in (
+ *all_sensor_descriptions(),
+ *BINARY_SENSORS,
+ *EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ BESS_CONNECTED_SENSOR,
+ ):
+ declares_path = description.field_path is not None
+ assert declares_path != description.derived, (
+ f"{description.key} must declare exactly one of field_path / derived=True"
+ )
+
+
+def test_residual_buckets_are_disjoint() -> None:
+ """A residual path is either producible or exempt, never both."""
+ assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_DERIVED_PATHS)
+ assert not (declared_field_paths() & RESIDUAL_DERIVED_PATHS)
From f4d0de0152176b8d74bfbb2fde9a9dc583797c75 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Tue, 18 Aug 2026 22:47:29 -0700
Subject: [PATCH 018/116] refactor: rename RESIDUAL_DERIVED_PATHS to
RESIDUAL_EXEMPT_PATHS
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Only some of its entries are derived. Five are schema-conditional — read
straight off a snapshot field that one adapter publishes and the other does
not — so "derived" misdescribes why they are exempt. The docstring now names
both reasons a path lands here.
---
custom_components/span_panel/field_paths.py | 23 ++++++++++++++-------
tests/test_field_paths.py | 6 +++---
2 files changed, 19 insertions(+), 10 deletions(-)
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 8570eeae..d1506a61 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -68,7 +68,7 @@ class FieldPathDeclarationMixin:
"""
-RESIDUAL_DERIVED_PATHS: frozenset[str] = frozenset(
+RESIDUAL_EXEMPT_PATHS: frozenset[str] = frozenset(
{
# Homie `$target` values — a pending-command echo, not a schema field.
# Neither adapter publishes a metadata row for them.
@@ -97,12 +97,21 @@ class FieldPathDeclarationMixin:
"battery.connected",
}
)
-"""Residual readers exempt from the producible check.
+"""Residual readers exempt from the producible check, for one of two reasons.
-Deliberately **not** returned by `declared_field_paths()`. Each entry is either
-produced by no adapter at all, or by only one of the two — so requiring it to be
-producible would fail against the other schema. Recorded here so the reads are
-still enumerated somewhere rather than being invisible.
+**Not produced by any adapter** — Homie `$target` echoes, values the library
+assembles from panel topology, and every `mid.*` field. There is no metadata row
+to check against on either schema.
+
+**Produced by only one adapter** — schema-conditional fields. The gate requires
+a path to be producible by *both* adapters, so a field present on one schema and
+absent from the other cannot satisfy it. Exempt is not the same as derived:
+these are read straight off a snapshot field, that field just is not on both
+schemas.
+
+Deliberately **not** returned by `declared_field_paths()`. Recorded here so the
+reads are still enumerated somewhere rather than being invisible. The per-entry
+comments say which of the two reasons applies.
"""
@@ -111,7 +120,7 @@ def declared_field_paths() -> frozenset[str]:
Derived entities are excluded: they have no single source field, so there is
nothing for an adapter to produce. Residual readers that no adapter (or only
- one) produces are excluded too, and are listed in `RESIDUAL_DERIVED_PATHS`.
+ one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`.
"""
# Deferred: the platform modules import `FieldPathDeclarationMixin` from
# here, and `binary_sensor` reaches the package root for its config-entry
diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py
index ddc5fd28..a5b12b3c 100644
--- a/tests/test_field_paths.py
+++ b/tests/test_field_paths.py
@@ -9,7 +9,7 @@
GRID_ISLANDABLE_SENSOR,
)
from custom_components.span_panel.field_paths import (
- RESIDUAL_DERIVED_PATHS,
+ RESIDUAL_EXEMPT_PATHS,
RESIDUAL_FIELD_PATHS,
declared_field_paths,
)
@@ -66,5 +66,5 @@ def test_every_description_declares_exactly_one() -> None:
def test_residual_buckets_are_disjoint() -> None:
"""A residual path is either producible or exempt, never both."""
- assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_DERIVED_PATHS)
- assert not (declared_field_paths() & RESIDUAL_DERIVED_PATHS)
+ assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_EXEMPT_PATHS)
+ assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS)
From 8b70c294a7c1cd3e27da2e8b84cfe50d177ec0e6 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Tue, 18 Aug 2026 23:46:33 -0700
Subject: [PATCH 019/116] refactor: complete the exempt field path enumeration
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fourteen further snapshot-field reads outside any entity description were in
neither bucket, including three in the same DeviceInfo call as bucketed mid.*
paths and one in the same availability property as battery.connected. That
adjacency is how the next reader concludes the constant is complete when it is
not.
All fourteen are exempt — none is producible by both adapters — so
declared_field_paths() stays at 46 and the gate is unchanged.
---
custom_components/span_panel/field_paths.py | 34 +++++++++++++++++++--
1 file changed, 32 insertions(+), 2 deletions(-)
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index d1506a61..f3f5edf7 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -10,7 +10,7 @@
fields to `battery.model` / `pv.model`).
Field path convention: ``{snapshot_type}.{field_name}`` — ``panel``,
-``circuit``, ``battery``, ``pv`` and ``evse``.
+``circuit``, ``battery``, ``pv``, ``evse`` and ``mid``.
"""
from __future__ import annotations
@@ -81,16 +81,46 @@ class FieldPathDeclarationMixin:
# No metadata row in either adapter — the panel reports it outside the
# typed field surface.
"panel.panel_size",
+ # The panel identity key behind every unique_id and the panel DeviceInfo
+ # (~30 read sites). Neither adapter publishes a row for it.
+ "panel.serial_number",
+ # Gates button availability at button.py:115. No row in either adapter —
+ # the same reason the `dsm_state` sensor is `derived=True`.
+ "panel.dsm_state",
+ # The circuit's own identity key, used for lookups and id construction
+ # (helpers.py, coordinator.py, entity_resolver.py). No row in either
+ # adapter.
+ "circuit.circuit_id",
# Neither adapter emits any `mid.*` metadata rows at all; util.py reads
- # these off the MID snapshot for device_info only.
+ # these off the MID snapshot for device_info, and sensor_panel.py reads
+ # the grid-forming name for an attribute.
"mid.hardware_version",
"mid.software_version",
+ "mid.vendor_name",
+ "mid.model",
+ "mid.serial_number",
+ "mid.grid_forming_device_name",
+ # The EVSE's Homie node id — an addressing handle used to build the
+ # sub-device identifier, not a published field. No row in either adapter.
+ "evse.node_id",
# Schema-conditional: schema_1 publishes it, schema_0 has no row.
"circuit.is_user_controllable",
# Schema-conditional: schema_0 publishes these, schema_1 has no row.
"circuit.always_on",
"circuit.is_sheddable",
"panel.wifi_ssid",
+ # Schema-conditional: schema_0 publishes these, schema_1 has no row.
+ # util.py builds the EVSE DeviceInfo from them; entity_resolver.py and
+ # sensor.py resolve the fed circuit through `feed_circuit_id`.
+ "evse.vendor_name",
+ "evse.model",
+ "evse.serial_number",
+ "evse.software_version",
+ "evse.feed_circuit_id",
+ # Schema-conditional: schema_0 publishes it, schema_1 derives islanding
+ # via `resolve_grid_islandable(inverters)`. Read at binary_sensor.py:408
+ # as an entity-creation gate, outside any description.
+ "panel.grid_islandable",
# Schema-conditional: schema_0 publishes it, schema_1's
# `_PROPERTY_FIELD_MAP` has no `connected` row — the same gap that makes
# the `bess_connected` binary sensor `derived=True`.
From 06ef60f0708877994c9da0d841b7fd20aed0d255 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Tue, 18 Aug 2026 23:57:39 -0700
Subject: [PATCH 020/116] test: assert declared field paths are producible by
both adapters
---
tests/test_field_path_conformance.py | 49 ++++++++++++++++++++++++++++
1 file changed, 49 insertions(+)
create mode 100644 tests/test_field_path_conformance.py
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
new file mode 100644
index 00000000..dc290705
--- /dev/null
+++ b/tests/test_field_path_conformance.py
@@ -0,0 +1,49 @@
+"""Every declared field path must be producible by each adapter, or derived.
+
+This is the test that would have caught the battery.product_name drift.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+import pytest
+from span_panel_api.models import FieldMetadata
+
+from custom_components.span_panel.field_paths import declared_field_paths
+from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata
+
+MetadataFn = Callable[[], dict[str, FieldMetadata]]
+
+_ADAPTERS: list[tuple[str, MetadataFn]] = [
+ ("schema_0", schema_zero_metadata),
+ ("schema_1", schema_one_metadata),
+]
+
+
+@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS)
+def test_every_declared_path_is_producible(adapter: str, metadata_fn: MetadataFn) -> None:
+ metadata = metadata_fn()
+ missing = sorted(path for path in declared_field_paths() if path not in metadata)
+ assert not missing, (
+ f"{adapter} does not produce declared field paths: {missing}. "
+ "Either the declaration is stale, or the entity should be derived=True."
+ )
+
+
+@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS)
+def test_gate_is_one_directional(adapter: str, metadata_fn: MetadataFn) -> None:
+ """A produced path nothing reads must NOT fail the build.
+
+ Additions are legal within a major version, so asserting the converse would
+ turn correct upstream behaviour into a red CI every time SPAN ships a
+ property. This test exists to stop someone "completing" the gate by adding
+ that assertion.
+ """
+ metadata = metadata_fn()
+ unread = sorted(set(metadata) - set(declared_field_paths()))
+ # The CI inventory the spec asks for: printed so the build log carries it,
+ # never asserted against. An addition is legal within a major version.
+ print(f"\n[{adapter}] produced but unread ({len(unread)}):")
+ for path in unread:
+ print(f" {path}")
From 863a429cece8a07a5ca6d353c781379ef62bf8c4 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:11:16 -0700
Subject: [PATCH 021/116] test: pin field path gate coverage and surface the
unread inventory in CI
---
tests/test_field_path_conformance.py | 74 +++++++++++++++++++++++++---
1 file changed, 68 insertions(+), 6 deletions(-)
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index dc290705..f799a9b6 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -5,11 +5,14 @@
from __future__ import annotations
+import ast
from collections.abc import Callable
+import pathlib
import pytest
from span_panel_api.models import FieldMetadata
+from custom_components.span_panel import field_paths as field_paths_module
from custom_components.span_panel.field_paths import declared_field_paths
from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata
@@ -20,6 +23,34 @@
("schema_1", schema_one_metadata),
]
+_PACKAGE_ROOT = pathlib.Path(field_paths_module.__file__).parent
+
+
+def _source_declared_paths() -> dict[str, str]:
+ """Every ``field_path="..."`` literal in the integration source, by module.
+
+ Read from the source text rather than from the platform collections, so the
+ hand-written tuple `declared_field_paths` iterates is pinned against what
+ the modules actually declare. A runtime scan of module attributes could not
+ do this: most descriptions are inline literals inside their collection, so
+ emptying the collection would hide them from the check as well as from the
+ gate.
+ """
+ found: dict[str, str] = {}
+ for source in sorted(_PACKAGE_ROOT.rglob("*.py")):
+ tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ for keyword in node.keywords:
+ if keyword.arg != "field_path":
+ continue
+ # Non-literal values are pass-throughs that copy an existing
+ # description's declaration, not new declarations.
+ if isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, str):
+ found.setdefault(keyword.value.value, source.name)
+ return found
+
@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS)
def test_every_declared_path_is_producible(adapter: str, metadata_fn: MetadataFn) -> None:
@@ -31,8 +62,35 @@ def test_every_declared_path_is_producible(adapter: str, metadata_fn: MetadataFn
)
+def test_gate_covers_every_declaration_in_the_source() -> None:
+ """A declaration the gate stops iterating must fail, not shrink silently.
+
+ `test_every_declared_path_is_producible` is monotone: a smaller declared set
+ always passes, because any subset of a producible set is producible. So
+ dropping a platform collection from `declared_field_paths`, or letting a
+ description fall out of its collection, would silently retire the gate for
+ every entity involved with no signal at all — the same invisible-omission
+ failure this whole module exists to prevent, one level up.
+
+ This is that signal. It compares against the source text, so it holds even
+ when a collection is emptied rather than unreferenced.
+ """
+ declared = declared_field_paths()
+ uncovered = sorted(
+ (path, module) for path, module in _source_declared_paths().items() if path not in declared
+ )
+ assert not uncovered, (
+ "declared_field_paths() does not cover field paths declared in the source: "
+ f"{uncovered}. A platform collection likely stopped being iterated, or a "
+ "description fell out of its collection — the gate is no longer checking "
+ "those entities."
+ )
+
+
@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS)
-def test_gate_is_one_directional(adapter: str, metadata_fn: MetadataFn) -> None:
+def test_gate_is_one_directional(
+ adapter: str, metadata_fn: MetadataFn, capsys: pytest.CaptureFixture[str]
+) -> None:
"""A produced path nothing reads must NOT fail the build.
Additions are legal within a major version, so asserting the converse would
@@ -42,8 +100,12 @@ def test_gate_is_one_directional(adapter: str, metadata_fn: MetadataFn) -> None:
"""
metadata = metadata_fn()
unread = sorted(set(metadata) - set(declared_field_paths()))
- # The CI inventory the spec asks for: printed so the build log carries it,
- # never asserted against. An addition is legal within a major version.
- print(f"\n[{adapter}] produced but unread ({len(unread)}):")
- for path in unread:
- print(f" {path}")
+ # The CI inventory the spec asks for: never asserted against, an addition is
+ # legal within a major version. Written with capture suspended so it reaches
+ # the build log under the plain `pytest` CI runs -- pytest swallows stdout
+ # from a passing test, which would leave the inventory existing in code and
+ # nowhere else.
+ with capsys.disabled():
+ print(f"\n[{adapter}] produced but unread ({len(unread)}):")
+ for path in unread:
+ print(f" {path}")
From b72bd2bedd3d8e3c3df307ff5c7f7e2dccaa990f Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:22:23 -0700
Subject: [PATCH 022/116] test: verify field_path declarations against their
value_fns
---
tests/test_field_path_introspection.py | 112 +++++++++++++++++++++++++
1 file changed, 112 insertions(+)
create mode 100644 tests/test_field_path_introspection.py
diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py
new file mode 100644
index 00000000..0b7196f1
--- /dev/null
+++ b/tests/test_field_path_introspection.py
@@ -0,0 +1,112 @@
+"""Verify each declared field_path against what its value_fn actually reads.
+
+Runs every value_fn against a proxy that records attribute access. The
+declaration stays authoritative — this only stops it drifting from the reader.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from custom_components.span_panel.sensor_definitions import all_sensor_descriptions
+
+# Attributes of the panel snapshot that are themselves sub-snapshots. Their
+# fields are addressed as "battery.x", not "panel.battery.x".
+_SUB_SNAPSHOTS = {"battery", "pv", "evse", "mid"}
+
+
+class _Recorder:
+ """Records every attribute path touched, and survives arithmetic.
+
+ value_fns do real work — `or "unknown"`, `a - b`, unary minus for PV sign
+ flips — so the proxy has to absorb those without raising and without
+ ending the recording.
+ """
+
+ def __init__(self, sink: set[str], prefix: str, root: bool = False) -> None:
+ object.__setattr__(self, "_sink", sink)
+ object.__setattr__(self, "_prefix", prefix)
+ object.__setattr__(self, "_root", root)
+
+ def __getattr__(self, name: str) -> _Recorder:
+ if name.startswith("_"):
+ raise AttributeError(name)
+ sink: set[str] = object.__getattribute__(self, "_sink")
+ prefix: str = object.__getattribute__(self, "_prefix")
+ root: bool = object.__getattribute__(self, "_root")
+ if root and name in _SUB_SNAPSHOTS:
+ return _Recorder(sink, name)
+ path = f"{prefix}.{name}" if prefix else name
+ sink.add(path)
+ return _Recorder(sink, path)
+
+ # Absorb the operations value_fns perform on the values they read.
+ def __bool__(self) -> bool:
+ return True
+
+ def __sub__(self, other: Any) -> _Recorder:
+ return self
+
+ def __rsub__(self, other: Any) -> _Recorder:
+ return self
+
+ def __neg__(self) -> _Recorder:
+ return self
+
+ def __or__(self, other: Any) -> _Recorder:
+ return self
+
+ def __call__(self, *args: Any, **kwargs: Any) -> _Recorder:
+ """Absorb method calls, e.g. `(m.grid_state or "unknown").lower()`.
+
+ The extra path this records (`mid.grid_state.lower`) is harmless: the
+ check is membership of the declared path, not set equality.
+ """
+ return self
+
+ def __eq__(self, other: Any) -> bool:
+ return False
+
+ def __hash__(self) -> int:
+ return 0
+
+
+# Every description class, and the snapshot type its value_fn receives.
+# A class missing here is silently skipped by the `prefix is None` guard below —
+# which is exactly the hole this test exists to close, so keep it complete.
+_ROOT_PREFIX = {
+ "SpanPanelCircuitsSensorEntityDescription": "circuit",
+ "SpanPanelDataSensorEntityDescription": "panel",
+ "SpanPanelStatusSensorEntityDescription": "panel",
+ "SpanPanelBatterySensorEntityDescription": "battery",
+ "SpanBessMetadataSensorEntityDescription": "battery",
+ # PV metadata value_fns take the whole panel snapshot and reach through
+ # `s.pv.x`, so the root prefix is "panel" and _SUB_SNAPSHOTS rewrites it.
+ "SpanPVMetadataSensorEntityDescription": "panel",
+ "SpanEvseSensorEntityDescription": "evse",
+ "SpanMidSensorEntityDescription": "mid",
+}
+
+
+def test_declared_paths_match_what_value_fns_read() -> None:
+ mismatches: list[str] = []
+
+ for description in all_sensor_descriptions():
+ if description.derived or description.field_path is None:
+ continue
+ prefix = _ROOT_PREFIX.get(type(description).__name__)
+ if prefix is None:
+ continue
+ sink: set[str] = set()
+ proxy = _Recorder(sink, prefix, root=(prefix == "panel"))
+ try:
+ description.value_fn(proxy)
+ except Exception as err: # noqa: BLE001
+ mismatches.append(f"{description.key}: value_fn raised {err!r}")
+ continue
+ if description.field_path not in sink:
+ mismatches.append(
+ f"{description.key}: declares {description.field_path!r} but reads {sorted(sink)}"
+ )
+
+ assert not mismatches, "Declarations disagree with readers:\n" + "\n".join(mismatches)
From d5b83ca3cd93e615b1748d95edfa5ffb3e02f143 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:24:15 -0700
Subject: [PATCH 023/116] test: introspect binary sensor declarations and fail
on unmapped classes
The gate in declared_field_paths() walks the binary sensor collections too,
so their declarations were producible-checked but never checked against
their readers. Cover them, and pin the introspected description set against
the gate's own so a new platform collection cannot be gated without being
verified.
An entity description class absent from the root-prefix map is now reported
as a failure rather than skipped -- a silent skip is the drift this test
exists to catch.
---
tests/test_field_path_introspection.py | 89 ++++++++++++++++++++++++--
1 file changed, 84 insertions(+), 5 deletions(-)
diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py
index 0b7196f1..826f028b 100644
--- a/tests/test_field_path_introspection.py
+++ b/tests/test_field_path_introspection.py
@@ -6,8 +6,13 @@
from __future__ import annotations
-from typing import Any
+from collections.abc import Callable, Iterator
+from typing import Any, Protocol, runtime_checkable
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_FIELD_PATHS,
+ declared_field_paths,
+)
from custom_components.span_panel.sensor_definitions import all_sensor_descriptions
# Attributes of the panel snapshot that are themselves sub-snapshots. Their
@@ -71,9 +76,61 @@ def __hash__(self) -> int:
return 0
+@runtime_checkable
+class _DeclaringDescription(Protocol):
+ """The surface this test needs from an entity description.
+
+ A protocol rather than a concrete type because the eight sensor classes and
+ the two binary-sensor classes share no base beyond
+ `FieldPathDeclarationMixin`, which does not carry `value_fn`.
+ """
+
+ @property
+ def key(self) -> str: ...
+
+ @property
+ def field_path(self) -> str | None: ...
+
+ @property
+ def derived(self) -> bool: ...
+
+ @property
+ def value_fn(self) -> Callable[[Any], object]: ...
+
+
+def _declaring_descriptions() -> Iterator[_DeclaringDescription]:
+ """Every entity description that carries a field-path declaration.
+
+ Mirrors the collections `declared_field_paths()` walks, so the gate and this
+ verifier cover the same descriptions;
+ `test_introspection_covers_every_declared_path` pins that they still do.
+ """
+ # Deferred for the same reason `field_paths` defers it: `binary_sensor`
+ # reaches the package root, and the root imports the platforms.
+ from custom_components.span_panel.binary_sensor import ( # noqa: PLC0415
+ BESS_CONNECTED_SENSOR,
+ BINARY_SENSORS,
+ EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ )
+
+ for description in (
+ *all_sensor_descriptions(),
+ *BINARY_SENSORS,
+ *EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ BESS_CONNECTED_SENSOR,
+ ):
+ if not isinstance(description, _DeclaringDescription):
+ raise TypeError(
+ f"entity description '{description.key}' carries no field-path declaration"
+ )
+ yield description
+
+
# Every description class, and the snapshot type its value_fn receives.
-# A class missing here is silently skipped by the `prefix is None` guard below —
-# which is exactly the hole this test exists to close, so keep it complete.
+# A class missing here is reported as a mismatch rather than skipped: a silent
+# skip is exactly the hole this test exists to close.
_ROOT_PREFIX = {
"SpanPanelCircuitsSensorEntityDescription": "circuit",
"SpanPanelDataSensorEntityDescription": "panel",
@@ -85,17 +142,24 @@ def __hash__(self) -> int:
"SpanPVMetadataSensorEntityDescription": "panel",
"SpanEvseSensorEntityDescription": "evse",
"SpanMidSensorEntityDescription": "mid",
+ "SpanPanelBinarySensorEntityDescription": "panel",
+ "SpanEvseBinarySensorEntityDescription": "evse",
}
def test_declared_paths_match_what_value_fns_read() -> None:
mismatches: list[str] = []
- for description in all_sensor_descriptions():
+ for description in _declaring_descriptions():
if description.derived or description.field_path is None:
continue
- prefix = _ROOT_PREFIX.get(type(description).__name__)
+ class_name = type(description).__name__
+ prefix = _ROOT_PREFIX.get(class_name)
if prefix is None:
+ mismatches.append(
+ f"{description.key}: {class_name} is absent from _ROOT_PREFIX, so its "
+ "declaration would go unverified"
+ )
continue
sink: set[str] = set()
proxy = _Recorder(sink, prefix, root=(prefix == "panel"))
@@ -110,3 +174,18 @@ def test_declared_paths_match_what_value_fns_read() -> None:
)
assert not mismatches, "Declarations disagree with readers:\n" + "\n".join(mismatches)
+
+
+def test_introspection_covers_every_declared_path() -> None:
+ """Every path the gate accepts must be one this test verified, or residual.
+
+ `_declaring_descriptions` restates the collections `declared_field_paths()`
+ walks. Without this, a platform collection added to the gate but not here
+ would be gated for producibility and never checked against its reader.
+ """
+ introspected = {
+ description.field_path
+ for description in _declaring_descriptions()
+ if not description.derived and description.field_path is not None
+ }
+ assert declared_field_paths() == frozenset(introspected | set(RESIDUAL_FIELD_PATHS))
From eb325503335b7e30ced839f1035d65b0857b7995 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:34:09 -0700
Subject: [PATCH 024/116] test: assert schema unit strings are recognised HA
units
---
tests/test_unit_vocabulary.py | 56 +++++++++++++++++++++++++++++++++++
1 file changed, 56 insertions(+)
create mode 100644 tests/test_unit_vocabulary.py
diff --git a/tests/test_unit_vocabulary.py b/tests/test_unit_vocabulary.py
new file mode 100644
index 00000000..765ff7a3
--- /dev/null
+++ b/tests/test_unit_vocabulary.py
@@ -0,0 +1,56 @@
+"""Schema unit strings must be recognisable HA units."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+from homeassistant.const import (
+ PERCENTAGE,
+ UnitOfElectricCurrent,
+ UnitOfElectricPotential,
+ UnitOfEnergy,
+ UnitOfPower,
+)
+import pytest
+from span_panel_api.models import FieldMetadata
+
+from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata
+
+MetadataFn = Callable[[], dict[str, FieldMetadata]]
+
+_KNOWN: set[str] = {
+ *(u.value for u in UnitOfPower),
+ *(u.value for u in UnitOfEnergy),
+ *(u.value for u in UnitOfElectricCurrent),
+ *(u.value for u in UnitOfElectricPotential),
+ PERCENTAGE,
+}
+
+UNIT_TRANSLATIONS: dict[str, str] = {}
+"""Schema unit string -> HA unit, for units the panel spells differently.
+
+Empty today. An entry here is a deliberate statement that the panel's spelling
+differs from HA's, not a licence to paper over a firmware bug.
+"""
+
+
+@pytest.mark.parametrize(
+ ("adapter", "metadata_fn"),
+ [("schema_0", schema_zero_metadata), ("schema_1", schema_one_metadata)],
+)
+def test_units_are_recognised(adapter: str, metadata_fn: MetadataFn) -> None:
+ unknown = sorted(
+ {
+ entry.unit
+ for entry in metadata_fn().values()
+ if entry.resolved
+ and entry.unit is not None
+ and entry.unit not in _KNOWN
+ and entry.unit not in UNIT_TRANSLATIONS
+ }
+ )
+ assert not unknown, (
+ f"{adapter} declares unit strings HA does not recognise: {unknown}. "
+ "Add a UNIT_TRANSLATIONS entry, or fix the mapping — do not let this "
+ "reach the unit check, where it becomes one Repair per sensor."
+ )
From 608d247e1707144bd8167f870e0c586b3d9fcd94 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:37:05 -0700
Subject: [PATCH 025/116] feat: surface the BESS part number as a diagnostic
sensor
---
custom_components/span_panel/sensor_definitions.py | 9 +++++++++
custom_components/span_panel/strings.json | 3 +++
custom_components/span_panel/translations/en.json | 3 +++
custom_components/span_panel/translations/es.json | 3 +++
custom_components/span_panel/translations/fr.json | 3 +++
custom_components/span_panel/translations/ja.json | 3 +++
custom_components/span_panel/translations/pt.json | 3 +++
tests/test_dps_and_bess.py | 14 ++++++++++++++
tests/test_promoted_sensors.py | 2 +-
tests/test_sensor_platform.py | 3 ++-
10 files changed, 44 insertions(+), 2 deletions(-)
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 4fd70973..fa3ebcb6 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -423,6 +423,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
SpanBessMetadataSensorEntityDescription,
SpanBessMetadataSensorEntityDescription,
SpanBessMetadataSensorEntityDescription,
+ SpanBessMetadataSensorEntityDescription,
] = (
SpanBessMetadataSensorEntityDescription(
key="vendor",
@@ -438,6 +439,14 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda b: b.model,
),
+ SpanBessMetadataSensorEntityDescription(
+ key="part_number",
+ field_path="battery.part_number",
+ translation_key="bess_part_number",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=lambda b: b.part_number,
+ ),
SpanBessMetadataSensorEntityDescription(
key="serial_number",
field_path="battery.serial_number",
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 83e427c2..a8f63db5 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -180,6 +180,9 @@
"bess_nameplate_capacity": {
"name": "Nameplate Capacity"
},
+ "bess_part_number": {
+ "name": "Part Number"
+ },
"bess_serial_number": {
"name": "Serial Number"
},
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 83e427c2..a8f63db5 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -180,6 +180,9 @@
"bess_nameplate_capacity": {
"name": "Nameplate Capacity"
},
+ "bess_part_number": {
+ "name": "Part Number"
+ },
"bess_serial_number": {
"name": "Serial Number"
},
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index ed94a353..fa90ee06 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -180,6 +180,9 @@
"bess_nameplate_capacity": {
"name": "Capacidad Nominal"
},
+ "bess_part_number": {
+ "name": "Número de Pieza"
+ },
"bess_serial_number": {
"name": "Número de Serie"
},
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 8061af76..0fd22ede 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -180,6 +180,9 @@
"bess_nameplate_capacity": {
"name": "Capacité Nominale"
},
+ "bess_part_number": {
+ "name": "Référence"
+ },
"bess_serial_number": {
"name": "Numéro de Série"
},
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 4e4e6458..bc1563aa 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -180,6 +180,9 @@
"bess_nameplate_capacity": {
"name": "定格容量"
},
+ "bess_part_number": {
+ "name": "型番"
+ },
"bess_serial_number": {
"name": "シリアル番号"
},
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index 166aa539..03ff093d 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -180,6 +180,9 @@
"bess_nameplate_capacity": {
"name": "Capacidade Nominal"
},
+ "bess_part_number": {
+ "name": "Número de Peça"
+ },
"bess_serial_number": {
"name": "Número de Série"
},
diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py
index 637284a5..0078f9ae 100644
--- a/tests/test_dps_and_bess.py
+++ b/tests/test_dps_and_bess.py
@@ -13,6 +13,7 @@
SpanPanelGFEOverrideButton,
)
from custom_components.span_panel.helpers import has_bess
+from custom_components.span_panel.sensor_definitions import BESS_METADATA_SENSORS
from custom_components.span_panel.sensor_panel import _grid_forming_device_name
from .factories import SpanPanelSnapshotFactory
@@ -242,3 +243,16 @@ def test_no_name_when_the_grid_itself_is_forming(self) -> None:
snapshot = SimpleNamespace(mid=SimpleNamespace(grid_forming_device_name=None))
assert _grid_forming_device_name(snapshot) is None
+
+
+# ---------------------------------------------------------------------------
+# BESS metadata sensor declarations
+# ---------------------------------------------------------------------------
+
+
+def test_bess_part_number_sensor_is_declared() -> None:
+ """The BESS SKU is surfaced, and declares the field it reads."""
+ part = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number")
+
+ assert part.field_path == "battery.part_number"
+ assert part.derived is False
diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py
index ee2ec445..25e1be67 100644
--- a/tests/test_promoted_sensors.py
+++ b/tests/test_promoted_sensors.py
@@ -250,7 +250,7 @@ class TestBessMetadataSensorDefinitions:
"""Test BESS metadata sensor definitions."""
def test_sensor_count(self):
- assert len(BESS_METADATA_SENSORS) == 6
+ assert len(BESS_METADATA_SENSORS) == 7
def test_all_have_translation_keys(self):
for desc in BESS_METADATA_SENSORS:
diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py
index e397ebbb..05c8ff95 100644
--- a/tests/test_sensor_platform.py
+++ b/tests/test_sensor_platform.py
@@ -345,10 +345,11 @@ def test_create_battery_sensors_returns_expected_entities_when_bess_present() ->
entities = create_battery_sensors(coordinator, snapshot)
keys = [entity.entity_description.key for entity in entities]
- assert len(entities) == 8
+ assert len(entities) == 9
assert "batteryPowerW" in keys
assert "storage_battery_percentage" in keys
assert "vendor" in keys
+ assert "part_number" in keys
def test_create_power_flow_sensors_gate_pv_and_site_flow() -> None:
From fa8a4e2f26599c25da14b57cbea9c79eae585ccd Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:47:43 -0700
Subject: [PATCH 026/116] test: pin the unit sample floor and the part number's
disabled default
---
tests/test_promoted_sensors.py | 15 +++++++++++++++
tests/test_unit_vocabulary.py | 24 ++++++++++++++++--------
2 files changed, 31 insertions(+), 8 deletions(-)
diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py
index 25e1be67..866d2eeb 100644
--- a/tests/test_promoted_sensors.py
+++ b/tests/test_promoted_sensors.py
@@ -274,6 +274,20 @@ def test_model_value_function(self):
desc = next(d for d in BESS_METADATA_SENSORS if d.key == "model")
assert desc.value_fn(battery) == "IQ Battery 10"
+ def test_part_number_value_function(self):
+ battery = SpanBatterySnapshotFactory.create(part_number="830-01234-01")
+ desc = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number")
+ assert desc.value_fn(battery) == "830-01234-01"
+
+ def test_part_number_is_disabled_by_default(self):
+ """A new entity on an existing install must not appear uninvited.
+
+ Asserted separately from the value function so a flip of this flag names
+ itself in the failure rather than hiding inside a value test.
+ """
+ desc = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number")
+ assert desc.entity_registry_enabled_default is False
+
def test_serial_number_value_function(self):
battery = SpanBatterySnapshotFactory.create(serial_number="BESS-12345")
desc = next(d for d in BESS_METADATA_SENSORS if d.key == "serial_number")
@@ -316,6 +330,7 @@ def test_build_bess_unique_id_different_keys(self):
for key in (
"vendor",
"model",
+ "part_number",
"serial_number",
"firmware_version",
"nameplate_capacity",
diff --git a/tests/test_unit_vocabulary.py b/tests/test_unit_vocabulary.py
index 765ff7a3..7f76c626 100644
--- a/tests/test_unit_vocabulary.py
+++ b/tests/test_unit_vocabulary.py
@@ -26,6 +26,9 @@
PERCENTAGE,
}
+_MIN_SAMPLED_UNITS = 5
+"""Floor on distinct unit strings an adapter must emit for the check to mean anything."""
+
UNIT_TRANSLATIONS: dict[str, str] = {}
"""Schema unit string -> HA unit, for units the panel spells differently.
@@ -39,15 +42,20 @@
[("schema_0", schema_zero_metadata), ("schema_1", schema_one_metadata)],
)
def test_units_are_recognised(adapter: str, metadata_fn: MetadataFn) -> None:
+ sampled = {e.unit for e in metadata_fn().values() if e.resolved and e.unit is not None}
+
+ # The check below is monotone: it passes for any subset, including the empty
+ # one. Pin the sample so a fixture path change, a fixture regenerated without
+ # units, or an adapter that stops populating `unit` cannot retire it silently.
+ # schema_0 emits 7 distinct strings and schema_1 emits 6 over 27 entries each,
+ # so 5 leaves room for one unit to be legitimately retired and still fails on
+ # a collapse.
+ assert len(sampled) >= _MIN_SAMPLED_UNITS, (
+ f"{adapter} emitted only {sorted(sampled)} — the vocabulary check has nothing to check"
+ )
+
unknown = sorted(
- {
- entry.unit
- for entry in metadata_fn().values()
- if entry.resolved
- and entry.unit is not None
- and entry.unit not in _KNOWN
- and entry.unit not in UNIT_TRANSLATIONS
- }
+ unit for unit in sampled if unit not in _KNOWN and unit not in UNIT_TRANSLATIONS
)
assert not unknown, (
f"{adapter} declares unit strings HA does not recognise: {unknown}. "
From 80d8259760ffbeface9a4a786e7e5b1ec75ee1a6 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:57:13 -0700
Subject: [PATCH 027/116] refactor: classify schema findings from the adapter's
resolved signal
Removes the telemetry-based capability inference, checks unit-less sensors for
resolution, and reads field_metadata through the protocol rather than naming
the transport class.
---
custom_components/span_panel/coordinator.py | 49 ++-
custom_components/span_panel/field_paths.py | 4 +-
.../span_panel/schema_expectations.py | 102 -----
.../span_panel/schema_validation.py | 216 +++-------
.../span_panel/sensor_definitions.py | 24 ++
tests/test_coordinator.py | 79 ++--
tests/test_schema_validation.py | 378 ++++++------------
7 files changed, 280 insertions(+), 572 deletions(-)
delete mode 100644 custom_components/span_panel/schema_expectations.py
diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py
index fb2ec28a..30d1cef8 100644
--- a/custom_components/span_panel/coordinator.py
+++ b/custom_components/span_panel/coordinator.py
@@ -23,12 +23,13 @@
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from span_panel_api import SpanMqttClient, SpanPanelSnapshot
+from span_panel_api import SpanMqttClient, SpanPanelClientProtocol, SpanPanelSnapshot
from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError
from .const import DOMAIN
from .id_builder import build_circuit_unique_id
-from .schema_validation import collect_sensor_definitions, validate_field_metadata
+from .schema_validation import SchemaFindings, evaluate_field_metadata
+from .sensor_definitions import sensor_descriptions_by_field_path
class SpanCircuitEnergySensorProtocol(Protocol):
@@ -90,6 +91,7 @@ def __init__(
# Schema validation — run once after first successful refresh
self._schema_validated = False
+ self._findings: SchemaFindings | None = None
# Energy dip compensation — sensors append events here during updates;
# drained and surfaced as a persistent notification after each cycle.
@@ -363,26 +365,41 @@ async def async_shutdown(self) -> None:
# --- Schema validation ---
def _run_schema_validation(self) -> None:
- """Run schema field metadata validation once at startup.
+ """Classify the adapter's field metadata once at startup.
- Compares the library's schema-derived field metadata against the
- integration's sensor definitions to detect unit mismatches. Also
- reports fields the integration doesn't map to any sensor.
+ Reads the metadata through ``SpanPanelClientProtocol`` so this module
+ never names a transport class, and stores the result for the platforms
+ and the Repairs reconciler to read.
"""
- field_metadata: dict[str, dict[str, object]] | None = None
- if isinstance(self._client, SpanMqttClient):
- raw = self._client.field_metadata
- if raw is not None:
- field_metadata = {
- k: {"unit": v.unit, "datatype": v.datatype} for k, v in raw.items()
- }
+ field_metadata = (
+ self._client.field_metadata
+ if isinstance(self._client, SpanPanelClientProtocol)
+ else None
+ )
if field_metadata is None:
- _LOGGER.debug("Schema validation skipped — no field metadata available")
+ # "Unknown", NOT "nothing is wrong". `field_metadata` is None for the
+ # whole _on_pre_rebuild -> retained-message window, and that fires on
+ # an ORDINARY reconnect (after MQTT_FULL_REBUILD_AFTER_FAILURES), not
+ # only on a generation change. Reconciling against empty findings here
+ # would delete every schema issue — and with it every dismissal the
+ # user has made. Keep the previous findings and skip this pass.
+ _LOGGER.debug("Schema validation skipped: metadata not available yet")
return
- sensor_defs = collect_sensor_definitions()
- validate_field_metadata(field_metadata, sensor_defs=sensor_defs)
+ self._findings = evaluate_field_metadata(
+ field_metadata, sensor_descriptions_by_field_path()
+ )
+
+ @property
+ def unresolved_paths(self) -> frozenset[str]:
+ """Field paths the adapter could not resolve. Empty when healthy."""
+ return self._findings.unresolved if self._findings is not None else frozenset()
+
+ @property
+ def schema_findings(self) -> SchemaFindings | None:
+ """Findings from the last completed validation pass, if any."""
+ return self._findings
# --- Hardware capability detection ---
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index f3f5edf7..e25a098a 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -4,8 +4,8 @@
cannot drift from the reader. A few readers are in entity code rather than on a
description; those are listed here.
-This module replaces `schema_expectations.SENSOR_FIELD_MAP`, a hand-maintained
-parallel dict that had already drifted once (it pointed at
+This module replaced `schema_expectations.SENSOR_FIELD_MAP` (since deleted), a
+hand-maintained parallel dict that had already drifted once (it pointed at
`battery.product_name` and `pv.product_name` after the library renamed those
fields to `battery.model` / `pv.model`).
diff --git a/custom_components/span_panel/schema_expectations.py b/custom_components/span_panel/schema_expectations.py
deleted file mode 100644
index 4ebe1f34..00000000
--- a/custom_components/span_panel/schema_expectations.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""Sensor-to-snapshot-field mapping for schema validation.
-
-Maps integration sensor definition keys to snapshot field paths. This is the
-integration's declaration of which snapshot fields it reads, expressed in
-transport-agnostic terms.
-
-The integration does NOT know about Homie, MQTT, node types, or property IDs.
-The ``span-panel-api`` library owns that knowledge and exposes field-level
-metadata keyed by snapshot field paths. This module bridges from sensor
-definitions (HA side) to field paths (library side).
-
-Field path convention: ``{snapshot_type}.{field_name}``
- - ``panel`` — SpanPanelSnapshot fields
- - ``circuit`` — SpanCircuitSnapshot fields
- - ``battery`` — SpanBatterySnapshot fields
- - ``pv`` — SpanPVSnapshot fields
- - ``evse`` — SpanEvseSnapshot fields
-
-Derived sensors (net energy, dsm_state, current_run_config) that compute
-values from multiple fields have no single source field and are excluded.
-"""
-
-from __future__ import annotations
-
-# ---------------------------------------------------------------------------
-# Sensor definition key → snapshot field path
-#
-# Every sensor the integration creates that reads a single snapshot field
-# should appear here. The sensor definition provides the HA unit; the
-# library's field metadata provides the schema-declared unit. The validation
-# module compares them.
-#
-# Entries are grouped by snapshot type for readability.
-# ---------------------------------------------------------------------------
-
-SENSOR_FIELD_MAP: dict[str, str] = {
- # --- Panel power sensors -------------------------------------------------
- "instantGridPowerW": "panel.instant_grid_power_w",
- "feedthroughPowerW": "panel.feedthrough_power_w",
- "batteryPowerW": "panel.power_flow_battery",
- "pvPowerW": "panel.power_flow_pv",
- "gridPowerFlowW": "panel.power_flow_grid",
- "sitePowerW": "panel.power_flow_site",
- # --- Panel energy sensors ------------------------------------------------
- "mainMeterEnergyProducedWh": "panel.main_meter_energy_produced_wh",
- "mainMeterEnergyConsumedWh": "panel.main_meter_energy_consumed_wh",
- "feedthroughEnergyProducedWh": "panel.feedthrough_energy_produced_wh",
- "feedthroughEnergyConsumedWh": "panel.feedthrough_energy_consumed_wh",
- # --- Panel diagnostic sensors --------------------------------------------
- "l1_voltage": "panel.l1_voltage",
- "l2_voltage": "panel.l2_voltage",
- "upstream_l1_current": "panel.upstream_l1_current_a",
- "upstream_l2_current": "panel.upstream_l2_current_a",
- "downstream_l1_current": "panel.downstream_l1_current_a",
- "downstream_l2_current": "panel.downstream_l2_current_a",
- "main_breaker_rating": "panel.main_breaker_rating_a",
- # --- Panel status sensors (enum/string — no unit, but tracked) -----------
- "main_relay_state": "panel.main_relay_state",
- "grid_forming_entity": "panel.dominant_power_source",
- "vendor_cloud": "panel.vendor_cloud",
- "software_version": "panel.firmware_version",
- # --- Circuit sensors -----------------------------------------------------
- "circuit_power": "circuit.instant_power_w",
- "circuit_energy_produced": "circuit.produced_energy_wh",
- "circuit_energy_consumed": "circuit.consumed_energy_wh",
- "circuit_current": "circuit.current_a",
- "circuit_breaker_rating": "circuit.breaker_rating_a",
- # --- Unmapped circuit sensors (same fields, different sensor keys) --------
- "instantPowerW": "circuit.instant_power_w",
- "producedEnergyWh": "circuit.produced_energy_wh",
- "consumedEnergyWh": "circuit.consumed_energy_wh",
- # --- Battery sensors -----------------------------------------------------
- "storage_battery_percentage": "battery.soe_percentage",
- "nameplate_capacity": "battery.nameplate_capacity_kwh",
- "soe_kwh": "battery.soe_kwh",
- # --- BESS metadata sensors -----------------------------------------------
- "vendor": "battery.vendor_name",
- "model": "battery.model",
- "serial_number": "battery.serial_number",
- "firmware_version": "battery.software_version",
- # --- PV metadata sensors -------------------------------------------------
- "pv_vendor": "pv.vendor_name",
- "pv_product": "pv.model",
- "pv_nameplate_capacity": "pv.nameplate_capacity_w",
- # --- EVSE sensors --------------------------------------------------------
- "evse_status": "evse.status",
- "evse_advertised_current": "evse.advertised_current_a",
- "evse_lock_state": "evse.lock_state",
-}
-
-# Derived sensors excluded from the map (computed from multiple fields):
-# dsm_state — multi-signal heuristic
-# dsm_grid_state — deprecated alias for dsm_state
-# current_run_config — tri-state derivation
-# mainMeterNetEnergyWh — consumed_wh - produced_wh
-# feedthroughNetEnergyWh — consumed_wh - produced_wh
-# circuit_energy_net — consumed_wh - produced_wh (or inverse for PV)
-
-
-def all_referenced_field_paths() -> frozenset[str]:
- """Return the set of all snapshot field paths referenced by any sensor."""
- return frozenset(SENSOR_FIELD_MAP.values())
diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py
index 34febe52..25fc82f1 100644
--- a/custom_components/span_panel/schema_validation.py
+++ b/custom_components/span_panel/schema_validation.py
@@ -1,176 +1,82 @@
-"""Schema validation — cross-check field metadata against sensor definitions.
+"""Compare adapter field metadata against what this integration declares it reads.
-Compares the ``span-panel-api`` library's field metadata (schema-derived units
-and datatypes keyed by snapshot field paths) against the integration's sensor
-definitions. All Homie/MQTT knowledge stays in the library; this module only
-sees snapshot field paths and HA sensor metadata.
+Consumes the library's three-way signal:
-Schema drift detection (diffing schema versions between firmware updates) is
-the library's responsibility. The integration only consumes the result.
+- entry, ``resolved=True`` — produced; the unit is meaningful
+- entry, ``resolved=False`` — a device is present but does not declare the
+ property. Degradation.
+- **no entry** — no device of that type. Hardware absent; not a defect.
-All output is log-only. No entity creation or sensor behavior changes.
-
-Phase 1 of the schema-driven changes plan.
-
-Usage:
- Called from the coordinator after the first successful data refresh.
- Requires ``span-panel-api`` to expose field metadata via the client protocol.
- Until that library change lands, ``validate_field_metadata()`` is a safe no-op.
+Because the adapter classifies absence, this module needs no capability table
+and never infers hardware presence from telemetry.
"""
from __future__ import annotations
+from dataclasses import dataclass
import logging
from homeassistant.components.sensor import SensorEntityDescription
+from span_panel_api.models import FieldMetadata
-from .schema_expectations import SENSOR_FIELD_MAP, all_referenced_field_paths
-from .sensor_definitions import (
- BATTERY_POWER_SENSOR,
- BATTERY_SENSOR,
- BESS_METADATA_SENSORS,
- CIRCUIT_BREAKER_RATING_SENSOR,
- CIRCUIT_CURRENT_SENSOR,
- CIRCUIT_SENSORS,
- DOWNSTREAM_L1_CURRENT_SENSOR,
- DOWNSTREAM_L2_CURRENT_SENSOR,
- EVSE_SENSORS,
- GRID_POWER_FLOW_SENSOR,
- L1_VOLTAGE_SENSOR,
- L2_VOLTAGE_SENSOR,
- MAIN_BREAKER_RATING_SENSOR,
- PANEL_DATA_STATUS_SENSORS,
- PANEL_ENERGY_SENSORS,
- PANEL_POWER_SENSORS,
- PV_METADATA_SENSORS,
- PV_POWER_SENSOR,
- SITE_POWER_SENSOR,
- STATUS_SENSORS,
- UNMAPPED_SENSORS,
- UPSTREAM_L1_CURRENT_SENSOR,
- UPSTREAM_L2_CURRENT_SENSOR,
-)
+from .field_paths import declared_field_paths
_LOGGER = logging.getLogger(__name__)
-def _cross_check_units(
- field_metadata: dict[str, dict[str, object]],
- sensor_defs: dict[str, SensorEntityDescription],
-) -> None:
- """Compare library-reported units against sensor definition units.
+@dataclass(frozen=True, slots=True)
+class UnitMismatch:
+ """A declared unit that disagrees with the schema's."""
- For each sensor in SENSOR_FIELD_MAP that has a ``native_unit_of_measurement``,
- look up the corresponding field path in the library's metadata and compare
- the declared unit.
- """
- for sensor_key, field_path in SENSOR_FIELD_MAP.items():
- sensor_def = sensor_defs.get(sensor_key)
- if sensor_def is None:
- continue
+ field_path: str
+ ha_unit: str
+ schema_unit: str
- ha_unit = sensor_def.native_unit_of_measurement
- if ha_unit is None:
- # Sensor has no unit (enum, string) — nothing to cross-check
- continue
- field_info = field_metadata.get(field_path)
- if field_info is None:
- _LOGGER.debug(
- "Schema cross-check: sensor '%s' reads field '%s' but "
- "library reports no metadata for it",
- sensor_key,
- field_path,
- )
- continue
+@dataclass(frozen=True, slots=True)
+class SchemaFindings:
+ """Outcome of one validation pass."""
+
+ unresolved: frozenset[str]
+ unit_mismatches: tuple[UnitMismatch, ...]
+ unread: frozenset[str]
- schema_unit = field_info.get("unit")
- if schema_unit is None:
- _LOGGER.debug(
- "Schema cross-check: field '%s' (sensor '%s') has no unit "
- "in library metadata, integration expects '%s'",
- field_path,
- sensor_key,
- ha_unit,
- )
- elif str(schema_unit) != str(ha_unit):
- _LOGGER.debug(
- "Schema cross-check: field '%s' (sensor '%s') unit is '%s' "
- "in library metadata, integration expects '%s'",
- field_path,
- sensor_key,
- schema_unit,
- ha_unit,
- )
-
-
-def _report_unmapped_fields(
- field_metadata: dict[str, dict[str, object]],
-) -> None:
- """Log fields in library metadata that no sensor definition references."""
- referenced = all_referenced_field_paths()
- for field_path in sorted(set(field_metadata) - referenced):
- _LOGGER.debug(
- "Schema: field '%s' in library metadata is not mapped to any sensor",
- field_path,
- )
-
-
-def validate_field_metadata(
- field_metadata: dict[str, dict[str, object]] | None,
+
+def evaluate_field_metadata(
+ field_metadata: dict[str, FieldMetadata] | None,
sensor_defs: dict[str, SensorEntityDescription] | None = None,
-) -> None:
- """Run integration-side schema validation checks.
-
- Args:
- field_metadata: The library's field metadata, keyed by snapshot field
- path (e.g. ``"panel.instant_grid_power_w"``). Each value is a dict
- with at least ``"unit"`` and ``"datatype"`` keys. None if the
- library does not yet expose metadata.
- sensor_defs: Dict of sensor_key → SensorEntityDescription for unit
- cross-checking. None skips the cross-check.
-
- """
+) -> SchemaFindings:
+ """Classify one snapshot of adapter metadata against our declarations."""
if field_metadata is None:
- _LOGGER.debug("Schema validation skipped — library does not expose field metadata")
- return
-
- if sensor_defs is not None:
- _cross_check_units(field_metadata, sensor_defs)
-
- _report_unmapped_fields(field_metadata)
-
-
-def collect_sensor_definitions() -> dict[str, SensorEntityDescription]:
- """Collect all sensor definitions into a dict keyed by sensor key.
-
- Only includes sensors that appear in SENSOR_FIELD_MAP (i.e. sensors
- that read a single snapshot field and are eligible for cross-checking).
- """
- all_defs: list[SensorEntityDescription] = [
- *PANEL_DATA_STATUS_SENSORS,
- *STATUS_SENSORS,
- *UNMAPPED_SENSORS,
- BATTERY_SENSOR,
- L1_VOLTAGE_SENSOR,
- L2_VOLTAGE_SENSOR,
- UPSTREAM_L1_CURRENT_SENSOR,
- UPSTREAM_L2_CURRENT_SENSOR,
- DOWNSTREAM_L1_CURRENT_SENSOR,
- DOWNSTREAM_L2_CURRENT_SENSOR,
- MAIN_BREAKER_RATING_SENSOR,
- CIRCUIT_CURRENT_SENSOR,
- CIRCUIT_BREAKER_RATING_SENSOR,
- *BESS_METADATA_SENSORS,
- *PV_METADATA_SENSORS,
- *PANEL_POWER_SENSORS,
- BATTERY_POWER_SENSOR,
- PV_POWER_SENSOR,
- GRID_POWER_FLOW_SENSOR,
- SITE_POWER_SENSOR,
- *PANEL_ENERGY_SENSORS,
- *CIRCUIT_SENSORS,
- *EVSE_SENSORS,
- ]
- mapped_keys = set(SENSOR_FIELD_MAP.keys())
- return {d.key: d for d in all_defs if d.key in mapped_keys}
+ return SchemaFindings(frozenset(), (), frozenset())
+
+ declared = declared_field_paths()
+ sensor_defs = sensor_defs or {}
+
+ unresolved: set[str] = set()
+ mismatches: list[UnitMismatch] = []
+
+ for field_path in declared:
+ entry = field_metadata.get(field_path)
+ if entry is None:
+ # Hardware not present. Not a defect, and deliberately silent.
+ continue
+ if not entry.resolved:
+ unresolved.add(field_path)
+ continue
+ description = sensor_defs.get(field_path)
+ if description is None:
+ continue
+ ha_unit = description.native_unit_of_measurement
+ if ha_unit is None or entry.unit is None:
+ continue
+ if str(entry.unit) != str(ha_unit):
+ mismatches.append(UnitMismatch(field_path, str(ha_unit), str(entry.unit)))
+
+ unread = frozenset(set(field_metadata) - set(declared))
+ for field_path in sorted(unread):
+ # An addition is legal within a major version. This is an inventory for
+ # us, never a user-facing finding.
+ _LOGGER.debug("Schema: %s is produced but no platform reads it", field_path)
+
+ return SchemaFindings(frozenset(unresolved), tuple(mismatches), unread)
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index fa3ebcb6..3224d15a 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -844,3 +844,27 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]:
CIRCUIT_CURRENT_SENSOR,
CIRCUIT_BREAKER_RATING_SENSOR,
)
+
+
+def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]:
+ """Every non-derived sensor description, keyed by the field path it reads.
+
+ Keyed by field path rather than `description.key` for the reason
+ `all_sensor_descriptions` returns a tuple: keys such as "model" and
+ "serial_number" repeat across device classes, so a dict keyed on them
+ silently drops descriptions. Derived descriptions are excluded — they read
+ several fields, or none, so no single path identifies them.
+
+ Lives here rather than at the call site so the narrowing to
+ `FieldPathDeclarationMixin` stays with the descriptions that carry it.
+ """
+ by_field_path: dict[str, SensorEntityDescription] = {}
+ for description in all_sensor_descriptions():
+ if not isinstance(description, FieldPathDeclarationMixin):
+ # `declared_field_paths` raises on this; here it is simply nothing
+ # to key on.
+ continue
+ if description.derived or description.field_path is None:
+ continue
+ by_field_path[description.field_path] = description
+ return by_field_path
diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py
index 48427766..b9711332 100644
--- a/tests/test_coordinator.py
+++ b/tests/test_coordinator.py
@@ -18,6 +18,8 @@
from custom_components.span_panel.coordinator import SpanPanelCoordinator
from homeassistant.core import HomeAssistant
from span_panel_api import SpanMqttClient
+from span_panel_api.models import FieldMetadata
+from span_panel_api.protocol import SpanPanelClientProtocol
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
@@ -230,61 +232,58 @@ async def test_on_snapshot_push_updates_state_and_runs_post_tasks(
async def test_run_schema_validation_skips_without_metadata(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
- """Schema validation should skip cleanly when no metadata is available."""
-
- class FakeSpanMqttClient:
- field_metadata = None
-
- client = FakeSpanMqttClient()
+ """No metadata means "unknown", so the pass must leave findings untouched.
+
+ `field_metadata` is None for the whole _on_pre_rebuild -> retained-message
+ window, which an ordinary reconnect opens. Producing empty findings here
+ would read as "every issue is resolved" to the Repairs reconciler.
+ """
+ client = MagicMock(spec=SpanPanelClientProtocol)
+ client.field_metadata = None
coordinator = _create_coordinator(hass, client=client)
caplog.set_level(logging.DEBUG)
-
- with patch(
- "custom_components.span_panel.coordinator.SpanMqttClient",
- FakeSpanMqttClient,
- ):
- coordinator._run_schema_validation()
+ coordinator._run_schema_validation()
assert "Schema validation skipped" in caplog.text
+ assert coordinator.schema_findings is None
+ assert coordinator.unresolved_paths == frozenset()
-async def test_run_schema_validation_validates_field_metadata(
+async def test_run_schema_validation_preserves_prior_findings(
hass: HomeAssistant,
) -> None:
- """Schema validation should pass field metadata to the validator."""
+ """A later pass without metadata must not erase what an earlier one found."""
+ client = MagicMock(spec=SpanPanelClientProtocol)
+ client.field_metadata = {
+ "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)
+ }
+ coordinator = _create_coordinator(hass, client=client)
+
+ coordinator._run_schema_validation()
+ assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"})
- class FakeField:
- def __init__(self, unit: str, datatype: str) -> None:
- self.unit = unit
- self.datatype = datatype
+ client.field_metadata = None
+ coordinator._run_schema_validation()
- class FakeSpanMqttClient:
- field_metadata = {"instantPowerW": FakeField("W", "number")}
+ assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"})
- client = FakeSpanMqttClient()
+
+async def test_run_schema_validation_reads_metadata_through_the_protocol(
+ hass: HomeAssistant,
+) -> None:
+ """The unit cross-check must key sensor definitions by field path."""
+ client = MagicMock(spec=SpanPanelClientProtocol)
+ client.field_metadata = {"circuit.instant_power_w": FieldMetadata("kW", "float")}
coordinator = _create_coordinator(hass, client=client)
- with (
- patch(
- "custom_components.span_panel.coordinator.SpanMqttClient",
- FakeSpanMqttClient,
- ),
- patch(
- "custom_components.span_panel.coordinator.collect_sensor_definitions",
- return_value={"sensor_defs": "ok"},
- ) as mock_collect,
- patch(
- "custom_components.span_panel.coordinator.validate_field_metadata"
- ) as mock_validate,
- ):
- coordinator._run_schema_validation()
+ coordinator._run_schema_validation()
- mock_collect.assert_called_once()
- mock_validate.assert_called_once_with(
- {"instantPowerW": {"unit": "W", "datatype": "number"}},
- sensor_defs={"sensor_defs": "ok"},
- )
+ findings = coordinator.schema_findings
+ assert findings is not None
+ assert [m.field_path for m in findings.unit_mismatches] == ["circuit.instant_power_w"]
+ assert findings.unit_mismatches[0].schema_unit == "kW"
+ assert findings.unit_mismatches[0].ha_unit == "W"
@pytest.mark.parametrize(
diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py
index 9bd2ade3..4c7d3c5a 100644
--- a/tests/test_schema_validation.py
+++ b/tests/test_schema_validation.py
@@ -1,269 +1,133 @@
-"""Tests for schema validation and sensor-to-field mapping."""
-
from __future__ import annotations
-import logging
-from unittest.mock import MagicMock
-
-import pytest
-from span_panel_api import (
- SpanBatterySnapshot,
- SpanCircuitSnapshot,
- SpanEvseSnapshot,
- SpanPanelSnapshot,
- SpanPVSnapshot,
-)
+from span_panel_api.models import FieldMetadata
-from custom_components.span_panel.schema_expectations import (
- SENSOR_FIELD_MAP,
- all_referenced_field_paths,
-)
+from custom_components.span_panel.field_paths import declared_field_paths
from custom_components.span_panel.schema_validation import (
- validate_field_metadata,
+ SchemaFindings,
+ evaluate_field_metadata,
)
from custom_components.span_panel.sensor_definitions import (
- BATTERY_POWER_SENSOR,
- BATTERY_SENSOR,
- BESS_METADATA_SENSORS,
- CIRCUIT_BREAKER_RATING_SENSOR,
- CIRCUIT_CURRENT_SENSOR,
- CIRCUIT_SENSORS,
- DOWNSTREAM_L1_CURRENT_SENSOR,
- DOWNSTREAM_L2_CURRENT_SENSOR,
- EVSE_SENSORS,
- GRID_POWER_FLOW_SENSOR,
- L1_VOLTAGE_SENSOR,
- L2_VOLTAGE_SENSOR,
- MAIN_BREAKER_RATING_SENSOR,
- PANEL_DATA_STATUS_SENSORS,
- PANEL_ENERGY_SENSORS,
- PANEL_POWER_SENSORS,
- PV_METADATA_SENSORS,
- PV_POWER_SENSOR,
- SITE_POWER_SENSOR,
- STATUS_SENSORS,
- UNMAPPED_SENSORS,
- UPSTREAM_L1_CURRENT_SENSOR,
- UPSTREAM_L2_CURRENT_SENSOR,
+ sensor_descriptions_by_field_path,
)
-_LOGGER_NAME = "custom_components.span_panel.schema_validation"
-
-
-# ---------------------------------------------------------------------------
-# Sensor field mapping tests
-# ---------------------------------------------------------------------------
-
-
-class TestSensorFieldMap:
- """Tests for the sensor-to-snapshot-field mapping."""
-
- def test_no_empty_keys_or_paths(self) -> None:
- """Every entry must have non-empty sensor key and field path."""
- for sensor_key, field_path in SENSOR_FIELD_MAP.items():
- assert sensor_key, "Empty sensor key in SENSOR_FIELD_MAP"
- assert field_path, f"Empty field path for sensor key '{sensor_key}'"
-
- def test_field_paths_follow_convention(self) -> None:
- """All field paths must be {snapshot_type}.{field_name}."""
- valid_prefixes = {"panel", "circuit", "battery", "pv", "evse"}
- for sensor_key, field_path in SENSOR_FIELD_MAP.items():
- parts = field_path.split(".", 1)
- assert len(parts) == 2, (
- f"Field path '{field_path}' for sensor '{sensor_key}' "
- f"does not follow 'type.field' convention"
- )
- assert parts[0] in valid_prefixes, (
- f"Field path '{field_path}' for sensor '{sensor_key}' "
- f"has unknown prefix '{parts[0]}'"
- )
-
- def test_sensor_keys_exist_in_definitions(self) -> None:
- """Every sensor key should match a real sensor definition."""
- all_defs = [
- *PANEL_DATA_STATUS_SENSORS,
- *STATUS_SENSORS,
- *UNMAPPED_SENSORS,
- BATTERY_SENSOR,
- L1_VOLTAGE_SENSOR,
- L2_VOLTAGE_SENSOR,
- UPSTREAM_L1_CURRENT_SENSOR,
- UPSTREAM_L2_CURRENT_SENSOR,
- DOWNSTREAM_L1_CURRENT_SENSOR,
- DOWNSTREAM_L2_CURRENT_SENSOR,
- MAIN_BREAKER_RATING_SENSOR,
- CIRCUIT_CURRENT_SENSOR,
- CIRCUIT_BREAKER_RATING_SENSOR,
- *BESS_METADATA_SENSORS,
- *PV_METADATA_SENSORS,
- *PANEL_POWER_SENSORS,
- BATTERY_POWER_SENSOR,
- PV_POWER_SENSOR,
- GRID_POWER_FLOW_SENSOR,
- SITE_POWER_SENSOR,
- *PANEL_ENERGY_SENSORS,
- *CIRCUIT_SENSORS,
- *EVSE_SENSORS,
- ]
- known_keys = {d.key for d in all_defs}
-
- for sensor_key in SENSOR_FIELD_MAP:
- assert sensor_key in known_keys, (
- f"Sensor key '{sensor_key}' in SENSOR_FIELD_MAP not found in sensor definitions"
- )
-
- def test_field_paths_match_snapshot_attrs(self) -> None:
- """Field names should match actual snapshot dataclass attributes."""
- snapshot_classes = {
- "panel": SpanPanelSnapshot,
- "circuit": SpanCircuitSnapshot,
- "battery": SpanBatterySnapshot,
- "pv": SpanPVSnapshot,
- "evse": SpanEvseSnapshot,
- }
-
- for sensor_key, field_path in SENSOR_FIELD_MAP.items():
- prefix, field_name = field_path.split(".", 1)
- cls = snapshot_classes[prefix]
- assert hasattr(cls, field_name) or field_name in {
- f.name for f in cls.__dataclass_fields__.values()
- }, (
- f"Field '{field_name}' from path '{field_path}' "
- f"(sensor '{sensor_key}') not found on {cls.__name__}"
- )
-
- def test_all_referenced_field_paths(self) -> None:
- """all_referenced_field_paths should return all unique values."""
- paths = all_referenced_field_paths()
- assert paths == frozenset(SENSOR_FIELD_MAP.values())
-
-
-# ---------------------------------------------------------------------------
-# Unit cross-check tests
-# ---------------------------------------------------------------------------
-
-
-def _make_sensor_def(key: str, unit: str | None) -> MagicMock:
- """Create a minimal mock SensorEntityDescription with key and unit."""
- mock = MagicMock(spec=["key", "native_unit_of_measurement"])
- mock.key = key
- mock.native_unit_of_measurement = unit
- return mock
-
-
-class TestUnitCrossCheck:
- """Tests for field metadata unit vs sensor definition unit cross-checking."""
-
- def test_matching_units_no_cross_check_message(
- self, caplog: pytest.LogCaptureFixture
- ) -> None:
- """Matching units should produce no cross-check log messages."""
- metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}}
- sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")}
- with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
- validate_field_metadata(metadata, sensor_defs=sensor_defs)
- assert not any("cross-check" in r.lower() for r in caplog.messages)
-
- def test_mismatched_units_logs_debug(
- self, caplog: pytest.LogCaptureFixture
- ) -> None:
- """Unit mismatch should produce a debug message naming both units."""
- metadata = {"panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"}}
- sensor_defs = {"instantGridPowerW": _make_sensor_def("instantGridPowerW", "W")}
- with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
- validate_field_metadata(metadata, sensor_defs=sensor_defs)
- cross_msgs = [m for m in caplog.messages if "cross-check" in m.lower()]
- assert len(cross_msgs) == 1
- assert "'kW'" in cross_msgs[0]
- assert "'W'" in cross_msgs[0]
-
- def test_missing_metadata_logs_debug(
- self, caplog: pytest.LogCaptureFixture
- ) -> None:
- """Sensor reading a field with no metadata should log debug."""
- metadata: dict[str, dict[str, object]] = {}
- sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")}
- with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
- validate_field_metadata(metadata, sensor_defs=sensor_defs)
- assert any("no metadata" in m for m in caplog.messages)
-
- def test_missing_schema_unit_logs_debug(
- self, caplog: pytest.LogCaptureFixture
- ) -> None:
- """Field with no unit in metadata but unit in sensor def should log debug."""
- metadata = {"panel.l1_voltage": {"datatype": "float"}}
- sensor_defs = {"l1_voltage": _make_sensor_def("l1_voltage", "V")}
- with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
- validate_field_metadata(metadata, sensor_defs=sensor_defs)
- assert any("no unit" in m for m in caplog.messages)
-
- def test_sensor_without_unit_skipped(
- self, caplog: pytest.LogCaptureFixture
- ) -> None:
- """Sensor with no native_unit_of_measurement should be skipped."""
- metadata = {"panel.main_relay_state": {"datatype": "enum"}}
- sensor_defs = {"main_relay_state": _make_sensor_def("main_relay_state", None)}
- with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
- validate_field_metadata(metadata, sensor_defs=sensor_defs)
- assert not any("cross-check" in m.lower() for m in caplog.messages)
-
- def test_all_output_is_debug_level(self, caplog: pytest.LogCaptureFixture) -> None:
- """All schema validation output should be DEBUG — never visible to users."""
- metadata = {
- "panel.instant_grid_power_w": {"unit": "kW", "datatype": "float"},
- "panel.l1_voltage": {"datatype": "float"},
- "panel.new_fancy_field": {"unit": "W", "datatype": "float"},
- }
- sensor_defs = {
- "instantGridPowerW": _make_sensor_def("instantGridPowerW", "W"),
- "l1_voltage": _make_sensor_def("l1_voltage", "V"),
- }
- with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
- validate_field_metadata(metadata, sensor_defs=sensor_defs)
- above_debug = [r for r in caplog.records if r.levelno > logging.DEBUG]
- assert len(above_debug) == 0, (
- f"Expected all DEBUG, got: {[(r.levelname, r.getMessage()) for r in above_debug]}"
- )
-
-
-# ---------------------------------------------------------------------------
-# Unmapped field detection tests
-# ---------------------------------------------------------------------------
-
-
-class TestUnmappedFields:
- """Tests for detecting fields the integration doesn't consume."""
-
- def test_unmapped_field_logs_debug(self, caplog: pytest.LogCaptureFixture) -> None:
- """Field not in SENSOR_FIELD_MAP values should log at DEBUG."""
- metadata = {"panel.new_fancy_field": {"unit": "W", "datatype": "float"}}
- with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
- validate_field_metadata(metadata)
- assert any(
- r.levelno == logging.DEBUG and "new_fancy_field" in r.getMessage()
- for r in caplog.records
- )
-
- def test_mapped_field_not_reported(self, caplog: pytest.LogCaptureFixture) -> None:
- """Field that IS in SENSOR_FIELD_MAP should not be reported as unmapped."""
- metadata = {"panel.instant_grid_power_w": {"unit": "W", "datatype": "float"}}
- with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
- validate_field_metadata(metadata)
- assert not any("not mapped" in m for m in caplog.messages)
-
-# ---------------------------------------------------------------------------
-# No-op when metadata unavailable
-# ---------------------------------------------------------------------------
+def test_unresolved_entry_is_degradation() -> None:
+ findings = evaluate_field_metadata(
+ {"circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)},
+ sensor_defs={},
+ )
+ assert "circuit.instant_power_w" in findings.unresolved
-class TestNoOp:
- """Tests for graceful handling when library doesn't expose metadata."""
+def test_absent_entry_is_not_degradation() -> None:
+ """No entry means the hardware is not installed — not a defect."""
+ findings = evaluate_field_metadata({}, sensor_defs={})
+ assert findings.unresolved == frozenset()
- def test_none_metadata_is_noop(self, caplog: pytest.LogCaptureFixture) -> None:
- """None metadata should produce no output above DEBUG."""
- with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
- validate_field_metadata(None)
- assert any("skipped" in m for m in caplog.messages)
- above_debug = [r for r in caplog.records if r.levelno > logging.DEBUG]
- assert len(above_debug) == 0
+
+def test_unit_mismatch_is_reported() -> None:
+ from homeassistant.components.sensor import SensorEntityDescription
+ from homeassistant.const import UnitOfPower
+
+ description = SensorEntityDescription(
+ key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT
+ )
+ findings = evaluate_field_metadata(
+ {"circuit.instant_power_w": FieldMetadata("kW", "float")},
+ sensor_defs={"circuit.instant_power_w": description},
+ )
+ assert findings.unit_mismatches[0].field_path == "circuit.instant_power_w"
+ assert findings.unit_mismatches[0].schema_unit == "kW"
+
+
+def test_unitless_sensor_still_checked_for_resolution() -> None:
+ """Resolution is checked before the unit is.
+
+ The old code short-circuited on `ha_unit is None` BEFORE the lookup, so enum
+ and string sensors could go dead with no signal.
+ """
+ from homeassistant.components.sensor import SensorEntityDescription
+
+ description = SensorEntityDescription(key="evse_status")
+ findings = evaluate_field_metadata(
+ {"evse.status": FieldMetadata(None, "unknown", resolved=False)},
+ sensor_defs={"evse.status": description},
+ )
+ assert "evse.status" in findings.unresolved
+
+
+def test_unresolved_entry_is_never_a_unit_mismatch() -> None:
+ """An unresolved entry carries `unit=None` by construction.
+
+ Comparing that against a declared unit would raise a false mismatch on every
+ affected sensor, so resolution must be branched on first.
+ """
+ from homeassistant.components.sensor import SensorEntityDescription
+ from homeassistant.const import UnitOfPower
+
+ description = SensorEntityDescription(
+ key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT
+ )
+ findings = evaluate_field_metadata(
+ {"circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)},
+ sensor_defs={"circuit.instant_power_w": description},
+ )
+ assert findings.unresolved == frozenset({"circuit.instant_power_w"})
+ assert findings.unit_mismatches == ()
+
+
+def test_matching_unit_is_not_a_mismatch() -> None:
+ from homeassistant.components.sensor import SensorEntityDescription
+ from homeassistant.const import UnitOfPower
+
+ description = SensorEntityDescription(
+ key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT
+ )
+ findings = evaluate_field_metadata(
+ {"circuit.instant_power_w": FieldMetadata("W", "float")},
+ sensor_defs={"circuit.instant_power_w": description},
+ )
+ assert findings.unit_mismatches == ()
+ assert findings.unresolved == frozenset()
+
+
+def test_produced_but_unread_fields_are_inventoried() -> None:
+ """An addition is legal within a major version — inventory, not a defect."""
+ findings = evaluate_field_metadata(
+ {"panel.some_future_field": FieldMetadata("W", "float")}, sensor_defs={}
+ )
+ assert findings.unread == frozenset({"panel.some_future_field"})
+ assert findings.unresolved == frozenset()
+ assert findings.unit_mismatches == ()
+
+
+def test_declared_and_resolved_fields_are_not_unread() -> None:
+ findings = evaluate_field_metadata(
+ {"circuit.instant_power_w": FieldMetadata("W", "float")}, sensor_defs={}
+ )
+ assert findings.unread == frozenset()
+
+
+def test_none_metadata_yields_empty_findings() -> None:
+ """The module-level fallback.
+
+ Callers that must distinguish "unknown" from "healthy" — the coordinator
+ does — check for None before calling.
+ """
+ findings = evaluate_field_metadata(None)
+ assert findings == SchemaFindings(frozenset(), (), frozenset())
+
+
+def test_every_declared_field_path_keys_a_sensor_or_a_residual_reader() -> None:
+ """`sensor_descriptions_by_field_path` must not drop descriptions.
+
+ Keys such as "model" and "serial_number" repeat across device classes, so a
+ dict keyed on `description.key` would silently collapse them.
+ """
+ by_path = sensor_descriptions_by_field_path()
+ assert by_path.keys() <= declared_field_paths()
+ assert {"battery.model", "pv.model"} <= by_path.keys()
+ for field_path, description in by_path.items():
+ assert description.field_path == field_path
+ assert not description.derived
From 4bff08fbc8fcf93b5a6faef4faae11ddd57dac73 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 00:58:29 -0700
Subject: [PATCH 028/116] test: cover the resolved-unitless and
undeclared-description branches
Brings schema_validation and sensor_definitions to full statement coverage and
pins where the missing-declaration check is enforced.
---
tests/test_schema_validation.py | 34 +++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py
index 4c7d3c5a..9da3ef8c 100644
--- a/tests/test_schema_validation.py
+++ b/tests/test_schema_validation.py
@@ -1,7 +1,9 @@
from __future__ import annotations
+import pytest
from span_panel_api.models import FieldMetadata
+from custom_components.span_panel import sensor_definitions
from custom_components.span_panel.field_paths import declared_field_paths
from custom_components.span_panel.schema_validation import (
SchemaFindings,
@@ -131,3 +133,35 @@ def test_every_declared_field_path_keys_a_sensor_or_a_residual_reader() -> None:
for field_path, description in by_path.items():
assert description.field_path == field_path
assert not description.derived
+
+
+def test_resolved_unitless_sensor_yields_no_mismatch() -> None:
+ """A resolved field read by an enum or string sensor has nothing to compare."""
+ from homeassistant.components.sensor import SensorEntityDescription
+
+ description = SensorEntityDescription(key="evse_status")
+ findings = evaluate_field_metadata(
+ {"evse.status": FieldMetadata(None, "enum")},
+ sensor_defs={"evse.status": description},
+ )
+ assert findings.unresolved == frozenset()
+ assert findings.unit_mismatches == ()
+
+
+def test_descriptions_without_a_declaration_are_not_keyed(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """`declared_field_paths` is the enforcement point; here there is no key.
+
+ A description that carries no `FieldPathDeclarationMixin` names no field, so
+ it cannot appear in a field-path-keyed map. `declared_field_paths` raises on
+ the same input, which is where the drift is caught.
+ """
+ from homeassistant.components.sensor import SensorEntityDescription
+
+ monkeypatch.setattr(
+ sensor_definitions,
+ "all_sensor_descriptions",
+ lambda: (SensorEntityDescription(key="undeclared"),),
+ )
+ assert sensor_definitions.sensor_descriptions_by_field_path() == {}
From a95243c6c3da5b3a4b1affa46d3c67919ea1cbc8 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 01:22:28 -0700
Subject: [PATCH 029/116] fix: excuse the known-bad schema_0 circuit power unit
label
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SPAN firmware declares the circuit active-power property as kW while publishing
watts. The sibling lugs device declares W for the same quantity, the library
consumes the value unscaled, and our sensor declares W — so the label is wrong
and the reading is right. Left unhandled, every schema_0 panel would raise a
permanent unit mismatch its owner cannot act on.
KNOWN_BAD_SCHEMA_UNITS excuses only the exact declared unit, so a future
firmware declaring anything else for the same field is still reported.
Also: exclude RESIDUAL_EXEMPT_PATHS from `unread` (they are read, just not
required of both adapters — 10 of schema_0's 17 were mislabelled); drop the
`| None` arm of evaluate_field_metadata so the sentinel cannot be answered with
"healthy"; and evaluate both real adapter fixtures in the tests rather than only
synthetic single-entry dicts.
---
custom_components/span_panel/field_paths.py | 54 +++++--
.../span_panel/schema_validation.py | 52 ++++--
.../span_panel/sensor_definitions.py | 40 ++---
tests/test_coordinator.py | 14 +-
tests/test_schema_validation.py | 152 +++++++++++++++---
5 files changed, 246 insertions(+), 66 deletions(-)
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index e25a098a..7031abdf 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -15,8 +15,11 @@
from __future__ import annotations
+from collections.abc import Iterable, Iterator
from dataclasses import dataclass
+from homeassistant.helpers.entity import EntityDescription
+
@dataclass(frozen=True, kw_only=True)
class FieldPathDeclarationMixin:
@@ -145,6 +148,31 @@ class FieldPathDeclarationMixin:
"""
+def iter_field_path_declarations[DescriptionT: EntityDescription](
+ descriptions: Iterable[DescriptionT],
+) -> Iterator[tuple[str, DescriptionT]]:
+ """Yield ``(field_path, description)`` for each description that declares one.
+
+ The single copy of the traversal rule — which descriptions declare a field,
+ and which are exempt — so a caller that only wants the paths and a caller
+ that wants the descriptions cannot drift apart on it.
+
+ Raises `TypeError` for a description that carries no
+ `FieldPathDeclarationMixin` at all: such a description would be dropped
+ silently, which is the drift this module exists to prevent. Descriptions
+ that carry the mixin but declare nothing (`derived`, or `field_path is
+ None`) are skipped, which is the declared-exempt case rather than drift.
+ """
+ for description in descriptions:
+ if not isinstance(description, FieldPathDeclarationMixin):
+ raise TypeError(
+ f"entity description '{description.key}' carries no field-path declaration"
+ )
+ if description.derived or description.field_path is None:
+ continue
+ yield description.field_path, description
+
+
def declared_field_paths() -> frozenset[str]:
"""Field paths the integration reads that must be producible by an adapter.
@@ -166,20 +194,16 @@ def declared_field_paths() -> frozenset[str]:
)
paths: set[str] = set(RESIDUAL_FIELD_PATHS)
- for description in (
- *all_sensor_descriptions(),
- *BINARY_SENSORS,
- *EVSE_BINARY_SENSORS,
- GRID_ISLANDABLE_SENSOR,
- BESS_CONNECTED_SENSOR,
- ):
- if not isinstance(description, FieldPathDeclarationMixin):
- # A description that cannot declare anything would be dropped
- # silently, which is the drift this module exists to prevent.
- raise TypeError(
- f"entity description '{description.key}' carries no field-path declaration"
+ paths.update(
+ field_path
+ for field_path, _ in iter_field_path_declarations(
+ (
+ *all_sensor_descriptions(),
+ *BINARY_SENSORS,
+ *EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ BESS_CONNECTED_SENSOR,
)
- if description.derived or description.field_path is None:
- continue
- paths.add(description.field_path)
+ )
+ )
return frozenset(paths)
diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py
index 25fc82f1..c8b848df 100644
--- a/custom_components/span_panel/schema_validation.py
+++ b/custom_components/span_panel/schema_validation.py
@@ -19,11 +19,33 @@
from homeassistant.components.sensor import SensorEntityDescription
from span_panel_api.models import FieldMetadata
-from .field_paths import declared_field_paths
+from .field_paths import RESIDUAL_EXEMPT_PATHS, declared_field_paths
_LOGGER = logging.getLogger(__name__)
+KNOWN_BAD_SCHEMA_UNITS: dict[str, str] = {
+ # SPAN firmware declares the circuit `active-power` property as "kW" while
+ # publishing watts. Three things agree that the label, not the reading, is
+ # wrong: the sibling `lugs` device declares the same quantity as "W"; the
+ # library consumes the value unscaled ("active-power is in watts",
+ # span_panel_api_schema_0/consumer.py:244); and the independent `span-hass`
+ # integration documents the same defect under "Known SPAN API Issue" and
+ # hardcodes the same override. Our `UnitOfPower.WATT` declaration is correct.
+ "circuit.instant_power_w": "kW",
+}
+"""Schema unit declarations this integration knowingly ignores, by field path.
+
+Every entry is a firmware defect worked around deliberately — the panel labels a
+property with a unit it does not publish — never a sensor whose unit we gave up
+on checking. Without this, a panel running the affected firmware raises a
+mismatch its owner cannot act on and that reflects no real defect.
+
+The match is exact. If firmware later declares something OTHER than the value
+here for the same field, that is new information and is still reported.
+"""
+
+
@dataclass(frozen=True, slots=True)
class UnitMismatch:
"""A declared unit that disagrees with the schema's."""
@@ -43,13 +65,17 @@ class SchemaFindings:
def evaluate_field_metadata(
- field_metadata: dict[str, FieldMetadata] | None,
+ field_metadata: dict[str, FieldMetadata],
sensor_defs: dict[str, SensorEntityDescription] | None = None,
) -> SchemaFindings:
- """Classify one snapshot of adapter metadata against our declarations."""
- if field_metadata is None:
- return SchemaFindings(frozenset(), (), frozenset())
-
+ """Classify one snapshot of adapter metadata against our declarations.
+
+ `field_metadata` is deliberately not optional. The client returns None until
+ its adapter is ready, and that sentinel means "unknown", not "healthy" —
+ answering it with empty findings would tell a reconciler every issue is
+ resolved. Callers interpret the sentinel themselves; see
+ `SpanPanelCoordinator._run_schema_validation`.
+ """
declared = declared_field_paths()
sensor_defs = sensor_defs or {}
@@ -70,10 +96,18 @@ def evaluate_field_metadata(
ha_unit = description.native_unit_of_measurement
if ha_unit is None or entry.unit is None:
continue
- if str(entry.unit) != str(ha_unit):
- mismatches.append(UnitMismatch(field_path, str(ha_unit), str(entry.unit)))
+ schema_unit = str(entry.unit)
+ if schema_unit == str(ha_unit):
+ continue
+ if KNOWN_BAD_SCHEMA_UNITS.get(field_path) == schema_unit:
+ # A firmware mislabel we already work around. See the constant.
+ continue
+ mismatches.append(UnitMismatch(field_path, str(ha_unit), schema_unit))
- unread = frozenset(set(field_metadata) - set(declared))
+ # `RESIDUAL_EXEMPT_PATHS` are read by the integration; they are exempt from
+ # the *producible* gate because only one adapter emits them, so they are
+ # absent from `declared` without being unread.
+ unread = frozenset(set(field_metadata) - set(declared) - RESIDUAL_EXEMPT_PATHS)
for field_path in sorted(unread):
# An addition is legal within a major version. This is an inventory for
# us, never a user-facing finding.
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 3224d15a..5af8eeed 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -35,7 +35,7 @@
SpanPanelSnapshot,
)
-from .field_paths import FieldPathDeclarationMixin
+from .field_paths import FieldPathDeclarationMixin, iter_field_path_declarations
@dataclass(frozen=True)
@@ -814,9 +814,11 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK
def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]:
"""Every sensor description, without deduplication.
- Deliberately not keyed by `description.key`: keys such as "model" and
- "serial_number" repeat across device classes, so a dict keyed on them
- silently drops descriptions.
+ A tuple rather than a dict because no one key identifies a description:
+ `description.key` and `field_path` are different namespaces, and neither is
+ unique across the whole set — several field paths are read by two
+ descriptions. Callers key by whichever suits them; see
+ `sensor_descriptions_by_field_path`.
"""
return (
*PANEL_DATA_STATUS_SENSORS,
@@ -849,22 +851,22 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]:
def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]:
"""Every non-derived sensor description, keyed by the field path it reads.
- Keyed by field path rather than `description.key` for the reason
- `all_sensor_descriptions` returns a tuple: keys such as "model" and
- "serial_number" repeat across device classes, so a dict keyed on them
- silently drops descriptions. Derived descriptions are excluded — they read
- several fields, or none, so no single path identifies them.
+ Keyed by field path because that is how the adapter keys its metadata;
+ `description.key` is a different namespace and would not line up. Derived
+ descriptions are excluded — they read several fields, or none, so no single
+ path identifies them.
- Lives here rather than at the call site so the narrowing to
- `FieldPathDeclarationMixin` stays with the descriptions that carry it.
+ A few field paths are read by two descriptions (an unmapped-circuit raw key
+ and its named-circuit twin), and only the first is kept. That is safe only
+ while such readers agree on `native_unit_of_measurement`, which is all this
+ map is consulted for; `test_readers_of_the_same_field_path_agree_on_unit`
+ pins that rather than leaving it to chance.
+
+ Lives here rather than at the call site so no consumer has to know how a
+ description declares its field; `field_paths.iter_field_path_declarations`
+ holds that rule.
"""
by_field_path: dict[str, SensorEntityDescription] = {}
- for description in all_sensor_descriptions():
- if not isinstance(description, FieldPathDeclarationMixin):
- # `declared_field_paths` raises on this; here it is simply nothing
- # to key on.
- continue
- if description.derived or description.field_path is None:
- continue
- by_field_path[description.field_path] = description
+ for field_path, description in iter_field_path_declarations(all_sensor_descriptions()):
+ by_field_path.setdefault(field_path, description)
return by_field_path
diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py
index b9711332..cc666ee5 100644
--- a/tests/test_coordinator.py
+++ b/tests/test_coordinator.py
@@ -272,18 +272,22 @@ async def test_run_schema_validation_preserves_prior_findings(
async def test_run_schema_validation_reads_metadata_through_the_protocol(
hass: HomeAssistant,
) -> None:
- """The unit cross-check must key sensor definitions by field path."""
+ """The unit cross-check must key sensor definitions by field path.
+
+ Not `circuit.instant_power_w`/"kW": that pair is in
+ `KNOWN_BAD_SCHEMA_UNITS`, so it would prove the exception, not the wiring.
+ """
client = MagicMock(spec=SpanPanelClientProtocol)
- client.field_metadata = {"circuit.instant_power_w": FieldMetadata("kW", "float")}
+ client.field_metadata = {"panel.l1_voltage": FieldMetadata("kV", "float")}
coordinator = _create_coordinator(hass, client=client)
coordinator._run_schema_validation()
findings = coordinator.schema_findings
assert findings is not None
- assert [m.field_path for m in findings.unit_mismatches] == ["circuit.instant_power_w"]
- assert findings.unit_mismatches[0].schema_unit == "kW"
- assert findings.unit_mismatches[0].ha_unit == "W"
+ assert [m.field_path for m in findings.unit_mismatches] == ["panel.l1_voltage"]
+ assert findings.unit_mismatches[0].schema_unit == "kV"
+ assert findings.unit_mismatches[0].ha_unit == "V"
@pytest.mark.parametrize(
diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py
index 9da3ef8c..b59f3dd9 100644
--- a/tests/test_schema_validation.py
+++ b/tests/test_schema_validation.py
@@ -1,17 +1,31 @@
from __future__ import annotations
+from collections.abc import Callable
+
import pytest
from span_panel_api.models import FieldMetadata
from custom_components.span_panel import sensor_definitions
-from custom_components.span_panel.field_paths import declared_field_paths
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ FieldPathDeclarationMixin,
+ declared_field_paths,
+)
from custom_components.span_panel.schema_validation import (
SchemaFindings,
evaluate_field_metadata,
)
from custom_components.span_panel.sensor_definitions import (
+ all_sensor_descriptions,
sensor_descriptions_by_field_path,
)
+from tests.adapter_fixtures import (
+ schema_one_metadata,
+ schema_one_metadata_batteryless,
+ schema_zero_metadata,
+)
+
+MetadataFn = Callable[[], dict[str, FieldMetadata]]
def test_unresolved_entry_is_degradation() -> None:
@@ -29,18 +43,23 @@ def test_absent_entry_is_not_degradation() -> None:
def test_unit_mismatch_is_reported() -> None:
+ """Deliberately not `circuit.instant_power_w`/"kW".
+
+ That pair is the one entry in `KNOWN_BAD_SCHEMA_UNITS`, so it would prove the
+ exception rather than the check.
+ """
from homeassistant.components.sensor import SensorEntityDescription
- from homeassistant.const import UnitOfPower
+ from homeassistant.const import UnitOfElectricPotential
description = SensorEntityDescription(
- key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT
+ key="l1_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT
)
findings = evaluate_field_metadata(
- {"circuit.instant_power_w": FieldMetadata("kW", "float")},
- sensor_defs={"circuit.instant_power_w": description},
+ {"panel.l1_voltage": FieldMetadata("kV", "float")},
+ sensor_defs={"panel.l1_voltage": description},
)
- assert findings.unit_mismatches[0].field_path == "circuit.instant_power_w"
- assert findings.unit_mismatches[0].schema_unit == "kW"
+ assert findings.unit_mismatches[0].field_path == "panel.l1_voltage"
+ assert findings.unit_mismatches[0].schema_unit == "kV"
def test_unitless_sensor_still_checked_for_resolution() -> None:
@@ -111,13 +130,14 @@ def test_declared_and_resolved_fields_are_not_unread() -> None:
assert findings.unread == frozenset()
-def test_none_metadata_yields_empty_findings() -> None:
- """The module-level fallback.
+def test_empty_metadata_is_healthy_not_unknown() -> None:
+ """A pass over a healthy panel is expressible and is not the None sentinel.
- Callers that must distinguish "unknown" from "healthy" — the coordinator
- does — check for None before calling.
+ Task 7 needs three distinct states; this is the middle one. "Unknown" is the
+ coordinator's `schema_findings is None`, which `evaluate_field_metadata` can
+ no longer produce — it no longer accepts the sentinel at all.
"""
- findings = evaluate_field_metadata(None)
+ findings = evaluate_field_metadata({})
assert findings == SchemaFindings(frozenset(), (), frozenset())
@@ -148,14 +168,14 @@ def test_resolved_unitless_sensor_yields_no_mismatch() -> None:
assert findings.unit_mismatches == ()
-def test_descriptions_without_a_declaration_are_not_keyed(
+def test_descriptions_without_a_declaration_raise(
monkeypatch: pytest.MonkeyPatch,
) -> None:
- """`declared_field_paths` is the enforcement point; here there is no key.
+ """Both consumers of the shared traversal reject an undeclared description.
- A description that carries no `FieldPathDeclarationMixin` names no field, so
- it cannot appear in a field-path-keyed map. `declared_field_paths` raises on
- the same input, which is where the drift is caught.
+ Skipping it would drop the sensor from the unit cross-check silently, which
+ is the drift `field_paths` exists to prevent. `declared_field_paths` raises
+ on the same input, and both now do so from one place.
"""
from homeassistant.components.sensor import SensorEntityDescription
@@ -164,4 +184,100 @@ def test_descriptions_without_a_declaration_are_not_keyed(
"all_sensor_descriptions",
lambda: (SensorEntityDescription(key="undeclared"),),
)
- assert sensor_definitions.sensor_descriptions_by_field_path() == {}
+ with pytest.raises(TypeError, match="carries no field-path declaration"):
+ sensor_definitions.sensor_descriptions_by_field_path()
+
+
+@pytest.mark.parametrize(
+ "metadata_fn",
+ [
+ pytest.param(schema_zero_metadata, id="schema_0"),
+ pytest.param(schema_one_metadata, id="schema_1"),
+ ],
+)
+def test_real_adapter_metadata_produces_no_findings(metadata_fn: MetadataFn) -> None:
+ """A healthy panel of either generation must be finding-free.
+
+ The rest of this file drives the evaluator with synthetic single-entry dicts,
+ which cannot show what real firmware actually declares. This is the standing
+ guard against a day-one Repair that no user can act on.
+ """
+ findings = evaluate_field_metadata(metadata_fn(), sensor_descriptions_by_field_path())
+ assert findings.unresolved == frozenset()
+ assert findings.unit_mismatches == ()
+
+
+def test_known_bad_schema_unit_exception_is_narrow() -> None:
+ """Only the exact known-bad unit is excused; anything else is new information."""
+ from homeassistant.components.sensor import SensorEntityDescription
+ from homeassistant.const import UnitOfPower
+
+ description = SensorEntityDescription(
+ key="circuit_power", native_unit_of_measurement=UnitOfPower.WATT
+ )
+ findings = evaluate_field_metadata(
+ {"circuit.instant_power_w": FieldMetadata("MW", "float")},
+ sensor_defs={"circuit.instant_power_w": description},
+ )
+ assert [m.schema_unit for m in findings.unit_mismatches] == ["MW"]
+
+
+def test_absent_hardware_on_real_metadata_is_not_degradation() -> None:
+ """A batteryless panel simply omits the battery rows — nothing is wrong.
+
+ Stronger than the empty-dict case, which passes whether or not the
+ `entry is None` arm exists: here 8 `battery.*` paths are declared and read,
+ and every one of them is missing from the adapter's output.
+ """
+ metadata = schema_one_metadata_batteryless()
+ battery_paths = {p for p in declared_field_paths() if p.startswith("battery.")}
+ assert battery_paths
+ assert battery_paths.isdisjoint(metadata)
+
+ findings = evaluate_field_metadata(metadata, sensor_descriptions_by_field_path())
+ assert findings.unresolved == frozenset()
+ assert findings.unit_mismatches == ()
+
+
+@pytest.mark.parametrize(
+ "metadata_fn",
+ [
+ pytest.param(schema_zero_metadata, id="schema_0"),
+ pytest.param(schema_one_metadata, id="schema_1"),
+ ],
+)
+def test_unread_excludes_readers_exempt_from_the_producible_gate(
+ metadata_fn: MetadataFn,
+) -> None:
+ """`RESIDUAL_EXEMPT_PATHS` are read, just not required of both adapters.
+
+ They are absent from `declared_field_paths()`, so a plain set difference
+ reports them as produced-but-unread — false for 10 of schema_0's 17.
+ """
+ findings = evaluate_field_metadata(metadata_fn(), sensor_descriptions_by_field_path())
+ assert findings.unread.isdisjoint(RESIDUAL_EXEMPT_PATHS)
+
+
+def test_readers_of_the_same_field_path_agree_on_unit() -> None:
+ """`sensor_descriptions_by_field_path` keeps one reader per path.
+
+ Several field paths are read by two descriptions (an unmapped-circuit raw
+ key and its named-circuit twin). Dropping one is only safe while they agree
+ on what the unit check would compare, so pin that here rather than trusting
+ it.
+ """
+ from collections import defaultdict
+
+ by_path: defaultdict[str, list[object]] = defaultdict(list)
+ for description in all_sensor_descriptions():
+ if not isinstance(description, FieldPathDeclarationMixin):
+ continue
+ if description.derived or description.field_path is None:
+ continue
+ by_path[description.field_path].append(description)
+
+ colliding = {path: ds for path, ds in by_path.items() if len(ds) > 1}
+ assert colliding, "expected at least one field path with two readers"
+ for path, descriptions in colliding.items():
+ units = {d.native_unit_of_measurement for d in descriptions}
+ assert len(units) == 1, f"readers of {path} disagree on unit: {units}"
From 997971cd399d4aa10cab5d4aa117f391c289b90a Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 01:42:40 -0700
Subject: [PATCH 030/116] feat: raise Repairs for unresolved fields and unit
mismatches
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
One issue per (entry, class, field path) so a dismissal cannot swallow a later
finding and one panel cannot clear another's, re-raised idempotently so
reconciliation never clears a dismissal. Issues are non-persistent: derived
from live state and re-asserted at startup from the tombstone that carries the
dismissal forward.
A produced field nothing reads stays a sanctioned addition — debug log only.
Adds async_remove_entry, which core does not provide, so a removed panel does
not leave its notices behind. Entity enumeration in a payload is a count plus
three examples, matched by the description's user-friendly suffix rather than
the snapshot field name.
---
custom_components/span_panel/__init__.py | 12 +
custom_components/span_panel/const.py | 4 +
custom_components/span_panel/coordinator.py | 35 ++-
custom_components/span_panel/field_paths.py | 40 ++-
.../span_panel/quality_scale.yaml | 4 +-
.../span_panel/schema_repairs.py | 137 ++++++++
custom_components/span_panel/strings.json | 8 +
.../span_panel/translations/en.json | 8 +
.../span_panel/translations/es.json | 8 +
.../span_panel/translations/fr.json | 8 +
.../span_panel/translations/ja.json | 8 +
.../span_panel/translations/pt.json | 8 +
tests/test_coordinator.py | 83 +++++
tests/test_schema_repairs.py | 297 ++++++++++++++++++
tests/test_schema_validation.py | 22 ++
15 files changed, 662 insertions(+), 20 deletions(-)
create mode 100644 custom_components/span_panel/schema_repairs.py
create mode 100644 tests/test_schema_repairs.py
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index 4bfcb60f..bad321f6 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -52,6 +52,7 @@
from .graph_horizon import GraphHorizonManager
from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401
from .options import SNAPSHOT_UPDATE_INTERVAL
+from .schema_repairs import async_clear_schema_issues
from .services import ( # noqa: F401
_async_register_favorites_services,
_async_register_graph_horizon_services,
@@ -282,6 +283,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -
return True
+async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> None:
+ """Clean up the Repairs this entry raised.
+
+ Core deletes neither issues nor their dismissals when a config entry is
+ removed, so a panel that is taken out of the system would otherwise leave
+ its schema notices behind forever. Scoped to this entry: another panel's
+ issues share the domain and must survive.
+ """
+ async_clear_schema_issues(hass, entry)
+
+
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: SpanPanelConfigEntry,
diff --git a/custom_components/span_panel/const.py b/custom_components/span_panel/const.py
index f18e4e09..1fcb803d 100644
--- a/custom_components/span_panel/const.py
+++ b/custom_components/span_panel/const.py
@@ -80,6 +80,10 @@
)
EVENT_CURRENT_ALERT = "span_panel_current_alert"
+# Fired alongside a schema Repair so an automation can react without polling
+# the issue registry. Same bus-event shape as the current alert above.
+EVENT_SCHEMA_ISSUE = "span_panel_schema_issue"
+
# Graph time horizon configuration
VALID_GRAPH_HORIZONS: Final[tuple[str, ...]] = ("5m", "1h", "1d", "1w", "1M")
DEFAULT_GRAPH_HORIZON = "5m"
diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py
index 30d1cef8..03ec29cb 100644
--- a/custom_components/span_panel/coordinator.py
+++ b/custom_components/span_panel/coordinator.py
@@ -27,7 +27,9 @@
from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError
from .const import DOMAIN
-from .id_builder import build_circuit_unique_id
+from .field_paths import iter_all_field_path_declarations
+from .id_builder import build_circuit_unique_id, get_user_friendly_suffix
+from .schema_repairs import async_sync_schema_issues
from .schema_validation import SchemaFindings, evaluate_field_metadata
from .sensor_definitions import sensor_descriptions_by_field_path
@@ -390,6 +392,37 @@ def _run_schema_validation(self) -> None:
self._findings = evaluate_field_metadata(
field_metadata, sensor_descriptions_by_field_path()
)
+ async_sync_schema_issues(
+ self.hass,
+ self.config_entry,
+ self._findings,
+ self._affected_entity_ids(self._findings.unresolved),
+ )
+
+ def _affected_entity_ids(self, field_paths: frozenset[str]) -> dict[str, list[str]]:
+ """Entity ids this entry owns that read each of `field_paths`.
+
+ Matched through `get_user_friendly_suffix(description.key)`, not the
+ snapshot field name: a unique_id ends in the suffix ("_power"), never in
+ the field ("instant_power_w"), so matching on the field would silently
+ find nothing and report every dead field as affecting zero entities.
+ """
+ entity_registry = er.async_get(self.hass)
+ entries = er.async_entries_for_config_entry(entity_registry, self.config_entry.entry_id)
+
+ suffixes_by_path: dict[str, set[str]] = {path: set() for path in field_paths}
+ for field_path, description in iter_all_field_path_declarations():
+ if field_path in suffixes_by_path:
+ suffixes_by_path[field_path].add(get_user_friendly_suffix(description.key))
+
+ return {
+ path: [
+ entry.entity_id
+ for entry in entries
+ if any(entry.unique_id.endswith(suffix) for suffix in suffixes)
+ ]
+ for path, suffixes in suffixes_by_path.items()
+ }
@property
def unresolved_paths(self) -> frozenset[str]:
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 7031abdf..496cef42 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -173,12 +173,13 @@ def iter_field_path_declarations[DescriptionT: EntityDescription](
yield description.field_path, description
-def declared_field_paths() -> frozenset[str]:
- """Field paths the integration reads that must be producible by an adapter.
+def iter_all_field_path_declarations() -> Iterator[tuple[str, EntityDescription]]:
+ """Yield every ``(field_path, description)`` pair across all platforms.
- Derived entities are excluded: they have no single source field, so there is
- nothing for an adapter to produce. Residual readers that no adapter (or only
- one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`.
+ The one place that knows which platforms carry declarations. Both consumers
+ read it: `declared_field_paths` wants only the paths, and the Repairs call
+ site wants the descriptions, so it can name the entities a dead field killed.
+ Keeping the assembly here is what stops those two views from drifting.
"""
# Deferred: the platform modules import `FieldPathDeclarationMixin` from
# here, and `binary_sensor` reaches the package root for its config-entry
@@ -193,17 +194,24 @@ def declared_field_paths() -> frozenset[str]:
all_sensor_descriptions,
)
- paths: set[str] = set(RESIDUAL_FIELD_PATHS)
- paths.update(
- field_path
- for field_path, _ in iter_field_path_declarations(
- (
- *all_sensor_descriptions(),
- *BINARY_SENSORS,
- *EVSE_BINARY_SENSORS,
- GRID_ISLANDABLE_SENSOR,
- BESS_CONNECTED_SENSOR,
- )
+ yield from iter_field_path_declarations(
+ (
+ *all_sensor_descriptions(),
+ *BINARY_SENSORS,
+ *EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ BESS_CONNECTED_SENSOR,
)
)
+
+
+def declared_field_paths() -> frozenset[str]:
+ """Field paths the integration reads that must be producible by an adapter.
+
+ Derived entities are excluded: they have no single source field, so there is
+ nothing for an adapter to produce. Residual readers that no adapter (or only
+ one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`.
+ """
+ paths: set[str] = set(RESIDUAL_FIELD_PATHS)
+ paths.update(field_path for field_path, _ in iter_all_field_path_declarations())
return frozenset(paths)
diff --git a/custom_components/span_panel/quality_scale.yaml b/custom_components/span_panel/quality_scale.yaml
index d4e56eeb..e00ca898 100644
--- a/custom_components/span_panel/quality_scale.yaml
+++ b/custom_components/span_panel/quality_scale.yaml
@@ -95,9 +95,7 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
- repair-issues:
- status: exempt
- comment: No actionable repair scenarios identified for this integration.
+ repair-issues: done
stale-devices: done
# Platinum
diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py
new file mode 100644
index 00000000..dd780c84
--- /dev/null
+++ b/custom_components/span_panel/schema_repairs.py
@@ -0,0 +1,137 @@
+"""Surface schema findings as Home Assistant Repairs.
+
+Two conditions reach the user, both defects: a field the adapter cannot resolve
+(a sensor is dead), and a unit that disagrees with the schema (a reading or its
+statistics are wrong). Both are reported regardless of install age — a defect is
+not a change.
+
+A third condition, a produced field nothing reads, is a sanctioned addition and
+stays in the debug log.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import issue_registry as ir
+
+from .const import DOMAIN, EVENT_SCHEMA_ISSUE
+from .schema_validation import SchemaFindings
+
+_MAX_EXAMPLES = 3
+
+
+def _unresolved_id(entry_id: str, field_path: str) -> str:
+ return f"unresolved_{entry_id}_{field_path}"
+
+
+def _unit_id(entry_id: str, field_path: str) -> str:
+ return f"unit_mismatch_{entry_id}_{field_path}"
+
+
+@callback
+def async_sync_schema_issues(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ findings: SchemaFindings,
+ entity_ids_by_path: Mapping[str, list[str]],
+) -> None:
+ """Reconcile Repairs against the current findings.
+
+ One issue per (class, field path). Aggregating would be actively harmful:
+ `async_get_or_create`'s update branch preserves `dismissed_version`, so a
+ user who dismissed an aggregate would never be told when another field
+ joined it.
+
+ Re-raises idempotently and deletes only on genuine resolution. Deleting is
+ the one thing that clears a dismissal — `dismissed_version` is never
+ compared against the running HA version, and neither the update branch nor
+ the store reload touches it — so a delete-then-recreate loop would wipe
+ every dismissal on every pass and turn an accepted notice into a permanent
+ nag.
+ """
+ registry = ir.async_get(hass)
+ wanted: set[str] = set()
+
+ for field_path in sorted(findings.unresolved):
+ issue_id = _unresolved_id(entry.entry_id, field_path)
+ wanted.add(issue_id)
+ # Sorted so an unchanged panel produces an unchanged payload: the update
+ # branch only rewrites the entry when something actually differs.
+ affected = sorted(entity_ids_by_path.get(field_path, []))
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ issue_id,
+ is_fixable=False,
+ # Derived from live state, so it is re-asserted at every startup. A
+ # non-persistent issue reloads as a tombstone carrying only the
+ # dismissal, which is what lets re-assertion happen without
+ # resurrecting one the user already accepted.
+ is_persistent=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="schema_field_unresolved",
+ translation_placeholders={
+ "field_path": field_path,
+ # A count plus a few examples, never the full list: one missing
+ # `circuit.instant_power_w` affects every circuit on the panel.
+ "count": str(len(affected)),
+ "examples": ", ".join(affected[:_MAX_EXAMPLES]) or "none",
+ },
+ )
+
+ for mismatch in findings.unit_mismatches:
+ issue_id = _unit_id(entry.entry_id, mismatch.field_path)
+ wanted.add(issue_id)
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ issue_id,
+ is_fixable=False,
+ is_persistent=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="schema_unit_mismatch",
+ translation_placeholders={
+ "field_path": mismatch.field_path,
+ "ha_unit": mismatch.ha_unit,
+ "schema_unit": mismatch.schema_unit,
+ },
+ )
+
+ for issue_id in _ours(registry, entry.entry_id) - wanted:
+ ir.async_delete_issue(hass, DOMAIN, issue_id)
+
+ if wanted:
+ hass.bus.async_fire(
+ EVENT_SCHEMA_ISSUE,
+ {
+ "entry_id": entry.entry_id,
+ "unresolved": sorted(findings.unresolved),
+ "unit_mismatches": [m.field_path for m in findings.unit_mismatches],
+ },
+ )
+
+
+def _ours(registry: ir.IssueRegistry, entry_id: str) -> set[str]:
+ """Our issue ids for ONE config entry.
+
+ Scoping by entry is not cosmetic: with a shared namespace, a healthy panel's
+ reconcile pass would delete a degraded panel's issues on every cycle, and
+ removing one panel would clear every panel's issues.
+ """
+ prefixes = (f"unresolved_{entry_id}_", f"unit_mismatch_{entry_id}_")
+ return {
+ issue_id
+ for (domain, issue_id) in registry.issues
+ if domain == DOMAIN and issue_id.startswith(prefixes)
+ }
+
+
+@callback
+def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove one entry's issues. Core does not do this on entry removal."""
+ registry = ir.async_get(hass)
+ for issue_id in _ours(registry, entry.entry_id):
+ ir.async_delete_issue(hass, DOMAIN, issue_id)
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index a8f63db5..e3e7a1d0 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -711,6 +711,14 @@
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
"description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look."
+ },
+ "schema_field_unresolved": {
+ "title": "SPAN Panel data field unavailable",
+ "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They will show as unavailable until an integration update adds support for the new firmware."
+ },
+ "schema_unit_mismatch": {
+ "title": "SPAN Panel unit mismatch",
+ "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Readings and long-term statistics for the affected entities may be wrong. The entity has not been changed automatically."
}
}
}
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index a8f63db5..e3e7a1d0 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -711,6 +711,14 @@
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
"description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look."
+ },
+ "schema_field_unresolved": {
+ "title": "SPAN Panel data field unavailable",
+ "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They will show as unavailable until an integration update adds support for the new firmware."
+ },
+ "schema_unit_mismatch": {
+ "title": "SPAN Panel unit mismatch",
+ "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Readings and long-term statistics for the affected entities may be wrong. The entity has not been changed automatically."
}
}
}
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index fa90ee06..45a1b1a4 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -711,6 +711,14 @@
"panel_upgraded_to_ebus_v1": {
"title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred",
"description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0. Nada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha mejorado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. La v1.0 no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como la v1.0 determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba.\n\nPuede descartar este aviso cuando lo haya revisado."
+ },
+ "schema_field_unresolved": {
+ "title": "Campo de datos del Panel SPAN no disponible",
+ "description": "El firmware de su panel ya no proporciona `{field_path}`. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Aparecerán como no disponibles hasta que una actualización de la integración añada compatibilidad con el nuevo firmware."
+ },
+ "schema_unit_mismatch": {
+ "title": "Discrepancia de unidades del Panel SPAN",
+ "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Las lecturas y las estadísticas a largo plazo de las entidades afectadas pueden ser incorrectas. La entidad no se ha modificado automáticamente."
}
}
}
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 0fd22ede..775deb0e 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -711,6 +711,14 @@
"panel_upgraded_to_ebus_v1": {
"title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau",
"description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0. Rien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui s'est amélioré.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. La v1.0 ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont v1.0 détermine la capacité de secours.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas.\n\nVous pouvez ignorer cet avis après consultation."
+ },
+ "schema_field_unresolved": {
+ "title": "Champ de données du Panneau SPAN indisponible",
+ "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. {count} entité(s) sont concernées (par exemple : {examples}). Elles resteront indisponibles jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel."
+ },
+ "schema_unit_mismatch": {
+ "title": "Incohérence d'unité du Panneau SPAN",
+ "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Les relevés et les statistiques à long terme des entités concernées peuvent être erronés. L'entité n'a pas été modifiée automatiquement."
}
}
}
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index bc1563aa..9a07cbf4 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -711,6 +711,14 @@
"panel_upgraded_to_ebus_v1": {
"title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました",
"description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。ご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は改善点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。\n\n内容を確認したら、この通知は閉じて構いません。"
+ },
+ "schema_field_unresolved": {
+ "title": "SPAN パネルのデータフィールドが利用できません",
+ "description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは「利用不可」と表示されます。"
+ },
+ "schema_unit_mismatch": {
+ "title": "SPAN パネルの単位の不一致",
+ "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。影響を受けるエンティティの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。"
}
}
}
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index 03ff093d..4b3dab29 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -711,6 +711,14 @@
"panel_upgraded_to_ebus_v1": {
"title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede",
"description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0. Nada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que melhorou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. A v1.0 não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a v1.0 determina a capacidade de reserva.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava.\n\nPode dispensar este aviso depois de o consultar."
+ },
+ "schema_field_unresolved": {
+ "title": "Campo de dados do Painel SPAN indisponível",
+ "description": "O firmware do seu painel já não fornece `{field_path}`. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). Ficarão indisponíveis até que uma atualização da integração adicione suporte ao novo firmware."
+ },
+ "schema_unit_mismatch": {
+ "title": "Discrepância de unidades do Painel SPAN",
+ "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. As leituras e as estatísticas de longo prazo das entidades afetadas podem estar erradas. A entidade não foi alterada automaticamente."
}
}
}
diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py
index cc666ee5..694a79f0 100644
--- a/tests/test_coordinator.py
+++ b/tests/test_coordinator.py
@@ -510,3 +510,86 @@ async def test_async_update_data_stale_data_error_marks_offline_and_returns_last
"is unavailable" in r.message and "MQTT broker disconnected" in r.message
for r in caplog.records
)
+
+
+async def test_schema_validation_raises_a_repair_naming_the_dead_entities(
+ hass: HomeAssistant,
+) -> None:
+ """The whole point of the feature: a dead field names the sensors it killed.
+
+ Entity ids are matched through `get_user_friendly_suffix(description.key)`.
+ A unique_id ends in the suffix ("_power"), never in the snapshot field name
+ ("instant_power_w"), so matching on the field would report every dead field
+ as affecting zero entities while still looking like it worked.
+ """
+ from homeassistant.helpers import (
+ entity_registry as er,
+ issue_registry as ir,
+ )
+
+ entry = MockConfigEntry(domain="span_panel", entry_id="entry-affected")
+ entry.add_to_hass(hass)
+ entity_registry = er.async_get(hass)
+ entity_registry.async_get_or_create(
+ "sensor",
+ "span_panel",
+ "span_sp3-001_0dad2f16cd514812ae1807b0457d473e_power",
+ suggested_object_id="span_panel_kitchen_power",
+ config_entry=entry,
+ )
+ entity_registry.async_get_or_create(
+ "sensor",
+ "span_panel",
+ "span_sp3-001_0dad2f16cd514812ae1807b0457d473e_energy_produced",
+ suggested_object_id="span_panel_kitchen_energy_produced",
+ config_entry=entry,
+ )
+
+ client = MagicMock(spec=SpanPanelClientProtocol)
+ client.field_metadata = {
+ "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)
+ }
+ coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry)
+
+ coordinator._run_schema_validation()
+
+ issue = ir.async_get(hass).async_get_issue(
+ "span_panel", "unresolved_entry-affected_circuit.instant_power_w"
+ )
+ assert issue is not None
+ assert issue.translation_placeholders["count"] == "1"
+ assert issue.translation_placeholders["examples"] == "sensor.span_panel_kitchen_power"
+
+
+async def test_schema_validation_without_metadata_raises_no_repair(
+ hass: HomeAssistant,
+) -> None:
+ """The "unknown" pass must not reconcile at all.
+
+ Reconciling against empty findings during the retained-message window would
+ delete every schema issue, and with it every dismissal the user has made.
+ """
+ from homeassistant.helpers import issue_registry as ir
+
+ entry = MockConfigEntry(domain="span_panel", entry_id="entry-unknown")
+ entry.add_to_hass(hass)
+ ir.async_create_issue(
+ hass,
+ "span_panel",
+ "unresolved_entry-unknown_circuit.instant_power_w",
+ is_fixable=False,
+ is_persistent=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="schema_field_unresolved",
+ translation_placeholders={"field_path": "x", "count": "0", "examples": "none"},
+ )
+
+ client = MagicMock(spec=SpanPanelClientProtocol)
+ client.field_metadata = None
+ coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry)
+
+ coordinator._run_schema_validation()
+
+ assert ir.async_get(hass).async_get_issue(
+ "span_panel", "unresolved_entry-unknown_circuit.instant_power_w"
+ )
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
new file mode 100644
index 00000000..b6f1108e
--- /dev/null
+++ b/tests/test_schema_repairs.py
@@ -0,0 +1,297 @@
+from __future__ import annotations
+
+from homeassistant.helpers import issue_registry as ir
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.span_panel.const import DOMAIN, EVENT_SCHEMA_ISSUE
+from custom_components.span_panel.schema_repairs import (
+ async_clear_schema_issues,
+ async_sync_schema_issues,
+)
+from custom_components.span_panel.schema_validation import SchemaFindings, UnitMismatch
+
+_PATH = "circuit.instant_power_w"
+
+
+@pytest.fixture
+def entry(hass) -> MockConfigEntry:
+ """Return a config entry added to hass. No conftest fixture exists for this."""
+ mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="abc123")
+ mock.add_to_hass(hass)
+ return mock
+
+
+def _issue_id(entry: MockConfigEntry, path: str = _PATH) -> str:
+ return f"unresolved_{entry.entry_id}_{path}"
+
+
+def _unit_issue_id(entry: MockConfigEntry, path: str = _PATH) -> str:
+ return f"unit_mismatch_{entry.entry_id}_{path}"
+
+
+async def test_unresolved_path_raises_one_issue(hass, entry) -> None:
+ findings = SchemaFindings(frozenset({_PATH}), (), frozenset())
+ async_sync_schema_issues(hass, entry, findings, {})
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry))
+
+
+async def test_issue_cleared_when_condition_resolves(hass, entry) -> None:
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {})
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {})
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None
+
+
+async def test_dismissal_survives_reconciliation(hass, entry) -> None:
+ """Re-raise idempotently rather than delete-then-recreate.
+
+ Deleting is the one thing that resets a dismissal, which would turn an
+ accepted notice into a permanent nag.
+ """
+ findings = SchemaFindings(frozenset({_PATH}), (), frozenset())
+ async_sync_schema_issues(hass, entry, findings, {})
+
+ issue_id = _issue_id(entry)
+ ir.async_ignore_issue(hass, DOMAIN, issue_id, True)
+ registry = ir.async_get(hass)
+ dismissed = registry.async_get_issue(DOMAIN, issue_id).dismissed_version
+ assert dismissed is not None
+
+ for _ in range(3):
+ async_sync_schema_issues(hass, entry, findings, {})
+
+ assert registry.async_get_issue(DOMAIN, issue_id).dismissed_version == dismissed
+
+
+async def test_dismissal_survives_a_changing_affected_entity_payload(hass, entry) -> None:
+ """The update branch replaces the placeholders and keeps the dismissal.
+
+ Stronger than the identical-payload case above: `async_get_or_create` skips
+ the write entirely when nothing changed, so a delete-then-recreate bug could
+ hide there. Here the placeholders genuinely differ between passes, forcing
+ the update branch to run.
+ """
+ findings = SchemaFindings(frozenset({_PATH}), (), frozenset())
+ async_sync_schema_issues(hass, entry, findings, {_PATH: ["sensor.a"]})
+
+ issue_id = _issue_id(entry)
+ ir.async_ignore_issue(hass, DOMAIN, issue_id, True)
+ registry = ir.async_get(hass)
+ dismissed = registry.async_get_issue(DOMAIN, issue_id).dismissed_version
+
+ async_sync_schema_issues(hass, entry, findings, {_PATH: ["sensor.a", "sensor.b"]})
+
+ issue = registry.async_get_issue(DOMAIN, issue_id)
+ assert issue.dismissed_version == dismissed
+ assert issue.translation_placeholders["count"] == "2"
+
+
+async def test_distinct_paths_get_distinct_issues(hass, entry) -> None:
+ """Dismissing one finding must not swallow a later, different one."""
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), {})
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), {}
+ )
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry, "b.two"))
+
+
+async def test_a_dismissed_finding_does_not_swallow_a_later_one(hass, entry) -> None:
+ """The reason one issue per (class, path) is not cosmetic.
+
+ Dismissing an aggregate would silence every finding that joined it later,
+ because the update branch preserves `dismissed_version`.
+ """
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), {})
+ ir.async_ignore_issue(hass, DOMAIN, _issue_id(entry, "a.one"), True)
+
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), {}
+ )
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry, "a.one")).dismissed_version
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry, "b.two")).dismissed_version is None
+
+
+async def test_one_entry_does_not_clear_another(hass) -> None:
+ """Two panels must not delete each other's issues on every reconcile."""
+ sick = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sick")
+ sick.add_to_hass(hass)
+ well = MockConfigEntry(domain=DOMAIN, data={}, unique_id="well")
+ well.add_to_hass(hass)
+
+ async_sync_schema_issues(hass, sick, SchemaFindings(frozenset({_PATH}), (), frozenset()), {})
+ async_sync_schema_issues(hass, well, SchemaFindings(frozenset(), (), frozenset()), {})
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _issue_id(sick))
+
+
+async def test_circuit_rename_and_commissioning_raise_no_issue(hass, entry) -> None:
+ """Tier-1 and Tier-2 changes are handled elsewhere and must stay silent."""
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset(), (), frozenset({"pv.model"})), {}
+ )
+ registry = ir.async_get(hass)
+ assert not [k for k in registry.issues if k[0] == DOMAIN]
+
+
+async def test_unit_mismatch_raises_its_own_issue(hass, entry) -> None:
+ """The second of the two user-facing defects: a reading may be wrong."""
+ mismatch = UnitMismatch("panel.l1_voltage", "V", "kV")
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {})
+
+ registry = ir.async_get(hass)
+ issue = registry.async_get_issue(DOMAIN, _unit_issue_id(entry, "panel.l1_voltage"))
+ assert issue is not None
+ assert issue.translation_key == "schema_unit_mismatch"
+ assert issue.translation_placeholders == {
+ "field_path": "panel.l1_voltage",
+ "ha_unit": "V",
+ "schema_unit": "kV",
+ }
+
+
+async def test_unit_mismatch_issue_is_cleared_on_its_own(hass, entry) -> None:
+ """Reconciliation must scope both classes, not just the unresolved one."""
+ mismatch = UnitMismatch("panel.l1_voltage", "V", "kV")
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {})
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {})
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, "panel.l1_voltage")) is None
+
+
+async def test_issues_are_not_persistent(hass, entry) -> None:
+ """Derived from live state, so they must be re-asserted at startup.
+
+ A non-persistent issue reloads as a tombstone carrying only the dismissal,
+ which is exactly what lets re-assertion happen without resurrecting one.
+ """
+ mismatch = UnitMismatch("panel.l1_voltage", "V", "kV")
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), {}
+ )
+
+ registry = ir.async_get(hass)
+ for issue_id in (_issue_id(entry), _unit_issue_id(entry, "panel.l1_voltage")):
+ issue = registry.async_get_issue(DOMAIN, issue_id)
+ assert issue.is_persistent is False
+ assert issue.is_fixable is False
+ assert issue.severity is ir.IssueSeverity.WARNING
+
+
+async def test_affected_entities_are_bounded_and_counted(hass, entry) -> None:
+ """One missing `circuit.instant_power_w` affects every circuit.
+
+ The payload carries the full count but only a few examples, so a 40-circuit
+ panel does not render a wall of entity ids.
+ """
+ affected = [f"sensor.circuit_{n}_power" for n in range(40)]
+ async_sync_schema_issues(
+ hass,
+ entry,
+ SchemaFindings(frozenset({_PATH}), (), frozenset()),
+ {_PATH: affected},
+ )
+
+ registry = ir.async_get(hass)
+ placeholders = registry.async_get_issue(DOMAIN, _issue_id(entry)).translation_placeholders
+ assert placeholders["count"] == "40"
+ assert placeholders["examples"].count(",") < 5
+ assert "sensor.circuit_0_power" in placeholders["examples"]
+
+
+async def test_no_affected_entities_still_reads_sensibly(hass, entry) -> None:
+ """An empty example list must not render as an empty string in the notice."""
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {})
+
+ registry = ir.async_get(hass)
+ placeholders = registry.async_get_issue(DOMAIN, _issue_id(entry)).translation_placeholders
+ assert placeholders["count"] == "0"
+ assert placeholders["examples"]
+
+
+async def test_findings_fire_an_event(hass, entry) -> None:
+ """Matches the `span_panel_current_alert` pattern so automations can react."""
+ events = []
+ hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append)
+
+ mismatch = UnitMismatch("panel.l1_voltage", "V", "kV")
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), {}
+ )
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].data == {
+ "entry_id": entry.entry_id,
+ "unresolved": [_PATH],
+ "unit_mismatches": ["panel.l1_voltage"],
+ }
+
+
+async def test_a_healthy_pass_fires_no_event(hass, entry) -> None:
+ events = []
+ hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append)
+
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset(), (), frozenset({"pv.model"})), {}
+ )
+ await hass.async_block_till_done()
+
+ assert events == []
+
+
+async def test_clearing_removes_only_this_entry(hass) -> None:
+ """`async_remove_entry` must not take a second panel's issues with it."""
+ removed = MockConfigEntry(domain=DOMAIN, data={}, unique_id="removed")
+ removed.add_to_hass(hass)
+ kept = MockConfigEntry(domain=DOMAIN, data={}, unique_id="kept")
+ kept.add_to_hass(hass)
+
+ findings = SchemaFindings(frozenset({_PATH}), (), frozenset())
+ async_sync_schema_issues(hass, removed, findings, {})
+ async_sync_schema_issues(hass, kept, findings, {})
+
+ async_clear_schema_issues(hass, removed)
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _issue_id(removed)) is None
+ assert registry.async_get_issue(DOMAIN, _issue_id(kept))
+
+
+async def test_reconciliation_leaves_other_domain_issues_alone(hass, entry) -> None:
+ """The upgrade repair shares our domain and must survive a reconcile pass."""
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"panel_upgraded_to_ebus_v1_{entry.entry_id}",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="panel_upgraded_to_ebus_v1",
+ )
+
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {})
+ async_clear_schema_issues(hass, entry)
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, f"panel_upgraded_to_ebus_v1_{entry.entry_id}")
+
+
+async def test_remove_entry_clears_this_entry_issues(hass, entry) -> None:
+ """Core does not delete our issues when the entry is removed."""
+ from custom_components.span_panel import async_remove_entry
+
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {})
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry))
+
+ await async_remove_entry(hass, entry)
+
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None
diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py
index b59f3dd9..cba9cd63 100644
--- a/tests/test_schema_validation.py
+++ b/tests/test_schema_validation.py
@@ -281,3 +281,25 @@ def test_readers_of_the_same_field_path_agree_on_unit() -> None:
for path, descriptions in colliding.items():
units = {d.native_unit_of_measurement for d in descriptions}
assert len(units) == 1, f"readers of {path} disagree on unit: {units}"
+
+
+def test_known_bad_schema_unit_exception_is_keyed_on_the_field_path() -> None:
+ """The other half of the pair: only `circuit.instant_power_w` is excused.
+
+ `test_known_bad_schema_unit_exception_is_narrow` pins the unit half — a
+ different unit on the same path is still reported. Without this, widening
+ the check to a unit-only membership test ("is kW ever known-bad?") would
+ pass the whole suite while silently excusing every field that declares kW.
+ """
+ from homeassistant.components.sensor import SensorEntityDescription
+ from homeassistant.const import UnitOfPower
+
+ description = SensorEntityDescription(
+ key="grid_power", native_unit_of_measurement=UnitOfPower.WATT
+ )
+ findings = evaluate_field_metadata(
+ {"panel.instant_grid_power_w": FieldMetadata("kW", "float")},
+ sensor_defs={"panel.instant_grid_power_w": description},
+ )
+ assert [m.field_path for m in findings.unit_mismatches] == ["panel.instant_grid_power_w"]
+ assert findings.unit_mismatches[0].schema_unit == "kW"
From a495c676044d6466110e4cbeca5ad518958e4168 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 02:04:08 -0700
Subject: [PATCH 031/116] fix: record affected entities from the entities
themselves
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Repairs named the entities a dead field takes down by re-deriving
unique_ids from entity descriptions through get_user_friendly_suffix. Only
circuit sensors build ids that way. Panel-data sensors use
get_panel_entity_suffix, whose mapping deliberately disagrees for six keys
(instantGridPowerW is current_power, not grid_power), and binary sensors use
the raw camelCase key, which a lowercasing helper can never match — eight
declared fields resolved to nothing, so the notice read "0 entity/entities are
affected" while the sensor was dead. endswith("power") also claimed every power
entity on the panel.
Entities now record field_path -> entity_id on the coordinator as they are
added to hass and withdraw it as they leave. Both halves come from the entity,
so no unique_id builder is consulted and nothing can drift from them.
Reconciliation moves out of the first-refresh path into
async_sync_schema_repairs, called after the platforms are forwarded: setup
awaits the first refresh well before any entity exists, so reconciling there
reported zero affected entities on every fresh install regardless of the
suffix bug.
---
custom_components/span_panel/__init__.py | 6 +
custom_components/span_panel/coordinator.py | 76 ++++---
custom_components/span_panel/entity.py | 38 ++++
custom_components/span_panel/field_paths.py | 40 ++--
tests/test_coordinator.py | 74 ++++---
tests/test_schema_repairs.py | 217 ++++++++++++++++++++
tests/test_setup_entry.py | 50 +++++
7 files changed, 420 insertions(+), 81 deletions(-)
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index bad321f6..866e3842 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -253,6 +253,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ # After the platforms, not before: schema validation runs on the first
+ # refresh, which is awaited above, but the Repairs it raises name the
+ # entities an unresolved field took down — and those entities only
+ # register themselves once their platform has added them.
+ coordinator.async_sync_schema_repairs()
except Exception:
if coordinator is not None:
await coordinator.async_shutdown()
diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py
index 03ec29cb..71e91374 100644
--- a/custom_components/span_panel/coordinator.py
+++ b/custom_components/span_panel/coordinator.py
@@ -14,7 +14,7 @@
from .graph_horizon import GraphHorizonManager
from homeassistant.components.persistent_notification import async_create
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
@@ -27,8 +27,7 @@
from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError
from .const import DOMAIN
-from .field_paths import iter_all_field_path_declarations
-from .id_builder import build_circuit_unique_id, get_user_friendly_suffix
+from .id_builder import build_circuit_unique_id
from .schema_repairs import async_sync_schema_issues
from .schema_validation import SchemaFindings, evaluate_field_metadata
from .sensor_definitions import sensor_descriptions_by_field_path
@@ -95,6 +94,13 @@ def __init__(
self._schema_validated = False
self._findings: SchemaFindings | None = None
+ # Which entities read which snapshot field, recorded by the entities
+ # themselves as they are added to hass. Authoritative rather than
+ # reverse-engineered: three platforms build unique_ids three different
+ # ways, so deriving entity ids from entity descriptions gets most of
+ # them wrong. See `SpanPanelEntity.async_added_to_hass`.
+ self._entity_ids_by_field_path: dict[str, set[str]] = {}
+
# Energy dip compensation — sensors append events here during updates;
# drained and surfaced as a persistent notification after each cycle.
self._pending_dip_events: list[tuple[str, float, float]] = []
@@ -392,38 +398,54 @@ def _run_schema_validation(self) -> None:
self._findings = evaluate_field_metadata(
field_metadata, sensor_descriptions_by_field_path()
)
- async_sync_schema_issues(
- self.hass,
- self.config_entry,
- self._findings,
- self._affected_entity_ids(self._findings.unresolved),
- )
- def _affected_entity_ids(self, field_paths: frozenset[str]) -> dict[str, list[str]]:
- """Entity ids this entry owns that read each of `field_paths`.
+ @callback
+ def async_register_field_path_entity(self, field_path: str, entity_id: str) -> None:
+ """Record that `entity_id` reads `field_path`.
- Matched through `get_user_friendly_suffix(description.key)`, not the
- snapshot field name: a unique_id ends in the suffix ("_power"), never in
- the field ("instant_power_w"), so matching on the field would silently
- find nothing and report every dead field as affecting zero entities.
+ Called by the entity itself, which is the only thing that knows both
+ halves for certain. Circuit, panel-data and binary-sensor entities each
+ build their unique_id from a different suffix rule, so a mapping derived
+ from entity descriptions would silently miss most of them.
"""
- entity_registry = er.async_get(self.hass)
- entries = er.async_entries_for_config_entry(entity_registry, self.config_entry.entry_id)
+ self._entity_ids_by_field_path.setdefault(field_path, set()).add(entity_id)
- suffixes_by_path: dict[str, set[str]] = {path: set() for path in field_paths}
- for field_path, description in iter_all_field_path_declarations():
- if field_path in suffixes_by_path:
- suffixes_by_path[field_path].add(get_user_friendly_suffix(description.key))
+ @callback
+ def async_unregister_field_path_entity(self, field_path: str, entity_id: str) -> None:
+ """Forget an entity that is leaving hass, so it stops inflating counts."""
+ entity_ids = self._entity_ids_by_field_path.get(field_path)
+ if entity_ids is None:
+ return
+ entity_ids.discard(entity_id)
+ if not entity_ids:
+ del self._entity_ids_by_field_path[field_path]
+ @property
+ def entity_ids_by_field_path(self) -> dict[str, list[str]]:
+ """Entities currently in hass, by the snapshot field each one reads."""
return {
- path: [
- entry.entity_id
- for entry in entries
- if any(entry.unique_id.endswith(suffix) for suffix in suffixes)
- ]
- for path, suffixes in suffixes_by_path.items()
+ field_path: sorted(entity_ids)
+ for field_path, entity_ids in self._entity_ids_by_field_path.items()
}
+ @callback
+ def async_sync_schema_repairs(self) -> None:
+ """Reconcile Repairs against the last validation pass.
+
+ Deliberately separate from `_run_schema_validation`: that runs on the
+ first refresh, which `async_setup_entry` awaits *before* forwarding the
+ platforms, so no entity exists yet and every Repair would report zero
+ affected entities. Called once the platforms are up instead.
+
+ Findings of None means "not yet known", never "healthy" — reconciling
+ against that would delete every issue and every dismissal with it.
+ """
+ if self._findings is None:
+ return
+ async_sync_schema_issues(
+ self.hass, self.config_entry, self._findings, self.entity_ids_by_field_path
+ )
+
@property
def unresolved_paths(self) -> frozenset[str]:
"""Field paths the adapter could not resolve. Empty when healthy."""
diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py
index afc2590f..f3d0a2ab 100644
--- a/custom_components/span_panel/entity.py
+++ b/custom_components/span_panel/entity.py
@@ -9,6 +9,7 @@
from .const import CONF_DEVICE_NAME
from .coordinator import SpanPanelCoordinator
+from .field_paths import FieldPathDeclarationMixin
from .util import snapshot_to_device_info
@@ -17,6 +18,43 @@ class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]):
_attr_has_entity_name = True
+ async def async_added_to_hass(self) -> None:
+ """Tell the coordinator which snapshot field this entity reads.
+
+ The entity is the only place both halves are known for certain: it holds
+ its own `field_path` declaration and its own `entity_id`. Reconstructing
+ the pair from entity descriptions instead would mean reimplementing three
+ different unique_id builders — circuit sensors, panel-data sensors and
+ binary sensors each use a different suffix rule — and would rot the next
+ time one of them changed.
+
+ Consumed by the schema Repairs, which name the entities a field the panel
+ stopped producing has taken down with it.
+ """
+ await super().async_added_to_hass()
+ field_path = self._declared_field_path()
+ if field_path is not None:
+ self.coordinator.async_register_field_path_entity(field_path, self.entity_id)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Stop counting this entity against its field."""
+ field_path = self._declared_field_path()
+ if field_path is not None:
+ self.coordinator.async_unregister_field_path_entity(field_path, self.entity_id)
+ await super().async_will_remove_from_hass()
+
+ def _declared_field_path(self) -> str | None:
+ """Return the snapshot field this entity reads, if its description declares one.
+
+ Platforms that carry no entity description (the circuit switch) or whose
+ description declares nothing (`derived` entities, which read several
+ fields or none) return None and are simply not tracked.
+ """
+ description: object = getattr(self, "entity_description", None)
+ if not isinstance(description, FieldPathDeclarationMixin) or description.derived:
+ return None
+ return description.field_path
+
@staticmethod
def _build_device_info(
coordinator: SpanPanelCoordinator,
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 496cef42..7031abdf 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -173,13 +173,12 @@ def iter_field_path_declarations[DescriptionT: EntityDescription](
yield description.field_path, description
-def iter_all_field_path_declarations() -> Iterator[tuple[str, EntityDescription]]:
- """Yield every ``(field_path, description)`` pair across all platforms.
+def declared_field_paths() -> frozenset[str]:
+ """Field paths the integration reads that must be producible by an adapter.
- The one place that knows which platforms carry declarations. Both consumers
- read it: `declared_field_paths` wants only the paths, and the Repairs call
- site wants the descriptions, so it can name the entities a dead field killed.
- Keeping the assembly here is what stops those two views from drifting.
+ Derived entities are excluded: they have no single source field, so there is
+ nothing for an adapter to produce. Residual readers that no adapter (or only
+ one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`.
"""
# Deferred: the platform modules import `FieldPathDeclarationMixin` from
# here, and `binary_sensor` reaches the package root for its config-entry
@@ -194,24 +193,17 @@ def iter_all_field_path_declarations() -> Iterator[tuple[str, EntityDescription]
all_sensor_descriptions,
)
- yield from iter_field_path_declarations(
- (
- *all_sensor_descriptions(),
- *BINARY_SENSORS,
- *EVSE_BINARY_SENSORS,
- GRID_ISLANDABLE_SENSOR,
- BESS_CONNECTED_SENSOR,
+ paths: set[str] = set(RESIDUAL_FIELD_PATHS)
+ paths.update(
+ field_path
+ for field_path, _ in iter_field_path_declarations(
+ (
+ *all_sensor_descriptions(),
+ *BINARY_SENSORS,
+ *EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ BESS_CONNECTED_SENSOR,
+ )
)
)
-
-
-def declared_field_paths() -> frozenset[str]:
- """Field paths the integration reads that must be producible by an adapter.
-
- Derived entities are excluded: they have no single source field, so there is
- nothing for an adapter to produce. Residual readers that no adapter (or only
- one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`.
- """
- paths: set[str] = set(RESIDUAL_FIELD_PATHS)
- paths.update(field_path for field_path, _ in iter_all_field_path_declarations())
return frozenset(paths)
diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py
index 694a79f0..adb0636f 100644
--- a/tests/test_coordinator.py
+++ b/tests/test_coordinator.py
@@ -512,46 +512,57 @@ async def test_async_update_data_stale_data_error_marks_offline_and_returns_last
)
-async def test_schema_validation_raises_a_repair_naming_the_dead_entities(
+async def test_entities_register_themselves_against_the_field_they_read(
hass: HomeAssistant,
) -> None:
- """The whole point of the feature: a dead field names the sensors it killed.
+ """The affected-entity map is recorded by entities, never derived from them.
- Entity ids are matched through `get_user_friendly_suffix(description.key)`.
- A unique_id ends in the suffix ("_power"), never in the snapshot field name
- ("instant_power_w"), so matching on the field would report every dead field
- as affecting zero entities while still looking like it worked.
+ Three unique_id builders are in play and they disagree, so reconstructing
+ entity ids from entity descriptions reported "0 affected" for most fields.
+ tests/test_schema_repairs.py drives real entities through a real platform;
+ this pins the coordinator side of the contract.
"""
- from homeassistant.helpers import (
- entity_registry as er,
- issue_registry as ir,
- )
+ coordinator = _create_coordinator(hass)
+
+ coordinator.async_register_field_path_entity("panel.door_state", "binary_sensor.door")
+ coordinator.async_register_field_path_entity("circuit.instant_power_w", "sensor.b")
+ coordinator.async_register_field_path_entity("circuit.instant_power_w", "sensor.a")
+ coordinator.async_register_field_path_entity("circuit.instant_power_w", "sensor.a")
+
+ assert coordinator.entity_ids_by_field_path == {
+ "panel.door_state": ["binary_sensor.door"],
+ "circuit.instant_power_w": ["sensor.a", "sensor.b"],
+ }
+
+ coordinator.async_unregister_field_path_entity("circuit.instant_power_w", "sensor.a")
+ coordinator.async_unregister_field_path_entity("panel.door_state", "binary_sensor.door")
+ # Unknown pairs are ignored rather than raising: removal can outlive setup.
+ coordinator.async_unregister_field_path_entity("panel.door_state", "binary_sensor.gone")
+
+ assert coordinator.entity_ids_by_field_path == {
+ "circuit.instant_power_w": ["sensor.b"]
+ }
+
+
+async def test_sync_schema_repairs_raises_a_repair_naming_the_dead_entities(
+ hass: HomeAssistant,
+) -> None:
+ """The whole point of the feature: a dead field names the sensors it killed."""
+ from homeassistant.helpers import issue_registry as ir
entry = MockConfigEntry(domain="span_panel", entry_id="entry-affected")
entry.add_to_hass(hass)
- entity_registry = er.async_get(hass)
- entity_registry.async_get_or_create(
- "sensor",
- "span_panel",
- "span_sp3-001_0dad2f16cd514812ae1807b0457d473e_power",
- suggested_object_id="span_panel_kitchen_power",
- config_entry=entry,
- )
- entity_registry.async_get_or_create(
- "sensor",
- "span_panel",
- "span_sp3-001_0dad2f16cd514812ae1807b0457d473e_energy_produced",
- suggested_object_id="span_panel_kitchen_energy_produced",
- config_entry=entry,
- )
-
client = MagicMock(spec=SpanPanelClientProtocol)
client.field_metadata = {
"circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)
}
coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry)
+ coordinator.async_register_field_path_entity(
+ "circuit.instant_power_w", "sensor.span_panel_kitchen_power"
+ )
coordinator._run_schema_validation()
+ coordinator.async_sync_schema_repairs()
issue = ir.async_get(hass).async_get_issue(
"span_panel", "unresolved_entry-affected_circuit.instant_power_w"
@@ -561,13 +572,14 @@ async def test_schema_validation_raises_a_repair_naming_the_dead_entities(
assert issue.translation_placeholders["examples"] == "sensor.span_panel_kitchen_power"
-async def test_schema_validation_without_metadata_raises_no_repair(
+async def test_sync_schema_repairs_is_a_no_op_while_findings_are_unknown(
hass: HomeAssistant,
) -> None:
- """The "unknown" pass must not reconcile at all.
+ """"Unknown" must not reconcile at all.
- Reconciling against empty findings during the retained-message window would
- delete every schema issue, and with it every dismissal the user has made.
+ `field_metadata` is None for the whole retained-message window, which an
+ ordinary reconnect opens. Reconciling against no findings would delete every
+ schema issue, and with it every dismissal the user has made.
"""
from homeassistant.helpers import issue_registry as ir
@@ -589,7 +601,9 @@ async def test_schema_validation_without_metadata_raises_no_repair(
coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry)
coordinator._run_schema_validation()
+ coordinator.async_sync_schema_repairs()
+ assert coordinator.schema_findings is None
assert ir.async_get(hass).async_get_issue(
"span_panel", "unresolved_entry-unknown_circuit.instant_power_w"
)
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index b6f1108e..0fa915d1 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -295,3 +295,220 @@ async def test_remove_entry_clears_this_entry_issues(hass, entry) -> None:
await async_remove_entry(hass, entry)
assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None
+
+
+# --- The affected-entity map, built from real entities ---------------------
+#
+# The map is populated by the entities themselves rather than reverse-engineered
+# from entity descriptions. Reverse-engineering was wrong three ways at once:
+# panel-data sensors build their unique_id from `get_panel_entity_suffix`, whose
+# `PANEL_ENTITY_SUFFIX_MAPPING` deliberately disagrees with the general mapping
+# (`instantGridPowerW` -> "current_power", not "grid_power"); binary sensors use
+# the raw camelCase key ("doorState"), which a lowercasing suffix helper can
+# never match; and an `endswith("power")` test claims every power entity on the
+# panel. Self-registration cannot drift from the builders because it never
+# consults them.
+
+_STYLE_PATHS = {
+ # circuit style — `get_user_friendly_suffix`, and the over-match case
+ "circuit.instant_power_w",
+ # panel-data style — `get_panel_entity_suffix`, which disagrees
+ "panel.instant_grid_power_w",
+ "panel.power_flow_battery",
+ "panel.power_flow_pv",
+ "panel.power_flow_site",
+ # binary-sensor style — the raw camelCase description key
+ "panel.door_state",
+}
+
+
+async def _entities_by_declared_path(hass):
+ """Build the real entities for a healthy panel, grouped by declared field.
+
+ Real platform setup, real entity classes, real unique_id builders — the
+ three id styles only differ because the builders differ, so anything less
+ faithful would not exercise the bug this replaced.
+ """
+ from unittest.mock import AsyncMock, MagicMock
+
+ from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered
+ from custom_components.span_panel.binary_sensor import (
+ async_setup_entry as binary_setup,
+ )
+ from custom_components.span_panel.coordinator import SpanPanelCoordinator
+ from custom_components.span_panel.field_paths import FieldPathDeclarationMixin
+ from custom_components.span_panel.sensor import async_setup_entry as sensor_setup
+
+ from .factories import (
+ SpanBatterySnapshotFactory,
+ SpanCircuitSnapshotFactory,
+ SpanPanelSnapshotFactory,
+ )
+
+ # Two circuits and the three power flows, so the over-match case has real
+ # panel power sensors to be wrongly claimed by.
+ snapshot = SpanPanelSnapshotFactory.create(
+ circuits={
+ "1": SpanCircuitSnapshotFactory.create(circuit_id="1", name="Kitchen"),
+ "2": SpanCircuitSnapshotFactory.create(circuit_id="2", name="Garage"),
+ },
+ battery=SpanBatterySnapshotFactory.create(soe_percentage=85.0, connected=True),
+ power_flow_battery=-250.0,
+ power_flow_pv=1250.0,
+ power_flow_site=3000.0,
+ )
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, data={}, title="SPAN Panel", unique_id=snapshot.serial_number
+ )
+ config_entry.add_to_hass(hass)
+ client = MagicMock()
+ client.stop_streaming = AsyncMock()
+ client.close = AsyncMock()
+ coordinator = SpanPanelCoordinator(hass, client, config_entry)
+ coordinator.data = snapshot
+ # A real panel device: the BESS sub-device declares `via_device`, and HA
+ # refuses to add an entity whose via_device is not a registered device id.
+ config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator,
+ panel_device_id=await ensure_device_registered(
+ hass, config_entry, snapshot, "SPAN Panel"
+ ),
+ )
+
+ grouped: dict[str, dict[str, list[object]]] = {}
+ for platform_domain, setup in (("sensor", sensor_setup), ("binary_sensor", binary_setup)):
+ added = MagicMock()
+ await setup(hass, config_entry, added)
+ for entity in added.call_args.args[0]:
+ description = getattr(entity, "entity_description", None)
+ if not isinstance(description, FieldPathDeclarationMixin):
+ continue
+ if description.derived or description.field_path not in _STYLE_PATHS:
+ continue
+ grouped.setdefault(description.field_path, {}).setdefault(
+ platform_domain, []
+ ).append(entity)
+
+ return coordinator, config_entry, grouped
+
+
+async def _add_to_platform(hass, config_entry, entities, platform_domain: str) -> None:
+ """Add real entities to a real entity platform, as HA does at setup."""
+ from pytest_homeassistant_custom_component.common import MockEntityPlatform
+
+ platform = MockEntityPlatform(hass, domain=platform_domain, platform_name=DOMAIN)
+ platform.config_entry = config_entry
+ await platform.async_add_entities(entities)
+ await hass.async_block_till_done()
+
+
+async def _stop_scheduling(coordinator) -> None:
+ """Cancel the coordinator's refresh timer and debouncer.
+
+ `SpanPanelCoordinator.async_shutdown` releases the client but does not chain
+ to the base implementation; in production the timer is unscheduled when the
+ last entity listener goes away, which these tests deliberately do not do.
+ """
+ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+ await DataUpdateCoordinator.async_shutdown(coordinator)
+
+
+async def test_affected_entities_span_all_three_unique_id_styles(hass) -> None:
+ """A dead field must name the entities that actually died — every style.
+
+ Three unique_id builders are in play and they do not agree, so any scheme
+ that re-derives entity ids from entity descriptions gets at least two of the
+ three wrong: it reports "0 affected" for a panel field whose sensor is dead,
+ and over-claims for a circuit field.
+ """
+ coordinator, config_entry, grouped = await _entities_by_declared_path(hass)
+ try:
+ assert _STYLE_PATHS <= grouped.keys(), (
+ f"fixture missed {_STYLE_PATHS - grouped.keys()}"
+ )
+
+ # The fixture really does cover three different builders: circuit suffix,
+ # panel entity suffix, raw camelCase key.
+ def _first(path: str, platform_domain: str):
+ return grouped[path][platform_domain][0]
+
+ assert _first("circuit.instant_power_w", "sensor").unique_id.endswith("_power")
+ assert _first("panel.instant_grid_power_w", "sensor").unique_id.endswith(
+ "_current_power"
+ )
+ assert _first("panel.door_state", "binary_sensor").unique_id.endswith("doorState")
+
+ # One platform per domain, as HA does — several platforms sharing a
+ # domain and platform name is not a shape the real integration produces.
+ for platform_domain in ("sensor", "binary_sensor"):
+ batch = [
+ entity
+ for by_domain in grouped.values()
+ for entity in by_domain.get(platform_domain, [])
+ ]
+ await _add_to_platform(hass, config_entry, batch, platform_domain)
+
+ affected = coordinator.entity_ids_by_field_path
+
+ for path, by_domain in grouped.items():
+ expected = sorted(
+ entity.entity_id for entities in by_domain.values() for entity in entities
+ )
+ assert affected[path] == expected, path
+ assert all(expected), f"{path} recorded an entity with no entity_id"
+
+ # The over-match case: a dead circuit power field must claim only circuit
+ # power entities, never the panel's own power sensors.
+ circuit_power = set(affected["circuit.instant_power_w"])
+ assert circuit_power
+ for other in _STYLE_PATHS - {"circuit.instant_power_w"}:
+ assert affected[other]
+ assert set(affected[other]).isdisjoint(circuit_power), (
+ f"{other} entities were claimed by circuit.instant_power_w"
+ )
+ finally:
+ await _stop_scheduling(coordinator)
+
+
+async def test_the_repair_payload_names_the_real_entities(hass) -> None:
+ """End to end: the notice a user reads carries real, resolvable entity ids."""
+ coordinator, config_entry, grouped = await _entities_by_declared_path(hass)
+ try:
+ entities = grouped["panel.instant_grid_power_w"]["sensor"]
+ await _add_to_platform(hass, config_entry, entities, "sensor")
+
+ async_sync_schema_issues(
+ hass,
+ config_entry,
+ SchemaFindings(frozenset({"panel.instant_grid_power_w"}), (), frozenset()),
+ coordinator.entity_ids_by_field_path,
+ )
+
+ issue = ir.async_get(hass).async_get_issue(
+ DOMAIN, f"unresolved_{config_entry.entry_id}_panel.instant_grid_power_w"
+ )
+ assert issue is not None
+ placeholders = issue.translation_placeholders
+ assert placeholders["count"] == str(len(entities))
+ assert placeholders["examples"] == entities[0].entity_id
+ assert hass.states.get(entities[0].entity_id) is not None
+ finally:
+ await _stop_scheduling(coordinator)
+
+
+async def test_removing_an_entity_drops_it_from_the_map(hass) -> None:
+ """A removed entity must stop inflating the count."""
+ coordinator, config_entry, grouped = await _entities_by_declared_path(hass)
+ try:
+ entities = grouped["panel.instant_grid_power_w"]["sensor"]
+ await _add_to_platform(hass, config_entry, entities, "sensor")
+ assert coordinator.entity_ids_by_field_path["panel.instant_grid_power_w"]
+
+ for entity in entities:
+ await entity.async_remove()
+ await hass.async_block_till_done()
+
+ assert "panel.instant_grid_power_w" not in coordinator.entity_ids_by_field_path
+ finally:
+ await _stop_scheduling(coordinator)
diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py
index af04a161..04842c66 100644
--- a/tests/test_setup_entry.py
+++ b/tests/test_setup_entry.py
@@ -288,3 +288,53 @@ async def test_async_setup_entry_shutdowns_coordinator_on_forward_failure(
await async_setup_entry(hass, entry)
coordinator.async_shutdown.assert_awaited_once()
+
+
+async def test_setup_syncs_schema_repairs_after_the_platforms(
+ hass: HomeAssistant,
+) -> None:
+ """Repairs must be reconciled after the platforms, never before.
+
+ A schema Repair names the entities an unresolved field took down, and those
+ entities record themselves only once their platform has added them. Schema
+ validation itself runs on the first refresh, which setup awaits well before
+ forwarding the platforms — reconciling there would report every dead field
+ as affecting zero entities.
+ """
+ entry = _create_v2_entry()
+ entry.add_to_hass(hass)
+ snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001")
+ client = MagicMock()
+ client.connect = AsyncMock()
+ coordinator = MagicMock()
+ coordinator.async_config_entry_first_refresh = AsyncMock()
+ coordinator.async_setup_streaming = AsyncMock()
+ coordinator.data = snapshot
+
+ order: list[str] = []
+ coordinator.async_sync_schema_repairs = MagicMock(
+ side_effect=lambda: order.append("sync")
+ )
+
+ async def _forward(*_args, **_kwargs) -> None:
+ order.append("forward")
+
+ with (
+ patch("custom_components.span_panel.async_register_commands"),
+ patch("custom_components.span_panel.SpanMqttClient", return_value=client),
+ patch(
+ "custom_components.span_panel.SpanPanelCoordinator",
+ return_value=coordinator,
+ ),
+ patch(
+ "custom_components.span_panel.ensure_device_registered",
+ AsyncMock(return_value="panel-device-id"),
+ ),
+ patch.object(
+ hass.config_entries, "async_forward_entry_setups", AsyncMock(side_effect=_forward)
+ ),
+ patch.object(hass.config_entries, "async_update_entry"),
+ ):
+ assert await async_setup_entry(hass, entry) is True
+
+ assert order == ["forward", "sync"]
From 740435242392ae94523578e57ba1f4aef3ad3a3d Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 02:05:05 -0700
Subject: [PATCH 032/116] test: pin that undeclared and derived entities are
not tracked
A derived entity reads several fields or none, and the circuit switch carries
no entity description at all; attributing either to a field would blame it for
a loss that may not have taken it down.
---
tests/test_schema_repairs.py | 44 ++++++++++++++++++++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index 0fa915d1..4e525377 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -512,3 +512,47 @@ async def test_removing_an_entity_drops_it_from_the_map(hass) -> None:
assert "panel.instant_grid_power_w" not in coordinator.entity_ids_by_field_path
finally:
await _stop_scheduling(coordinator)
+
+
+async def test_entities_without_a_declaration_are_not_tracked(hass) -> None:
+ """A derived entity must not be blamed for a field it only partly reads.
+
+ Derived entities compute from several fields or none, and the circuit switch
+ carries no entity description at all. Tracking either would attribute an
+ entity to a field whose loss may not have taken it down.
+ """
+ coordinator, config_entry, _ = await _entities_by_declared_path(hass)
+ try:
+ from unittest.mock import MagicMock
+
+ from custom_components.span_panel.binary_sensor import (
+ BESS_CONNECTED_SENSOR,
+ async_setup_entry as binary_setup,
+ )
+ from custom_components.span_panel.switch import (
+ async_setup_entry as switch_setup,
+ )
+
+ assert BESS_CONNECTED_SENSOR.derived, "fixture assumes a derived description"
+
+ added = MagicMock()
+ await binary_setup(hass, config_entry, added)
+ derived = [
+ entity
+ for entity in added.call_args.args[0]
+ if getattr(entity, "entity_description", None) is BESS_CONNECTED_SENSOR
+ ]
+ assert derived
+
+ added = MagicMock()
+ await switch_setup(hass, config_entry, added)
+ switches = list(added.call_args.args[0])
+ assert switches
+ assert not hasattr(switches[0], "entity_description")
+
+ await _add_to_platform(hass, config_entry, derived, "binary_sensor")
+ await _add_to_platform(hass, config_entry, switches, "switch")
+
+ assert coordinator.entity_ids_by_field_path == {}
+ finally:
+ await _stop_scheduling(coordinator)
From 30738a486bdda487c968fa657884970bd497b077 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 02:28:59 -0700
Subject: [PATCH 033/116] fix: retry schema validation while the answer is
unknown, and name residual readers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two defects, both surfacing as a Repair that tells the user nothing.
The once-only guard was set before validation ran, so a first pass landing in
the metadata-not-ready window — which an ordinary reconnect opens — spent the
guard on a pass that answered nothing and left findings at None for the life of
the entry. The guard now sets only once findings exist, and a pass that first
succeeds after setup reconciles itself rather than waiting for a reload.
Five field paths are read from entity code rather than a description, so
nothing registered them and a dead circuit.relay_state reported "0 affected"
while every breaker switch was out. The switch, select and circuit sensors now
declare those reads; a test pins the declarations against RESIDUAL_FIELD_PATHS
in both directions.
The unresolved-field notice no longer promises the entities will go
unavailable: losing a circuit's name or tabs degrades naming and attributes
without taking the entity down.
---
custom_components/span_panel/coordinator.py | 42 ++++-
custom_components/span_panel/entity.py | 52 ++++--
custom_components/span_panel/select.py | 12 +-
.../span_panel/sensor_circuit.py | 16 +-
custom_components/span_panel/strings.json | 2 +-
custom_components/span_panel/switch.py | 11 +-
.../span_panel/translations/en.json | 2 +-
.../span_panel/translations/es.json | 2 +-
.../span_panel/translations/fr.json | 2 +-
.../span_panel/translations/ja.json | 2 +-
.../span_panel/translations/pt.json | 2 +-
tests/test_coordinator.py | 87 +++++++++-
tests/test_schema_repairs.py | 155 ++++++++++++++++--
13 files changed, 336 insertions(+), 51 deletions(-)
diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py
index 71e91374..8542b49a 100644
--- a/custom_components/span_panel/coordinator.py
+++ b/custom_components/span_panel/coordinator.py
@@ -90,9 +90,14 @@ def __init__(
# and trigger a reload so the factory creates the appropriate sensors.
self._known_capabilities: frozenset[str] | None = None
- # Schema validation — run once after first successful refresh
+ # Schema validation — runs once SUCCESSFULLY; a pass that finds no
+ # metadata yet leaves the flag unset so a later one can still answer.
self._schema_validated = False
self._findings: SchemaFindings | None = None
+ # True once `async_setup_entry` has forwarded the platforms and asked for
+ # the first reconcile. Before that there are no entities for a finding to
+ # name; after it, a late first success must reconcile itself.
+ self._platforms_ready = False
# Which entities read which snapshot field, recorded by the entities
# themselves as they are added to hass. Authoritative rather than
@@ -398,6 +403,15 @@ def _run_schema_validation(self) -> None:
self._findings = evaluate_field_metadata(
field_metadata, sensor_descriptions_by_field_path()
)
+ # Only now: metadata is static within a session, so one success is
+ # enough and re-reading identical inputs on every pass would be waste.
+ self._schema_validated = True
+
+ if self._platforms_ready:
+ # A late first success. Setup already passed its reconcile point, so
+ # nothing else will raise these — do it here, where the entities that
+ # the findings name are guaranteed to exist.
+ self._sync_repairs()
@callback
def async_register_field_path_entity(self, field_path: str, entity_id: str) -> None:
@@ -430,12 +444,21 @@ def entity_ids_by_field_path(self) -> dict[str, list[str]]:
@callback
def async_sync_schema_repairs(self) -> None:
- """Reconcile Repairs against the last validation pass.
+ """Reconcile Repairs now that the platforms are up.
+
+ Called by `async_setup_entry` after the platforms are forwarded, which is
+ the earliest point the entities a finding names exist: validation runs on
+ the first refresh, and setup awaits that *before* forwarding anything.
+
+ Also records that the reconcile point has passed, so a validation pass
+ that first succeeds later reconciles itself rather than waiting for the
+ next reload.
+ """
+ self._platforms_ready = True
+ self._sync_repairs()
- Deliberately separate from `_run_schema_validation`: that runs on the
- first refresh, which `async_setup_entry` awaits *before* forwarding the
- platforms, so no entity exists yet and every Repair would report zero
- affected entities. Called once the platforms are up instead.
+ def _sync_repairs(self) -> None:
+ """Reconcile Repairs against the findings, if there are any yet.
Findings of None means "not yet known", never "healthy" — reconciling
against that would delete every issue and every dismissal with it.
@@ -612,9 +635,12 @@ async def _run_post_update_tasks(self, snapshot: SpanPanelSnapshot) -> None:
streaming the polling path effectively never fires. This shared method
ensures reload requests are processed regardless of transport mode.
"""
- # One-shot schema validation after first successful refresh
+ # Schema validation: at most once SUCCESSFULLY, retried only while the
+ # answer is still unknown. The guard is set inside `_run_schema_validation`
+ # for that reason — setting it here disabled the feature for the life of
+ # the entry whenever the very first pass landed in the metadata-not-ready
+ # window, which an ordinary reconnect opens.
if not self._schema_validated:
- self._schema_validated = True
self._run_schema_validation()
# Check for pending solar entity migration (v1 solar → v2 PV circuit)
diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py
index f3d0a2ab..e4fcca26 100644
--- a/custom_components/span_panel/entity.py
+++ b/custom_components/span_panel/entity.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from typing import ClassVar
+
from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -18,12 +20,26 @@ class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]):
_attr_has_entity_name = True
+ _residual_field_paths: ClassVar[tuple[str, ...]] = ()
+ """Snapshot fields this entity reads from entity code, not from a description.
+
+ A handful of reads cannot be expressed as a description `field_path`: the
+ switch has no entity description at all, the select wraps one, and a circuit
+ entity's name, tabs and attributes are read outside any `value_fn`.
+ `field_paths.RESIDUAL_FIELD_PATHS` already names that exact set for the
+ producible gate; declaring the same paths here is what lets a Repair say
+ which entities a dead one takes with it, instead of "0 affected".
+
+ `test_every_residual_field_path_is_claimed_by_an_entity` pins the two lists
+ to each other in both directions.
+ """
+
async def async_added_to_hass(self) -> None:
- """Tell the coordinator which snapshot field this entity reads.
+ """Tell the coordinator which snapshot fields this entity reads.
The entity is the only place both halves are known for certain: it holds
- its own `field_path` declaration and its own `entity_id`. Reconstructing
- the pair from entity descriptions instead would mean reimplementing three
+ its own declarations and its own `entity_id`. Reconstructing the pair
+ from entity descriptions instead would mean reimplementing three
different unique_id builders — circuit sensors, panel-data sensors and
binary sensors each use a different suffix rule — and would rot the next
time one of them changed.
@@ -32,28 +48,32 @@ async def async_added_to_hass(self) -> None:
stopped producing has taken down with it.
"""
await super().async_added_to_hass()
- field_path = self._declared_field_path()
- if field_path is not None:
+ for field_path in self._declared_field_paths():
self.coordinator.async_register_field_path_entity(field_path, self.entity_id)
async def async_will_remove_from_hass(self) -> None:
- """Stop counting this entity against its field."""
- field_path = self._declared_field_path()
- if field_path is not None:
+ """Stop counting this entity against its fields."""
+ for field_path in self._declared_field_paths():
self.coordinator.async_unregister_field_path_entity(field_path, self.entity_id)
await super().async_will_remove_from_hass()
- def _declared_field_path(self) -> str | None:
- """Return the snapshot field this entity reads, if its description declares one.
+ def _declared_field_paths(self) -> tuple[str, ...]:
+ """Return every snapshot field this entity reads.
- Platforms that carry no entity description (the circuit switch) or whose
- description declares nothing (`derived` entities, which read several
- fields or none) return None and are simply not tracked.
+ The description's `field_path` when it declares one, plus any residual
+ reads. A description that declares nothing (`derived` entities, which
+ read several fields or none) contributes nothing, and a platform with no
+ entity description at all — the circuit switch — contributes only its
+ residual reads.
"""
description: object = getattr(self, "entity_description", None)
- if not isinstance(description, FieldPathDeclarationMixin) or description.derived:
- return None
- return description.field_path
+ if (
+ isinstance(description, FieldPathDeclarationMixin)
+ and not description.derived
+ and description.field_path is not None
+ ):
+ return (description.field_path, *self._residual_field_paths)
+ return self._residual_field_paths
@staticmethod
def _build_device_info(
diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py
index 30f54042..22993eac 100644
--- a/custom_components/span_panel/select.py
+++ b/custom_components/span_panel/select.py
@@ -2,7 +2,7 @@
from collections.abc import Callable, Mapping
import logging
-from typing import Any, Final
+from typing import Any, ClassVar, Final
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
@@ -84,6 +84,16 @@ def __init__(
class SpanPanelCircuitsSelect(SpanPanelEntity, SelectEntity):
"""Represent a select entity for Span Panel circuits."""
+ # Read in entity code rather than through a description: the wrapper
+ # class is not a frozen dataclass description, so it cannot carry the
+ # declaration. `priority` is the selected option (~80); `name` and
+ # `tabs` build the display name (~125-148).
+ _residual_field_paths: ClassVar[tuple[str, ...]] = (
+ "circuit.priority",
+ "circuit.name",
+ "circuit.tabs",
+ )
+
def __init__(
self,
coordinator: SpanPanelCoordinator,
diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py
index af0a0d68..b519aa90 100644
--- a/custom_components/span_panel/sensor_circuit.py
+++ b/custom_components/span_panel/sensor_circuit.py
@@ -4,7 +4,7 @@
from collections.abc import Mapping
import logging
-from typing import Any
+from typing import Any, ClassVar
from homeassistant.helpers.device_registry import DeviceInfo
from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot
@@ -91,6 +91,17 @@ class SpanCircuitPowerSensor(
):
"""Circuit power/current/breaker-rating sensor with extra state attributes."""
+ # Beyond the value the description declares: `name` and `tabs` build the
+ # entity's identity (~56-83), and `tabs`, `relay_state`, `relay_requester`
+ # and `priority` are republished as state attributes (~231-243).
+ _residual_field_paths: ClassVar[tuple[str, ...]] = (
+ "circuit.name",
+ "circuit.tabs",
+ "circuit.relay_state",
+ "circuit.relay_requester",
+ "circuit.priority",
+ )
+
def __init__(
self,
data_coordinator: SpanPanelCoordinator,
@@ -250,6 +261,9 @@ class SpanCircuitEnergySensor(
):
"""Circuit energy sensor with grace period tracking."""
+ # Naming only; this sensor publishes no circuit attributes.
+ _residual_field_paths: ClassVar[tuple[str, ...]] = ("circuit.name", "circuit.tabs")
+
def __init__(
self,
data_coordinator: SpanPanelCoordinator,
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index e3e7a1d0..f3522630 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -714,7 +714,7 @@
},
"schema_field_unresolved": {
"title": "SPAN Panel data field unavailable",
- "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They will show as unavailable until an integration update adds support for the new firmware."
+ "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware."
},
"schema_unit_mismatch": {
"title": "SPAN Panel unit mismatch",
diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py
index 88427934..7a368e1b 100644
--- a/custom_components/span_panel/switch.py
+++ b/custom_components/span_panel/switch.py
@@ -2,7 +2,7 @@
from collections.abc import Mapping
import logging
-from typing import Any
+from typing import Any, ClassVar
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
@@ -43,6 +43,15 @@ def _unnamed_switch_fallback(circuit: SpanCircuitSnapshot, circuit_id: str) -> s
class SpanPanelCircuitsSwitch(SpanPanelEntity, SwitchEntity):
"""Represent a switch entity."""
+ # Read in entity code rather than through a description: this platform
+ # has no entity description at all. `relay_state` is the switch's own
+ # state (line ~262); `name` and `tabs` build its display name (~77-101).
+ _residual_field_paths: ClassVar[tuple[str, ...]] = (
+ "circuit.relay_state",
+ "circuit.name",
+ "circuit.tabs",
+ )
+
def __init__(
self,
coordinator: SpanPanelCoordinator,
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index e3e7a1d0..f3522630 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -714,7 +714,7 @@
},
"schema_field_unresolved": {
"title": "SPAN Panel data field unavailable",
- "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They will show as unavailable until an integration update adds support for the new firmware."
+ "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware."
},
"schema_unit_mismatch": {
"title": "SPAN Panel unit mismatch",
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 45a1b1a4..8d79397d 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -714,7 +714,7 @@
},
"schema_field_unresolved": {
"title": "Campo de datos del Panel SPAN no disponible",
- "description": "El firmware de su panel ya no proporciona `{field_path}`. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Aparecerán como no disponibles hasta que una actualización de la integración añada compatibilidad con el nuevo firmware."
+ "description": "El firmware de su panel ya no proporciona `{field_path}`. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Pueden quedar no disponibles, o informar datos incompletos, hasta que una actualización de la integración añada compatibilidad con el nuevo firmware."
},
"schema_unit_mismatch": {
"title": "Discrepancia de unidades del Panel SPAN",
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 775deb0e..512fecad 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -714,7 +714,7 @@
},
"schema_field_unresolved": {
"title": "Champ de données du Panneau SPAN indisponible",
- "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. {count} entité(s) sont concernées (par exemple : {examples}). Elles resteront indisponibles jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel."
+ "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. {count} entité(s) sont concernées (par exemple : {examples}). Elles peuvent devenir indisponibles, ou renvoyer des données incomplètes, jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel."
},
"schema_unit_mismatch": {
"title": "Incohérence d'unité du Panneau SPAN",
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 9a07cbf4..d92876aa 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -714,7 +714,7 @@
},
"schema_field_unresolved": {
"title": "SPAN パネルのデータフィールドが利用できません",
- "description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは「利用不可」と表示されます。"
+ "description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは利用不可になるか、不完全なデータを報告する可能性があります。"
},
"schema_unit_mismatch": {
"title": "SPAN パネルの単位の不一致",
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index 4b3dab29..90bacfeb 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -714,7 +714,7 @@
},
"schema_field_unresolved": {
"title": "Campo de dados do Painel SPAN indisponível",
- "description": "O firmware do seu painel já não fornece `{field_path}`. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). Ficarão indisponíveis até que uma atualização da integração adicione suporte ao novo firmware."
+ "description": "O firmware do seu painel já não fornece `{field_path}`. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). Podem ficar indisponíveis, ou comunicar dados incompletos, até que uma atualização da integração adicione suporte ao novo firmware."
},
"schema_unit_mismatch": {
"title": "Discrepância de unidades do Painel SPAN",
diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py
index adb0636f..eda1c34c 100644
--- a/tests/test_coordinator.py
+++ b/tests/test_coordinator.py
@@ -119,8 +119,17 @@ async def test_run_post_update_tasks_validates_once_and_schedules_reload(
snapshot = SpanPanelSnapshotFactory.create()
coordinator._reload_requested = True
+ # The real `_run_schema_validation` sets the guard itself, and only once it
+ # has actually produced findings; the stand-in has to emulate that or the
+ # second pass would legitimately retry. `_a_none_first_pass...` below covers
+ # the retry side.
+ def _succeed() -> None:
+ coordinator._schema_validated = True
+
with (
- patch.object(coordinator, "_run_schema_validation") as mock_validate,
+ patch.object(
+ coordinator, "_run_schema_validation", side_effect=_succeed
+ ) as mock_validate,
patch.object(coordinator, "_fire_dip_notification", AsyncMock()) as mock_notify,
patch.object(coordinator, "_async_reload_task", AsyncMock()) as mock_reload,
patch.object(hass, "async_create_task") as mock_create_task,
@@ -607,3 +616,79 @@ async def test_sync_schema_repairs_is_a_no_op_while_findings_are_unknown(
assert ir.async_get(hass).async_get_issue(
"span_panel", "unresolved_entry-unknown_circuit.instant_power_w"
)
+
+
+async def test_a_none_first_pass_does_not_disable_validation_for_the_session(
+ hass: HomeAssistant,
+) -> None:
+ """One unlucky first pass must not silence the feature until the next reload.
+
+ `field_metadata` is None for the whole not-ready / pre-rebuild window, which
+ an ordinary reconnect opens. Setting the once-only guard before validation
+ ran meant that a first pass landing in that window left findings at None
+ forever: the guard was set, later passes skipped, and the single reconcile at
+ setup had nothing to raise. Zero issues, for the life of the entry.
+ """
+ from homeassistant.helpers import issue_registry as ir
+
+ entry = MockConfigEntry(domain="span_panel", entry_id="entry-late")
+ entry.add_to_hass(hass)
+ client = MagicMock(spec=SpanPanelClientProtocol)
+ client.field_metadata = None
+ coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, client), entry)
+ coordinator.async_register_field_path_entity(
+ "circuit.instant_power_w", "sensor.kitchen_power"
+ )
+ snapshot = SpanPanelSnapshotFactory.create()
+
+ with patch.object(coordinator, "_fire_dip_notification", AsyncMock()):
+ # The pass that happens during setup's first refresh — metadata not ready.
+ await coordinator._run_post_update_tasks(snapshot)
+ assert coordinator.schema_findings is None
+ assert coordinator._schema_validated is False
+
+ # Setup finishes and reconciles; there is nothing to raise yet.
+ coordinator.async_sync_schema_repairs()
+ assert not [k for k in ir.async_get(hass).issues if k[0] == "span_panel"]
+
+ # Metadata arrives, and the panel turns out to be degraded.
+ client.field_metadata = {
+ "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)
+ }
+ for _ in range(5):
+ await coordinator._run_post_update_tasks(snapshot)
+
+ assert coordinator.schema_findings is not None
+ assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"})
+
+ issue = ir.async_get(hass).async_get_issue(
+ "span_panel", "unresolved_entry-late_circuit.instant_power_w"
+ )
+ assert issue is not None
+ assert issue.translation_placeholders["count"] == "1"
+ assert issue.translation_placeholders["examples"] == "sensor.kitchen_power"
+
+
+async def test_validation_runs_at_most_once_successfully(hass: HomeAssistant) -> None:
+ """Metadata is static within a session; re-reading it every pass is waste.
+
+ The retry above must not turn into revalidation. Once a pass has produced
+ findings, later passes leave them alone.
+ """
+ client = MagicMock(spec=SpanPanelClientProtocol)
+ client.field_metadata = {
+ "circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)
+ }
+ coordinator = _create_coordinator(hass, client=client)
+ snapshot = SpanPanelSnapshotFactory.create()
+
+ with patch.object(coordinator, "_fire_dip_notification", AsyncMock()):
+ await coordinator._run_post_update_tasks(snapshot)
+ assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"})
+
+ # A later pass sees different metadata and must not act on it.
+ client.field_metadata = {}
+ for _ in range(3):
+ await coordinator._run_post_update_tasks(snapshot)
+
+ assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"})
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index 4e525377..269255c9 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -514,25 +514,22 @@ async def test_removing_an_entity_drops_it_from_the_map(hass) -> None:
await _stop_scheduling(coordinator)
-async def test_entities_without_a_declaration_are_not_tracked(hass) -> None:
- """A derived entity must not be blamed for a field it only partly reads.
+async def test_derived_entities_are_not_tracked(hass) -> None:
+ """A derived entity must not be blamed for one of the fields it combines.
- Derived entities compute from several fields or none, and the circuit switch
- carries no entity description at all. Tracking either would attribute an
- entity to a field whose loss may not have taken it down.
+ Derived entities compute from several fields or none, so no single field's
+ loss can be said to have taken them down. They declare no `field_path` and
+ no residual reads, and must therefore land in no bucket at all.
"""
- coordinator, config_entry, _ = await _entities_by_declared_path(hass)
- try:
- from unittest.mock import MagicMock
+ from unittest.mock import MagicMock
- from custom_components.span_panel.binary_sensor import (
- BESS_CONNECTED_SENSOR,
- async_setup_entry as binary_setup,
- )
- from custom_components.span_panel.switch import (
- async_setup_entry as switch_setup,
- )
+ from custom_components.span_panel.binary_sensor import (
+ BESS_CONNECTED_SENSOR,
+ async_setup_entry as binary_setup,
+ )
+ coordinator, config_entry, _ = await _entities_by_declared_path(hass)
+ try:
assert BESS_CONNECTED_SENSOR.derived, "fixture assumes a derived description"
added = MagicMock()
@@ -544,15 +541,139 @@ async def test_entities_without_a_declaration_are_not_tracked(hass) -> None:
]
assert derived
+ await _add_to_platform(hass, config_entry, derived, "binary_sensor")
+
+ assert coordinator.entity_ids_by_field_path == {}
+ finally:
+ await _stop_scheduling(coordinator)
+
+
+async def test_a_platform_with_no_description_still_registers_its_residuals(
+ hass,
+) -> None:
+ """The circuit switch carries no entity description at all.
+
+ It is tracked purely through `_residual_field_paths`, which is the whole
+ reason that hook exists.
+ """
+ from unittest.mock import MagicMock
+
+ from custom_components.span_panel.switch import async_setup_entry as switch_setup
+
+ coordinator, config_entry, _ = await _entities_by_declared_path(hass)
+ try:
added = MagicMock()
await switch_setup(hass, config_entry, added)
switches = list(added.call_args.args[0])
assert switches
assert not hasattr(switches[0], "entity_description")
- await _add_to_platform(hass, config_entry, derived, "binary_sensor")
await _add_to_platform(hass, config_entry, switches, "switch")
- assert coordinator.entity_ids_by_field_path == {}
+ expected = sorted(s.entity_id for s in switches)
+ assert coordinator.entity_ids_by_field_path == {
+ "circuit.relay_state": expected,
+ "circuit.name": expected,
+ "circuit.tabs": expected,
+ }
finally:
await _stop_scheduling(coordinator)
+
+
+# --- Residual reads -------------------------------------------------------
+#
+# Five field paths are read from entity code rather than from a description's
+# `field_path`: the switch's relay state, the select's priority, and the name,
+# tabs and relay requester a circuit entity uses for its identity and its
+# attributes. `RESIDUAL_FIELD_PATHS` lists them for the producible gate. Nothing
+# declared them on the entities, so a dead `circuit.relay_state` reported "0
+# entity/entities are affected" while every breaker switch on the panel was out.
+
+
+async def test_a_dead_relay_state_names_the_breaker_switches(hass) -> None:
+ """The residual set must not reproduce the "0 affected" lie."""
+ from unittest.mock import MagicMock
+
+ from custom_components.span_panel.switch import async_setup_entry as switch_setup
+
+ coordinator, config_entry, _ = await _entities_by_declared_path(hass)
+ try:
+ added = MagicMock()
+ await switch_setup(hass, config_entry, added)
+ switches = list(added.call_args.args[0])
+ assert len(switches) == 2, "fixture should build one switch per circuit"
+
+ await _add_to_platform(hass, config_entry, switches, "switch")
+
+ affected = coordinator.entity_ids_by_field_path
+ assert affected["circuit.relay_state"] == sorted(s.entity_id for s in switches)
+
+ async_sync_schema_issues(
+ hass,
+ config_entry,
+ SchemaFindings(frozenset({"circuit.relay_state"}), (), frozenset()),
+ affected,
+ )
+ issue = ir.async_get(hass).async_get_issue(
+ DOMAIN, f"unresolved_{config_entry.entry_id}_circuit.relay_state"
+ )
+ assert issue is not None
+ assert issue.translation_placeholders["count"] == "2"
+ assert issue.translation_placeholders["examples"] != "none"
+ finally:
+ await _stop_scheduling(coordinator)
+
+
+async def test_a_dead_priority_names_the_selects(hass) -> None:
+ """The select's own state comes from `circuit.priority`."""
+ from unittest.mock import MagicMock
+
+ from custom_components.span_panel.select import async_setup_entry as select_setup
+
+ coordinator, config_entry, _ = await _entities_by_declared_path(hass)
+ try:
+ added = MagicMock()
+ await select_setup(hass, config_entry, added)
+ selects = list(added.call_args.args[0])
+ assert selects
+
+ await _add_to_platform(hass, config_entry, selects, "select")
+
+ affected = coordinator.entity_ids_by_field_path
+ assert affected["circuit.priority"] == sorted(s.entity_id for s in selects)
+ finally:
+ await _stop_scheduling(coordinator)
+
+
+def test_every_residual_field_path_is_claimed_by_an_entity() -> None:
+ """No residual read may be left with nothing to name.
+
+ `RESIDUAL_FIELD_PATHS` exists because these reads live in entity code rather
+ than on a description. That is exactly why they cannot be discovered — so
+ each one is declared on the entity that makes it, and this pins the two lists
+ together. A new residual entry with no declaring entity would otherwise ship
+ a Repair that says "0 affected" when the answer is "all of them".
+ """
+ from custom_components.span_panel.field_paths import RESIDUAL_FIELD_PATHS
+ from custom_components.span_panel.select import SpanPanelCircuitsSelect
+ from custom_components.span_panel.sensor_circuit import (
+ SpanCircuitEnergySensor,
+ SpanCircuitPowerSensor,
+ )
+ from custom_components.span_panel.switch import SpanPanelCircuitsSwitch
+
+ declared: set[str] = set()
+ for entity_class in (
+ SpanPanelCircuitsSwitch,
+ SpanPanelCircuitsSelect,
+ SpanCircuitPowerSensor,
+ SpanCircuitEnergySensor,
+ ):
+ declared.update(entity_class._residual_field_paths)
+
+ assert RESIDUAL_FIELD_PATHS <= declared, (
+ f"undeclared residual reads: {RESIDUAL_FIELD_PATHS - declared}"
+ )
+ assert declared <= RESIDUAL_FIELD_PATHS, (
+ f"declared but not in the residual set: {declared - RESIDUAL_FIELD_PATHS}"
+ )
From e35bba9c24dddc5f3da8215b0198fa87f822870a Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 02:36:39 -0700
Subject: [PATCH 034/116] feat: mark entities unavailable when their field is
unresolved
An entity whose declared snapshot field the adapter cannot resolve now
reports unavailable instead of publishing a parsed default. The probe lives
on SpanPanelEntity so binary sensors are covered too, and runs ahead of the
grace-period branches in SpanSensorBase and SpanPanelBinarySensor, which
return True on their own.
---
custom_components/span_panel/binary_sensor.py | 10 +
custom_components/span_panel/entity.py | 40 +++
custom_components/span_panel/sensor_base.py | 6 +
tests/test_schema_availability.py | 265 ++++++++++++++++++
4 files changed, 321 insertions(+)
create mode 100644 tests/test_schema_availability.py
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index f4f80fb0..6c3f4e7d 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -197,7 +197,17 @@ def available(self) -> bool:
- Panel status sensor: always available to show online/offline state
- Hardware status sensors: remain available when offline to show Unknown state
- Other binary sensors (switches): become unavailable when panel is offline
+
+ The unresolved-field probe runs ahead of all of that, for the same
+ reason it precedes the grace-period branch in `SpanSensorBase`: the
+ offline branch below returns True, so a probe after it would leave
+ `door_state`, `eth0_link` and `wlan_link` reporting a field the adapter
+ cannot resolve. `panel_status` and the derived sensors declare no
+ `field_path`, so the probe never fires for them.
"""
+ if self._reads_an_unresolved_field:
+ return False
+
# Panel status sensor should always be available to show online/offline state
if hasattr(self.entity_description, "key") and self.entity_description.key == PANEL_STATUS:
return True
diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py
index e4fcca26..291807c3 100644
--- a/custom_components/span_panel/entity.py
+++ b/custom_components/span_panel/entity.py
@@ -75,6 +75,46 @@ def _declared_field_paths(self) -> tuple[str, ...]:
return (description.field_path, *self._residual_field_paths)
return self._residual_field_paths
+ @property
+ def _reads_an_unresolved_field(self) -> bool:
+ """True when this entity's own source field could not be resolved.
+
+ An O(1) membership test against a set that is empty on a healthy panel,
+ so the normal case costs one hash lookup per availability read.
+
+ Only the description's `field_path` counts -- the entity's value source.
+ Residual paths are deliberately excluded: `circuit.name` and
+ `circuit.tabs` feed naming and attributes, and a circuit's power reading
+ is still true when they are gone. The switch and select read their state
+ through residual paths and so are not covered here; the Repair still
+ names them.
+ """
+ description: object = getattr(self, "entity_description", None)
+ if (
+ isinstance(description, FieldPathDeclarationMixin)
+ and not description.derived
+ and description.field_path is not None
+ ):
+ return description.field_path in self.coordinator.unresolved_paths
+ return False
+
+ @property
+ def available(self) -> bool:
+ """False when this entity's snapshot field could not be resolved.
+
+ Any value we would report for an unresolvable field is a default rather
+ than a reading, and a default is indistinguishable from a real one at
+ the dashboard. Reporting unavailable is reporting, not correcting: the
+ entity keeps its shape and comes back when the field does.
+
+ This covers the resolution-failure case only. A field the adapter
+ resolves but the device stops publishing still reaches HA as a parsed
+ default; that needs the snapshot model to admit None.
+ """
+ if self._reads_an_unresolved_field:
+ return False
+ return super().available
+
@staticmethod
def _build_device_info(
coordinator: SpanPanelCoordinator,
diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py
index 1d86f1d1..88dbf41f 100644
--- a/custom_components/span_panel/sensor_base.py
+++ b/custom_components/span_panel/sensor_base.py
@@ -321,7 +321,13 @@ def available(self) -> bool:
Keep entities available during a panel_offline condition so sensors can show
their grace period state (last_valid_state) or None when grace period expires.
+
+ The unresolved-field probe runs first: the grace-period branch below
+ returns True unconditionally, so probing after it would let every
+ offline sensor keep reporting a field the adapter cannot resolve.
"""
+ if self._reads_an_unresolved_field:
+ return False
try:
if getattr(self.coordinator, "panel_offline", False):
return True
diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py
new file mode 100644
index 00000000..86185678
--- /dev/null
+++ b/tests/test_schema_availability.py
@@ -0,0 +1,265 @@
+"""An entity whose declared field could not be resolved reports unavailable.
+
+The Repairs from `schema_repairs` explain and aggregate; this is the same fact
+told where a user actually looks. It also mitigates the user-visible symptom of
+the "absent property parses to 0.0 rather than None" defect *for the
+resolution-failure case*: a field the adapter cannot resolve at all would
+otherwise render as a live zero, which for a TOTAL_INCREASING energy sensor
+reads as a counter reset. It does nothing for a field that resolves but stops
+being published at runtime -- that needs the snapshot model to admit None.
+"""
+
+from __future__ import annotations
+
+from typing import cast
+from unittest.mock import MagicMock
+
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from span_panel_api import SpanMqttClient, SpanPanelSnapshot
+
+from custom_components.span_panel import (
+ SpanPanelRuntimeData,
+ binary_sensor,
+ button,
+ select,
+ sensor_base,
+ switch,
+)
+from custom_components.span_panel.binary_sensor import (
+ BESS_CONNECTED_SENSOR,
+ BINARY_SENSORS,
+ EVSE_BINARY_SENSORS,
+ SpanEvseBinarySensor,
+ SpanPanelBinarySensor,
+)
+from custom_components.span_panel.const import (
+ CONF_DEVICE_NAME,
+ PANEL_STATUS,
+ SYSTEM_ETHERNET_LINK,
+)
+from custom_components.span_panel.coordinator import SpanPanelCoordinator
+from custom_components.span_panel.entity import SpanPanelEntity
+from custom_components.span_panel.schema_validation import SchemaFindings
+from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor
+from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS
+
+from .factories import (
+ SpanCircuitSnapshotFactory,
+ SpanEvseSnapshotFactory,
+ SpanPanelSnapshotFactory,
+)
+
+_CIRCUIT_POWER_PATH = "circuit.instant_power_w"
+_ETHERNET_LINK_PATH = "panel.eth0_link"
+
+
+def _make_coordinator(hass: HomeAssistant) -> SpanPanelCoordinator:
+ """Return a real coordinator, so `unresolved_paths` is the real property.
+
+ A MagicMock would answer `_findings` with a mock and `unresolved_paths` with
+ a mock whose `__contains__` is False -- the probe would look correct while
+ never firing.
+ """
+ snapshot = SpanPanelSnapshotFactory.create(
+ circuits={"c1": SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen")}
+ )
+ entry = MockConfigEntry(
+ domain="span_panel",
+ data={CONF_HOST: "192.168.1.50", CONF_DEVICE_NAME: "SPAN Panel"},
+ options={},
+ title="SPAN Panel",
+ unique_id=snapshot.serial_number,
+ )
+ entry.add_to_hass(hass)
+ coordinator = SpanPanelCoordinator(hass, cast(SpanMqttClient, MagicMock()), entry)
+ coordinator.data = snapshot
+ entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+ return coordinator
+
+
+def _snapshot(coordinator: SpanPanelCoordinator) -> SpanPanelSnapshot:
+ return coordinator.data
+
+
+def _circuit_power_entity(coordinator: SpanPanelCoordinator) -> SpanCircuitPowerSensor:
+ """Build a sensor reading `circuit.instant_power_w` -- the `SpanSensorBase` branch."""
+ description = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_power")
+ assert description.field_path == _CIRCUIT_POWER_PATH
+ return SpanCircuitPowerSensor(coordinator, description, _snapshot(coordinator), "c1")
+
+
+def _ethernet_link_entity(coordinator: SpanPanelCoordinator) -> SpanPanelBinarySensor:
+ """Build a binary sensor reading `panel.eth0_link` -- the `SpanPanelEntity` branch.
+
+ Binary sensors do not inherit `SpanSensorBase`, so this is the second base
+ class the probe has to live on.
+ """
+ description = next(desc for desc in BINARY_SENSORS if desc.key == SYSTEM_ETHERNET_LINK)
+ assert description.field_path == _ETHERNET_LINK_PATH
+ return SpanPanelBinarySensor(coordinator, description)
+
+
+async def test_sensor_unavailable_when_its_field_is_unresolved(hass: HomeAssistant) -> None:
+ """A dead field must not render as a live 0.0."""
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset({_CIRCUIT_POWER_PATH}), (), frozenset())
+
+ assert _circuit_power_entity(coordinator).available is False
+
+
+async def test_sensor_available_when_findings_are_clean(hass: HomeAssistant) -> None:
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset(), (), frozenset())
+
+ assert _circuit_power_entity(coordinator).available is True
+
+
+async def test_sensor_available_while_findings_are_unknown(hass: HomeAssistant) -> None:
+ """No validation pass has completed yet; that is not a reason to go dark."""
+ coordinator = _make_coordinator(hass)
+ assert coordinator.schema_findings is None
+
+ assert _circuit_power_entity(coordinator).available is True
+
+
+async def test_sensor_unavailable_when_unresolved_and_panel_offline(
+ hass: HomeAssistant,
+) -> None:
+ """The probe must precede the grace-period branch.
+
+ `SpanSensorBase.available` returns True while `panel_offline` so the sensor
+ can show its grace-period state. Probing after that check would let every
+ offline sensor report a resolved-looking value for a field that is gone.
+ """
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset({_CIRCUIT_POWER_PATH}), (), frozenset())
+ coordinator._panel_offline = True
+
+ assert coordinator.panel_offline is True
+ assert _circuit_power_entity(coordinator).available is False
+
+
+async def test_sensor_unaffected_by_an_unrelated_unresolved_field(
+ hass: HomeAssistant,
+) -> None:
+ """Only the entity's own field counts; a neighbour's failure is not its own."""
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset())
+
+ assert _circuit_power_entity(coordinator).available is True
+
+
+async def test_binary_sensor_unavailable_when_its_field_is_unresolved(
+ hass: HomeAssistant,
+) -> None:
+ """`SpanPanelBinarySensor` extends `SpanPanelEntity`, not `SpanSensorBase`."""
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset())
+
+ assert _ethernet_link_entity(coordinator).available is False
+
+
+async def test_binary_sensor_available_when_findings_are_clean(hass: HomeAssistant) -> None:
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset(), (), frozenset())
+
+ assert _ethernet_link_entity(coordinator).available is True
+
+
+async def test_binary_sensor_unavailable_when_unresolved_and_panel_offline(
+ hass: HomeAssistant,
+) -> None:
+ """`SpanPanelBinarySensor` has its own offline branch that returns True.
+
+ The hardware-status sensors stay available while the panel is offline so
+ they can show Unknown. That branch bypasses `super().available`, so the
+ probe has to run ahead of it here too -- the same ordering `SpanSensorBase`
+ needs.
+ """
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset())
+ coordinator._panel_offline = True
+
+ assert _ethernet_link_entity(coordinator).available is False
+
+
+async def test_panel_status_binary_sensor_is_never_probed(hass: HomeAssistant) -> None:
+ """It reports coordinator reachability, not a snapshot field."""
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset())
+ description = next(desc for desc in BINARY_SENSORS if desc.key == PANEL_STATUS)
+ assert description.derived is True
+
+ entity = SpanPanelBinarySensor(coordinator, description)
+
+ assert entity.available is True
+
+
+async def test_derived_entity_is_never_probed(hass: HomeAssistant) -> None:
+ """A derived entity declares no source field, so nothing can unresolve it.
+
+ `bess_connected` reads `battery.connected`, which only one adapter
+ publishes -- exactly why the description is `derived`. Probing a derived
+ description would make availability depend on a path it never declared.
+ """
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset({"battery.connected"}), (), frozenset())
+ assert BESS_CONNECTED_SENSOR.derived is True
+
+ entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR)
+
+ assert entity.available is True
+
+
+async def test_evse_binary_sensor_is_covered_by_the_base_class(hass: HomeAssistant) -> None:
+ """`SpanEvseBinarySensor` defines no `available`, so it exercises `entity.py`.
+
+ The circuit sensor goes through `SpanSensorBase.available` and the panel
+ binary sensor through `SpanPanelBinarySensor.available`; neither reaches the
+ override on `SpanPanelEntity` itself. This one does.
+ """
+ coordinator = _make_coordinator(hass)
+ coordinator.data = SpanPanelSnapshotFactory.create(
+ evse={"evse-0": SpanEvseSnapshotFactory.create()}
+ )
+ coordinator._findings = SchemaFindings(frozenset({"evse.status"}), (), frozenset())
+ description = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == "evse_charging")
+ assert description.field_path == "evse.status"
+ assert "available" not in vars(SpanEvseBinarySensor)
+
+ entity = SpanEvseBinarySensor(coordinator, description, "evse-0")
+
+ assert entity.available is False
+
+
+def test_every_available_override_is_accounted_for() -> None:
+ """A new `available` override must decide where the probe sits.
+
+ `SpanSensorBase` and `SpanPanelBinarySensor` return True on their own
+ before delegating, so each carries the probe ahead of that branch. The
+ switch, select and button return False when the panel is offline and
+ otherwise delegate, so the override on `SpanPanelEntity` covers them.
+ Anything new in this list has to answer the same question, and this
+ assertion is what forces it to be asked.
+ """
+ overriders = {
+ cls.__name__
+ for module in (binary_sensor, button, select, sensor_base, switch)
+ for cls in vars(module).values()
+ if isinstance(cls, type)
+ and issubclass(cls, SpanPanelEntity)
+ and cls is not SpanPanelEntity # the base itself, imported into each module
+ and "available" in vars(cls)
+ }
+
+ assert overriders == {
+ "SpanSensorBase",
+ "SpanPanelBinarySensor",
+ "SpanPanelCircuitsSwitch",
+ "SpanPanelGFEOverrideButton",
+ "SpanPanelCircuitsSelect",
+ }
From 47d50e8e9fb42c9cab948485865832150671d151 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 02:55:39 -0700
Subject: [PATCH 035/116] fix: evse_ev_connected declares evse.status instead
of claiming derived
It reads exactly one field that both adapters produce, which is a
declaration. Classified derived, it was skipped by _declared_field_paths:
the Repair for a dead evse.status never named it and the availability probe
never fired for it, while its sibling evse_charging got both from the same
field. The derived docstring now states the rule as a countable test, and a
new introspection test fails any derived description that reads exactly one
producible field.
---
custom_components/span_panel/binary_sensor.py | 2 +-
custom_components/span_panel/field_paths.py | 28 ++++++++++--
tests/test_field_path_introspection.py | 45 +++++++++++++++++++
tests/test_schema_availability.py | 12 ++++-
4 files changed, 80 insertions(+), 7 deletions(-)
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 6c3f4e7d..90a35e08 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -327,7 +327,7 @@ class SpanEvseBinarySensorEntityDescription(
),
SpanEvseBinarySensorEntityDescription(
key="evse_ev_connected",
- derived=True,
+ field_path="evse.status",
translation_key="evse_ev_connected",
device_class=BinarySensorDeviceClass.PLUG,
value_fn=lambda e: (e.status or "") in _EV_CONNECTED_STATUSES,
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 7031abdf..8a8ac2ea 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -43,10 +43,30 @@ class FieldPathDeclarationMixin:
"""
derived: bool = False
- """True when the value is computed from several fields, or none.
-
- Derived entities have no single source field, so they are exempt from the
- producible check.
+ """True only when there is no single source field to declare.
+
+ Exactly one of these three must hold, and the test is mechanical — count the
+ snapshot fields the `value_fn` reads:
+
+ 1. it reads **no** snapshot field, or
+ 2. it reads **more than one**, or
+ 3. it reads exactly one that no adapter, or only one adapter, produces.
+
+ Reading exactly one producible field is **always** a declaration, however
+ much arithmetic, mapping or membership-testing is applied on top. "Computed
+ from `status`" is not derivation: `field_path="evse.status"` with a
+ `value_fn` of `status in {...}` declares its source correctly and still
+ computes whatever it likes. `evse_ev_connected` was misclassified this way,
+ which cost it both a Repair mention and its unavailability, while its
+ sibling `evse_charging` — same field, same shape — got both.
+
+ Case 3 keeps the producible gate satisfiable: it requires a path both
+ adapters emit, so a schema-conditional field cannot be declared. Those are
+ listed in `RESIDUAL_EXEMPT_PATHS` when the integration reads them outside a
+ description.
+
+ `test_no_derived_description_reads_one_producible_field` enforces this rule
+ against every derived description rather than against any one instance.
"""
diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py
index 826f028b..89bf2740 100644
--- a/tests/test_field_path_introspection.py
+++ b/tests/test_field_path_introspection.py
@@ -14,6 +14,7 @@
declared_field_paths,
)
from custom_components.span_panel.sensor_definitions import all_sensor_descriptions
+from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata
# Attributes of the panel snapshot that are themselves sub-snapshots. Their
# fields are addressed as "battery.x", not "panel.battery.x".
@@ -189,3 +190,47 @@ def test_introspection_covers_every_declared_path() -> None:
if not description.derived and description.field_path is not None
}
assert declared_field_paths() == frozenset(introspected | set(RESIDUAL_FIELD_PATHS))
+
+
+def test_no_derived_description_reads_one_producible_field() -> None:
+ """`derived` must mean no field, several fields, or an unproducible one.
+
+ Pins the rule, not the instance that broke it. `evse_ev_connected` read
+ exactly `evse.status` — one field both adapters produce — while declaring
+ itself derived, so `_declared_field_paths` skipped it: the Repair for a dead
+ `evse.status` never named it and the availability probe never fired for it,
+ though its sibling `evse_charging` got both from the very same field.
+
+ Producibility is what makes this checkable: the recorder also picks up
+ method names and other noise, and intersecting with what both adapters
+ actually emit leaves only real fields.
+ """
+ producible = set(schema_zero_metadata()) & set(schema_one_metadata())
+ offenders: list[str] = []
+
+ for description in _declaring_descriptions():
+ if not description.derived:
+ continue
+ class_name = type(description).__name__
+ prefix = _ROOT_PREFIX.get(class_name)
+ if prefix is None:
+ offenders.append(
+ f"{description.key}: {class_name} is absent from _ROOT_PREFIX, so its "
+ "derived classification would go unverified"
+ )
+ continue
+ sink: set[str] = set()
+ proxy = _Recorder(sink, prefix, root=(prefix == "panel"))
+ try:
+ description.value_fn(proxy)
+ except Exception as err: # noqa: BLE001
+ offenders.append(f"{description.key}: value_fn raised {err!r}")
+ continue
+ read = sorted(sink & producible)
+ if len(read) == 1:
+ offenders.append(
+ f"{description.key}: derived=True but reads exactly one producible field, "
+ f"{read[0]!r} — that is a declaration, so set field_path={read[0]!r}"
+ )
+
+ assert not offenders, "Misclassified derived descriptions:\n" + "\n".join(offenders)
diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py
index 86185678..c2400fe1 100644
--- a/tests/test_schema_availability.py
+++ b/tests/test_schema_availability.py
@@ -16,6 +16,7 @@
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
+import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from span_panel_api import SpanMqttClient, SpanPanelSnapshot
@@ -215,19 +216,26 @@ async def test_derived_entity_is_never_probed(hass: HomeAssistant) -> None:
assert entity.available is True
-async def test_evse_binary_sensor_is_covered_by_the_base_class(hass: HomeAssistant) -> None:
+@pytest.mark.parametrize("key", ["evse_charging", "evse_ev_connected"])
+async def test_evse_binary_sensor_is_covered_by_the_base_class(
+ hass: HomeAssistant, key: str
+) -> None:
"""`SpanEvseBinarySensor` defines no `available`, so it exercises `entity.py`.
The circuit sensor goes through `SpanSensorBase.available` and the panel
binary sensor through `SpanPanelBinarySensor.available`; neither reaches the
override on `SpanPanelEntity` itself. This one does.
+
+ Both EVSE binary sensors read `evse.status`, so both must answer the same
+ way. `evse_ev_connected` used to be `derived=True` and so stayed available
+ while its sibling went dark on the very same dead field.
"""
coordinator = _make_coordinator(hass)
coordinator.data = SpanPanelSnapshotFactory.create(
evse={"evse-0": SpanEvseSnapshotFactory.create()}
)
coordinator._findings = SchemaFindings(frozenset({"evse.status"}), (), frozenset())
- description = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == "evse_charging")
+ description = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == key)
assert description.field_path == "evse.status"
assert "available" not in vars(SpanEvseBinarySensor)
From cb1b776da8f5a9c80ab6bc89054ce630962a6653 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 09:51:11 -0700
Subject: [PATCH 036/116] chore: point editable sources at the base library
worktree
The merged branch developed against a temporary surgery worktree. The base
branch must depend on the durable one, which now carries the same merged
library work.
---
pyproject.toml | 8 ++++----
uv.lock | 20 ++++++++++----------
2 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index b042b9c5..56a165ac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,9 +47,9 @@ package = false
[tool.uv.sources]
# This branch develops against the in-progress schema-1 adapter, which lives in a
# worktree rather than the primary checkout the symlink points at.
-span-panel-api = { path = "../../span/span-panel-api-p3", editable = true }
-span-panel-api-schema-0 = { path = "../../span/span-panel-api-p3/packages/schema-0", editable = true }
-span-panel-api-schema-1 = { path = "../../span/span-panel-api-p3/packages/schema-1", editable = true }
+span-panel-api = { path = "../../span/span-panel-api-p2", editable = true }
+span-panel-api-schema-0 = { path = "../../span/span-panel-api-p2/packages/schema-0", editable = true }
+span-panel-api-schema-1 = { path = "../../span/span-panel-api-p2/packages/schema-1", editable = true }
[tool.jscpd]
path = ["custom_components/span_panel", "./*.{html,md}"]
@@ -129,7 +129,7 @@ exclude = [
]
extraPaths = [
"./custom_components",
- "../../span/span-panel-api-p3/src",
+ "../../span/span-panel-api-p2/src",
"../ha-synthetic-sensors/src"
]
pythonPlatform = "Darwin"
diff --git a/uv.lock b/uv.lock
index 87d23906..949093d8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2523,9 +2523,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "homeassistant", specifier = "==2026.8.0" },
- { name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
- { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" },
- { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" },
+ { name = "span-panel-api", editable = "../../span/span-panel-api-p2" },
+ { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" },
+ { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" },
]
[package.metadata.requires-dev]
@@ -2551,7 +2551,7 @@ dev = [
[[package]]
name = "span-panel-api"
version = "3.0.0b4"
-source = { editable = "../../span/span-panel-api-p3" }
+source = { editable = "../../span/span-panel-api-p2" }
dependencies = [
{ name = "httpx" },
{ name = "paho-mqtt" },
@@ -2578,8 +2578,8 @@ dev = [
{ name = "pytest-cov" },
{ name = "radon" },
{ name = "ruff", specifier = ">=0.15.5" },
- { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" },
- { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" },
+ { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" },
+ { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" },
{ name = "twine", specifier = ">=7.0" },
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
{ name = "vulture", specifier = ">=2.14" },
@@ -2588,18 +2588,18 @@ dev = [
[[package]]
name = "span-panel-api-schema-0"
version = "1.0.0b4"
-source = { editable = "../../span/span-panel-api-p3/packages/schema-0" }
+source = { editable = "../../span/span-panel-api-p2/packages/schema-0" }
dependencies = [
{ name = "span-panel-api" },
]
[package.metadata]
-requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" }]
+requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p2" }]
[[package]]
name = "span-panel-api-schema-1"
version = "0.1.0b4"
-source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
+source = { editable = "../../span/span-panel-api-p2/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
{ name = "span-panel-api" },
@@ -2608,7 +2608,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "ebus-sdk", specifier = ">=0.19.0,<1.0" },
- { name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
+ { name = "span-panel-api", editable = "../../span/span-panel-api-p2" },
]
[[package]]
From 6cdbaeb793e9623e05a56aedc39c1f6f7919ce3f Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 12:17:41 -0700
Subject: [PATCH 037/116] fix(repairs): do not raise a Repair nothing the user
owns is affected by
Both schema Repairs claim something the user owns is broken: a sensor is
dead, or a reading may be wrong. A field path no enabled entity reads
supports neither claim.
`vendor_cloud` is `entity_registry_enabled_default=False`, so it is
registered and never added to hass and never self-registers with the
coordinator. A fresh install against the flat simulator therefore raised
"`panel.vendor_cloud` ... 0 entity/entities are affected (for example:
none)" beside two genuine notices, which is how a category of Repair
gets ignored.
Both classes now look up the affected entities first and skip the finding
when there are none, logging the suppressed path at debug. The unit
mismatch class did not look them up at all before. A suppressed path
never enters `wanted`, so the existing reconcile sweep deletes an issue
whose last affected entity has gone rather than leaving it orphaned.
The unit mismatch notice now carries the same count and bounded examples
as the unresolved one; every locale already had the fragment translated
for the other message. The `span_panel_schema_issue` payload now lists
the paths that actually raised, so a partly suppressed pass cannot name
a path that a fully suppressed pass omits entirely.
---
.../span_panel/schema_repairs.py | 74 +++++-
custom_components/span_panel/strings.json | 2 +-
.../span_panel/translations/en.json | 2 +-
.../span_panel/translations/es.json | 2 +-
.../span_panel/translations/fr.json | 2 +-
.../span_panel/translations/ja.json | 2 +-
.../span_panel/translations/pt.json | 2 +-
tests/test_schema_repairs.py | 238 +++++++++++++++---
8 files changed, 270 insertions(+), 54 deletions(-)
diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py
index dd780c84..9a52d2fe 100644
--- a/custom_components/span_panel/schema_repairs.py
+++ b/custom_components/span_panel/schema_repairs.py
@@ -7,11 +7,18 @@
A third condition, a produced field nothing reads, is a sanctioned addition and
stays in the debug log.
+
+Every Repair here claims something the user owns is broken, so a finding whose
+field path no enabled entity reads is not raised at all. Disabled-by-default
+descriptions are registered but never added to hass, so they never self-register
+with the coordinator and a finding against one has nothing to name; telling the
+user "0 entity/entities are affected" only teaches them to ignore the category.
"""
from __future__ import annotations
from collections.abc import Mapping
+import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@@ -20,6 +27,8 @@
from .const import DOMAIN, EVENT_SCHEMA_ISSUE
from .schema_validation import SchemaFindings
+_LOGGER = logging.getLogger(__name__)
+
_MAX_EXAMPLES = 3
@@ -31,6 +40,35 @@ def _unit_id(entry_id: str, field_path: str) -> str:
return f"unit_mismatch_{entry_id}_{field_path}"
+def _affected(entity_ids_by_path: Mapping[str, list[str]], field_path: str) -> list[str]:
+ """Entities in hass that read this field, sorted.
+
+ Sorted so an unchanged panel produces an unchanged payload: the update branch
+ only rewrites the entry when something actually differs.
+ """
+ return sorted(entity_ids_by_path.get(field_path, []))
+
+
+def _log_suppressed(condition: str, field_path: str) -> None:
+ _LOGGER.debug(
+ "Suppressed %s Repair for %s: no enabled entity reads that field path, so "
+ "nothing the user owns is affected",
+ condition,
+ field_path,
+ )
+
+
+def _placeholders(affected: list[str]) -> dict[str, str]:
+ """Count plus a bounded sample, never the full list.
+
+ One missing `circuit.instant_power_w` affects every circuit on the panel.
+ """
+ return {
+ "count": str(len(affected)),
+ "examples": ", ".join(affected[:_MAX_EXAMPLES]),
+ }
+
+
@callback
def async_sync_schema_issues(
hass: HomeAssistant,
@@ -45,6 +83,11 @@ def async_sync_schema_issues(
user who dismissed an aggregate would never be told when another field
joined it.
+ A finding with no affected entity is suppressed rather than raised, and a
+ suppressed path leaves `wanted`, so an issue raised while entities existed is
+ deleted once the last one goes — the same path that clears a genuinely
+ resolved finding.
+
Re-raises idempotently and deletes only on genuine resolution. Deleting is
the one thing that clears a dismissal — `dismissed_version` is never
compared against the running HA version, and neither the update branch nor
@@ -54,13 +97,17 @@ def async_sync_schema_issues(
"""
registry = ir.async_get(hass)
wanted: set[str] = set()
+ raised_unresolved: list[str] = []
+ raised_mismatches: list[str] = []
for field_path in sorted(findings.unresolved):
+ affected = _affected(entity_ids_by_path, field_path)
+ if not affected:
+ _log_suppressed("unresolved-field", field_path)
+ continue
issue_id = _unresolved_id(entry.entry_id, field_path)
wanted.add(issue_id)
- # Sorted so an unchanged panel produces an unchanged payload: the update
- # branch only rewrites the entry when something actually differs.
- affected = sorted(entity_ids_by_path.get(field_path, []))
+ raised_unresolved.append(field_path)
ir.async_create_issue(
hass,
DOMAIN,
@@ -73,18 +120,17 @@ def async_sync_schema_issues(
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key="schema_field_unresolved",
- translation_placeholders={
- "field_path": field_path,
- # A count plus a few examples, never the full list: one missing
- # `circuit.instant_power_w` affects every circuit on the panel.
- "count": str(len(affected)),
- "examples": ", ".join(affected[:_MAX_EXAMPLES]) or "none",
- },
+ translation_placeholders={"field_path": field_path, **_placeholders(affected)},
)
for mismatch in findings.unit_mismatches:
+ affected = _affected(entity_ids_by_path, mismatch.field_path)
+ if not affected:
+ _log_suppressed("unit-mismatch", mismatch.field_path)
+ continue
issue_id = _unit_id(entry.entry_id, mismatch.field_path)
wanted.add(issue_id)
+ raised_mismatches.append(mismatch.field_path)
ir.async_create_issue(
hass,
DOMAIN,
@@ -97,6 +143,7 @@ def async_sync_schema_issues(
"field_path": mismatch.field_path,
"ha_unit": mismatch.ha_unit,
"schema_unit": mismatch.schema_unit,
+ **_placeholders(affected),
},
)
@@ -104,12 +151,15 @@ def async_sync_schema_issues(
ir.async_delete_issue(hass, DOMAIN, issue_id)
if wanted:
+ # The event mirrors what the user was actually told: a suppressed finding
+ # is deliberately not user-facing, and an automation reacting to one
+ # would be reacting to a defect that took nothing down.
hass.bus.async_fire(
EVENT_SCHEMA_ISSUE,
{
"entry_id": entry.entry_id,
- "unresolved": sorted(findings.unresolved),
- "unit_mismatches": [m.field_path for m in findings.unit_mismatches],
+ "unresolved": raised_unresolved,
+ "unit_mismatches": raised_mismatches,
},
)
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index f3522630..76f25f4b 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -718,7 +718,7 @@
},
"schema_unit_mismatch": {
"title": "SPAN Panel unit mismatch",
- "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Readings and long-term statistics for the affected entities may be wrong. The entity has not been changed automatically."
+ "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. {count} entity/entities are affected (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
}
}
}
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index f3522630..76f25f4b 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -718,7 +718,7 @@
},
"schema_unit_mismatch": {
"title": "SPAN Panel unit mismatch",
- "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Readings and long-term statistics for the affected entities may be wrong. The entity has not been changed automatically."
+ "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. {count} entity/entities are affected (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
}
}
}
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 8d79397d..fdf1faea 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -718,7 +718,7 @@
},
"schema_unit_mismatch": {
"title": "Discrepancia de unidades del Panel SPAN",
- "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Las lecturas y las estadísticas a largo plazo de las entidades afectadas pueden ser incorrectas. La entidad no se ha modificado automáticamente."
+ "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente."
}
}
}
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 512fecad..3bb0dab4 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -718,7 +718,7 @@
},
"schema_unit_mismatch": {
"title": "Incohérence d'unité du Panneau SPAN",
- "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Les relevés et les statistiques à long terme des entités concernées peuvent être erronés. L'entité n'a pas été modifiée automatiquement."
+ "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. {count} entité(s) sont concernées (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement."
}
}
}
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index d92876aa..8e017e2d 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -718,7 +718,7 @@
},
"schema_unit_mismatch": {
"title": "SPAN パネルの単位の不一致",
- "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。影響を受けるエンティティの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。"
+ "description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。{count} 個のエンティティが影響を受けます (例: {examples})。これらの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。"
}
}
}
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index 90bacfeb..ee392d75 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -718,7 +718,7 @@
},
"schema_unit_mismatch": {
"title": "Discrepância de unidades do Painel SPAN",
- "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. As leituras e as estatísticas de longo prazo das entidades afetadas podem estar erradas. A entidade não foi alterada automaticamente."
+ "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente."
}
}
}
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index 269255c9..946d8bb4 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import logging
+
from homeassistant.helpers import issue_registry as ir
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -12,6 +14,13 @@
from custom_components.span_panel.schema_validation import SchemaFindings, UnitMismatch
_PATH = "circuit.instant_power_w"
+_UNIT_PATH = "panel.l1_voltage"
+
+# Both Repairs claim something the user owns is broken, so nothing is raised for a
+# field path no enabled entity reads. Every call below that expects an issue has
+# to name the entities the finding took down.
+_AFFECTED = {_PATH: ["sensor.a"]}
+_UNIT_AFFECTED = {_UNIT_PATH: ["sensor.voltage"]}
@pytest.fixture
@@ -32,15 +41,17 @@ def _unit_issue_id(entry: MockConfigEntry, path: str = _PATH) -> str:
async def test_unresolved_path_raises_one_issue(hass, entry) -> None:
findings = SchemaFindings(frozenset({_PATH}), (), frozenset())
- async_sync_schema_issues(hass, entry, findings, {})
+ async_sync_schema_issues(hass, entry, findings, _AFFECTED)
registry = ir.async_get(hass)
assert registry.async_get_issue(DOMAIN, _issue_id(entry))
async def test_issue_cleared_when_condition_resolves(hass, entry) -> None:
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {})
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {})
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), _AFFECTED
+ )
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), _AFFECTED)
registry = ir.async_get(hass)
assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None
@@ -53,7 +64,7 @@ async def test_dismissal_survives_reconciliation(hass, entry) -> None:
accepted notice into a permanent nag.
"""
findings = SchemaFindings(frozenset({_PATH}), (), frozenset())
- async_sync_schema_issues(hass, entry, findings, {})
+ async_sync_schema_issues(hass, entry, findings, _AFFECTED)
issue_id = _issue_id(entry)
ir.async_ignore_issue(hass, DOMAIN, issue_id, True)
@@ -62,7 +73,7 @@ async def test_dismissal_survives_reconciliation(hass, entry) -> None:
assert dismissed is not None
for _ in range(3):
- async_sync_schema_issues(hass, entry, findings, {})
+ async_sync_schema_issues(hass, entry, findings, _AFFECTED)
assert registry.async_get_issue(DOMAIN, issue_id).dismissed_version == dismissed
@@ -92,9 +103,12 @@ async def test_dismissal_survives_a_changing_affected_entity_payload(hass, entry
async def test_distinct_paths_get_distinct_issues(hass, entry) -> None:
"""Dismissing one finding must not swallow a later, different one."""
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), {})
+ affected = {"a.one": ["sensor.one"], "b.two": ["sensor.two"]}
async_sync_schema_issues(
- hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), {}
+ hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), affected
+ )
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), affected
)
registry = ir.async_get(hass)
assert registry.async_get_issue(DOMAIN, _issue_id(entry, "b.two"))
@@ -106,11 +120,14 @@ async def test_a_dismissed_finding_does_not_swallow_a_later_one(hass, entry) ->
Dismissing an aggregate would silence every finding that joined it later,
because the update branch preserves `dismissed_version`.
"""
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), {})
+ affected = {"a.one": ["sensor.one"], "b.two": ["sensor.two"]}
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({"a.one"}), (), frozenset()), affected
+ )
ir.async_ignore_issue(hass, DOMAIN, _issue_id(entry, "a.one"), True)
async_sync_schema_issues(
- hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), {}
+ hass, entry, SchemaFindings(frozenset({"a.one", "b.two"}), (), frozenset()), affected
)
registry = ir.async_get(hass)
@@ -125,17 +142,26 @@ async def test_one_entry_does_not_clear_another(hass) -> None:
well = MockConfigEntry(domain=DOMAIN, data={}, unique_id="well")
well.add_to_hass(hass)
- async_sync_schema_issues(hass, sick, SchemaFindings(frozenset({_PATH}), (), frozenset()), {})
- async_sync_schema_issues(hass, well, SchemaFindings(frozenset(), (), frozenset()), {})
+ async_sync_schema_issues(
+ hass, sick, SchemaFindings(frozenset({_PATH}), (), frozenset()), _AFFECTED
+ )
+ async_sync_schema_issues(hass, well, SchemaFindings(frozenset(), (), frozenset()), _AFFECTED)
registry = ir.async_get(hass)
assert registry.async_get_issue(DOMAIN, _issue_id(sick))
async def test_circuit_rename_and_commissioning_raise_no_issue(hass, entry) -> None:
- """Tier-1 and Tier-2 changes are handled elsewhere and must stay silent."""
+ """Tier-1 and Tier-2 changes are handled elsewhere and must stay silent.
+
+ Silent because of what they are, not because nothing reads them: the map
+ names a live entity for the field, and it still raises nothing.
+ """
async_sync_schema_issues(
- hass, entry, SchemaFindings(frozenset(), (), frozenset({"pv.model"})), {}
+ hass,
+ entry,
+ SchemaFindings(frozenset(), (), frozenset({"pv.model"})),
+ {"pv.model": ["sensor.pv_model"]},
)
registry = ir.async_get(hass)
assert not [k for k in registry.issues if k[0] == DOMAIN]
@@ -143,28 +169,36 @@ async def test_circuit_rename_and_commissioning_raise_no_issue(hass, entry) -> N
async def test_unit_mismatch_raises_its_own_issue(hass, entry) -> None:
"""The second of the two user-facing defects: a reading may be wrong."""
- mismatch = UnitMismatch("panel.l1_voltage", "V", "kV")
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {})
+ mismatch = UnitMismatch(_UNIT_PATH, "V", "kV")
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), _UNIT_AFFECTED
+ )
registry = ir.async_get(hass)
- issue = registry.async_get_issue(DOMAIN, _unit_issue_id(entry, "panel.l1_voltage"))
+ issue = registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH))
assert issue is not None
assert issue.translation_key == "schema_unit_mismatch"
assert issue.translation_placeholders == {
- "field_path": "panel.l1_voltage",
+ "field_path": _UNIT_PATH,
"ha_unit": "V",
"schema_unit": "kV",
+ "count": "1",
+ "examples": "sensor.voltage",
}
async def test_unit_mismatch_issue_is_cleared_on_its_own(hass, entry) -> None:
"""Reconciliation must scope both classes, not just the unresolved one."""
- mismatch = UnitMismatch("panel.l1_voltage", "V", "kV")
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {})
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {})
+ mismatch = UnitMismatch(_UNIT_PATH, "V", "kV")
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), _UNIT_AFFECTED
+ )
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset(), (), frozenset()), _UNIT_AFFECTED
+ )
registry = ir.async_get(hass)
- assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, "panel.l1_voltage")) is None
+ assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) is None
async def test_issues_are_not_persistent(hass, entry) -> None:
@@ -173,13 +207,16 @@ async def test_issues_are_not_persistent(hass, entry) -> None:
A non-persistent issue reloads as a tombstone carrying only the dismissal,
which is exactly what lets re-assertion happen without resurrecting one.
"""
- mismatch = UnitMismatch("panel.l1_voltage", "V", "kV")
+ mismatch = UnitMismatch(_UNIT_PATH, "V", "kV")
async_sync_schema_issues(
- hass, entry, SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), {}
+ hass,
+ entry,
+ SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()),
+ _AFFECTED | _UNIT_AFFECTED,
)
registry = ir.async_get(hass)
- for issue_id in (_issue_id(entry), _unit_issue_id(entry, "panel.l1_voltage")):
+ for issue_id in (_issue_id(entry), _unit_issue_id(entry, _UNIT_PATH)):
issue = registry.async_get_issue(DOMAIN, issue_id)
assert issue.is_persistent is False
assert issue.is_fixable is False
@@ -207,14 +244,104 @@ async def test_affected_entities_are_bounded_and_counted(hass, entry) -> None:
assert "sensor.circuit_0_power" in placeholders["examples"]
-async def test_no_affected_entities_still_reads_sensibly(hass, entry) -> None:
- """An empty example list must not render as an empty string in the notice."""
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {})
+# --- Findings nobody owns -------------------------------------------------
+#
+# `vendor_cloud` is `entity_registry_enabled_default=False`, so it is registered
+# and never added to hass. A fresh install against the flat simulator raised
+# "`panel.vendor_cloud` ... 0 entity/entities are affected (for example: none)"
+# beside two genuine notices, which is how a category of Repair gets ignored.
+
+
+async def test_a_finding_no_enabled_entity_reads_raises_no_issue(hass, entry) -> None:
+ """The disabled-by-default case: nothing the user owns is affected."""
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({"panel.vendor_cloud"}), (), frozenset()), {}
+ )
registry = ir.async_get(hass)
- placeholders = registry.async_get_issue(DOMAIN, _issue_id(entry)).translation_placeholders
- assert placeholders["count"] == "0"
- assert placeholders["examples"]
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry, "panel.vendor_cloud")) is None
+ assert not [k for k in registry.issues if k[0] == DOMAIN]
+
+
+async def test_a_unit_mismatch_no_enabled_entity_reads_raises_no_issue(hass, entry) -> None:
+ """The same rule for the second class: no reading of the user's is wrong."""
+ mismatch = UnitMismatch(_UNIT_PATH, "V", "kV")
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (mismatch,), frozenset()), {})
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) is None
+ assert not [k for k in registry.issues if k[0] == DOMAIN]
+
+
+async def test_suppression_only_silences_the_path_nobody_reads(hass, entry) -> None:
+ """The real install: two genuine notices, one suppressed, in one pass."""
+ async_sync_schema_issues(
+ hass,
+ entry,
+ SchemaFindings(frozenset({_PATH, "panel.vendor_cloud"}), (), frozenset()),
+ _AFFECTED,
+ )
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry))
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry, "panel.vendor_cloud")) is None
+
+
+async def test_a_suppressed_finding_is_logged(hass, entry, caplog) -> None:
+ """Suppressed is not discarded: the field path stays reachable in the log."""
+ with caplog.at_level(logging.DEBUG, logger="custom_components.span_panel.schema_repairs"):
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({"panel.vendor_cloud"}), (), frozenset()), {}
+ )
+
+ assert "panel.vendor_cloud" in caplog.text
+ assert "no enabled entity reads" in caplog.text
+
+
+async def test_an_issue_is_deleted_when_its_last_affected_entity_goes(hass, entry) -> None:
+ """The transition the reconcile pass has to cover.
+
+ A path raised while entities read it, then disabled or removed, must have its
+ issue deleted rather than left orphaned at "0 affected".
+ """
+ findings = SchemaFindings(frozenset({_PATH}), (), frozenset())
+ async_sync_schema_issues(hass, entry, findings, _AFFECTED)
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry))
+
+ async_sync_schema_issues(hass, entry, findings, {})
+
+ assert registry.async_get_issue(DOMAIN, _issue_id(entry)) is None
+
+
+async def test_a_unit_mismatch_issue_is_deleted_when_its_entities_go(hass, entry) -> None:
+ """The same transition for the second class."""
+ findings = SchemaFindings(frozenset(), (UnitMismatch(_UNIT_PATH, "V", "kV"),), frozenset())
+ async_sync_schema_issues(hass, entry, findings, _UNIT_AFFECTED)
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH))
+
+ async_sync_schema_issues(hass, entry, findings, {})
+
+ assert registry.async_get_issue(DOMAIN, _unit_issue_id(entry, _UNIT_PATH)) is None
+
+
+async def test_a_dismissal_survives_the_entity_leaving_and_returning(hass, entry) -> None:
+ """Suppression deletes, and a delete is the one thing that clears a dismissal.
+
+ That is the accepted cost of not nagging about a finding nobody owns: the
+ notice is genuinely new when an entity starts reading the field again.
+ """
+ findings = SchemaFindings(frozenset({_PATH}), (), frozenset())
+ async_sync_schema_issues(hass, entry, findings, _AFFECTED)
+ ir.async_ignore_issue(hass, DOMAIN, _issue_id(entry), True)
+
+ async_sync_schema_issues(hass, entry, findings, {})
+ async_sync_schema_issues(hass, entry, findings, _AFFECTED)
+
+ issue = ir.async_get(hass).async_get_issue(DOMAIN, _issue_id(entry))
+ assert issue is not None
+ assert issue.dismissed_version is None
async def test_findings_fire_an_event(hass, entry) -> None:
@@ -222,9 +349,12 @@ async def test_findings_fire_an_event(hass, entry) -> None:
events = []
hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append)
- mismatch = UnitMismatch("panel.l1_voltage", "V", "kV")
+ mismatch = UnitMismatch(_UNIT_PATH, "V", "kV")
async_sync_schema_issues(
- hass, entry, SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()), {}
+ hass,
+ entry,
+ SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()),
+ _AFFECTED | _UNIT_AFFECTED,
)
await hass.async_block_till_done()
@@ -232,10 +362,44 @@ async def test_findings_fire_an_event(hass, entry) -> None:
assert events[0].data == {
"entry_id": entry.entry_id,
"unresolved": [_PATH],
- "unit_mismatches": ["panel.l1_voltage"],
+ "unit_mismatches": [_UNIT_PATH],
}
+async def test_the_event_carries_only_what_the_user_was_told(hass, entry) -> None:
+ """A suppressed finding is not user-facing, so it is not in the event either.
+
+ Otherwise an automation would react to a defect that took nothing down, and
+ an all-suppressed pass — which fires nothing at all — would disagree with a
+ partly-suppressed one.
+ """
+ events = []
+ hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append)
+
+ async_sync_schema_issues(
+ hass,
+ entry,
+ SchemaFindings(frozenset({_PATH, "panel.vendor_cloud"}), (), frozenset()),
+ _AFFECTED,
+ )
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].data["unresolved"] == [_PATH]
+
+
+async def test_an_all_suppressed_pass_fires_no_event(hass, entry) -> None:
+ events = []
+ hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append)
+
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({"panel.vendor_cloud"}), (), frozenset()), {}
+ )
+ await hass.async_block_till_done()
+
+ assert events == []
+
+
async def test_a_healthy_pass_fires_no_event(hass, entry) -> None:
events = []
hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append)
@@ -256,8 +420,8 @@ async def test_clearing_removes_only_this_entry(hass) -> None:
kept.add_to_hass(hass)
findings = SchemaFindings(frozenset({_PATH}), (), frozenset())
- async_sync_schema_issues(hass, removed, findings, {})
- async_sync_schema_issues(hass, kept, findings, {})
+ async_sync_schema_issues(hass, removed, findings, _AFFECTED)
+ async_sync_schema_issues(hass, kept, findings, _AFFECTED)
async_clear_schema_issues(hass, removed)
@@ -288,7 +452,9 @@ async def test_remove_entry_clears_this_entry_issues(hass, entry) -> None:
"""Core does not delete our issues when the entry is removed."""
from custom_components.span_panel import async_remove_entry
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), {})
+ async_sync_schema_issues(
+ hass, entry, SchemaFindings(frozenset({_PATH}), (), frozenset()), _AFFECTED
+ )
registry = ir.async_get(hass)
assert registry.async_get_issue(DOMAIN, _issue_id(entry))
From 9b78cc7aa24ba68f49d4fe542f7ef9d6f208daa1 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:04:17 -0700
Subject: [PATCH 038/116] feat(repairs): notice entities that were added
disabled
A release that adds a diagnostic with entity_registry_enabled_default=False
grows nobody's entity list uninvited, which is the point -- and is also why
the addition reaches the user through nothing at all. battery.part_number
shipped that way and was only found by opening the device's disabled-entity
list on a hunch.
Setup now snapshots the entry's registered unique_ids immediately before the
platforms are forwarded and, once they are up, raises one Repair naming
everything that is both newly registered and disabled_by INTEGRATION.
Silent on a first install: with nothing registered beforehand every entity is
new, so the notice would name the whole integration.
Keyed on the entry AND a digest of the exact set of new unique_ids, for the
reason that forced per-field ids on the degradation Repairs --
async_get_or_create's update branch preserves dismissed_version while
replacing placeholders, so an entry-wide id would rewrite a notice the user
put away instead of raising a new one.
An event rather than a condition, which needs three things together: it is
persistent, because the diff cannot produce it twice and a tombstone would
vanish unread at the first restart after the upgrade; the defect reconcile
pass cannot reach its ids, so the startup that raised it does not then delete
it; and raising is skipped outright when the id already exists.
IssueSeverity has no informational member -- CRITICAL, ERROR, WARNING -- so
WARNING is the mildest available, the floor panel_upgraded_to_ebus_v1 already
settled on for the same reason.
---
custom_components/span_panel/__init__.py | 17 +-
.../span_panel/schema_repairs.py | 167 ++++++++-
custom_components/span_panel/strings.json | 4 +
.../span_panel/translations/en.json | 4 +
.../span_panel/translations/es.json | 4 +
.../span_panel/translations/fr.json | 4 +
.../span_panel/translations/ja.json | 4 +
.../span_panel/translations/pt.json | 4 +
tests/test_new_entity_notice.py | 336 ++++++++++++++++++
tests/test_setup_entry.py | 69 ++++
10 files changed, 607 insertions(+), 6 deletions(-)
create mode 100644 tests/test_new_entity_notice.py
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index 866e3842..4d221be2 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -52,7 +52,11 @@
from .graph_horizon import GraphHorizonManager
from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401
from .options import SNAPSHOT_UPDATE_INTERVAL
-from .schema_repairs import async_clear_schema_issues
+from .schema_repairs import (
+ async_clear_schema_issues,
+ async_notice_new_disabled_entities,
+ async_registered_unique_ids,
+)
from .services import ( # noqa: F401
_async_register_favorites_services,
_async_register_graph_horizon_services,
@@ -252,6 +256,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
),
)
+ # Taken before the forward, because forwarding is what registers the
+ # entities: everything absent here and present afterwards is an entity
+ # this version of the integration added.
+ known_unique_ids = async_registered_unique_ids(hass, entry)
+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# After the platforms, not before: schema validation runs on the first
@@ -259,6 +268,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
# entities an unresolved field took down — and those entities only
# register themselves once their platform has added them.
coordinator.async_sync_schema_repairs()
+
+ # Also after the platforms, for the other half of the same reason: a
+ # newly added entity is only in the registry once its platform has added
+ # it. An addition that arrives disabled reaches the user through nothing
+ # else at all.
+ async_notice_new_disabled_entities(hass, entry, known_unique_ids)
except Exception:
if coordinator is not None:
await coordinator.async_shutdown()
diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py
index 9a52d2fe..9511fed6 100644
--- a/custom_components/span_panel/schema_repairs.py
+++ b/custom_components/span_panel/schema_repairs.py
@@ -8,6 +8,14 @@
A third condition, a produced field nothing reads, is a sanctioned addition and
stays in the debug log.
+One further notice lives here and is not a defect at all: entities that this
+setup registered for the first time and disabled by default. Registering a new
+diagnostic disabled is what keeps an upgrade from growing everybody's entity
+list uninvited, but it also makes the addition invisible — the user only finds
+it by opening the device's disabled-entity list. That notice is an event, not a
+condition, and is raised on different terms from the two defects; see
+`async_notice_new_disabled_entities`.
+
Every Repair here claims something the user owns is broken, so a finding whose
field path no enabled entity reads is not raised at all. Disabled-by-default
descriptions are registered but never added to hass, so they never self-register
@@ -17,12 +25,13 @@
from __future__ import annotations
-from collections.abc import Mapping
+from collections.abc import Collection, Mapping
+import hashlib
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers import entity_registry as er, issue_registry as ir
from .const import DOMAIN, EVENT_SCHEMA_ISSUE
from .schema_validation import SchemaFindings
@@ -31,6 +40,8 @@
_MAX_EXAMPLES = 3
+_NEW_ENTITIES_PREFIX = "new_entities_"
+
def _unresolved_id(entry_id: str, field_path: str) -> str:
return f"unresolved_{entry_id}_{field_path}"
@@ -40,6 +51,25 @@ def _unit_id(entry_id: str, field_path: str) -> str:
return f"unit_mismatch_{entry_id}_{field_path}"
+def _new_entities_id(entry_id: str, unique_ids: Collection[str]) -> str:
+ """One id per (entry, exact set of new entities).
+
+ Keyed on the set and not on the entry alone for the reason the two defect
+ notices are keyed per field path: `async_get_or_create`'s update branch
+ preserves `dismissed_version` while replacing the placeholders, so an entry-
+ wide id would let a user who dismissed "Part Number appeared" never be told
+ about the next addition — it would silently rewrite the notice they already
+ put away.
+
+ Truncated to 12 hex characters. The digest only has to separate one set from
+ another within a single config entry, and the id ends up in a storage file a
+ human occasionally reads.
+ """
+ joined = "\n".join(sorted(unique_ids))
+ digest = hashlib.sha256(joined.encode()).hexdigest()[:12]
+ return f"{_NEW_ENTITIES_PREFIX}{entry_id}_{digest}"
+
+
def _affected(entity_ids_by_path: Mapping[str, list[str]], field_path: str) -> list[str]:
"""Entities in hass that read this field, sorted.
@@ -164,12 +194,123 @@ def async_sync_schema_issues(
)
+@callback
+def async_registered_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> frozenset[str]:
+ """Return the unique_ids already registered for this entry.
+
+ Taken by `async_setup_entry` immediately before the platforms are forwarded,
+ which is the last moment "already registered" still means "registered by an
+ earlier run". Unique_ids rather than entity_ids: an entity_id is the user's
+ to rename, a unique_id is the identity the registry itself keys on.
+ """
+ return frozenset(
+ registry_entry.unique_id
+ for registry_entry in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
+ )
+
+
+def _label(registry_entry: er.RegistryEntry) -> str:
+ """Return what to call an entity the user has never seen.
+
+ A disabled entity has no state, so there is no friendly name to read off the
+ state machine — only what the registry recorded when the platform added it.
+ The entity_id is the last resort rather than the first choice because it is
+ the name the user will *not* see in the device's disabled-entity list.
+ """
+ return registry_entry.name or registry_entry.original_name or registry_entry.entity_id
+
+
+@callback
+def async_notice_new_disabled_entities(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ known_unique_ids: Collection[str],
+) -> None:
+ """Tell the user about entities this setup added and disabled.
+
+ A release that adds a diagnostic with `entity_registry_enabled_default=False`
+ grows nobody's entity list, which is the point — and is also why the addition
+ reaches the user through nothing at all. This is the notice for that: the
+ entities exist, they are switched off, and here is what they are called.
+
+ Silent on a first install. With nothing registered beforehand every entity is
+ new, so the notice would name the entire integration and teach the user to
+ ignore it. An empty `known_unique_ids` is the probe for that:
+ `er.async_entries_for_config_entry` answers with nothing for an entry that
+ has never registered anything.
+
+ An EVENT, not a condition, and that shapes three decisions:
+
+ * `is_persistent=True`, unlike the two defect notices. Those are re-derived
+ from live state at every startup, so they can afford to reload as
+ tombstones. This one cannot be re-derived at all: on the next startup the
+ entity is in `known_unique_ids` and the diff is empty by construction. A
+ non-persistent issue would therefore vanish unread at the first restart
+ after the upgrade, which for a user who was away is the same silent add
+ the notice exists to prevent.
+ * It is never reconciled away. `async_sync_schema_issues` deletes the defect
+ ids it does not re-derive, and applying that here would delete this notice
+ on the very next startup. `_ours` is deliberately scoped to the two defect
+ prefixes so the reconcile pass cannot reach these ids.
+ * Raising is skipped outright when the id already exists, so the notice is
+ asserted once and then left alone. Nothing re-derives it, so an update pass
+ could only rewrite the placeholders of a notice the user has already read
+ or dismissed.
+
+ Severity is the mildest Home Assistant offers. `IssueSeverity` has no
+ informational member — it is CRITICAL, ERROR, WARNING — so WARNING is the
+ floor, the same floor `panel_upgraded_to_ebus_v1` settled on for the same
+ reason: nothing is broken and no action is required.
+ """
+ if not known_unique_ids:
+ _LOGGER.debug(
+ "Suppressed new-entity notice for %s: nothing was registered before this "
+ "setup, so this is a first install and every entity is new",
+ entry.entry_id,
+ )
+ return
+
+ new_disabled = [
+ registry_entry
+ for registry_entry in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
+ if registry_entry.unique_id not in known_unique_ids
+ and registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
+ ]
+ if not new_disabled:
+ return
+
+ issue_id = _new_entities_id(
+ entry.entry_id, [registry_entry.unique_id for registry_entry in new_disabled]
+ )
+ if ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None:
+ _LOGGER.debug("New-entity notice %s already raised; leaving it alone", issue_id)
+ return
+
+ labels = sorted(_label(registry_entry) for registry_entry in new_disabled)
+ _LOGGER.debug("Raising new-entity notice %s for %s", issue_id, labels)
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ issue_id,
+ is_fixable=False,
+ is_persistent=True,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="new_entities_disabled",
+ translation_placeholders=_placeholders(labels),
+ )
+
+
def _ours(registry: ir.IssueRegistry, entry_id: str) -> set[str]:
- """Our issue ids for ONE config entry.
+ """Return the reconcilable issue ids for ONE config entry.
Scoping by entry is not cosmetic: with a shared namespace, a healthy panel's
reconcile pass would delete a degraded panel's issues on every cycle, and
removing one panel would clear every panel's issues.
+
+ Scoping to the two defect prefixes is not cosmetic either. Everything in here
+ is re-derived on every pass and deleted when it stops being derived; the
+ new-entity notice is derived exactly once and must survive a pass that cannot
+ re-derive it. See `_new_entity_notices`.
"""
prefixes = (f"unresolved_{entry_id}_", f"unit_mismatch_{entry_id}_")
return {
@@ -179,9 +320,25 @@ def _ours(registry: ir.IssueRegistry, entry_id: str) -> set[str]:
}
+def _new_entity_notices(registry: ir.IssueRegistry, entry_id: str) -> set[str]:
+ """Return this entry's new-entity notices. Cleared on removal, never reconciled."""
+ prefix = f"{_NEW_ENTITIES_PREFIX}{entry_id}_"
+ return {
+ issue_id
+ for (domain, issue_id) in registry.issues
+ if domain == DOMAIN and issue_id.startswith(prefix)
+ }
+
+
@callback
def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Remove one entry's issues. Core does not do this on entry removal."""
+ """Remove one entry's issues. Core does not do this on entry removal.
+
+ Covers the new-entity notices too, and they are the ones that need it most:
+ they are persistent, so unlike the defect notices they would not even be
+ demoted to tombstones by a restart — a removed panel would leave them
+ standing forever.
+ """
registry = ir.async_get(hass)
- for issue_id in _ours(registry, entry.entry_id):
+ for issue_id in _ours(registry, entry.entry_id) | _new_entity_notices(registry, entry.entry_id):
ir.async_delete_issue(hass, DOMAIN, issue_id)
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 76f25f4b..74760c53 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -708,6 +708,10 @@
}
},
"issues": {
+ "new_entities_disabled": {
+ "title": "New SPAN Panel entities are available",
+ "description": "This update added data your panel was already reporting but this integration did not yet expose. {count} new entity/entities were created for it (for example: {examples}).\n\nThey were created **disabled**, so nothing appeared in your dashboards or history uninvited. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
+ },
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
"description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look."
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 76f25f4b..74760c53 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -708,6 +708,10 @@
}
},
"issues": {
+ "new_entities_disabled": {
+ "title": "New SPAN Panel entities are available",
+ "description": "This update added data your panel was already reporting but this integration did not yet expose. {count} new entity/entities were created for it (for example: {examples}).\n\nThey were created **disabled**, so nothing appeared in your dashboards or history uninvited. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
+ },
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
"description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look."
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index fdf1faea..6134baec 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -708,6 +708,10 @@
}
},
"issues": {
+ "new_entities_disabled": {
+ "title": "Hay nuevas entidades del Panel SPAN disponibles",
+ "description": "Esta actualización ha añadido datos que su panel ya informaba pero que esta integración aún no exponía. Se han creado {count} entidad(es) nueva(s) para ello (por ejemplo: {examples}).\n\nSe han creado **deshabilitadas**, por lo que no ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado."
+ },
"panel_upgraded_to_ebus_v1": {
"title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred",
"description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0. Nada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha mejorado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. La v1.0 no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como la v1.0 determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba.\n\nPuede descartar este aviso cuando lo haya revisado."
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 3bb0dab4..f22b4bab 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -708,6 +708,10 @@
}
},
"issues": {
+ "new_entities_disabled": {
+ "title": "De nouvelles entités du Panneau SPAN sont disponibles",
+ "description": "Cette mise à jour a ajouté des données que votre panneau signalait déjà mais que cette intégration n'exposait pas encore. {count} nouvelle(s) entité(s) ont été créées pour cela (par exemple : {examples}).\n\nElles ont été créées **désactivées**, afin que rien n'apparaisse dans vos tableaux de bord ni dans votre historique sans votre accord. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil."
+ },
"panel_upgraded_to_ebus_v1": {
"title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau",
"description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0. Rien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui s'est amélioré.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. La v1.0 ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont v1.0 détermine la capacité de secours.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas.\n\nVous pouvez ignorer cet avis après consultation."
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 8e017e2d..1350b7d0 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -708,6 +708,10 @@
}
},
"issues": {
+ "new_entities_disabled": {
+ "title": "SPAN パネルの新しいエンティティが利用可能です",
+ "description": "この更新により、パネルがすでに報告していたものの、この統合がまだ公開していなかったデータが追加されました。そのために {count} 個の新しいエンティティが作成されました (例: {examples})。\n\nこれらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。"
+ },
"panel_upgraded_to_ebus_v1": {
"title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました",
"description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。ご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は改善点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。\n\n内容を確認したら、この通知は閉じて構いません。"
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index ee392d75..d9dbd5e9 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -708,6 +708,10 @@
}
},
"issues": {
+ "new_entities_disabled": {
+ "title": "Estão disponíveis novas entidades do Painel SPAN",
+ "description": "Esta atualização acrescentou dados que o seu painel já comunicava mas que esta integração ainda não expunha. Foram criadas {count} nova(s) entidade(s) para o efeito (por exemplo: {examples}).\n\nForam criadas **desativadas**, pelo que nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto."
+ },
"panel_upgraded_to_ebus_v1": {
"title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede",
"description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0. Nada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que melhorou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. A v1.0 não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a v1.0 determina a capacidade de reserva.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava.\n\nPode dispensar este aviso depois de o consultar."
diff --git a/tests/test_new_entity_notice.py b/tests/test_new_entity_notice.py
new file mode 100644
index 00000000..7440b2c5
--- /dev/null
+++ b/tests/test_new_entity_notice.py
@@ -0,0 +1,336 @@
+"""An entity that arrives disabled must not arrive silently.
+
+`battery.part_number` shipped with `entity_registry_enabled_default=False` so
+that upgrading would not grow anybody's entity list uninvited. It worked, and
+the cost was that nothing whatsoever told the user the sensor now existed --
+they found it by opening the device's disabled-entity list on a hunch.
+
+These cover the notice that closes that gap, and the four ways it could be worse
+than nothing: shouting on a first install, being swallowed by an earlier
+dismissal, nagging on every restart, or vanishing unread at the first one.
+"""
+
+from __future__ import annotations
+
+from homeassistant.helpers import entity_registry as er, issue_registry as ir
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.schema_repairs import (
+ _new_entities_id,
+ async_clear_schema_issues,
+ async_notice_new_disabled_entities,
+ async_registered_unique_ids,
+ async_sync_schema_issues,
+)
+from custom_components.span_panel.schema_validation import SchemaFindings
+
+_PART_NUMBER = "sp3-001_bess_part_number"
+
+
+@pytest.fixture
+def entry(hass) -> MockConfigEntry:
+ """Return a config entry in hass. No conftest fixture exists for this."""
+ mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001")
+ mock.add_to_hass(hass)
+ return mock
+
+
+def _register(
+ hass,
+ entry: MockConfigEntry,
+ unique_id: str,
+ *,
+ disabled: bool = True,
+ name: str | None = None,
+) -> er.RegistryEntry:
+ """Register one entity the way a platform would."""
+ return er.async_get(hass).async_get_or_create(
+ "sensor",
+ DOMAIN,
+ unique_id,
+ config_entry=entry,
+ original_name=name,
+ disabled_by=er.RegistryEntryDisabler.INTEGRATION if disabled else None,
+ )
+
+
+def _notices(hass, entry: MockConfigEntry) -> list[ir.IssueEntry]:
+ prefix = f"new_entities_{entry.entry_id}_"
+ return [
+ issue
+ for (domain, issue_id), issue in ir.async_get(hass).issues.items()
+ if domain == DOMAIN and issue_id.startswith(prefix)
+ ]
+
+
+# --- The gap this closes --------------------------------------------------
+
+
+async def test_a_new_disabled_entity_raises_one_notice_naming_it(hass, entry) -> None:
+ """The `battery.part_number` case, end to end."""
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False, name="Serial Number")
+ known = async_registered_unique_ids(hass, entry)
+
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ notices = _notices(hass, entry)
+ assert len(notices) == 1
+ assert notices[0].translation_key == "new_entities_disabled"
+ assert notices[0].translation_placeholders == {"count": "1", "examples": "Part Number"}
+
+
+async def test_the_notice_is_informational_and_not_fixable(hass, entry) -> None:
+ """Nothing is broken; data became available.
+
+ `IssueSeverity` has no informational member -- CRITICAL, ERROR, WARNING --
+ so WARNING is the mildest Home Assistant offers, and is what the equally
+ action-free `panel_upgraded_to_ebus_v1` notice settled on.
+ """
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ notice = _notices(hass, entry)[0]
+ assert notice.severity is ir.IssueSeverity.WARNING
+ assert notice.is_fixable is False
+
+
+async def test_the_notice_falls_back_to_the_entity_id_when_unnamed(hass, entry) -> None:
+ """A disabled entity has no state, so there is no friendly name to read."""
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+ registered = _register(hass, entry, _PART_NUMBER, name=None)
+
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ assert _notices(hass, entry)[0].translation_placeholders["examples"] == registered.entity_id
+
+
+# --- Silent on a first install --------------------------------------------
+
+
+async def test_a_first_install_raises_no_notice(hass, entry) -> None:
+ """Everything is new, so naming it would name the whole integration."""
+ known = async_registered_unique_ids(hass, entry)
+ assert known == frozenset()
+
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ _register(hass, entry, "sp3-001_bess_model", name="Model")
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ assert _notices(hass, entry) == []
+
+
+async def test_another_entrys_history_does_not_make_this_one_established(hass) -> None:
+ """The probe is scoped per entry, so a second panel still installs quietly."""
+ established = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-old")
+ established.add_to_hass(hass)
+ _register(hass, established, "sp3-old_bess_serial_number", disabled=False)
+
+ fresh = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-new")
+ fresh.add_to_hass(hass)
+ known = async_registered_unique_ids(hass, fresh)
+ _register(hass, fresh, "sp3-new_bess_part_number", name="Part Number")
+
+ async_notice_new_disabled_entities(hass, fresh, known)
+
+ assert _notices(hass, fresh) == []
+
+
+# --- Only the invisible additions -----------------------------------------
+
+
+async def test_an_entity_that_is_enabled_by_default_raises_no_notice(hass, entry) -> None:
+ """It shows up in the user's entity list on its own; a notice is noise."""
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+
+ _register(hass, entry, "sp3-001_bess_model", disabled=False, name="Model")
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ assert _notices(hass, entry) == []
+
+
+async def test_a_user_disabled_entity_is_not_a_new_addition(hass, entry) -> None:
+ """Only `INTEGRATION` means "we shipped it switched off"."""
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+
+ er.async_get(hass).async_get_or_create(
+ "sensor",
+ DOMAIN,
+ "sp3-001_bess_model",
+ config_entry=entry,
+ disabled_by=er.RegistryEntryDisabler.USER,
+ )
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ assert _notices(hass, entry) == []
+
+
+async def test_the_named_entities_are_bounded_and_counted(hass, entry) -> None:
+ """The same rule the defect Repairs follow: a few names plus a count."""
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+
+ for n in range(12):
+ _register(hass, entry, f"sp3-001_new_{n:02d}", name=f"New Sensor {n:02d}")
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ placeholders = _notices(hass, entry)[0].translation_placeholders
+ assert placeholders["count"] == "12"
+ assert placeholders["examples"].count(",") < 5
+ assert "New Sensor 00" in placeholders["examples"]
+
+
+# --- Dismissing one set must not swallow a later, different set -----------
+
+
+async def test_a_dismissed_notice_does_not_swallow_a_later_addition(hass, entry) -> None:
+ """The trap that forced per-field ids on the degradation Repairs.
+
+ `async_get_or_create`'s update branch preserves `dismissed_version` while
+ replacing the placeholders, so an entry-wide id would rewrite the notice the
+ user put away rather than raising a new one.
+ """
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+
+ known = async_registered_unique_ids(hass, entry)
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ first = _notices(hass, entry)[0]
+ ir.async_ignore_issue(hass, DOMAIN, first.issue_id, True)
+ assert ir.async_get(hass).async_get_issue(DOMAIN, first.issue_id).dismissed_version
+
+ known = async_registered_unique_ids(hass, entry)
+ _register(hass, entry, "sp3-001_bess_cell_temperature", name="Cell Temperature")
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ by_id = {notice.issue_id: notice for notice in _notices(hass, entry)}
+ assert len(by_id) == 2
+ second = next(issue for issue_id, issue in by_id.items() if issue_id != first.issue_id)
+ assert second.dismissed_version is None
+ assert second.translation_placeholders["examples"] == "Cell Temperature"
+ assert by_id[first.issue_id].dismissed_version is not None
+
+
+async def test_the_id_is_keyed_on_the_exact_set(hass, entry) -> None:
+ """Two different sets produce two different ids; the same set, one id."""
+ assert _new_entities_id("e1", ["a"]) != _new_entities_id("e1", ["a", "b"])
+ assert _new_entities_id("e1", ["a"]) != _new_entities_id("e2", ["a"])
+ assert _new_entities_id("e1", ["b", "a"]) == _new_entities_id("e1", ["a", "b"])
+
+
+# --- An event, not a condition --------------------------------------------
+
+
+async def test_the_notice_is_persistent(hass, entry) -> None:
+ """It cannot be re-derived, so it has to survive the restart itself.
+
+ On the next startup the entity is in the known set and the diff is empty by
+ construction. A non-persistent issue reloads as a tombstone, so this notice
+ would disappear unread at the first restart after the upgrade -- the same
+ silent add it exists to prevent, one step later.
+ """
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ assert _notices(hass, entry)[0].is_persistent is True
+
+
+async def test_a_restart_with_no_new_entities_neither_duplicates_nor_re_raises(hass, entry) -> None:
+ """The event property, exercised the way the field will exercise it."""
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ raised = _notices(hass, entry)[0]
+ created = raised.created
+
+ for _ in range(3):
+ # Each pass is a setup: probe the registry, forward nothing new, notice.
+ async_notice_new_disabled_entities(hass, entry, async_registered_unique_ids(hass, entry))
+
+ notices = _notices(hass, entry)
+ assert len(notices) == 1
+ assert notices[0].issue_id == raised.issue_id
+ assert notices[0].created == created
+
+
+async def test_a_dismissed_notice_is_not_resurrected_by_a_restart(hass, entry) -> None:
+ """Dismissing it must end it, not defer it to the next startup."""
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ issue_id = _notices(hass, entry)[0].issue_id
+ ir.async_ignore_issue(hass, DOMAIN, issue_id, True)
+ dismissed = ir.async_get(hass).async_get_issue(DOMAIN, issue_id).dismissed_version
+
+ async_notice_new_disabled_entities(hass, entry, async_registered_unique_ids(hass, entry))
+
+ assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id).dismissed_version == dismissed
+
+
+async def test_the_defect_reconcile_pass_does_not_delete_the_notice(hass, entry) -> None:
+ """`_ours` is scoped to the two defect prefixes on purpose.
+
+ The reconcile pass deletes every id it did not re-derive. This notice is
+ derived exactly once, so a shared scope would delete it on the same startup
+ that raised it.
+ """
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ async_notice_new_disabled_entities(hass, entry, known)
+ issue_id = _notices(hass, entry)[0].issue_id
+
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {})
+
+ assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
+
+
+# --- Removal ---------------------------------------------------------------
+
+
+async def test_the_notice_is_cleared_when_the_entry_is_removed(hass, entry) -> None:
+ """Persistent, so a restart would not even demote it to a tombstone."""
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ async_notice_new_disabled_entities(hass, entry, known)
+ assert _notices(hass, entry)
+
+ async_clear_schema_issues(hass, entry)
+
+ assert _notices(hass, entry) == []
+
+
+async def test_removing_one_entry_leaves_another_entrys_notice(hass) -> None:
+ """Two panels share the domain; one leaving must not silence the other."""
+ kept = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-kept")
+ kept.add_to_hass(hass)
+ removed = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-removed")
+ removed.add_to_hass(hass)
+
+ for panel in (kept, removed):
+ _register(hass, panel, f"{panel.unique_id}_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, panel)
+ _register(hass, panel, f"{panel.unique_id}_bess_part_number", name="Part Number")
+ async_notice_new_disabled_entities(hass, panel, known)
+
+ async_clear_schema_issues(hass, removed)
+
+ assert _notices(hass, kept)
+ assert _notices(hass, removed) == []
diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py
index 04842c66..18f7e15a 100644
--- a/tests/test_setup_entry.py
+++ b/tests/test_setup_entry.py
@@ -24,6 +24,7 @@
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
from .factories import SpanPanelSnapshotFactory
@@ -338,3 +339,71 @@ async def _forward(*_args, **_kwargs) -> None:
assert await async_setup_entry(hass, entry) is True
assert order == ["forward", "sync"]
+
+
+async def test_setup_probes_the_registry_before_the_platforms_and_notices_after(
+ hass: HomeAssistant,
+) -> None:
+ """The new-entity notice brackets the forward, and both halves matter.
+
+ The probe has to run before forwarding, because forwarding is what registers
+ the entities -- probing afterwards would find every entity already known and
+ the notice could never fire. The notice has to run after, because a newly
+ added entity is only in the registry once its platform has added it.
+ """
+ entry = _create_v2_entry()
+ entry.add_to_hass(hass)
+ snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001")
+ client = MagicMock()
+ client.connect = AsyncMock()
+ coordinator = MagicMock()
+ coordinator.async_config_entry_first_refresh = AsyncMock()
+ coordinator.async_setup_streaming = AsyncMock()
+ coordinator.data = snapshot
+
+ registry = er.async_get(hass)
+ registry.async_get_or_create("sensor", DOMAIN, "already-there", config_entry=entry)
+
+ order: list[str] = []
+ coordinator.async_sync_schema_repairs = MagicMock(side_effect=lambda: order.append("sync"))
+
+ async def _forward(*_args, **_kwargs) -> None:
+ order.append("forward")
+ registry.async_get_or_create(
+ "sensor",
+ DOMAIN,
+ "added-by-the-forward",
+ config_entry=entry,
+ disabled_by=er.RegistryEntryDisabler.INTEGRATION,
+ )
+
+ seen: list[frozenset[str]] = []
+
+ def _notice(_hass, _entry, known_unique_ids) -> None:
+ order.append("notice")
+ seen.append(frozenset(known_unique_ids))
+
+ with (
+ patch("custom_components.span_panel.async_register_commands"),
+ patch("custom_components.span_panel.SpanMqttClient", return_value=client),
+ patch(
+ "custom_components.span_panel.SpanPanelCoordinator",
+ return_value=coordinator,
+ ),
+ patch(
+ "custom_components.span_panel.ensure_device_registered",
+ AsyncMock(return_value="panel-device-id"),
+ ),
+ patch.object(
+ hass.config_entries, "async_forward_entry_setups", AsyncMock(side_effect=_forward)
+ ),
+ patch.object(hass.config_entries, "async_update_entry"),
+ patch(
+ "custom_components.span_panel.async_notice_new_disabled_entities",
+ side_effect=_notice,
+ ),
+ ):
+ assert await async_setup_entry(hass, entry) is True
+
+ assert order == ["forward", "sync", "notice"]
+ assert seen == [frozenset({"already-there"})]
From 488b2fe03cddb7c9ccca66b47e07637e450ee819 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:23:05 -0700
Subject: [PATCH 039/116] fix(repairs): state the observation, not a cause the
trigger cannot establish
The new-entity notice fires on any entity newly registered for the entry with
disabled_by INTEGRATION, but the text asserted one specific cause: "This
update added data your panel was already reporting but this integration did
not yet expose."
Three ordinary paths fire it with no update involved. A breaker added to an
established panel brings a per-circuit CIRCUIT_BREAKER_RATING_SENSOR, which
is disabled by default. Enabling solar in the options flow is the user's own
deliberate action, reported back to them as something an update did.
Attaching a BESS brings BESS_METADATA_SENSORS with it.
A feature whose purpose is telling the user the truth about an addition must
not tell some of them something false. The text now states what was observed
and offers the causes as possibilities. Rewritten in strings.json and all
five locales in the same pass -- a partial edit would leave some users with
the false claim.
Also from review, both cheap:
The already-raised guard was unobservable and its comment oversold it. It
does not prevent duplication -- the shared id does that. It prevents a repeat
of the same set from rewriting the text of a notice the user has already
read, via async_get_or_create's update branch. A test now pins that through
the one path where the diff can repeat: an entity removed from the registry
and later re-registered under a different name.
_ours and _new_entity_notices were the same function twice, now one
_scoped_issue_ids taking the prefixes it may see. The reconcile pass passes
the defect prefixes only, which is the reason the scope is a parameter at
all; removal passes every prefix.
---
.../span_panel/schema_repairs.py | 55 ++++++++++---------
custom_components/span_panel/strings.json | 2 +-
.../span_panel/translations/en.json | 2 +-
.../span_panel/translations/es.json | 2 +-
.../span_panel/translations/fr.json | 2 +-
.../span_panel/translations/ja.json | 2 +-
.../span_panel/translations/pt.json | 2 +-
tests/test_new_entity_notice.py | 28 ++++++++++
8 files changed, 63 insertions(+), 32 deletions(-)
diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py
index 9511fed6..b45b7106 100644
--- a/custom_components/span_panel/schema_repairs.py
+++ b/custom_components/span_panel/schema_repairs.py
@@ -40,6 +40,9 @@
_MAX_EXAMPLES = 3
+# Issue-id prefixes. `_DEFECT_PREFIXES` is what the reconcile pass owns; the
+# new-entity notice is deliberately not among them. See `_scoped_issue_ids`.
+_DEFECT_PREFIXES = ("unresolved_", "unit_mismatch_")
_NEW_ENTITIES_PREFIX = "new_entities_"
@@ -177,7 +180,9 @@ def async_sync_schema_issues(
},
)
- for issue_id in _ours(registry, entry.entry_id) - wanted:
+ # Defect prefixes only: everything in this scope is re-derived on every pass,
+ # and the new-entity notice is not re-derivable at all.
+ for issue_id in _scoped_issue_ids(registry, entry.entry_id, _DEFECT_PREFIXES) - wanted:
ir.async_delete_issue(hass, DOMAIN, issue_id)
if wanted:
@@ -250,12 +255,15 @@ def async_notice_new_disabled_entities(
the notice exists to prevent.
* It is never reconciled away. `async_sync_schema_issues` deletes the defect
ids it does not re-derive, and applying that here would delete this notice
- on the very next startup. `_ours` is deliberately scoped to the two defect
- prefixes so the reconcile pass cannot reach these ids.
- * Raising is skipped outright when the id already exists, so the notice is
- asserted once and then left alone. Nothing re-derives it, so an update pass
- could only rewrite the placeholders of a notice the user has already read
- or dismissed.
+ on the very next startup. Its reconcile scope is `_DEFECT_PREFIXES`, which
+ deliberately does not include this one.
+ * Raising is skipped outright when the id already exists. What that buys is
+ narrow and worth stating exactly: a repeat of the same set cannot rewrite
+ the text of a notice the user has already read. Without it the repeat would
+ take `async_get_or_create`'s update branch, which replaces the placeholders
+ — so a set that came back with a renamed entity would silently restate
+ itself. It buys nothing against duplication, which the shared id already
+ rules out.
Severity is the mildest Home Assistant offers. `IssueSeverity` has no
informational member — it is CRITICAL, ERROR, WARNING — so WARNING is the
@@ -300,33 +308,27 @@ def async_notice_new_disabled_entities(
)
-def _ours(registry: ir.IssueRegistry, entry_id: str) -> set[str]:
- """Return the reconcilable issue ids for ONE config entry.
+def _scoped_issue_ids(
+ registry: ir.IssueRegistry, entry_id: str, prefixes: tuple[str, ...]
+) -> set[str]:
+ """Return this entry's issue ids under the given id prefixes.
Scoping by entry is not cosmetic: with a shared namespace, a healthy panel's
reconcile pass would delete a degraded panel's issues on every cycle, and
removing one panel would clear every panel's issues.
- Scoping to the two defect prefixes is not cosmetic either. Everything in here
- is re-derived on every pass and deleted when it stops being derived; the
- new-entity notice is derived exactly once and must survive a pass that cannot
- re-derive it. See `_new_entity_notices`.
+ Which prefixes is not cosmetic either, and is why this takes them rather than
+ answering for the whole domain. The reconcile pass deletes every id it did
+ not re-derive, so it may only ever see `_DEFECT_PREFIXES`; the new-entity
+ notice is derived exactly once and would not survive being reconciled against
+ a pass that cannot re-derive it. Removal, which deletes unconditionally, is
+ the one caller that passes every prefix.
"""
- prefixes = (f"unresolved_{entry_id}_", f"unit_mismatch_{entry_id}_")
+ scoped = tuple(f"{prefix}{entry_id}_" for prefix in prefixes)
return {
issue_id
for (domain, issue_id) in registry.issues
- if domain == DOMAIN and issue_id.startswith(prefixes)
- }
-
-
-def _new_entity_notices(registry: ir.IssueRegistry, entry_id: str) -> set[str]:
- """Return this entry's new-entity notices. Cleared on removal, never reconciled."""
- prefix = f"{_NEW_ENTITIES_PREFIX}{entry_id}_"
- return {
- issue_id
- for (domain, issue_id) in registry.issues
- if domain == DOMAIN and issue_id.startswith(prefix)
+ if domain == DOMAIN and issue_id.startswith(scoped)
}
@@ -340,5 +342,6 @@ def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None:
standing forever.
"""
registry = ir.async_get(hass)
- for issue_id in _ours(registry, entry.entry_id) | _new_entity_notices(registry, entry.entry_id):
+ every_prefix = (*_DEFECT_PREFIXES, _NEW_ENTITIES_PREFIX)
+ for issue_id in _scoped_issue_ids(registry, entry.entry_id, every_prefix):
ir.async_delete_issue(hass, DOMAIN, issue_id)
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 74760c53..c839aad1 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -710,7 +710,7 @@
"issues": {
"new_entities_disabled": {
"title": "New SPAN Panel entities are available",
- "description": "This update added data your panel was already reporting but this integration did not yet expose. {count} new entity/entities were created for it (for example: {examples}).\n\nThey were created **disabled**, so nothing appeared in your dashboards or history uninvited. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
+ "description": "{count} new entity/entities have appeared for your SPAN Panel (for example: {examples}). They were created **disabled**, so nothing appeared in your dashboards or history uninvited.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
},
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 74760c53..c839aad1 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -710,7 +710,7 @@
"issues": {
"new_entities_disabled": {
"title": "New SPAN Panel entities are available",
- "description": "This update added data your panel was already reporting but this integration did not yet expose. {count} new entity/entities were created for it (for example: {examples}).\n\nThey were created **disabled**, so nothing appeared in your dashboards or history uninvited. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
+ "description": "{count} new entity/entities have appeared for your SPAN Panel (for example: {examples}). They were created **disabled**, so nothing appeared in your dashboards or history uninvited.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
},
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 6134baec..795b897f 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -710,7 +710,7 @@
"issues": {
"new_entities_disabled": {
"title": "Hay nuevas entidades del Panel SPAN disponibles",
- "description": "Esta actualización ha añadido datos que su panel ya informaba pero que esta integración aún no exponía. Se han creado {count} entidad(es) nueva(s) para ello (por ejemplo: {examples}).\n\nSe han creado **deshabilitadas**, por lo que no ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado."
+ "description": "Han aparecido {count} entidad(es) nueva(s) en su Panel SPAN (por ejemplo: {examples}). Se han creado **deshabilitadas**, por lo que no ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido.\n\nPueden aparecer entidades nuevas por varios motivos: una actualización de la integración que expone más de lo que informa su panel, un circuito añadido al panel, o hardware como una batería o energía solar que se pone en marcha. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado."
},
"panel_upgraded_to_ebus_v1": {
"title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred",
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index f22b4bab..44b0fa65 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -710,7 +710,7 @@
"issues": {
"new_entities_disabled": {
"title": "De nouvelles entités du Panneau SPAN sont disponibles",
- "description": "Cette mise à jour a ajouté des données que votre panneau signalait déjà mais que cette intégration n'exposait pas encore. {count} nouvelle(s) entité(s) ont été créées pour cela (par exemple : {examples}).\n\nElles ont été créées **désactivées**, afin que rien n'apparaisse dans vos tableaux de bord ni dans votre historique sans votre accord. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil."
+ "description": "{count} nouvelle(s) entité(s) sont apparues pour votre Panneau SPAN (par exemple : {examples}). Elles ont été créées **désactivées**, afin que rien n'apparaisse dans vos tableaux de bord ni dans votre historique sans votre accord.\n\nDe nouvelles entités peuvent apparaître pour plusieurs raisons : une mise à jour de l'intégration qui expose davantage de ce que votre panneau signale, un circuit ajouté au panneau, ou du matériel tel qu'une batterie ou du solaire qui entre en service. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil."
},
"panel_upgraded_to_ebus_v1": {
"title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau",
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 1350b7d0..7c2bf0c2 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -710,7 +710,7 @@
"issues": {
"new_entities_disabled": {
"title": "SPAN パネルの新しいエンティティが利用可能です",
- "description": "この更新により、パネルがすでに報告していたものの、この統合がまだ公開していなかったデータが追加されました。そのために {count} 個の新しいエンティティが作成されました (例: {examples})。\n\nこれらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。"
+ "description": "SPAN パネルに {count} 個の新しいエンティティが現れました (例: {examples})。これらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。\n\n新しいエンティティが現れる理由はいくつかあります。パネルが報告する内容をより多く公開する統合の更新、パネルに追加された回路、あるいはバッテリーや太陽光などのハードウェアの稼働開始などです。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。"
},
"panel_upgraded_to_ebus_v1": {
"title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました",
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index d9dbd5e9..f2db3d6c 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -710,7 +710,7 @@
"issues": {
"new_entities_disabled": {
"title": "Estão disponíveis novas entidades do Painel SPAN",
- "description": "Esta atualização acrescentou dados que o seu painel já comunicava mas que esta integração ainda não expunha. Foram criadas {count} nova(s) entidade(s) para o efeito (por exemplo: {examples}).\n\nForam criadas **desativadas**, pelo que nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto."
+ "description": "Apareceram {count} nova(s) entidade(s) no seu Painel SPAN (por exemplo: {examples}). Foram criadas **desativadas**, pelo que nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido.\n\nPodem aparecer novas entidades por vários motivos: uma atualização da integração que expõe mais do que o seu painel comunica, um circuito acrescentado ao painel, ou hardware como uma bateria ou energia solar que entra em funcionamento. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto."
},
"panel_upgraded_to_ebus_v1": {
"title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede",
diff --git a/tests/test_new_entity_notice.py b/tests/test_new_entity_notice.py
index 7440b2c5..78da5ef8 100644
--- a/tests/test_new_entity_notice.py
+++ b/tests/test_new_entity_notice.py
@@ -267,6 +267,34 @@ async def test_a_restart_with_no_new_entities_neither_duplicates_nor_re_raises(h
assert notices[0].created == created
+async def test_a_repeat_of_the_same_set_cannot_rewrite_the_notice(hass, entry) -> None:
+ """What the already-raised guard actually buys.
+
+ It buys nothing against duplication -- the shared id rules that out. It buys
+ exactly this: a set that comes back cannot silently restate a notice the user
+ has already read. Without the guard the repeat takes `async_get_or_create`'s
+ update branch, which replaces the placeholders in place.
+
+ The set comes back when an entity is removed from the registry and later
+ re-registered -- a BESS taken off the panel and put back -- because the
+ unique_ids, and therefore the digest, are unchanged.
+ """
+ _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
+ known = async_registered_unique_ids(hass, entry)
+ added = _register(hass, entry, _PART_NUMBER, name="Part Number")
+ async_notice_new_disabled_entities(hass, entry, known)
+ issue_id = _notices(hass, entry)[0].issue_id
+
+ er.async_get(hass).async_remove(added.entity_id)
+ _register(hass, entry, _PART_NUMBER, name="BESS Part Number")
+ async_notice_new_disabled_entities(hass, entry, known)
+
+ notices = _notices(hass, entry)
+ assert len(notices) == 1
+ assert notices[0].issue_id == issue_id
+ assert notices[0].translation_placeholders["examples"] == "Part Number"
+
+
async def test_a_dismissed_notice_is_not_resurrected_by_a_restart(hass, entry) -> None:
"""Dismissing it must end it, not defer it to the next startup."""
_register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
From 86e477ccdb02faa45d8e416ccc19ff0e62525f3f Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:13:03 -0700
Subject: [PATCH 040/116] test(field-paths): assert every exemption's reason
against both adapters
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`RESIDUAL_EXEMPT_PATHS` was 26 entries whose reason for being exempt lived in a
prose comment that nothing executed. An entry could be mislabelled from the day
it was written, go stale when the library changed what it publishes, or become
producible by both adapters — at which point it should be promoted to a
declaration — and no test would notice any of it.
Make the reason part of the data: `Producibility` (NEITHER / SCHEMA_0_ONLY /
SCHEMA_1_ONLY, with no BOTH member because such a path is not exemptible), and
`RESIDUAL_EXEMPT_PATHS` as a Mapping from path to that annotation. Four new
conformance tests build both adapters' metadata from the vendored fixtures and
check each annotation against it, demand promotion for anything both adapters
produce, pin the inventory so an entry cannot quietly disappear, and check each
exempt path still has a reader in the package source.
Bookkeeping only: `declared_field_paths()` returns the same 47 paths, verified
by diffing the dumped set against the previous commit's. The sole runtime
consumer needs the key view (schema_validation.py:110).
---
custom_components/span_panel/field_paths.py | 142 ++++++++++--------
.../span_panel/schema_validation.py | 8 +-
tests/test_field_path_conformance.py | 126 +++++++++++++++-
tests/test_field_paths.py | 4 +-
4 files changed, 212 insertions(+), 68 deletions(-)
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 8a8ac2ea..25b5cb0b 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -15,8 +15,10 @@
from __future__ import annotations
-from collections.abc import Iterable, Iterator
+from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass
+from enum import Enum
+from types import MappingProxyType
from homeassistant.helpers.entity import EntityDescription
@@ -91,80 +93,96 @@ class FieldPathDeclarationMixin:
"""
-RESIDUAL_EXEMPT_PATHS: frozenset[str] = frozenset(
+class Producibility(Enum):
+ """Which adapters publish a metadata row for an exempt residual path.
+
+ There is deliberately no `BOTH` member. A path both adapters produce
+ satisfies the producible gate, so it belongs in `declared_field_paths()`
+ rather than in an exemption; `test_no_exempt_path_is_producible_by_both`
+ turns that missing member into a failure naming the path to promote.
+ """
+
+ NEITHER = "neither"
+ """No metadata row on either adapter."""
+
+ SCHEMA_0_ONLY = "schema_0_only"
+ """Produced by the schema_0 adapter, absent from schema_1."""
+
+ SCHEMA_1_ONLY = "schema_1_only"
+ """Produced by the schema_1 adapter, absent from schema_0."""
+
+
+RESIDUAL_EXEMPT_PATHS: Mapping[str, Producibility] = MappingProxyType(
{
# Homie `$target` values — a pending-command echo, not a schema field.
- # Neither adapter publishes a metadata row for them.
- "circuit.relay_state_target",
- "circuit.priority_target",
+ "circuit.relay_state_target": Producibility.NEITHER,
+ "circuit.priority_target": Producibility.NEITHER,
# Assembled by the library from panel topology rather than read from a
- # schema property; no metadata row in either adapter.
- "circuit.device_type",
- "circuit.relative_position",
- # No metadata row in either adapter — the panel reports it outside the
- # typed field surface.
- "panel.panel_size",
+ # schema property.
+ "circuit.device_type": Producibility.NEITHER,
+ "circuit.relative_position": Producibility.NEITHER,
+ # The panel reports it outside the typed field surface.
+ "panel.panel_size": Producibility.NEITHER,
# The panel identity key behind every unique_id and the panel DeviceInfo
- # (~30 read sites). Neither adapter publishes a row for it.
- "panel.serial_number",
- # Gates button availability at button.py:115. No row in either adapter —
- # the same reason the `dsm_state` sensor is `derived=True`.
- "panel.dsm_state",
+ # (~30 read sites).
+ "panel.serial_number": Producibility.NEITHER,
+ # Gates button availability at button.py:115 — the same reason the
+ # `dsm_state` sensor is `derived=True`.
+ "panel.dsm_state": Producibility.NEITHER,
# The circuit's own identity key, used for lookups and id construction
- # (helpers.py, coordinator.py, entity_resolver.py). No row in either
- # adapter.
- "circuit.circuit_id",
- # Neither adapter emits any `mid.*` metadata rows at all; util.py reads
- # these off the MID snapshot for device_info, and sensor_panel.py reads
- # the grid-forming name for an attribute.
- "mid.hardware_version",
- "mid.software_version",
- "mid.vendor_name",
- "mid.model",
- "mid.serial_number",
- "mid.grid_forming_device_name",
+ # (helpers.py, coordinator.py, entity_resolver.py).
+ "circuit.circuit_id": Producibility.NEITHER,
+ # util.py reads these off the MID snapshot for device_info, and
+ # sensor_panel.py reads the grid-forming name for an attribute.
+ "mid.hardware_version": Producibility.NEITHER,
+ "mid.software_version": Producibility.NEITHER,
+ "mid.vendor_name": Producibility.NEITHER,
+ "mid.model": Producibility.NEITHER,
+ "mid.serial_number": Producibility.NEITHER,
+ "mid.grid_forming_device_name": Producibility.NEITHER,
# The EVSE's Homie node id — an addressing handle used to build the
- # sub-device identifier, not a published field. No row in either adapter.
- "evse.node_id",
- # Schema-conditional: schema_1 publishes it, schema_0 has no row.
- "circuit.is_user_controllable",
- # Schema-conditional: schema_0 publishes these, schema_1 has no row.
- "circuit.always_on",
- "circuit.is_sheddable",
- "panel.wifi_ssid",
- # Schema-conditional: schema_0 publishes these, schema_1 has no row.
- # util.py builds the EVSE DeviceInfo from them; entity_resolver.py and
+ # sub-device identifier, not a published field.
+ "evse.node_id": Producibility.NEITHER,
+ "circuit.is_user_controllable": Producibility.SCHEMA_1_ONLY,
+ "circuit.always_on": Producibility.SCHEMA_0_ONLY,
+ "circuit.is_sheddable": Producibility.SCHEMA_0_ONLY,
+ "panel.wifi_ssid": Producibility.SCHEMA_0_ONLY,
+ # util.py builds the EVSE DeviceInfo from these; entity_resolver.py and
# sensor.py resolve the fed circuit through `feed_circuit_id`.
- "evse.vendor_name",
- "evse.model",
- "evse.serial_number",
- "evse.software_version",
- "evse.feed_circuit_id",
- # Schema-conditional: schema_0 publishes it, schema_1 derives islanding
- # via `resolve_grid_islandable(inverters)`. Read at binary_sensor.py:408
- # as an entity-creation gate, outside any description.
- "panel.grid_islandable",
- # Schema-conditional: schema_0 publishes it, schema_1's
- # `_PROPERTY_FIELD_MAP` has no `connected` row — the same gap that makes
- # the `bess_connected` binary sensor `derived=True`.
- "battery.connected",
+ "evse.vendor_name": Producibility.SCHEMA_0_ONLY,
+ "evse.model": Producibility.SCHEMA_0_ONLY,
+ "evse.serial_number": Producibility.SCHEMA_0_ONLY,
+ "evse.software_version": Producibility.SCHEMA_0_ONLY,
+ "evse.feed_circuit_id": Producibility.SCHEMA_0_ONLY,
+ # schema_1 derives islanding via `resolve_grid_islandable(inverters)`
+ # instead. Read at binary_sensor.py:408 as an entity-creation gate,
+ # outside any description.
+ "panel.grid_islandable": Producibility.SCHEMA_0_ONLY,
+ # schema_1's `_PROPERTY_FIELD_MAP` has no `connected` row — the same gap
+ # that makes the `bess_connected` binary sensor `derived=True`.
+ "battery.connected": Producibility.SCHEMA_0_ONLY,
}
)
-"""Residual readers exempt from the producible check, for one of two reasons.
+"""Residual readers exempt from the producible check, and why each is exempt.
+
+The gate requires a path to be producible by *both* adapters, so a read is
+exempt for one of two reasons, and the annotation says which:
+`Producibility.NEITHER` for values no adapter publishes a metadata row for —
+Homie `$target` echoes, values the library assembles from panel topology, every
+`mid.*` field; `SCHEMA_0_ONLY` / `SCHEMA_1_ONLY` for schema-conditional fields
+present on one schema and absent from the other.
-**Not produced by any adapter** — Homie `$target` echoes, values the library
-assembles from panel topology, and every `mid.*` field. There is no metadata row
-to check against on either schema.
+Exempt is not the same as derived: these are read straight off a snapshot field,
+that field just is not on both schemas.
-**Produced by only one adapter** — schema-conditional fields. The gate requires
-a path to be producible by *both* adapters, so a field present on one schema and
-absent from the other cannot satisfy it. Exempt is not the same as derived:
-these are read straight off a snapshot field, that field just is not on both
-schemas.
+The annotations are not documentation. `tests/test_field_path_conformance.py`
+builds both adapters' metadata from the vendored fixtures and asserts every
+entry's annotation against what those adapters actually produce, so a stale
+reason fails the build instead of misleading a reader. A path that becomes
+producible by both fails there too, demanding promotion to a declaration.
Deliberately **not** returned by `declared_field_paths()`. Recorded here so the
-reads are still enumerated somewhere rather than being invisible. The per-entry
-comments say which of the two reasons applies.
+reads are still enumerated somewhere rather than being invisible.
"""
diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py
index c8b848df..d7fc945c 100644
--- a/custom_components/span_panel/schema_validation.py
+++ b/custom_components/span_panel/schema_validation.py
@@ -105,9 +105,11 @@ def evaluate_field_metadata(
mismatches.append(UnitMismatch(field_path, str(ha_unit), schema_unit))
# `RESIDUAL_EXEMPT_PATHS` are read by the integration; they are exempt from
- # the *producible* gate because only one adapter emits them, so they are
- # absent from `declared` without being unread.
- unread = frozenset(set(field_metadata) - set(declared) - RESIDUAL_EXEMPT_PATHS)
+ # the *producible* gate because only one adapter emits them, or neither
+ # does, so they are absent from `declared` without being unread. Only the
+ # paths matter here; each entry's `Producibility` annotation is what the
+ # conformance tests verify.
+ unread = frozenset(set(field_metadata) - set(declared) - RESIDUAL_EXEMPT_PATHS.keys())
for field_path in sorted(unread):
# An addition is legal within a major version. This is an inventory for
# us, never a user-facing finding.
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index f799a9b6..27277ac8 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -1,11 +1,17 @@
"""Every declared field path must be producible by each adapter, or derived.
This is the test that would have caught the battery.product_name drift.
+
+A read that no adapter, or only one, produces cannot satisfy that gate, so it is
+exempted in `RESIDUAL_EXEMPT_PATHS`. The second half of this module holds those
+exemptions to the same standard: each one states which adapters produce it, and
+that statement is checked against the adapters rather than left as prose.
"""
from __future__ import annotations
import ast
+from collections import Counter
from collections.abc import Callable
import pathlib
@@ -13,7 +19,11 @@
from span_panel_api.models import FieldMetadata
from custom_components.span_panel import field_paths as field_paths_module
-from custom_components.span_panel.field_paths import declared_field_paths
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ Producibility,
+ declared_field_paths,
+)
from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata
MetadataFn = Callable[[], dict[str, FieldMetadata]]
@@ -109,3 +119,117 @@ def test_gate_is_one_directional(
print(f"\n[{adapter}] produced but unread ({len(unread)}):")
for path in unread:
print(f" {path}")
+
+
+def _adapter_paths() -> tuple[frozenset[str], frozenset[str]]:
+ """Return the paths each adapter actually produces, from the vendored fixtures."""
+ return frozenset(schema_zero_metadata()), frozenset(schema_one_metadata())
+
+
+def _observed_producibility(
+ path: str, s0: frozenset[str], s1: frozenset[str]
+) -> Producibility | None:
+ """Classify a path by which adapters produce it.
+
+ `None` is the fourth, illegal case — produced by both — which has no
+ `Producibility` member precisely because such a path is not exemptible.
+ """
+ match (path in s0, path in s1):
+ case (True, True):
+ return None
+ case (True, False):
+ return Producibility.SCHEMA_0_ONLY
+ case (False, True):
+ return Producibility.SCHEMA_1_ONLY
+ case _:
+ return Producibility.NEITHER
+
+
+def test_every_exempt_path_matches_its_annotation() -> None:
+ """Each exemption's stated reason is checked against the adapters that run.
+
+ Before these annotations the 26 exemptions were checked against nothing:
+ the neither/one-adapter distinction lived in prose, so an entry could be
+ mislabelled from the day it was written, or go stale when the library
+ changed what it publishes, with no signal anywhere.
+ """
+ s0, s1 = _adapter_paths()
+ wrong = [
+ (path, annotated.name, observed.name if observed else "BOTH")
+ for path, annotated in RESIDUAL_EXEMPT_PATHS.items()
+ if (observed := _observed_producibility(path, s0, s1)) is not annotated
+ ]
+ assert not wrong, (
+ "RESIDUAL_EXEMPT_PATHS annotations disagree with the adapters "
+ f"(path, annotated, actual): {wrong}. Either the annotation is stale or "
+ "the library changed what it produces."
+ )
+
+
+def test_no_exempt_path_is_producible_by_both() -> None:
+ """A path both adapters produce is a declaration, not an exemption.
+
+ The exemption exists only because the gate demands producibility by both.
+ Once both produce it, the reason has evaporated and the path should be
+ declared — silently leaving it exempt would retire the gate for that read.
+ """
+ s0, s1 = _adapter_paths()
+ promotable = sorted(RESIDUAL_EXEMPT_PATHS.keys() & s0 & s1)
+ assert not promotable, (
+ f"exempt paths are now producible by both adapters: {promotable}. "
+ "Promote each to a declaration — a description's `field_path=`, or "
+ "`RESIDUAL_FIELD_PATHS` for a reader in entity code — so the producible "
+ "gate covers it again."
+ )
+
+
+_EXPECTED_EXEMPT_COUNTS: dict[Producibility, int] = {
+ Producibility.NEITHER: 15,
+ Producibility.SCHEMA_0_ONLY: 10,
+ Producibility.SCHEMA_1_ONLY: 1,
+}
+"""The exemption inventory, by reason. See `test_exempt_inventory_is_complete`."""
+
+
+def test_exempt_inventory_is_complete() -> None:
+ """An exemption disappearing must be a deliberate edit, not an accident.
+
+ Every other check here is consistency-only: deleting an entry leaves the
+ survivors perfectly annotated, so the set could quietly shrink and no test
+ would notice — the read would simply stop being enumerated anywhere. The
+ completeness of the set cannot be derived (a `Producibility.NEITHER` path
+ has, by definition, no adapter row to discover it from, and matching reads
+ by leaf attribute name collides across snapshot types), so it is pinned by
+ size per reason instead. Changing a count is fine; doing it in the same
+ commit as the entry, with a reason, is the point.
+ """
+ counts = Counter(RESIDUAL_EXEMPT_PATHS.values())
+ assert counts == _EXPECTED_EXEMPT_COUNTS, (
+ f"exemption inventory changed: {counts} != {_EXPECTED_EXEMPT_COUNTS}. An "
+ "entry was added or removed; update the expected counts in the same "
+ "commit if that was intended."
+ )
+
+
+def test_every_exempt_path_still_has_a_reader() -> None:
+ """An exemption outlives its reader silently; the list only ever grows.
+
+ Heuristic by necessity — an exempt path is read outside any description, so
+ there is no declaration to match against, only source text. Matching the
+ leaf attribute anywhere in the package is deliberately generous: it cannot
+ accuse a live read, and it still catches the last reader of a field being
+ deleted while its exemption stays behind.
+ """
+ read_attributes = {
+ node.attr
+ for source in sorted(_PACKAGE_ROOT.rglob("*.py"))
+ for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"), filename=str(source)))
+ if isinstance(node, ast.Attribute)
+ }
+ unread = sorted(
+ path for path in RESIDUAL_EXEMPT_PATHS if path.split(".", 1)[1] not in read_attributes
+ )
+ assert not unread, (
+ f"exempt paths no longer read anywhere in the package: {unread}. The "
+ "reader was removed; drop the exemption with it."
+ )
diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py
index a5b12b3c..c8b4f72b 100644
--- a/tests/test_field_paths.py
+++ b/tests/test_field_paths.py
@@ -66,5 +66,5 @@ def test_every_description_declares_exactly_one() -> None:
def test_residual_buckets_are_disjoint() -> None:
"""A residual path is either producible or exempt, never both."""
- assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_EXEMPT_PATHS)
- assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS)
+ assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_EXEMPT_PATHS.keys())
+ assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS.keys())
From 242bebc38c0b520b34be570bf229be7f525fd748 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:21:45 -0700
Subject: [PATCH 041/116] refactor(field-paths): derive residual paths from the
entities that read them
`RESIDUAL_FIELD_PATHS` restated, as a module constant, exactly what the four
entity classes already declare in `_residual_field_paths` -- the same fact in
two places, kept coherent by a bidirectional test that was itself a third copy
of the class list.
The entity's copy is the one that has to be right: it is what
`async_added_to_hass` registers, so it is what a Repair names when a field
dies. So the constant goes and `residual_field_paths()` collects the paths off
a recursive `SpanPanelEntity.__subclasses__()` walk instead, with the platform
imports deferred for the same cycle-avoidance reason `declared_field_paths()`
already defers its own.
A walk sees only what has been imported, which is a hole where a residual could
go missing silently -- the derived set would just be smaller, and everything
downstream of it is monotone in the direction that hides that. Closed the same
way the conformance gate closes it for `field_path=`: an AST scan for
`_residual_field_paths` tuple literals over the package source, asserted
against the walk in both directions.
`declared_field_paths()` returns the same 47 paths.
---
custom_components/span_panel/entity.py | 13 ++--
custom_components/span_panel/field_paths.py | 71 +++++++++++++------
tests/test_field_path_conformance.py | 75 ++++++++++++++++++++-
tests/test_field_path_introspection.py | 4 +-
tests/test_field_paths.py | 4 +-
tests/test_schema_repairs.py | 60 +++--------------
6 files changed, 144 insertions(+), 83 deletions(-)
diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py
index 291807c3..125bd113 100644
--- a/custom_components/span_panel/entity.py
+++ b/custom_components/span_panel/entity.py
@@ -26,12 +26,15 @@ class SpanPanelEntity(CoordinatorEntity[SpanPanelCoordinator]):
A handful of reads cannot be expressed as a description `field_path`: the
switch has no entity description at all, the select wraps one, and a circuit
entity's name, tabs and attributes are read outside any `value_fn`.
- `field_paths.RESIDUAL_FIELD_PATHS` already names that exact set for the
- producible gate; declaring the same paths here is what lets a Repair say
- which entities a dead one takes with it, instead of "0 affected".
+ Declaring them here is what lets a Repair say which entities a dead field
+ takes with it, instead of "0 affected".
- `test_every_residual_field_path_is_claimed_by_an_entity` pins the two lists
- to each other in both directions.
+ This is the only place these paths are written down:
+ `field_paths.residual_field_paths()` collects them from every subclass, so
+ the producible gate covers exactly what the entities claim.
+
+ Keep the list short. A new entry is a hint that the reader belongs on a
+ description instead, where the declaration and the reader are one object.
"""
async def async_added_to_hass(self) -> None:
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 25b5cb0b..dfe3da84 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -72,27 +72,6 @@ class FieldPathDeclarationMixin:
"""
-RESIDUAL_FIELD_PATHS: frozenset[str] = frozenset(
- {
- # switch.py reads this in entity code, not via a description value_fn
- "circuit.relay_state",
- # select.py uses a wrapper class rather than a frozen dataclass
- # description, so it cannot carry the field as a dataclass field
- "circuit.priority",
- # Consumed by entity naming and attributes rather than by any platform
- "circuit.name",
- "circuit.tabs",
- # sensor_circuit.py publishes this as a circuit attribute
- "circuit.relay_requester",
- }
-)
-"""Readers not carried on an entity description, and producible by both adapters.
-
-Keep this small. A new entry is a hint that the reader belongs on a
-description instead.
-"""
-
-
class Producibility(Enum):
"""Which adapters publish a metadata row for an exempt residual path.
@@ -211,6 +190,54 @@ def iter_field_path_declarations[DescriptionT: EntityDescription](
yield description.field_path, description
+def _walk_subclasses[EntityT](root: type[EntityT]) -> Iterator[type[EntityT]]:
+ """Yield every subclass of `root`, transitively.
+
+ `__subclasses__()` is one level deep; platform entities sit two or three
+ levels below the base (`SpanSensorBase` -> `SpanCircuitPowerSensor`), so a
+ single level would miss exactly the classes that carry residual reads.
+ """
+ for subclass in root.__subclasses__():
+ yield subclass
+ yield from _walk_subclasses(subclass)
+
+
+def residual_field_paths() -> frozenset[str]:
+ """Field paths read from entity code rather than from a description.
+
+ A handful of reads cannot be expressed as a description `field_path`: the
+ switch has no entity description at all, the select wraps one rather than
+ being a frozen dataclass, and a circuit entity's name, tabs and attributes
+ are read outside any `value_fn`. Each such read is declared on the entity
+ that makes it -- `SpanPanelEntity._residual_field_paths` -- because the
+ entity is what a Repair has to name when the field dies.
+
+ Collected from those declarations rather than restated as a constant here:
+ a second copy would need a test to hold it against the first, and the copy
+ that the Repair actually consumes is the entity's.
+
+ The walk sees only classes Python has imported, so the platform modules
+ that declare residuals are imported here explicitly. A residual declared in
+ a module this function does not reach would go missing silently, so
+ `test_source_residuals_match_the_subclass_walk` scans the package source
+ for `_residual_field_paths` assignments and fails on any the walk missed.
+ """
+ # Deferred for the same cycle-avoidance reason as `declared_field_paths()`
+ # below: every platform module imports this one for the declaration mixin.
+ from . import ( # noqa: F401 pylint: disable=import-outside-toplevel,unused-import
+ select,
+ sensor_circuit,
+ switch,
+ )
+ from .entity import SpanPanelEntity # pylint: disable=import-outside-toplevel
+
+ return frozenset(
+ path
+ for entity_class in _walk_subclasses(SpanPanelEntity)
+ for path in entity_class._residual_field_paths # pylint: disable=protected-access
+ )
+
+
def declared_field_paths() -> frozenset[str]:
"""Field paths the integration reads that must be producible by an adapter.
@@ -231,7 +258,7 @@ def declared_field_paths() -> frozenset[str]:
all_sensor_descriptions,
)
- paths: set[str] = set(RESIDUAL_FIELD_PATHS)
+ paths: set[str] = set(residual_field_paths())
paths.update(
field_path
for field_path, _ in iter_field_path_declarations(
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 27277ac8..25cb48ec 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -23,6 +23,7 @@
RESIDUAL_EXEMPT_PATHS,
Producibility,
declared_field_paths,
+ residual_field_paths,
)
from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata
@@ -97,6 +98,74 @@ def test_gate_covers_every_declaration_in_the_source() -> None:
)
+def _source_residual_paths() -> dict[str, str]:
+ """Every `_residual_field_paths` tuple literal in the integration source, by module.
+
+ The runtime counterpart, `residual_field_paths()`, unions the class
+ attribute over a `SpanPanelEntity.__subclasses__()` walk, and a walk sees
+ only what has been imported. This reads the same declarations out of the
+ source text, where importedness is not a factor.
+ """
+ found: dict[str, str] = {}
+ for source in sorted(_PACKAGE_ROOT.rglob("*.py")):
+ tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
+ for node in ast.walk(tree):
+ targets: list[ast.expr]
+ if isinstance(node, ast.AnnAssign):
+ targets = [node.target]
+ elif isinstance(node, ast.Assign):
+ targets = list(node.targets)
+ else:
+ continue
+ if not any(
+ isinstance(target, ast.Name) and target.id == "_residual_field_paths"
+ for target in targets
+ ):
+ continue
+ if not isinstance(node.value, ast.Tuple):
+ continue
+ for element in node.value.elts:
+ if isinstance(element, ast.Constant) and isinstance(element.value, str):
+ found.setdefault(element.value, source.name)
+ return found
+
+
+def test_source_residuals_match_the_subclass_walk() -> None:
+ """A residual the walk cannot see must fail, not vanish.
+
+ `residual_field_paths()` imports the platform modules that declare
+ residuals so their classes exist to be walked. A class declaring one in a
+ module it does not import — a new platform, or an existing one that stops
+ being imported — would drop out of the producible gate and out of the
+ Repair's affected-entity count with no signal at all: the derived set would
+ simply be smaller, and every check downstream of it is monotone in exactly
+ the direction that hides the loss.
+
+ This is that signal, read from the source text where import order does not
+ apply. The converse direction matters too: a residual assembled at runtime
+ rather than written as a tuple literal is invisible to this scan, so the
+ scan would silently stop pinning it.
+ """
+ from_source = _source_residual_paths()
+ from_walk = residual_field_paths()
+
+ unwalked = sorted(
+ (path, module) for path, module in from_source.items() if path not in from_walk
+ )
+ assert not unwalked, (
+ f"residual paths declared in the source but not reached by the subclass walk: "
+ f"{unwalked}. `residual_field_paths()` does not import the declaring module, so "
+ "the producible gate and the Repair's affected-entity count both miss these reads."
+ )
+
+ unscanned = sorted(from_walk - from_source.keys())
+ assert not unscanned, (
+ f"residual paths the source scan cannot see: {unscanned}. They are not written "
+ "as string literals in a `_residual_field_paths` tuple, so this pin no longer "
+ "covers them — declare them literally."
+ )
+
+
@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS)
def test_gate_is_one_directional(
adapter: str, metadata_fn: MetadataFn, capsys: pytest.CaptureFixture[str]
@@ -177,9 +246,9 @@ def test_no_exempt_path_is_producible_by_both() -> None:
promotable = sorted(RESIDUAL_EXEMPT_PATHS.keys() & s0 & s1)
assert not promotable, (
f"exempt paths are now producible by both adapters: {promotable}. "
- "Promote each to a declaration — a description's `field_path=`, or "
- "`RESIDUAL_FIELD_PATHS` for a reader in entity code — so the producible "
- "gate covers it again."
+ "Promote each to a declaration — a description's `field_path=`, or an "
+ "entity's `_residual_field_paths` for a reader in entity code — so the "
+ "producible gate covers it again."
)
diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py
index 89bf2740..3ae46805 100644
--- a/tests/test_field_path_introspection.py
+++ b/tests/test_field_path_introspection.py
@@ -10,8 +10,8 @@
from typing import Any, Protocol, runtime_checkable
from custom_components.span_panel.field_paths import (
- RESIDUAL_FIELD_PATHS,
declared_field_paths,
+ residual_field_paths,
)
from custom_components.span_panel.sensor_definitions import all_sensor_descriptions
from tests.adapter_fixtures import schema_one_metadata, schema_zero_metadata
@@ -189,7 +189,7 @@ def test_introspection_covers_every_declared_path() -> None:
for description in _declaring_descriptions()
if not description.derived and description.field_path is not None
}
- assert declared_field_paths() == frozenset(introspected | set(RESIDUAL_FIELD_PATHS))
+ assert declared_field_paths() == frozenset(introspected | residual_field_paths())
def test_no_derived_description_reads_one_producible_field() -> None:
diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py
index c8b4f72b..5c0d95ed 100644
--- a/tests/test_field_paths.py
+++ b/tests/test_field_paths.py
@@ -10,8 +10,8 @@
)
from custom_components.span_panel.field_paths import (
RESIDUAL_EXEMPT_PATHS,
- RESIDUAL_FIELD_PATHS,
declared_field_paths,
+ residual_field_paths,
)
from custom_components.span_panel.sensor_definitions import (
CIRCUIT_SENSORS,
@@ -66,5 +66,5 @@ def test_every_description_declares_exactly_one() -> None:
def test_residual_buckets_are_disjoint() -> None:
"""A residual path is either producible or exempt, never both."""
- assert not (RESIDUAL_FIELD_PATHS & RESIDUAL_EXEMPT_PATHS.keys())
+ assert not (residual_field_paths() & RESIDUAL_EXEMPT_PATHS.keys())
assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS.keys())
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index 946d8bb4..6db31053 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -536,9 +536,7 @@ async def _entities_by_declared_path(hass):
# refuses to add an entity whose via_device is not a registered device id.
config_entry.runtime_data = SpanPanelRuntimeData(
coordinator=coordinator,
- panel_device_id=await ensure_device_registered(
- hass, config_entry, snapshot, "SPAN Panel"
- ),
+ panel_device_id=await ensure_device_registered(hass, config_entry, snapshot, "SPAN Panel"),
)
grouped: dict[str, dict[str, list[object]]] = {}
@@ -551,9 +549,9 @@ async def _entities_by_declared_path(hass):
continue
if description.derived or description.field_path not in _STYLE_PATHS:
continue
- grouped.setdefault(description.field_path, {}).setdefault(
- platform_domain, []
- ).append(entity)
+ grouped.setdefault(description.field_path, {}).setdefault(platform_domain, []).append(
+ entity
+ )
return coordinator, config_entry, grouped
@@ -590,9 +588,7 @@ async def test_affected_entities_span_all_three_unique_id_styles(hass) -> None:
"""
coordinator, config_entry, grouped = await _entities_by_declared_path(hass)
try:
- assert _STYLE_PATHS <= grouped.keys(), (
- f"fixture missed {_STYLE_PATHS - grouped.keys()}"
- )
+ assert _STYLE_PATHS <= grouped.keys(), f"fixture missed {_STYLE_PATHS - grouped.keys()}"
# The fixture really does cover three different builders: circuit suffix,
# panel entity suffix, raw camelCase key.
@@ -600,9 +596,7 @@ def _first(path: str, platform_domain: str):
return grouped[path][platform_domain][0]
assert _first("circuit.instant_power_w", "sensor").unique_id.endswith("_power")
- assert _first("panel.instant_grid_power_w", "sensor").unique_id.endswith(
- "_current_power"
- )
+ assert _first("panel.instant_grid_power_w", "sensor").unique_id.endswith("_current_power")
assert _first("panel.door_state", "binary_sensor").unique_id.endswith("doorState")
# One platform per domain, as HA does — several platforms sharing a
@@ -751,9 +745,11 @@ async def test_a_platform_with_no_description_still_registers_its_residuals(
# Five field paths are read from entity code rather than from a description's
# `field_path`: the switch's relay state, the select's priority, and the name,
# tabs and relay requester a circuit entity uses for its identity and its
-# attributes. `RESIDUAL_FIELD_PATHS` lists them for the producible gate. Nothing
-# declared them on the entities, so a dead `circuit.relay_state` reported "0
-# entity/entities are affected" while every breaker switch on the panel was out.
+# attributes. Each is declared on the entity that reads it, which is where
+# `field_paths.residual_field_paths()` collects them from for the producible
+# gate. Nothing declared them on the entities, so a dead `circuit.relay_state`
+# reported "0 entity/entities are affected" while every breaker switch on the
+# panel was out.
async def test_a_dead_relay_state_names_the_breaker_switches(hass) -> None:
@@ -809,37 +805,3 @@ async def test_a_dead_priority_names_the_selects(hass) -> None:
assert affected["circuit.priority"] == sorted(s.entity_id for s in selects)
finally:
await _stop_scheduling(coordinator)
-
-
-def test_every_residual_field_path_is_claimed_by_an_entity() -> None:
- """No residual read may be left with nothing to name.
-
- `RESIDUAL_FIELD_PATHS` exists because these reads live in entity code rather
- than on a description. That is exactly why they cannot be discovered — so
- each one is declared on the entity that makes it, and this pins the two lists
- together. A new residual entry with no declaring entity would otherwise ship
- a Repair that says "0 affected" when the answer is "all of them".
- """
- from custom_components.span_panel.field_paths import RESIDUAL_FIELD_PATHS
- from custom_components.span_panel.select import SpanPanelCircuitsSelect
- from custom_components.span_panel.sensor_circuit import (
- SpanCircuitEnergySensor,
- SpanCircuitPowerSensor,
- )
- from custom_components.span_panel.switch import SpanPanelCircuitsSwitch
-
- declared: set[str] = set()
- for entity_class in (
- SpanPanelCircuitsSwitch,
- SpanPanelCircuitsSelect,
- SpanCircuitPowerSensor,
- SpanCircuitEnergySensor,
- ):
- declared.update(entity_class._residual_field_paths)
-
- assert RESIDUAL_FIELD_PATHS <= declared, (
- f"undeclared residual reads: {RESIDUAL_FIELD_PATHS - declared}"
- )
- assert declared <= RESIDUAL_FIELD_PATHS, (
- f"declared but not in the residual set: {declared - RESIDUAL_FIELD_PATHS}"
- )
From e3e64c21d3dfde886c03ca0485ae27d96e5f216a Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:29:13 -0700
Subject: [PATCH 042/116] test(field-paths): derive the recorder prefix from
the value_fn annotation
The class-name map had to be edited for every new description class -- ten
entries and growing -- and a class missing from it was the original silent
skip, patched into a recorded mismatch but not removed.
Resolve the snapshot type from the value_fn annotation instead
(get_type_hints, so the stringified annotations resolve) and map the five
snapshot types to their prefixes. A new description class over an existing
snapshot type now needs no map edit, and its prefix cannot be wrong: it comes
from the annotation mypy already checks the value_fn body against.
An unresolvable annotation or an unmapped snapshot type raises, and both
callers record it as a mismatch naming the description -- never a skip.
---
tests/test_field_path_introspection.py | 131 ++++++++++++++++++-------
1 file changed, 93 insertions(+), 38 deletions(-)
diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py
index 3ae46805..35bbe9be 100644
--- a/tests/test_field_path_introspection.py
+++ b/tests/test_field_path_introspection.py
@@ -6,8 +6,16 @@
from __future__ import annotations
-from collections.abc import Callable, Iterator
-from typing import Any, Protocol, runtime_checkable
+from collections.abc import Callable, Iterator, Mapping
+from typing import Any, Protocol, get_args, get_type_hints, runtime_checkable
+
+from span_panel_api import (
+ SpanBatterySnapshot,
+ SpanCircuitSnapshot,
+ SpanEvseSnapshot,
+ SpanMidSnapshot,
+ SpanPanelSnapshot,
+)
from custom_components.span_panel.field_paths import (
declared_field_paths,
@@ -129,43 +137,95 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]:
yield description
-# Every description class, and the snapshot type its value_fn receives.
-# A class missing here is reported as a mismatch rather than skipped: a silent
-# skip is exactly the hole this test exists to close.
-_ROOT_PREFIX = {
- "SpanPanelCircuitsSensorEntityDescription": "circuit",
- "SpanPanelDataSensorEntityDescription": "panel",
- "SpanPanelStatusSensorEntityDescription": "panel",
- "SpanPanelBatterySensorEntityDescription": "battery",
- "SpanBessMetadataSensorEntityDescription": "battery",
- # PV metadata value_fns take the whole panel snapshot and reach through
- # `s.pv.x`, so the root prefix is "panel" and _SUB_SNAPSHOTS rewrites it.
- "SpanPVMetadataSensorEntityDescription": "panel",
- "SpanEvseSensorEntityDescription": "evse",
- "SpanMidSensorEntityDescription": "mid",
- "SpanPanelBinarySensorEntityDescription": "panel",
- "SpanEvseBinarySensorEntityDescription": "evse",
+# Every snapshot type a value_fn can take, and the prefix its fields are
+# addressed by. Keyed by the snapshot type rather than by description class
+# name: the class name map had to be edited for every new description class —
+# the recurring event — while this one grows only when the library grows a new
+# snapshot type. A new description class over an existing snapshot type needs no
+# edit here, and its prefix cannot be wrong, because it comes from the same
+# annotation mypy checks the value_fn bodies against.
+#
+# PV has no entry: PV metadata value_fns take the whole panel snapshot and reach
+# through `s.pv.x`, so their prefix is "panel" and `_SUB_SNAPSHOTS` rewrites it.
+_SNAPSHOT_PREFIX: Mapping[type, str] = {
+ SpanCircuitSnapshot: "circuit",
+ SpanPanelSnapshot: "panel",
+ SpanBatterySnapshot: "battery",
+ SpanEvseSnapshot: "evse",
+ SpanMidSnapshot: "mid",
}
+class _UndeterminedPrefix(Exception):
+ """A description's snapshot prefix could not be determined.
+
+ Raised, never swallowed: a description whose prefix is unknown must be
+ reported by its caller as a mismatch. Skipping it is what let a description
+ class absent from the old class-name map drop out of verification entirely.
+ """
+
+
+def _snapshot_type(description: _DeclaringDescription) -> type:
+ """Return the snapshot type this description's `value_fn` is annotated to take.
+
+ `from __future__ import annotations` stringifies the annotation, so this
+ resolves it with `get_type_hints`, which evaluates it in the defining
+ module's namespace — every mixin's module imports the snapshot types it
+ names, so resolution succeeds.
+ """
+ cls = type(description)
+ try:
+ hints = get_type_hints(cls)
+ except Exception as err: # noqa: BLE001
+ raise _UndeterminedPrefix(
+ f"{cls.__name__}: value_fn annotation does not resolve ({err!r})"
+ ) from err
+ annotation = hints.get("value_fn")
+ if annotation is None:
+ raise _UndeterminedPrefix(f"{cls.__name__} carries no value_fn annotation")
+ args = get_args(annotation)
+ if len(args) != 2 or not isinstance(args[0], list) or not args[0]:
+ raise _UndeterminedPrefix(
+ f"{cls.__name__}: value_fn annotated {annotation!r} names no parameter type"
+ )
+ parameter = args[0][0]
+ if not isinstance(parameter, type):
+ raise _UndeterminedPrefix(
+ f"{cls.__name__}: value_fn takes {parameter!r}, which is not a snapshot class"
+ )
+ return parameter
+
+
+def _record_reads(description: _DeclaringDescription) -> set[str]:
+ """Run a description's `value_fn` against the recorder, return what it read.
+
+ Raises `_UndeterminedPrefix` when the snapshot type cannot be resolved or is
+ absent from `_SNAPSHOT_PREFIX`; anything the `value_fn` itself raises
+ propagates unchanged.
+ """
+ snapshot_type = _snapshot_type(description)
+ prefix = _SNAPSHOT_PREFIX.get(snapshot_type)
+ if prefix is None:
+ raise _UndeterminedPrefix(
+ f"{type(description).__name__}: value_fn takes {snapshot_type.__name__}, "
+ "which is absent from _SNAPSHOT_PREFIX"
+ )
+ sink: set[str] = set()
+ description.value_fn(_Recorder(sink, prefix, root=snapshot_type is SpanPanelSnapshot))
+ return sink
+
+
def test_declared_paths_match_what_value_fns_read() -> None:
mismatches: list[str] = []
for description in _declaring_descriptions():
if description.derived or description.field_path is None:
continue
- class_name = type(description).__name__
- prefix = _ROOT_PREFIX.get(class_name)
- if prefix is None:
- mismatches.append(
- f"{description.key}: {class_name} is absent from _ROOT_PREFIX, so its "
- "declaration would go unverified"
- )
- continue
- sink: set[str] = set()
- proxy = _Recorder(sink, prefix, root=(prefix == "panel"))
try:
- description.value_fn(proxy)
+ sink = _record_reads(description)
+ except _UndeterminedPrefix as err:
+ mismatches.append(f"{description.key}: {err}, so its declaration would go unverified")
+ continue
except Exception as err: # noqa: BLE001
mismatches.append(f"{description.key}: value_fn raised {err!r}")
continue
@@ -211,18 +271,13 @@ def test_no_derived_description_reads_one_producible_field() -> None:
for description in _declaring_descriptions():
if not description.derived:
continue
- class_name = type(description).__name__
- prefix = _ROOT_PREFIX.get(class_name)
- if prefix is None:
+ try:
+ sink = _record_reads(description)
+ except _UndeterminedPrefix as err:
offenders.append(
- f"{description.key}: {class_name} is absent from _ROOT_PREFIX, so its "
- "derived classification would go unverified"
+ f"{description.key}: {err}, so its derived classification would go unverified"
)
continue
- sink: set[str] = set()
- proxy = _Recorder(sink, prefix, root=(prefix == "panel"))
- try:
- description.value_fn(proxy)
except Exception as err: # noqa: BLE001
offenders.append(f"{description.key}: value_fn raised {err!r}")
continue
From d7ce6f980c83067691ce0b6608a6e717dc4e81e7 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:34:42 -0700
Subject: [PATCH 043/116] feat(field-paths): say why a description is derived,
and verify the reason
`derived: bool` conflated four situations -- reads nothing, reads several
fields, reads one field only one adapter produces, and the mistake of marking a
single producible field derived. That last one is how evse_ev_connected went
unnoticed: it declared no path, so the Repair never named it and the
availability probe never fired for it.
Replace it with `derived: DerivedReason | None`, three variants, each a claim
the recorder can check: NO_SOURCE_FIELD reads nothing either adapter publishes,
MULTIPLE_FIELDS reads two or more, SCHEMA_CONDITIONAL_FIELD reads exactly one
that only one adapter produces. There is deliberately no variant for "reads a
field no adapter produces" -- the recorder cannot tell that from reading no
field at all, and an unverifiable variant is what this replaces.
test_derived_reasons_match_what_value_fns_read asserts every one of the 11
declarations against what its value_fn actually reads, alongside the existing
single-producible-field check.
An enum member is truthy and None is falsy, so every consumer -- field_paths,
entity.py's declaration and probe gates -- is unchanged. The
declares-exactly-one pin needed the comparison made explicit: bool != enum was
true for every description, declaring and derived alike.
---
custom_components/span_panel/binary_sensor.py | 8 +-
custom_components/span_panel/entity.py | 8 +-
custom_components/span_panel/field_paths.py | 115 ++++++++++++------
.../span_panel/sensor_definitions.py | 22 ++--
tests/test_dps_and_bess.py | 2 +-
tests/test_field_path_conformance.py | 2 +-
tests/test_field_path_introspection.py | 67 +++++++++-
tests/test_field_paths.py | 9 +-
tests/test_schema_availability.py | 5 +-
9 files changed, 174 insertions(+), 64 deletions(-)
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 90a35e08..3c2ef7ec 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -30,7 +30,7 @@
)
from .coordinator import SpanPanelCoordinator
from .entity import SpanPanelEntity
-from .field_paths import FieldPathDeclarationMixin
+from .field_paths import DerivedReason, FieldPathDeclarationMixin
from .helpers import (
build_binary_sensor_unique_id_for_entry,
build_evse_unique_id_for_entry,
@@ -103,7 +103,7 @@ class SpanPanelBinarySensorEntityDescription(
key=PANEL_STATUS,
# Reports coordinator reachability, not a snapshot field — the value_fn
# is a placeholder the entity class overrides.
- derived=True,
+ derived=DerivedReason.NO_SOURCE_FIELD,
translation_key="panel_status",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -141,7 +141,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
GRID_ISLANDABLE_SENSOR = SpanPanelBinarySensorEntityDescription(
key="grid_islandable",
- derived=True,
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
translation_key="grid_islandable",
device_class=BinarySensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -150,7 +150,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
BESS_CONNECTED_SENSOR = SpanPanelBinarySensorEntityDescription(
key="bess_connected",
- derived=True,
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
translation_key="bess_connected",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py
index 125bd113..f4ca67b5 100644
--- a/custom_components/span_panel/entity.py
+++ b/custom_components/span_panel/entity.py
@@ -64,10 +64,10 @@ def _declared_field_paths(self) -> tuple[str, ...]:
"""Return every snapshot field this entity reads.
The description's `field_path` when it declares one, plus any residual
- reads. A description that declares nothing (`derived` entities, which
- read several fields or none) contributes nothing, and a platform with no
- entity description at all — the circuit switch — contributes only its
- residual reads.
+ reads. A description that declares nothing (`derived` entities, whose
+ `DerivedReason` says why no single field is theirs) contributes nothing,
+ and a platform with no entity description at all — the circuit switch —
+ contributes only its residual reads.
"""
description: object = getattr(self, "entity_description", None)
if (
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index dfe3da84..6e50ddc6 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -23,58 +23,64 @@
from homeassistant.helpers.entity import EntityDescription
-@dataclass(frozen=True, kw_only=True)
-class FieldPathDeclarationMixin:
- """Declares which snapshot field an entity description reads.
+class DerivedReason(Enum):
+ """Why an entity description declares no single source field.
- Mixed into every required-keys mixin so the declaration and the reader are
- the same object. Defined once here rather than repeated on each mixin, so
- the two fields cannot themselves drift apart across platforms.
+ A description is derived for exactly one of these reasons, and which one is
+ mechanical — count the snapshot fields its `value_fn` reads.
+ `test_derived_reasons_match_what_value_fns_read` runs every derived
+ description against the recorder and asserts the reason it claims, so a
+ wrong reason fails the build rather than misinforming a reader.
- The fields are keyword-only: an entity description flattens this mixin's
- fields ahead of ``EntityDescription.key``, which has no default, so a
- positional pair here would make every description unconstructable.
+ Reading exactly one producible field is **always** a declaration, however
+ much arithmetic, mapping or membership-testing is applied on top. "Computed
+ from `status`" is not derivation: `field_path="evse.status"` with a
+ `value_fn` of `status in {...}` declares its source correctly and still
+ computes whatever it likes. `evse_ev_connected` was misclassified as derived
+ this way, which cost it both a Repair mention and its unavailability, while
+ its sibling `evse_charging` — same field, same shape — got both. That is the
+ conflation this enum exists to break: as a bare `bool`, all three reasons
+ below and that mistake looked identical.
"""
- field_path: str | None = None
- """Snapshot field this entity reads, e.g. "circuit.instant_power_w".
+ NO_SOURCE_FIELD = "no_source_field"
+ """Reads no field either adapter publishes a metadata row for.
- Declared here rather than in a parallel map so the declaration and the
- reader are the same object. Verified against `value_fn` by the proxy test
- in tests/test_field_path_introspection.py.
+ Either it reads nothing off the snapshot at all (`panel_status` reports
+ coordinator reachability) or the field it reads is one no adapter produces
+ (`dsm_state`, every `mid.*` read). Deliberately one member rather than two:
+ the recorder cannot tell those apart — both leave an empty intersection with
+ what the adapters emit — and a variant nothing can verify is exactly what
+ this enum replaces.
"""
- derived: bool = False
- """True only when there is no single source field to declare.
-
- Exactly one of these three must hold, and the test is mechanical — count the
- snapshot fields the `value_fn` reads:
-
- 1. it reads **no** snapshot field, or
- 2. it reads **more than one**, or
- 3. it reads exactly one that no adapter, or only one adapter, produces.
+ MULTIPLE_FIELDS = "multiple_fields"
+ """Combines two or more producible fields, so no one of them is the source.
- Reading exactly one producible field is **always** a declaration, however
- much arithmetic, mapping or membership-testing is applied on top. "Computed
- from `status`" is not derivation: `field_path="evse.status"` with a
- `value_fn` of `status in {...}` declares its source correctly and still
- computes whatever it likes. `evse_ev_connected` was misclassified this way,
- which cost it both a Repair mention and its unavailability, while its
- sibling `evse_charging` — same field, same shape — got both.
+ The net-energy sensors subtract produced from consumed; blaming either field
+ alone for the entity would be wrong.
+ """
- Case 3 keeps the producible gate satisfiable: it requires a path both
- adapters emit, so a schema-conditional field cannot be declared. Those are
- listed in `RESIDUAL_EXEMPT_PATHS` when the integration reads them outside a
- description.
+ SCHEMA_CONDITIONAL_FIELD = "schema_conditional_field"
+ """Reads exactly one field, which only one adapter produces.
- `test_no_derived_description_reads_one_producible_field` enforces this rule
- against every derived description rather than against any one instance.
+ Keeps the producible gate satisfiable: the gate requires a path both
+ adapters emit, so a schema-conditional field cannot be declared. If the
+ other adapter ever grows the field, this stops being true and the
+ verification fails, demanding promotion to a `field_path` declaration.
"""
class Producibility(Enum):
"""Which adapters publish a metadata row for an exempt residual path.
+ A *path's* producibility, not a *description's* classification: kept beside
+ `DerivedReason` because the two are verified from the same pair of adapter
+ metadata sets, and deliberately separate because they describe different
+ subjects. `bess_connected` is `SCHEMA_CONDITIONAL_FIELD` while the
+ `battery.connected` path it reads is `SCHEMA_0_ONLY` — two facts about two
+ things.
+
There is deliberately no `BOTH` member. A path both adapters produce
satisfies the producible gate, so it belongs in `declared_field_paths()`
rather than in an exemption; `test_no_exempt_path_is_producible_by_both`
@@ -91,6 +97,43 @@ class Producibility(Enum):
"""Produced by the schema_1 adapter, absent from schema_0."""
+@dataclass(frozen=True, kw_only=True)
+class FieldPathDeclarationMixin:
+ """Declares which snapshot field an entity description reads.
+
+ Mixed into every required-keys mixin so the declaration and the reader are
+ the same object. Defined once here rather than repeated on each mixin, so
+ the two fields cannot themselves drift apart across platforms.
+
+ The fields are keyword-only: an entity description flattens this mixin's
+ fields ahead of ``EntityDescription.key``, which has no default, so a
+ positional pair here would make every description unconstructable.
+ """
+
+ field_path: str | None = None
+ """Snapshot field this entity reads, e.g. "circuit.instant_power_w".
+
+ Declared here rather than in a parallel map so the declaration and the
+ reader are the same object. Verified against `value_fn` by the proxy test
+ in tests/test_field_path_introspection.py.
+ """
+
+ derived: DerivedReason | None = None
+ """Why this entity has no single source field to declare, or `None`.
+
+ Set only when `field_path` is not: the two are alternatives, pinned by
+ `test_every_description_declares_exactly_one`. Which reason applies is not
+ a matter of opinion — see `DerivedReason`, whose members are each asserted
+ against what the `value_fn` actually reads.
+
+ A reason rather than a flag because `bool` conflated four situations, and
+ that conflation is how `evse_ev_connected` — one producible field, marked
+ derived — stayed invisible to both the Repair count and the availability
+ probe. Every consumer tests this by truthiness, which an enum member and
+ `None` answer exactly as `True` and `False` did.
+ """
+
+
RESIDUAL_EXEMPT_PATHS: Mapping[str, Producibility] = MappingProxyType(
{
# Homie `$target` values — a pending-command echo, not a schema field.
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 5af8eeed..1ea54b87 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -35,7 +35,11 @@
SpanPanelSnapshot,
)
-from .field_paths import FieldPathDeclarationMixin, iter_field_path_declarations
+from .field_paths import (
+ DerivedReason,
+ FieldPathDeclarationMixin,
+ iter_field_path_declarations,
+)
@dataclass(frozen=True)
@@ -103,7 +107,7 @@ class SpanPanelBatterySensorEntityDescription(
] = (
SpanPanelDataSensorEntityDescription(
key="dsm_state",
- derived=True,
+ derived=DerivedReason.NO_SOURCE_FIELD,
translation_key="dsm_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -113,7 +117,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="dsm_grid_state",
- derived=True,
+ derived=DerivedReason.NO_SOURCE_FIELD,
translation_key="dsm_grid_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -122,7 +126,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="current_run_config",
- derived=True,
+ derived=DerivedReason.NO_SOURCE_FIELD,
translation_key="current_run_config",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -140,7 +144,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="grid_forming_entity",
- derived=True,
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
translation_key="grid_forming_entity",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -395,7 +399,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
MID_SENSORS: tuple[SpanMidSensorEntityDescription, ...] = (
SpanMidSensorEntityDescription(
key="mid_grid_state",
- derived=True,
+ derived=DerivedReason.NO_SOURCE_FIELD,
translation_key="mid_grid_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -659,7 +663,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="mainMeterNetEnergyWh",
- derived=True,
+ derived=DerivedReason.MULTIPLE_FIELDS,
translation_key="main_meter_net_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -672,7 +676,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="feedthroughNetEnergyWh",
- derived=True,
+ derived=DerivedReason.MULTIPLE_FIELDS,
translation_key="feedthrough_net_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -732,7 +736,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_net",
- derived=True,
+ derived=DerivedReason.MULTIPLE_FIELDS,
name="Net Energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py
index 0078f9ae..d8a64bfa 100644
--- a/tests/test_dps_and_bess.py
+++ b/tests/test_dps_and_bess.py
@@ -255,4 +255,4 @@ def test_bess_part_number_sensor_is_declared() -> None:
part = next(d for d in BESS_METADATA_SENSORS if d.key == "part_number")
assert part.field_path == "battery.part_number"
- assert part.derived is False
+ assert part.derived is None
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 25cb48ec..070b0f04 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -69,7 +69,7 @@ def test_every_declared_path_is_producible(adapter: str, metadata_fn: MetadataFn
missing = sorted(path for path in declared_field_paths() if path not in metadata)
assert not missing, (
f"{adapter} does not produce declared field paths: {missing}. "
- "Either the declaration is stale, or the entity should be derived=True."
+ "Either the declaration is stale, or the entity should declare a DerivedReason."
)
diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py
index 35bbe9be..56ac4a1f 100644
--- a/tests/test_field_path_introspection.py
+++ b/tests/test_field_path_introspection.py
@@ -18,6 +18,7 @@
)
from custom_components.span_panel.field_paths import (
+ DerivedReason,
declared_field_paths,
residual_field_paths,
)
@@ -101,7 +102,7 @@ def key(self) -> str: ...
def field_path(self) -> str | None: ...
@property
- def derived(self) -> bool: ...
+ def derived(self) -> DerivedReason | None: ...
@property
def value_fn(self) -> Callable[[Any], object]: ...
@@ -284,8 +285,68 @@ def test_no_derived_description_reads_one_producible_field() -> None:
read = sorted(sink & producible)
if len(read) == 1:
offenders.append(
- f"{description.key}: derived=True but reads exactly one producible field, "
- f"{read[0]!r} — that is a declaration, so set field_path={read[0]!r}"
+ f"{description.key}: derived={description.derived} but reads exactly one "
+ f"producible field, {read[0]!r} — that is a declaration, so set "
+ f"field_path={read[0]!r}"
)
assert not offenders, "Misclassified derived descriptions:\n" + "\n".join(offenders)
+
+
+def test_derived_reasons_match_what_value_fns_read() -> None:
+ """Each derived description's stated reason must be the one its reads imply.
+
+ `derived` used to be a `bool` covering four different situations, and it was
+ that conflation which hid `evse_ev_connected`: a single producible field
+ marked derived looked exactly like a genuine multi-field derivation. The
+ reason is only worth its syntax if it is checked, so each variant is a claim
+ about the recorder's output and is asserted as one:
+
+ * `NO_SOURCE_FIELD` — reads nothing either adapter publishes,
+ * `MULTIPLE_FIELDS` — reads two or more fields an adapter publishes,
+ * `SCHEMA_CONDITIONAL_FIELD` — reads exactly one, produced by one adapter
+ only. When the other adapter grows it, this fails and demands promotion to
+ a `field_path` declaration.
+
+ Intersecting with what the adapters emit is what makes the count meaningful:
+ the recorder also picks up method names and other noise.
+ """
+ schema_0 = set(schema_zero_metadata())
+ schema_1 = set(schema_one_metadata())
+ produced = schema_0 | schema_1
+ offenders: list[str] = []
+
+ for description in _declaring_descriptions():
+ reason = description.derived
+ if reason is None:
+ continue
+ try:
+ sink = _record_reads(description)
+ except _UndeterminedPrefix as err:
+ offenders.append(f"{description.key}: {err}, so its reason would go unverified")
+ continue
+ except Exception as err: # noqa: BLE001
+ offenders.append(f"{description.key}: value_fn raised {err!r}")
+ continue
+ read = sorted(sink & produced)
+ if reason is DerivedReason.NO_SOURCE_FIELD and read:
+ offenders.append(
+ f"{description.key}: claims NO_SOURCE_FIELD but reads {read} — "
+ "the reason is MULTIPLE_FIELDS, SCHEMA_CONDITIONAL_FIELD, or it is a "
+ "declaration"
+ )
+ elif reason is DerivedReason.MULTIPLE_FIELDS and len(read) < 2:
+ offenders.append(
+ f"{description.key}: claims MULTIPLE_FIELDS but reads {read} — "
+ "one field or none is a different reason"
+ )
+ elif reason is DerivedReason.SCHEMA_CONDITIONAL_FIELD and (
+ len(read) != 1 or read[0] in schema_0 & schema_1
+ ):
+ offenders.append(
+ f"{description.key}: claims SCHEMA_CONDITIONAL_FIELD but reads {read}, "
+ f"of which {sorted(set(read) & schema_0 & schema_1)} are produced by both "
+ "adapters"
+ )
+
+ assert not offenders, "Derived reasons disagree with readers:\n" + "\n".join(offenders)
diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py
index 5c0d95ed..19830353 100644
--- a/tests/test_field_paths.py
+++ b/tests/test_field_paths.py
@@ -10,6 +10,7 @@
)
from custom_components.span_panel.field_paths import (
RESIDUAL_EXEMPT_PATHS,
+ DerivedReason,
declared_field_paths,
residual_field_paths,
)
@@ -22,7 +23,7 @@
def test_circuit_power_declares_its_field_path() -> None:
power = next(d for d in CIRCUIT_SENSORS if d.key == "circuit_power")
assert power.field_path == "circuit.instant_power_w"
- assert power.derived is False
+ assert power.derived is None
def test_derived_sensor_declares_no_path() -> None:
@@ -30,7 +31,7 @@ def test_derived_sensor_declares_no_path() -> None:
from custom_components.span_panel.sensor_definitions import PANEL_DATA_STATUS_SENSORS
dsm = next(d for d in PANEL_DATA_STATUS_SENSORS if d.key == "dsm_state")
- assert dsm.derived is True
+ assert dsm.derived is DerivedReason.NO_SOURCE_FIELD
assert dsm.field_path is None
@@ -59,8 +60,8 @@ def test_every_description_declares_exactly_one() -> None:
BESS_CONNECTED_SENSOR,
):
declares_path = description.field_path is not None
- assert declares_path != description.derived, (
- f"{description.key} must declare exactly one of field_path / derived=True"
+ assert declares_path != (description.derived is not None), (
+ f"{description.key} must declare exactly one of field_path / a DerivedReason"
)
diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py
index c2400fe1..2d62aaf8 100644
--- a/tests/test_schema_availability.py
+++ b/tests/test_schema_availability.py
@@ -42,6 +42,7 @@
)
from custom_components.span_panel.coordinator import SpanPanelCoordinator
from custom_components.span_panel.entity import SpanPanelEntity
+from custom_components.span_panel.field_paths import DerivedReason
from custom_components.span_panel.schema_validation import SchemaFindings
from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor
from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS
@@ -193,7 +194,7 @@ async def test_panel_status_binary_sensor_is_never_probed(hass: HomeAssistant) -
coordinator = _make_coordinator(hass)
coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset())
description = next(desc for desc in BINARY_SENSORS if desc.key == PANEL_STATUS)
- assert description.derived is True
+ assert description.derived is DerivedReason.NO_SOURCE_FIELD
entity = SpanPanelBinarySensor(coordinator, description)
@@ -209,7 +210,7 @@ async def test_derived_entity_is_never_probed(hass: HomeAssistant) -> None:
"""
coordinator = _make_coordinator(hass)
coordinator._findings = SchemaFindings(frozenset({"battery.connected"}), (), frozenset())
- assert BESS_CONNECTED_SENSOR.derived is True
+ assert BESS_CONNECTED_SENSOR.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD
entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR)
From 18971d02babd92a12f7956f0db306ff4371af90d Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 14:37:18 -0700
Subject: [PATCH 044/116] test(field-paths): pin the residual read no other
entity class claims
circuit.relay_requester is declared by one class alone. If that class stopped
declaring it the path would leave residual_field_paths() entirely and the
producible gate would quietly stop covering a read that is still happening.
Asserted through the Repair's own output, so it fails on the observable
consequence rather than on one list disagreeing with another.
---
tests/test_schema_repairs.py | 44 ++++++++++++++++++++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index 6db31053..c78e18dd 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -786,6 +786,50 @@ async def test_a_dead_relay_state_names_the_breaker_switches(hass) -> None:
await _stop_scheduling(coordinator)
+async def test_a_dead_circuit_attribute_names_the_power_sensors(hass) -> None:
+ """The circuit power sensor's residual reads must name it, one by one.
+
+ Every other residual is claimed by more than one entity class, so dropping
+ it from any single class leaves the read enumerated somewhere and the panel
+ still describable. `circuit.relay_requester` is claimed by this class alone:
+ it is republished as a state attribute here and nowhere else, so if this
+ entity stopped declaring it, the path would leave
+ `field_paths.residual_field_paths()` entirely and the producible gate would
+ quietly stop covering a read that is still happening.
+
+ Stated as the Repair's own output rather than as a second copy of the
+ declaration, so it fails on the observable consequence -- a dead attribute
+ naming no entity -- instead of on a list disagreeing with a list.
+ """
+ from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor
+
+ coordinator, config_entry, grouped = await _entities_by_declared_path(hass)
+ try:
+ power_sensors = [
+ entity
+ for entity in grouped["circuit.instant_power_w"]["sensor"]
+ if isinstance(entity, SpanCircuitPowerSensor)
+ ]
+ assert len(power_sensors) == 2, "fixture should build one power sensor per circuit"
+
+ await _add_to_platform(hass, config_entry, power_sensors, "sensor")
+
+ expected = sorted(sensor.entity_id for sensor in power_sensors)
+ assert coordinator.entity_ids_by_field_path == {
+ # The description's own declaration.
+ "circuit.instant_power_w": expected,
+ # Identity, read outside any value_fn.
+ "circuit.name": expected,
+ "circuit.tabs": expected,
+ # Republished as state attributes.
+ "circuit.relay_state": expected,
+ "circuit.relay_requester": expected,
+ "circuit.priority": expected,
+ }
+ finally:
+ await _stop_scheduling(coordinator)
+
+
async def test_a_dead_priority_names_the_selects(hass) -> None:
"""The select's own state comes from `circuit.priority`."""
from unittest.mock import MagicMock
From 0caf2922aa8f1bd3627b974678133a153e3b339c Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 15:04:32 -0700
Subject: [PATCH 045/116] chore: pin the library and schema-1 at b5
The reference payloads moved into the packages: the flat schema to
span_panel_api, the parent/child tree to span-panel-api-schema-1, since a
retained-topic tree needs the vocabulary that interprets it.
---
custom_components/span_panel/manifest.json | 4 ++--
pyproject.toml | 4 ++--
uv.lock | 4 ++--
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 4781e14f..400717d8 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,9 +22,9 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b4",
+ "span-panel-api==3.0.0b5",
"span-panel-api-schema-0==1.0.0b4",
- "span-panel-api-schema-1==0.1.0b4"
+ "span-panel-api-schema-1==0.1.0b5"
],
"version": "2.1.0",
"zeroconf": [
diff --git a/pyproject.toml b/pyproject.toml
index 56a165ac..49c7c78e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,13 +8,13 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b4",
+ "span-panel-api==3.0.0b5",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
# and that is the list Home Assistant actually reads.
"span-panel-api-schema-0==1.0.0b4",
- "span-panel-api-schema-1==0.1.0b4",
+ "span-panel-api-schema-1==0.1.0b5",
]
[dependency-groups]
diff --git a/uv.lock b/uv.lock
index 949093d8..b94ffd87 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b4"
+version = "3.0.0b5"
source = { editable = "../../span/span-panel-api-p2" }
dependencies = [
{ name = "httpx" },
@@ -2598,7 +2598,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap
[[package]]
name = "span-panel-api-schema-1"
-version = "0.1.0b4"
+version = "0.1.0b5"
source = { editable = "../../span/span-panel-api-p2/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
From d86af335afb888ce0e4fb13adf29766dac8cd128 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 17:44:08 -0700
Subject: [PATCH 046/116] feat(sensor): surface the shed forecast as two
duration sensors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The panel has published `energy.ebus.capability.shed-forecast` since r202633 and
nothing read it. Its two live estimates are the highest-value unread properties
on a v1.0 panel — how long before the panel starts shedding circuits, and how
long before the battery is spent — so they land as sensors that are enabled by
default and not filed under diagnostics. Surfacing them behind a disabled
default would be surfacing them in name only.
The two hypothetical-full-charge figures and the confidence enum ride as
attributes on the sensor each one refines. They answer "what would this
installation give me from a full battery", which moves when the hardware does
rather than as the battery drains; separate entities would put a near-constant
on a graph beside the countdown it qualifies. Which twin belongs to which sensor
is carried on the description, not compared against `description.key`, so a
rename cannot silently swap two plausible-looking durations.
Creation is gated twice, because absence has two shapes. `has_shed_forecast`
answers whether the panel publishes the capability at all — false on every flat
panel — and joins `detect_capabilities`, so a panel that gains it on a firmware
upgrade picks the sensors up on the reload the coordinator already performs. The
per-description gate then asks whether *this* estimate is among what the node
publishes: the catalog marks all four times SHOULD rather than MUST, so a
partial node is legal firmware and the half it omits must produce no entity
rather than one permanently unknown. The presence test is the description's own
`value_fn`, so the gate cannot drift away from the read.
Both descriptions are `SCHEMA_CONDITIONAL_FIELD` rather than declaring a
`field_path`: the producible gate demands a path both adapters emit and no flat
panel publishes this capability. The two live estimates join
`RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY` — schema_1 maps them, which buys unit
validation against the panel's own `$description` — and the three refinements as
`NEITHER`, matching the `mid.*` attribute reads.
Tests build a real snapshot through the real schema_1 adapter over the vendored
capture, and read every expected value out of that capture rather than writing
it as a literal. Each reading is then proved by republishing it, deleting it, or
dropping the node. Hardcoding the captured 3037 and crossing the two full-charge
twins fails seven of them.
---
CHANGELOG.md | 15 +
README.md | 19 +
custom_components/span_panel/field_paths.py | 19 +
custom_components/span_panel/helpers.py | 40 +-
custom_components/span_panel/icons.json | 6 +
custom_components/span_panel/sensor.py | 39 ++
.../span_panel/sensor_definitions.py | 93 ++++
custom_components/span_panel/sensor_panel.py | 93 ++++
custom_components/span_panel/strings.json | 24 +-
.../span_panel/translations/en.json | 24 +-
.../span_panel/translations/es.json | 6 +
.../span_panel/translations/fr.json | 6 +
.../span_panel/translations/ja.json | 6 +
.../span_panel/translations/pt.json | 6 +
tests/adapter_fixtures.py | 74 ++-
tests/test_field_path_conformance.py | 9 +-
tests/test_shed_forecast.py | 475 ++++++++++++++++++
tests/test_unit_vocabulary.py | 5 +
18 files changed, 921 insertions(+), 38 deletions(-)
create mode 100644 tests/test_shed_forecast.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42b75d54..17e1ab7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
All notable changes to this project will be documented in this file.
+## [Unreleased]
+
+### Added
+
+- **Backup planning, in minutes: two new sensors on panels running the v1.0 data model.** **Time to Priority Shed** is how long before the panel starts shedding
+ circuits, and **Backup Time Remaining** is how long before the battery is spent. Your panel has been publishing both since firmware r202633 and nothing read
+ them; they are the numbers you would actually set an alarm on, so they are enabled by default and sit beside the power and energy sensors rather than under
+ diagnostics.
+- **Each forecast sensor carries the refinements that qualify it** as attributes: `full_charge_time_to_priority_shed` / `full_charge_total_time_remaining` —
+ what the same estimate would be from a full battery — and `forecast_confidence`, the panel's own `LOW` / `MEDIUM` / `HIGH` assessment of the estimate. They
+ refine a number already on screen rather than adding two near-constant entities to your entity list.
+- Both sensors are created only where the panel publishes the estimate behind them. A panel on the older data model, or one whose firmware publishes only part
+ of the forecast, gets no entity for what it cannot report rather than one permanently unknown — and a panel that gains the capability on a firmware upgrade
+ picks the sensors up on the reload the integration already performs.
+
## [2.1.0] - 8/2026
Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does.
diff --git a/README.md b/README.md
index de316de8..85689dcb 100644
--- a/README.md
+++ b/README.md
@@ -149,6 +149,25 @@ The following terms appear throughout this document and in the integration's sen
| Downstream L2 Current | Current | A | Downstream lugs L2 current |
| Main Breaker Rating | Current | A | Main breaker amperage |
+### Shed Forecast Sensors (v1.0 data model only)
+
+Created only when your panel publishes the `shed-forecast` capability, and only for the estimates it actually publishes.
+
+| Sensor | Device Class | Unit | Notes |
+| --------------------- | ------------ | ---- | ---------------------------------------------------------------- |
+| Time to Priority Shed | Duration | min | Estimated time before the next priority tier of circuits is shed |
+| Backup Time Remaining | Duration | min | Estimated time before every sheddable circuit is shed (off-grid) |
+
+#### Shed Forecast Sensor Attributes
+
+Present only when the panel publishes them.
+
+| Attribute | Type | On | Notes |
+| ----------------------------------- | ------ | --------------------- | ------------------------------------------------------ |
+| `full_charge_time_to_priority_shed` | int | Time to Priority Shed | The same estimate assuming the battery starts full |
+| `full_charge_total_time_remaining` | int | Backup Time Remaining | The same estimate assuming the battery starts full |
+| `forecast_confidence` | string | both | The panel's own assessment: `LOW`, `MEDIUM`, or `HIGH` |
+
### Power Flow Sensors (v2 only)
| Sensor | Device Class | Unit | Notes |
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 6e50ddc6..c060a7f0 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -165,7 +165,26 @@ class FieldPathDeclarationMixin:
# The EVSE's Homie node id — an addressing handle used to build the
# sub-device identifier, not a published field.
"evse.node_id": Producibility.NEITHER,
+ # The shed-forecast refinements, read for attributes on the two forecast
+ # sensors (`SpanShedForecastSensor.extra_state_attributes`). schema_1
+ # reads all three into the snapshot but carries a `_PROPERTY_FIELD_MAP`
+ # row for neither: they qualify the two live estimates rather than being
+ # readings of their own, so there is no unit surface for a row to
+ # describe. Same shape as the `mid.*` attribute reads above.
+ "panel.shed_full_charge_time_to_priority_shed_min": Producibility.NEITHER,
+ "panel.shed_full_charge_total_time_remaining_min": Producibility.NEITHER,
+ "panel.shed_forecast_confidence": Producibility.NEITHER,
"circuit.is_user_controllable": Producibility.SCHEMA_1_ONLY,
+ # The two backup-planning estimates behind `time_to_priority_shed` and
+ # `shed_total_time_remaining`, whose descriptions are
+ # `SCHEMA_CONDITIONAL_FIELD` for exactly this reason: no flat panel
+ # publishes `energy.ebus.capability.shed-forecast` at all, so the
+ # producible gate — which demands both adapters — cannot be satisfied.
+ # schema_1 does carry a metadata row for each, which is what makes the
+ # annotation SCHEMA_1_ONLY rather than NEITHER and buys the pair unit
+ # and datatype validation against the panel's own `$description`.
+ "panel.shed_time_to_priority_shed_min": Producibility.SCHEMA_1_ONLY,
+ "panel.shed_total_time_remaining_min": Producibility.SCHEMA_1_ONLY,
"circuit.always_on": Producibility.SCHEMA_0_ONLY,
"circuit.is_sheddable": Producibility.SCHEMA_0_ONLY,
"panel.wifi_ssid": Producibility.SCHEMA_0_ONLY,
diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py
index 2bcc2e20..f4d1d240 100644
--- a/custom_components/span_panel/helpers.py
+++ b/custom_components/span_panel/helpers.py
@@ -102,6 +102,7 @@
"has_mid",
"has_power_flows",
"has_pv",
+ "has_shed_forecast",
"is_panel_level_sensor_key",
"resolve_evse_display_suffix",
]
@@ -274,6 +275,36 @@ def has_mid(snapshot: SpanPanelSnapshot) -> bool:
return snapshot.mid is not None
+def has_shed_forecast(snapshot: SpanPanelSnapshot) -> bool:
+ """Detect whether the panel publishes an `energy.ebus.capability.shed-forecast` node.
+
+ Presence of the *capability*, from presence of any of the five fields it
+ fills. The library models each one as `None` when unpublished, and a panel
+ with no such node fills none of them, so any non-`None` field is the node —
+ there is no telemetry value that could be mistaken for it. That is why this
+ reads all five rather than only the two that back sensors: a firmware
+ publishing the node with a partial property set still has the capability,
+ and the per-sensor gate in `create_shed_forecast_sensors` is what decides
+ which entities that firmware can actually support.
+
+ Always false on flat firmware, which publishes no such node at all.
+
+ DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema
+ version, so a panel that hot-loads parent/child mid-life gains the
+ capability, reloads, and the sensors appear.
+ """
+ return any(
+ value is not None
+ for value in (
+ snapshot.shed_time_to_priority_shed_min,
+ snapshot.shed_total_time_remaining_min,
+ snapshot.shed_full_charge_time_to_priority_shed_min,
+ snapshot.shed_full_charge_total_time_remaining_min,
+ snapshot.shed_forecast_confidence,
+ )
+ )
+
+
def has_evse(snapshot: SpanPanelSnapshot) -> bool:
"""Detect whether an EVSE (EV charger) is commissioned."""
return len(snapshot.evse) > 0
@@ -282,8 +313,11 @@ def has_evse(snapshot: SpanPanelSnapshot) -> bool:
def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]:
"""Derive the set of optional capabilities present in the snapshot.
- Used by the coordinator to detect when new hardware (BESS, PV, EVSE, MID) appears
- and trigger a reload so new sensors are created.
+ Used by the coordinator to detect when new hardware (BESS, PV, EVSE, MID) or a
+ new published capability (shed-forecast) appears, and trigger a reload so new
+ sensors are created. A capability is not hardware, but it reaches this the same
+ way — the panel starts publishing a node it did not publish before — and the
+ consequence is identical: entities that could not be created at setup now can.
"""
caps: set[str] = set()
if has_bess(snapshot):
@@ -296,4 +330,6 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]:
caps.add("evse")
if has_mid(snapshot):
caps.add("mid")
+ if has_shed_forecast(snapshot):
+ caps.add("shed_forecast")
return frozenset(caps)
diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json
index 4a7e9445..43ca46f4 100644
--- a/custom_components/span_panel/icons.json
+++ b/custom_components/span_panel/icons.json
@@ -180,12 +180,18 @@
"pv_vendor": {
"default": "mdi:domain"
},
+ "shed_total_time_remaining": {
+ "default": "mdi:battery-clock-outline"
+ },
"site_power": {
"default": "mdi:home-lightning-bolt"
},
"software_version": {
"default": "mdi:information"
},
+ "time_to_priority_shed": {
+ "default": "mdi:timer-alert-outline"
+ },
"upstream_l1_current": {
"default": "mdi:current-ac"
},
diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py
index 66acba12..cc327b49 100644
--- a/custom_components/span_panel/sensor.py
+++ b/custom_components/span_panel/sensor.py
@@ -25,6 +25,7 @@
has_mid,
has_power_flows,
has_pv,
+ has_shed_forecast,
resolve_evse_display_suffix,
)
from .sensor_base import SpanEnergySensorBase, SpanSensorBase
@@ -53,6 +54,7 @@
PANEL_POWER_SENSORS,
PV_METADATA_SENSORS,
PV_POWER_SENSOR,
+ SHED_FORECAST_SENSORS,
SITE_POWER_SENSOR,
STATUS_SENSORS,
UNMAPPED_SENSORS,
@@ -69,6 +71,7 @@
SpanPanelPowerSensor,
SpanPanelStatus,
SpanPVMetadataSensor,
+ SpanShedForecastSensor,
)
from .util import bess_device_info, evse_device_info, mid_device_info
@@ -86,6 +89,7 @@
"SpanPanelPowerSensor",
"SpanPanelStatus",
"SpanSensorBase",
+ "SpanShedForecastSensor",
"SpanUnmappedCircuitSensor",
]
@@ -360,6 +364,38 @@ def create_mid_sensors(
return [SpanMidSensor(coordinator, desc, snapshot, mid_info) for desc in MID_SENSORS]
+def create_shed_forecast_sensors(
+ coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
+) -> list[SpanShedForecastSensor]:
+ """Create the backup-planning forecast sensors the panel can actually fill.
+
+ Two gates, not one, because absence has two shapes here. `has_shed_forecast`
+ answers whether the panel publishes the capability at all — false on every
+ flat panel and on any v1.0 firmware that omits the node, and the reason a
+ reload creates these when a panel gains it mid-life. The per-description
+ check then answers whether *this* estimate is among what the node publishes:
+ the catalog marks all four times SHOULD rather than MUST, so a partial
+ node is legal and the half it omits must produce no entity rather than one
+ permanently unknown.
+
+ The presence test is the description's own `value_fn`. The field a sensor
+ reads is exactly the field whose absence should suppress it, so asking the
+ reader is what keeps the gate from drifting away from the read.
+
+ DUAL-SCHEMA: gated on what the snapshot carries, never on a version or a
+ config flag. When the flat path retires, the first gate goes and the second
+ stays — a v1.0 panel may still publish a partial node.
+ """
+ if not has_shed_forecast(snapshot):
+ return []
+
+ return [
+ SpanShedForecastSensor(coordinator, description, snapshot)
+ for description in SHED_FORECAST_SENSORS
+ if description.value_fn(snapshot) is not None
+ ]
+
+
def create_battery_sensors(
coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
) -> list[SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor]:
@@ -446,6 +482,7 @@ def create_native_sensors(
| SpanPVMetadataSensor
| SpanEvseSensor
| SpanMidSensor
+ | SpanShedForecastSensor
]:
"""Create all native sensors for the platform."""
entities: list[
@@ -461,6 +498,7 @@ def create_native_sensors(
| SpanPVMetadataSensor
| SpanEvseSensor
| SpanMidSensor
+ | SpanShedForecastSensor
] = []
# Create different sensor types
@@ -470,6 +508,7 @@ def create_native_sensors(
entities.extend(create_unmapped_circuit_sensors(coordinator, snapshot))
entities.extend(create_battery_sensors(coordinator, snapshot))
entities.extend(create_mid_sensors(coordinator, snapshot))
+ entities.extend(create_shed_forecast_sensors(coordinator, snapshot))
entities.extend(create_power_flow_sensors(coordinator, snapshot))
entities.extend(create_evse_sensors(coordinator, snapshot))
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 1ea54b87..38e14688 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -25,6 +25,7 @@
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
+ UnitOfTime,
)
from homeassistant.helpers.entity import EntityCategory
from span_panel_api import (
@@ -334,6 +335,97 @@ class SpanPanelBatterySensorEntityDescription(
)
)
+# ---------------------------------------------------------------------------
+# Shed forecast (v1.0 `shed-forecast`, conditionally created)
+# ---------------------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class SpanShedForecastRequiredKeysMixin(FieldPathDeclarationMixin):
+ """Required keys mixin for the backup-planning forecast sensors.
+
+ Carries a second reader beside `value_fn`, which the other panel mixins do
+ not need. The capability publishes each live estimate with a
+ hypothetical-full-charge twin, and the twin refines the number on screen
+ rather than standing on its own — so it belongs to the sensor as an
+ attribute, and which twin belongs to which sensor is a fact about the
+ pairing rather than about the entity class.
+
+ Declared here so that pairing is data. Reading it off `description.key`
+ inside the entity would put a string comparison between the two halves of
+ something the catalog states outright, which is how a rename silently
+ swaps two plausible-looking durations.
+ """
+
+ value_fn: Callable[[SpanPanelSnapshot], int | None]
+
+ full_charge_attribute: str
+ """Attribute name the hypothetical twin is published under."""
+
+ full_charge_fn: Callable[[SpanPanelSnapshot], int | None]
+ """Reads the twin. `None` when the panel does not publish it."""
+
+
+@dataclass(frozen=True, kw_only=True)
+class SpanShedForecastSensorEntityDescription(
+ SensorEntityDescription, SpanShedForecastRequiredKeysMixin
+):
+ """Describes one of the two backup-planning forecast sensors."""
+
+
+SHED_FORECAST_SENSORS: tuple[
+ SpanShedForecastSensorEntityDescription,
+ SpanShedForecastSensorEntityDescription,
+] = (
+ SpanShedForecastSensorEntityDescription(
+ key="time_to_priority_shed",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="time_to_priority_shed",
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ suggested_display_precision=0,
+ value_fn=lambda s: s.shed_time_to_priority_shed_min,
+ full_charge_attribute="full_charge_time_to_priority_shed",
+ full_charge_fn=lambda s: s.shed_full_charge_time_to_priority_shed_min,
+ ),
+ SpanShedForecastSensorEntityDescription(
+ key="shed_total_time_remaining",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="shed_total_time_remaining",
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ suggested_display_precision=0,
+ value_fn=lambda s: s.shed_total_time_remaining_min,
+ full_charge_attribute="full_charge_total_time_remaining",
+ full_charge_fn=lambda s: s.shed_full_charge_total_time_remaining_min,
+ ),
+)
+"""The two live estimates from `energy.ebus.capability.shed-forecast` 0.1.
+
+**Enabled by default, and not diagnostic.** These are the numbers a user plans a
+backup around — how long before the panel starts shedding circuits, how long
+before the battery is spent — so they belong beside the power and energy
+sensors rather than under the diagnostics fold. That is the whole argument for
+surfacing them ahead of the rest of the unread v1.0 surface.
+
+**`derived` rather than a `field_path` declaration, by the producible rule.**
+The gate requires a path *both* adapters produce, and no flat panel publishes
+this capability at all; `SCHEMA_CONDITIONAL_FIELD` is what that situation is
+called. The paths are enumerated in `RESIDUAL_EXEMPT_PATHS` instead, annotated
+`SCHEMA_1_ONLY`, and the conformance suite checks that annotation against what
+the adapters actually emit — so if flat firmware ever grew the capability, the
+build would fail and demand promotion rather than leaving the read ungated.
+
+The two hypothetical-full-charge figures ride as attributes rather than as
+sensors of their own. They answer "what would this installation give me from a
+full battery", which moves when the hardware does and not as the battery
+drains; a separate entity would put a near-constant on a graph beside the
+countdown it qualifies.
+"""
+
+
# ---------------------------------------------------------------------------
# Circuit diagnostic sensors (promoted from attributes)
# ---------------------------------------------------------------------------
@@ -835,6 +927,7 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]:
*PANEL_ENERGY_SENSORS,
*CIRCUIT_SENSORS,
*EVSE_SENSORS,
+ *SHED_FORECAST_SENSORS,
BATTERY_SENSOR,
BATTERY_POWER_SENSOR,
PV_POWER_SENSOR,
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index e49539ed..9f1bf38d 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -25,6 +25,7 @@
SpanPanelDataSensorEntityDescription,
SpanPanelStatusSensorEntityDescription,
SpanPVMetadataSensorEntityDescription,
+ SpanShedForecastSensorEntityDescription,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -91,6 +92,98 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot:
return snapshot
+class SpanShedForecastSensor(
+ SpanSensorBase[SpanShedForecastSensorEntityDescription, SpanPanelSnapshot]
+):
+ """One of the two backup-planning estimates, with its refinements attached.
+
+ Created only where the panel publishes the estimate this sensor reads, so a
+ panel with no `shed-forecast` node — every flat panel, and any v1.0 panel
+ whose firmware omits the capability — gets no entity rather than one stuck
+ at unknown. See `create_shed_forecast_sensors`.
+ """
+
+ # `_residual_field_paths` stays empty on purpose. The attribute reads below
+ # are not declarable: neither adapter carries a metadata row for those three
+ # fields, so declaring them here would put them in `declared_field_paths()`
+ # where the producible gate rejects anything one adapter cannot emit. They
+ # are enumerated in `RESIDUAL_EXEMPT_PATHS` as `Producibility.NEITHER`
+ # instead, which is where the `mid.*` attribute reads live for the same
+ # reason.
+
+ def __init__(
+ self,
+ data_coordinator: SpanPanelCoordinator,
+ description: SpanShedForecastSensorEntityDescription,
+ snapshot: SpanPanelSnapshot,
+ ) -> None:
+ """Initialize the shed-forecast sensor, keeping a typed handle on its description.
+
+ `SensorEntity.entity_description` is annotated as the base
+ `SensorEntityDescription`, so reading the two extra members off it would
+ need either a narrowing override — which mypy rejects on a mutable
+ attribute — or a `getattr`, which is the same thing with the check
+ removed. Keeping the description under a name of our own is what makes
+ `full_charge_fn` and `full_charge_attribute` statically checked; the same
+ move `SpanPanelPowerSensor` makes for `_description_key`.
+ """
+ super().__init__(data_coordinator, description, snapshot)
+ self._forecast = description
+
+ def _generate_unique_id(
+ self,
+ snapshot: SpanPanelSnapshot,
+ description: SpanShedForecastSensorEntityDescription,
+ ) -> str:
+ """Generate unique ID for a shed-forecast sensor."""
+ return construct_panel_unique_id_for_entry(
+ self.coordinator, snapshot, description.key, self._device_name
+ )
+
+ def _generate_friendly_name(
+ self,
+ snapshot: SpanPanelSnapshot,
+ description: SpanShedForecastSensorEntityDescription,
+ ) -> str:
+ """Generate friendly name for a shed-forecast sensor."""
+ if description.name is not None and description.name is not UNDEFINED:
+ return str(description.name)
+ return "Shed Forecast"
+
+ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot:
+ """Get the data source for the shed-forecast sensor."""
+ return snapshot
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any] | None:
+ """The hypothetical-full-charge twin, and the estimate's confidence.
+
+ Both are omitted when the panel does not publish them, rather than
+ appearing as `None`. An attribute that is present and empty reads as a
+ reading the panel failed to produce; an absent one reads as a firmware
+ that does not carry it, which is what this is.
+
+ Which twin belongs to this sensor comes from the description, not from a
+ comparison against `key` — the pairing is stated once, where the two
+ readers sit beside each other.
+ """
+ snapshot = self.coordinator.data
+ if snapshot is None:
+ return None
+
+ attributes: dict[str, Any] = {}
+
+ full_charge = self._forecast.full_charge_fn(snapshot)
+ if full_charge is not None:
+ attributes[self._forecast.full_charge_attribute] = full_charge
+
+ confidence = snapshot.shed_forecast_confidence
+ if confidence is not None:
+ attributes["forecast_confidence"] = confidence
+
+ return attributes or None
+
+
class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]):
"""Span Panel hardware status sensor entity."""
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index c839aad1..11908f34 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -192,15 +192,6 @@
"bess_vendor": {
"name": "Vendor"
},
- "mid_grid_state": {
- "name": "Grid State",
- "state": {
- "up": "Up",
- "down": "Down",
- "degraded": "Degraded",
- "unknown": "Unknown"
- }
- },
"current_run_config": {
"name": "Current Run Config",
"state": {
@@ -313,6 +304,15 @@
"unknown": "Unknown"
}
},
+ "mid_grid_state": {
+ "name": "Grid State",
+ "state": {
+ "up": "Up",
+ "down": "Down",
+ "degraded": "Degraded",
+ "unknown": "Unknown"
+ }
+ },
"pv_nameplate_capacity": {
"name": "PV Nameplate Capacity"
},
@@ -325,12 +325,18 @@
"pv_vendor": {
"name": "PV Vendor"
},
+ "shed_total_time_remaining": {
+ "name": "Backup Time Remaining"
+ },
"site_power": {
"name": "Site Power"
},
"software_version": {
"name": "Software Version"
},
+ "time_to_priority_shed": {
+ "name": "Time to Priority Shed"
+ },
"upstream_l1_current": {
"name": "Upstream L1 Current"
},
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index c839aad1..11908f34 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -192,15 +192,6 @@
"bess_vendor": {
"name": "Vendor"
},
- "mid_grid_state": {
- "name": "Grid State",
- "state": {
- "up": "Up",
- "down": "Down",
- "degraded": "Degraded",
- "unknown": "Unknown"
- }
- },
"current_run_config": {
"name": "Current Run Config",
"state": {
@@ -313,6 +304,15 @@
"unknown": "Unknown"
}
},
+ "mid_grid_state": {
+ "name": "Grid State",
+ "state": {
+ "up": "Up",
+ "down": "Down",
+ "degraded": "Degraded",
+ "unknown": "Unknown"
+ }
+ },
"pv_nameplate_capacity": {
"name": "PV Nameplate Capacity"
},
@@ -325,12 +325,18 @@
"pv_vendor": {
"name": "PV Vendor"
},
+ "shed_total_time_remaining": {
+ "name": "Backup Time Remaining"
+ },
"site_power": {
"name": "Site Power"
},
"software_version": {
"name": "Software Version"
},
+ "time_to_priority_shed": {
+ "name": "Time to Priority Shed"
+ },
"upstream_l1_current": {
"name": "Upstream L1 Current"
},
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 795b897f..603c749b 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -325,12 +325,18 @@
"pv_vendor": {
"name": "Fabricante PV"
},
+ "shed_total_time_remaining": {
+ "name": "Tiempo de Respaldo Restante"
+ },
"site_power": {
"name": "Potencia del Sitio"
},
"software_version": {
"name": "Versión de Software"
},
+ "time_to_priority_shed": {
+ "name": "Tiempo hasta el Corte por Prioridad"
+ },
"upstream_l1_current": {
"name": "Corriente Ascendente L1"
},
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 44b0fa65..096f209d 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -325,12 +325,18 @@
"pv_vendor": {
"name": "Fournisseur PV"
},
+ "shed_total_time_remaining": {
+ "name": "Autonomie de Secours Restante"
+ },
"site_power": {
"name": "Puissance du Site"
},
"software_version": {
"name": "Version du Logiciel"
},
+ "time_to_priority_shed": {
+ "name": "Temps avant Délestage Prioritaire"
+ },
"upstream_l1_current": {
"name": "Courant Amont L1"
},
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 7c2bf0c2..6b1e7530 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -325,12 +325,18 @@
"pv_vendor": {
"name": "PVベンダー"
},
+ "shed_total_time_remaining": {
+ "name": "バックアップ残り時間"
+ },
"site_power": {
"name": "サイト電力"
},
"software_version": {
"name": "ソフトウェアバージョン"
},
+ "time_to_priority_shed": {
+ "name": "優先遮断までの時間"
+ },
"upstream_l1_current": {
"name": "上流側L1電流"
},
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index f2db3d6c..b793f4e7 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -325,12 +325,18 @@
"pv_vendor": {
"name": "Fornecedor PV"
},
+ "shed_total_time_remaining": {
+ "name": "Tempo de Reserva Restante"
+ },
"site_power": {
"name": "Potência do Local"
},
"software_version": {
"name": "Versão do Software"
},
+ "time_to_priority_shed": {
+ "name": "Tempo até o Corte por Prioridade"
+ },
"upstream_l1_current": {
"name": "Corrente Upstream L1"
},
diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py
index 473161ca..c71743ca 100644
--- a/tests/adapter_fixtures.py
+++ b/tests/adapter_fixtures.py
@@ -17,32 +17,74 @@
import pathlib
from ebus_sdk.homie import DiscoveredDevice
-from span_panel_api.models import FieldMetadata
+from span_panel_api.models import FieldMetadata, SpanPanelSnapshot
_FIXTURES = pathlib.Path(__file__).parent / "fixtures"
+SCHEMA_ONE_PANEL = "example-40t-001"
+"""Device id of the enclosure in `schema_one_tree.json`.
-def _devices(name: str) -> list[DiscoveredDevice]:
- """Rebuild discovered devices from a retained-topic capture.
+Named rather than inlined because a test that mutates one of the panel's
+published topics has to address the panel, and every such test would otherwise
+carry its own copy of the simulator's device naming.
+"""
+
+
+def _device(device_id: str, topics: dict[str, str]) -> DiscoveredDevice:
+ """Rebuild one discovered device from its retained topics.
Mirrors the library's own builder (test_schema_one_devices.py:26-37):
`update_description` parses the JSON string, `update_property` stores each
non-`$` topic as a property value.
"""
+ device = DiscoveredDevice(device_id, "ebus")
+ device.update_description(topics["$description"])
+ device.update_state(topics.get("$state", "ready"))
+ for topic, value in topics.items():
+ if topic.startswith("$"):
+ continue
+ node, _, prop = topic.partition("/")
+ if prop:
+ device.update_property(node, prop, value)
+ return device
+
+
+def _devices(name: str) -> list[DiscoveredDevice]:
+ """Rebuild discovered devices from a named retained-topic capture."""
tree = json.loads((_FIXTURES / name).read_text())
- devices: list[DiscoveredDevice] = []
- for device_id, topics in tree.items():
- device = DiscoveredDevice(device_id, "ebus")
- device.update_description(topics["$description"])
- device.update_state(topics.get("$state", "ready"))
- for topic, value in topics.items():
- if topic.startswith("$"):
- continue
- node, _, prop = topic.partition("/")
- if prop:
- device.update_property(node, prop, value)
- devices.append(device)
- return devices
+ return [_device(device_id, topics) for device_id, topics in tree.items()]
+
+
+def schema_one_tree() -> dict[str, dict[str, str]]:
+ """A mutable copy of the parent/child capture, ready to be rewritten.
+
+ Copied per call, and one level deep, which is as deep as a topic goes: a
+ test proves a reading came off the wire by republishing it and asserting the
+ entity followed, and that is impossible against a shared immutable capture.
+ """
+ tree = json.loads((_FIXTURES / "schema_one_tree.json").read_text())
+ return {device_id: dict(topics) for device_id, topics in tree.items()}
+
+
+def schema_one_snapshot(tree: dict[str, dict[str, str]] | None = None) -> SpanPanelSnapshot:
+ """Build a real snapshot from the capture, through the real schema_1 mapper.
+
+ The point of going the long way round rather than through
+ `SpanPanelSnapshotFactory`: a factory takes the value a test hands it, so an
+ assertion against it proves only that the test and the entity agree. Driving
+ the actual adapter over the actual capture makes the published topic the
+ source of truth, so republishing one is a mutation the entity has to follow.
+ """
+ from span_panel_api_schema_1.snapshot import build_snapshot
+
+ tree = schema_one_tree() if tree is None else tree
+ panel = _device(SCHEMA_ONE_PANEL, tree[SCHEMA_ONE_PANEL])
+ children = [
+ _device(device_id, topics)
+ for device_id, topics in tree.items()
+ if device_id != SCHEMA_ONE_PANEL
+ ]
+ return build_snapshot(panel, children)
def schema_zero_metadata() -> dict[str, FieldMetadata]:
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 070b0f04..e3598eb7 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -253,9 +253,14 @@ def test_no_exempt_path_is_producible_by_both() -> None:
_EXPECTED_EXEMPT_COUNTS: dict[Producibility, int] = {
- Producibility.NEITHER: 15,
+ # +3 with the shed forecast: the two full-charge refinements and the
+ # confidence enum, read as attributes on the two forecast sensors and
+ # carried by no adapter's metadata map.
+ Producibility.NEITHER: 18,
Producibility.SCHEMA_0_ONLY: 10,
- Producibility.SCHEMA_1_ONLY: 1,
+ # +2 with the shed forecast: the two live estimates, which schema_1 maps and
+ # flat firmware does not publish at all.
+ Producibility.SCHEMA_1_ONLY: 3,
}
"""The exemption inventory, by reason. See `test_exempt_inventory_is_complete`."""
diff --git a/tests/test_shed_forecast.py b/tests/test_shed_forecast.py
new file mode 100644
index 00000000..a6bfb083
--- /dev/null
+++ b/tests/test_shed_forecast.py
@@ -0,0 +1,475 @@
+"""The backup-planning forecast, surfaced as two sensors and three attributes.
+
+Every assertion here runs against a real snapshot built by the real schema_1
+adapter over the vendored capture, and every expected value is read out of that
+capture rather than written as a literal. That is deliberate and it is the whole
+design of this module: a test that pins the same constant the code pins passes
+whether or not the wire is ever read, so each reading is proved by republishing
+it — a different value, or none — and asserting the entity moved.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+from span_panel_api import SpanPanelSnapshot
+
+from custom_components.span_panel import SpanPanelRuntimeData
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ Producibility,
+)
+from custom_components.span_panel.helpers import detect_capabilities, has_shed_forecast
+from custom_components.span_panel.sensor import create_shed_forecast_sensors
+from custom_components.span_panel.sensor_definitions import SHED_FORECAST_SENSORS
+from custom_components.span_panel.sensor_panel import SpanShedForecastSensor
+from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
+from homeassistant.const import CONF_HOST, UnitOfTime
+from homeassistant.helpers.entity import EntityCategory
+
+from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree
+from .factories import SpanPanelSnapshotFactory
+
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+NODE = "shed-forecast"
+
+TIME_TO_PRIORITY_SHED = "time-to-priority-shed"
+TOTAL_TIME_REMAINING = "total-time-remaining"
+FULL_CHARGE_TIME_TO_PRIORITY_SHED = "full-charge-time-to-priority-shed"
+FULL_CHARGE_TOTAL_TIME_REMAINING = "full-charge-total-time-remaining"
+CONFIDENCE = "confidence"
+
+TIME_TO_PRIORITY_SHED_KEY = "time_to_priority_shed"
+TOTAL_TIME_REMAINING_KEY = "shed_total_time_remaining"
+
+
+@pytest.fixture(autouse=True)
+def _mock_entity_registry() -> Any:
+ """Patch entity registry lookups used during sensor construction."""
+ registry = MagicMock()
+ registry.async_get_entity_id.return_value = None
+ with patch(
+ "custom_components.span_panel.sensor_base.er.async_get",
+ return_value=registry,
+ ):
+ yield registry
+
+
+def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock:
+ """A coordinator-like mock carrying one snapshot."""
+ coordinator = MagicMock()
+ coordinator.data = snapshot
+ coordinator.hass = MagicMock()
+ coordinator.panel_offline = False
+ coordinator.unresolved_paths = frozenset()
+ coordinator.config_entry = MockConfigEntry(
+ domain="span_panel",
+ data={CONF_HOST: "192.168.1.50"},
+ options={},
+ title="SPAN Panel",
+ unique_id=snapshot.serial_number,
+ )
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+ return coordinator
+
+
+def _published(property_id: str) -> str:
+ """What the capture publishes for one forecast property."""
+ return schema_one_tree()[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"]
+
+
+def _republishing(**topics: str) -> SpanPanelSnapshot:
+ """A snapshot from the capture with some forecast topics rewritten."""
+ tree = schema_one_tree()
+ for property_id, value in topics.items():
+ tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id.replace('_', '-')}"] = value
+ return schema_one_snapshot(tree)
+
+
+def _without(*property_ids: str) -> SpanPanelSnapshot:
+ """A snapshot from a capture that stopped publishing (and declaring) properties."""
+ tree = schema_one_tree()
+ description = json.loads(tree[SCHEMA_ONE_PANEL]["$description"])
+ for property_id in property_ids:
+ del tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"]
+ del description["nodes"][NODE]["properties"][property_id]
+ tree[SCHEMA_ONE_PANEL]["$description"] = json.dumps(description)
+ return schema_one_snapshot(tree)
+
+
+def _without_node() -> SpanPanelSnapshot:
+ """A snapshot from a capture with no `shed-forecast` node at all."""
+ tree = schema_one_tree()
+ for topic in [t for t in tree[SCHEMA_ONE_PANEL] if t.startswith(f"{NODE}/")]:
+ del tree[SCHEMA_ONE_PANEL][topic]
+ description = json.loads(tree[SCHEMA_ONE_PANEL]["$description"])
+ del description["nodes"][NODE]
+ tree[SCHEMA_ONE_PANEL]["$description"] = json.dumps(description)
+ return schema_one_snapshot(tree)
+
+
+def _sensors(snapshot: SpanPanelSnapshot) -> dict[str, SpanShedForecastSensor]:
+ """Whatever the platform creates for this snapshot, keyed by description key."""
+ created = create_shed_forecast_sensors(_coordinator(snapshot), snapshot)
+ return {sensor.entity_description.key: sensor for sensor in created}
+
+
+def _state(snapshot: SpanPanelSnapshot, key: str) -> float | int | str | None:
+ """The state one forecast sensor reports for a snapshot."""
+ sensor = _sensors(snapshot)[key]
+ sensor._update_native_value()
+ return sensor.native_value
+
+
+def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]:
+ return _sensors(snapshot)[key].extra_state_attributes or {}
+
+
+# ---------------------------------------------------------------------------
+# The premise: the capture publishes the capability
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_publishes_the_whole_capability() -> None:
+ """Guard the premise for every test below, all of which read the capture for
+ their expected value: a capture that stopped publishing the node would make
+ them vacuously true rather than failing."""
+ panel = schema_one_tree()[SCHEMA_ONE_PANEL]
+
+ for property_id in (
+ TIME_TO_PRIORITY_SHED,
+ TOTAL_TIME_REMAINING,
+ FULL_CHARGE_TIME_TO_PRIORITY_SHED,
+ FULL_CHARGE_TOTAL_TIME_REMAINING,
+ CONFIDENCE,
+ ):
+ assert f"{NODE}/{property_id}" in panel
+
+
+# ---------------------------------------------------------------------------
+# States follow the wire
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("key", "property_id"),
+ [
+ (TIME_TO_PRIORITY_SHED_KEY, TIME_TO_PRIORITY_SHED),
+ (TOTAL_TIME_REMAINING_KEY, TOTAL_TIME_REMAINING),
+ ],
+)
+def test_each_sensor_reports_the_value_the_panel_published(key: str, property_id: str) -> None:
+ assert _state(schema_one_snapshot(), key) == float(_published(property_id))
+
+
+def test_the_two_sensors_do_not_report_the_same_reading() -> None:
+ """The capture publishes different values for the two estimates, so a wiring
+ that crossed them would fail here rather than looking plausible."""
+ snapshot = schema_one_snapshot()
+
+ assert _state(snapshot, TIME_TO_PRIORITY_SHED_KEY) != _state(
+ snapshot, TOTAL_TIME_REMAINING_KEY
+ )
+
+
+@pytest.mark.parametrize(
+ ("key", "property_id", "republished"),
+ [
+ (TIME_TO_PRIORITY_SHED_KEY, TIME_TO_PRIORITY_SHED, "17"),
+ (TOTAL_TIME_REMAINING_KEY, TOTAL_TIME_REMAINING, "1440"),
+ ],
+)
+def test_republishing_an_estimate_moves_its_sensor(
+ key: str, property_id: str, republished: str
+) -> None:
+ """The mutation proof. Each republished value differs from every value the
+ capture carries, so a sensor pinned to a constant — or wired to the wrong
+ property — cannot report it."""
+ snapshot = _republishing(**{property_id.replace("-", "_"): republished})
+
+ assert _state(snapshot, key) == float(republished)
+ assert _state(snapshot, key) != float(_published(property_id))
+
+
+@pytest.mark.parametrize(
+ ("key", "property_id"),
+ [
+ (TIME_TO_PRIORITY_SHED_KEY, TIME_TO_PRIORITY_SHED),
+ (TOTAL_TIME_REMAINING_KEY, TOTAL_TIME_REMAINING),
+ ],
+)
+def test_zero_minutes_is_a_state_and_not_an_absence(key: str, property_id: str) -> None:
+ """Shedding starts now is a reading, and the most important one the capability
+ reports. A gate that treated zero as absence would delete the entity exactly
+ when a user needs it."""
+ snapshot = _republishing(**{property_id.replace("-", "_"): "0"})
+
+ assert key in _sensors(snapshot)
+ assert _state(snapshot, key) == 0.0
+
+
+# ---------------------------------------------------------------------------
+# Attributes follow the wire
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("key", "attribute", "property_id"),
+ [
+ (
+ TIME_TO_PRIORITY_SHED_KEY,
+ "full_charge_time_to_priority_shed",
+ FULL_CHARGE_TIME_TO_PRIORITY_SHED,
+ ),
+ (
+ TOTAL_TIME_REMAINING_KEY,
+ "full_charge_total_time_remaining",
+ FULL_CHARGE_TOTAL_TIME_REMAINING,
+ ),
+ ],
+)
+def test_each_sensor_carries_its_own_full_charge_twin(
+ key: str, attribute: str, property_id: str
+) -> None:
+ """The pairing, not just the presence: each estimate carries the
+ full-charge figure that refines *it*."""
+ attributes = _attributes(schema_one_snapshot(), key)
+
+ assert attributes[attribute] == int(_published(property_id))
+
+
+def test_no_sensor_carries_the_other_sensors_twin() -> None:
+ """The capture publishes 3038 and 4320, and the second happens to equal the
+ live total — so only the pairing check above plus this one can tell a correct
+ wiring from a crossed one."""
+ assert "full_charge_total_time_remaining" not in _attributes(
+ schema_one_snapshot(), TIME_TO_PRIORITY_SHED_KEY
+ )
+ assert "full_charge_time_to_priority_shed" not in _attributes(
+ schema_one_snapshot(), TOTAL_TIME_REMAINING_KEY
+ )
+
+
+@pytest.mark.parametrize(
+ ("key", "attribute", "property_id"),
+ [
+ (
+ TIME_TO_PRIORITY_SHED_KEY,
+ "full_charge_time_to_priority_shed",
+ FULL_CHARGE_TIME_TO_PRIORITY_SHED,
+ ),
+ (
+ TOTAL_TIME_REMAINING_KEY,
+ "full_charge_total_time_remaining",
+ FULL_CHARGE_TOTAL_TIME_REMAINING,
+ ),
+ ],
+)
+def test_republishing_a_full_charge_figure_moves_its_attribute(
+ key: str, attribute: str, property_id: str
+) -> None:
+ snapshot = _republishing(**{property_id.replace("-", "_"): "999"})
+
+ assert _attributes(snapshot, key)[attribute] == 999
+
+
+@pytest.mark.parametrize(
+ ("key", "attribute", "property_id"),
+ [
+ (
+ TIME_TO_PRIORITY_SHED_KEY,
+ "full_charge_time_to_priority_shed",
+ FULL_CHARGE_TIME_TO_PRIORITY_SHED,
+ ),
+ (
+ TOTAL_TIME_REMAINING_KEY,
+ "full_charge_total_time_remaining",
+ FULL_CHARGE_TOTAL_TIME_REMAINING,
+ ),
+ ],
+)
+def test_a_full_charge_figure_the_panel_drops_takes_its_attribute_with_it(
+ key: str, attribute: str, property_id: str
+) -> None:
+ """Absent, not `None`. An attribute that is present and empty reads as a
+ reading the panel failed to produce; this is firmware that does not carry the
+ property at all."""
+ snapshot = _without(property_id)
+
+ assert attribute not in _attributes(snapshot, key)
+ # The sensor itself is unaffected: the estimate it reads is still published.
+ assert _state(snapshot, key) == float(
+ _published(TIME_TO_PRIORITY_SHED if key == TIME_TO_PRIORITY_SHED_KEY else TOTAL_TIME_REMAINING)
+ )
+
+
+@pytest.mark.parametrize("key", [TIME_TO_PRIORITY_SHED_KEY, TOTAL_TIME_REMAINING_KEY])
+def test_confidence_rides_on_both_sensors(key: str) -> None:
+ """It qualifies both estimates, so it appears on both."""
+ assert _attributes(schema_one_snapshot(), key)["forecast_confidence"] == _published(
+ CONFIDENCE
+ )
+
+
+@pytest.mark.parametrize("key", [TIME_TO_PRIORITY_SHED_KEY, TOTAL_TIME_REMAINING_KEY])
+def test_republishing_confidence_moves_the_attribute(key: str) -> None:
+ snapshot = _republishing(confidence="LOW")
+
+ assert _attributes(snapshot, key)["forecast_confidence"] == "LOW"
+ assert _attributes(snapshot, key)["forecast_confidence"] != _published(CONFIDENCE)
+
+
+@pytest.mark.parametrize("key", [TIME_TO_PRIORITY_SHED_KEY, TOTAL_TIME_REMAINING_KEY])
+def test_confidence_the_panel_drops_takes_its_attribute_with_it(key: str) -> None:
+ assert "forecast_confidence" not in _attributes(_without(CONFIDENCE), key)
+
+
+def test_a_sensor_with_neither_refinement_publishes_no_attributes_at_all() -> None:
+ """`None` rather than an empty dict, which is what the entity contract asks
+ for and what stops an empty attribute block rendering."""
+ snapshot = _without(FULL_CHARGE_TIME_TO_PRIORITY_SHED, CONFIDENCE)
+
+ assert _sensors(snapshot)[TIME_TO_PRIORITY_SHED_KEY].extra_state_attributes is None
+
+
+# ---------------------------------------------------------------------------
+# Creation is gated on what the panel publishes
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_creates_both_sensors() -> None:
+ assert set(_sensors(schema_one_snapshot())) == {
+ TIME_TO_PRIORITY_SHED_KEY,
+ TOTAL_TIME_REMAINING_KEY,
+ }
+
+
+def test_a_panel_with_no_forecast_node_gets_no_sensors() -> None:
+ """The absence test. A dead entity stuck at unknown is worse than no entity:
+ it occupies the entity list, breaks a dashboard card, and cannot be told
+ apart from a panel whose forecast has failed."""
+ snapshot = _without_node()
+
+ assert has_shed_forecast(snapshot) is False
+ assert create_shed_forecast_sensors(_coordinator(snapshot), snapshot) == []
+
+
+def test_a_flat_panel_gets_no_sensors() -> None:
+ """The same absence by the other route: flat firmware publishes no such
+ capability, so the factory's default snapshot carries none of the fields."""
+ snapshot = SpanPanelSnapshotFactory.create()
+
+ assert has_shed_forecast(snapshot) is False
+ assert create_shed_forecast_sensors(_coordinator(snapshot), snapshot) == []
+
+
+@pytest.mark.parametrize(
+ ("dropped", "surviving"),
+ [
+ (TIME_TO_PRIORITY_SHED, TOTAL_TIME_REMAINING_KEY),
+ (TOTAL_TIME_REMAINING, TIME_TO_PRIORITY_SHED_KEY),
+ ],
+)
+def test_a_partial_node_creates_only_the_sensor_it_can_fill(
+ dropped: str, surviving: str
+) -> None:
+ """The catalog marks all four times SHOULD, not MUST, so a partial node is
+ legal firmware rather than a defect — and the half it omits must produce no
+ entity rather than one permanently unknown."""
+ snapshot = _without(dropped)
+
+ assert has_shed_forecast(snapshot) is True
+ assert set(_sensors(snapshot)) == {surviving}
+
+
+def test_the_forecast_appearing_is_a_capability_change() -> None:
+ """Which is how a panel that gains the node mid-life gets the sensors: the
+ coordinator reloads on a new capability."""
+ assert "shed_forecast" not in detect_capabilities(SpanPanelSnapshotFactory.create())
+ assert "shed_forecast" in detect_capabilities(schema_one_snapshot())
+ assert "shed_forecast" not in detect_capabilities(_without_node())
+
+
+# ---------------------------------------------------------------------------
+# Shape of the entities
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("description", SHED_FORECAST_SENSORS, ids=lambda d: d.key)
+def test_both_are_duration_sensors_in_the_unit_the_capability_declares(
+ description: Any,
+) -> None:
+ assert description.device_class is SensorDeviceClass.DURATION
+ assert description.state_class is SensorStateClass.MEASUREMENT
+ assert description.native_unit_of_measurement == UnitOfTime.MINUTES
+
+
+@pytest.mark.parametrize("description", SHED_FORECAST_SENSORS, ids=lambda d: d.key)
+def test_both_are_enabled_by_default_and_not_filed_under_diagnostics(
+ description: Any,
+) -> None:
+ """These are the numbers a user plans a backup around, which is the whole
+ argument for surfacing them ahead of the rest of the unread v1.0 surface. A
+ disabled or diagnostic sensor would surface them in name only."""
+ assert description.entity_registry_enabled_default is True
+ assert description.entity_category is not EntityCategory.DIAGNOSTIC
+
+
+def test_the_declared_unit_matches_what_the_panel_declares() -> None:
+ """HA's unit against the tree's, for the two paths schema_1 carries metadata
+ for. A disagreement here is what the integration's unit-mismatch Repair
+ reports at runtime; catching it in the suite is cheaper."""
+ from .adapter_fixtures import schema_one_metadata
+
+ metadata = schema_one_metadata()
+ for description, field_path in (
+ (SHED_FORECAST_SENSORS[0], "panel.shed_time_to_priority_shed_min"),
+ (SHED_FORECAST_SENSORS[1], "panel.shed_total_time_remaining_min"),
+ ):
+ assert metadata[field_path].unit == description.native_unit_of_measurement
+
+
+def test_the_two_sensors_get_distinct_unique_ids() -> None:
+ """They live on the same device and differ only by description key."""
+ sensors = _sensors(schema_one_snapshot())
+ unique_ids = {sensor.unique_id for sensor in sensors.values()}
+
+ assert len(unique_ids) == len(sensors)
+ for unique_id in unique_ids:
+ assert schema_one_snapshot().serial_number.lower() in unique_id
+
+
+# ---------------------------------------------------------------------------
+# Conformance annotations
+# ---------------------------------------------------------------------------
+
+
+def test_the_two_live_estimates_are_exempt_as_schema_1_only() -> None:
+ """Pinned here as well as in the conformance suite, because the reason is
+ specific to this capability: no flat panel publishes it, so the producible
+ gate cannot be satisfied and the descriptions must stay derived."""
+ assert (
+ RESIDUAL_EXEMPT_PATHS["panel.shed_time_to_priority_shed_min"]
+ is Producibility.SCHEMA_1_ONLY
+ )
+ assert (
+ RESIDUAL_EXEMPT_PATHS["panel.shed_total_time_remaining_min"]
+ is Producibility.SCHEMA_1_ONLY
+ )
+
+
+def test_the_three_refinements_are_exempt_as_neither() -> None:
+ """No adapter maps them, by design — they qualify the two estimates rather
+ than being readings of their own."""
+ for path in (
+ "panel.shed_full_charge_time_to_priority_shed_min",
+ "panel.shed_full_charge_total_time_remaining_min",
+ "panel.shed_forecast_confidence",
+ ):
+ assert RESIDUAL_EXEMPT_PATHS[path] is Producibility.NEITHER
diff --git a/tests/test_unit_vocabulary.py b/tests/test_unit_vocabulary.py
index 7f76c626..a9f90e02 100644
--- a/tests/test_unit_vocabulary.py
+++ b/tests/test_unit_vocabulary.py
@@ -10,6 +10,7 @@
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
+ UnitOfTime,
)
import pytest
from span_panel_api.models import FieldMetadata
@@ -23,6 +24,10 @@
*(u.value for u in UnitOfEnergy),
*(u.value for u in UnitOfElectricCurrent),
*(u.value for u in UnitOfElectricPotential),
+ # `shed-forecast` declares its four estimates in `min`, which is
+ # `UnitOfTime.MINUTES` — the first non-electrical quantity a SPAN panel
+ # publishes, and the reason a time vocabulary belongs here at all.
+ *(u.value for u in UnitOfTime),
PERCENTAGE,
}
From eb8f9a655c41c1eda92d7b2ecec87099ad81e030 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 17:58:16 -0700
Subject: [PATCH 047/116] feat(field-paths): degrade a schema-conditional
entity like any other
A `SCHEMA_CONDITIONAL_FIELD` description named no field, so the two halves
of the degradation apparatus both skipped it: `_reads_an_unresolved_field`
required `not description.derived`, and `evaluate_field_metadata` only ever
asked the adapter about `declared_field_paths()`. Such an entity therefore
kept publishing a parsed default when its field went unresolved, and the
Repair for that field could name nobody -- which the suppression rule turns
into no Repair at all. That is the `evse_ev_connected` failure reached by a
second route, and Task 1 had just shipped two entities down it.
The exemption is from the *producible* gate, which is a claim about the
other adapter. The adapter that is running still publishes a metadata row
for the field and still reports whether the panel resolved it. So the
description now names its source field alongside `derived`: `field_path`
answers "what is this entity's value", `derived` answers "why is that path
outside the both-adapters gate", and only the first is what the Repair and
the availability probe need.
The deliberate exclusion is untouched and now has a test of its own:
`circuit.name` and `circuit.tabs` are residual reads that decorate a
reading rather than being it, and a circuit's power is still true when they
are gone. `evaluate_field_metadata` widens to the descriptions' source
fields, not to every non-NEITHER exemption, for exactly that reason --
device_info fields and circuit attributes stay out.
`panel.dominant_power_source` joins RESIDUAL_EXEMPT_PATHS. The
`grid_forming_entity` sensor read it while it was enumerated nowhere, so
the evaluator logged it as produced-but-unread with an entity reading it.
`test_schema_conditional_descriptions_name_an_exempt_field` is what makes
that omission impossible to repeat, and the introspection proxy now checks
the named path against what the value_fn reads.
---
custom_components/span_panel/binary_sensor.py | 2 +
custom_components/span_panel/entity.py | 55 +++---
custom_components/span_panel/field_paths.py | 163 ++++++++++++++----
.../span_panel/schema_validation.py | 12 +-
.../span_panel/sensor_definitions.py | 23 ++-
tests/test_field_path_conformance.py | 15 +-
tests/test_field_path_introspection.py | 31 +++-
tests/test_field_paths.py | 87 +++++++---
tests/test_schema_availability.py | 48 +++++-
tests/test_schema_repairs.py | 56 ++++--
tests/test_schema_validation.py | 55 +++++-
11 files changed, 434 insertions(+), 113 deletions(-)
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 3c2ef7ec..7c2d9f1c 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -141,6 +141,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
GRID_ISLANDABLE_SENSOR = SpanPanelBinarySensorEntityDescription(
key="grid_islandable",
+ field_path="panel.grid_islandable",
derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
translation_key="grid_islandable",
device_class=BinarySensorDeviceClass.POWER,
@@ -150,6 +151,7 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
BESS_CONNECTED_SENSOR = SpanPanelBinarySensorEntityDescription(
key="bess_connected",
+ field_path="battery.connected",
derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
translation_key="bess_connected",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
diff --git a/custom_components/span_panel/entity.py b/custom_components/span_panel/entity.py
index f4ca67b5..eae3dc85 100644
--- a/custom_components/span_panel/entity.py
+++ b/custom_components/span_panel/entity.py
@@ -60,22 +60,36 @@ async def async_will_remove_from_hass(self) -> None:
self.coordinator.async_unregister_field_path_entity(field_path, self.entity_id)
await super().async_will_remove_from_hass()
+ def _source_field_path(self) -> str | None:
+ """Return the snapshot field this entity's value comes from, if any.
+
+ The description's `field_path`, regardless of `derived`. A
+ `SCHEMA_CONDITIONAL_FIELD` description is exempt from the *producible*
+ gate, which is a statement about the other adapter, not about this
+ entity: the adapter that does produce the field publishes a metadata
+ row for it, that row can come back unresolved, and when it does this
+ entity's reading is a default rather than a measurement — exactly as
+ for a plain declaration.
+
+ `None` for `MULTIPLE_FIELDS` and `NO_SOURCE_FIELD` descriptions, which
+ have no single field to blame, and for the circuit switch, which has no
+ entity description at all.
+ """
+ description: object = getattr(self, "entity_description", None)
+ if isinstance(description, FieldPathDeclarationMixin):
+ return description.field_path
+ return None
+
def _declared_field_paths(self) -> tuple[str, ...]:
"""Return every snapshot field this entity reads.
- The description's `field_path` when it declares one, plus any residual
- reads. A description that declares nothing (`derived` entities, whose
- `DerivedReason` says why no single field is theirs) contributes nothing,
- and a platform with no entity description at all — the circuit switch —
- contributes only its residual reads.
+ Its source field when it has one, plus any residual reads. A platform
+ with no entity description at all — the circuit switch — contributes
+ only its residual reads.
"""
- description: object = getattr(self, "entity_description", None)
- if (
- isinstance(description, FieldPathDeclarationMixin)
- and not description.derived
- and description.field_path is not None
- ):
- return (description.field_path, *self._residual_field_paths)
+ source = self._source_field_path()
+ if source is not None:
+ return (source, *self._residual_field_paths)
return self._residual_field_paths
@property
@@ -91,15 +105,16 @@ def _reads_an_unresolved_field(self) -> bool:
is still true when they are gone. The switch and select read their state
through residual paths and so are not covered here; the Repair still
names them.
+
+ `derived` is deliberately not consulted. It says why a path is outside
+ the both-adapters producible gate, which is a fact about the *other*
+ adapter; the adapter running here still resolves the field or fails to.
+ Excluding schema-conditional entities from this probe left them with a
+ default they present as a reading -- the `evse_ev_connected` failure
+ mode, reached by a different route.
"""
- description: object = getattr(self, "entity_description", None)
- if (
- isinstance(description, FieldPathDeclarationMixin)
- and not description.derived
- and description.field_path is not None
- ):
- return description.field_path in self.coordinator.unresolved_paths
- return False
+ source = self._source_field_path()
+ return source is not None and source in self.coordinator.unresolved_paths
@property
def available(self) -> bool:
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index c060a7f0..57d1cda9 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -65,9 +65,20 @@ class DerivedReason(Enum):
"""Reads exactly one field, which only one adapter produces.
Keeps the producible gate satisfiable: the gate requires a path both
- adapters emit, so a schema-conditional field cannot be declared. If the
+ adapters emit, so a schema-conditional field cannot satisfy it. If the
other adapter ever grows the field, this stops being true and the
- verification fails, demanding promotion to a `field_path` declaration.
+ verification fails, demanding promotion to a plain `field_path` declaration.
+
+ Alone among the reasons, this one still names its source: the description
+ sets `field_path` **as well as** `derived`, and
+ `test_schema_conditional_descriptions_name_their_field` holds it to that.
+ The two attributes answer different questions -- "what does this entity's
+ value come from" and "why is that path outside the both-adapters gate" --
+ and only the first is what a Repair and the availability probe need. Leaving
+ it unset excused schema-conditional entities from both, which is the
+ `evse_ev_connected` failure mode one level along: an entity whose field the
+ panel stopped resolving would keep publishing a default, and the Repair
+ naming that field would say "0 entities affected".
"""
@@ -111,20 +122,30 @@ class FieldPathDeclarationMixin:
"""
field_path: str | None = None
- """Snapshot field this entity reads, e.g. "circuit.instant_power_w".
+ """Snapshot field this entity's value comes from, e.g. "circuit.instant_power_w".
Declared here rather than in a parallel map so the declaration and the
reader are the same object. Verified against `value_fn` by the proxy test
in tests/test_field_path_introspection.py.
+
+ Set whenever the entity *has* a single source field — including when that
+ field is schema-conditional and so cannot enter the producible gate. What
+ keeps a schema-conditional path out of the gate is `derived`, not the
+ absence of this. Consumers that ask "which field is this entity's value"
+ (`SpanPanelEntity._declared_field_paths`, `_reads_an_unresolved_field`)
+ therefore read this alone; the gate additionally consults `derived`.
"""
derived: DerivedReason | None = None
- """Why this entity has no single source field to declare, or `None`.
+ """Why this entity's source field is outside the producible gate, or `None`.
+
+ Which reason applies is not a matter of opinion — see `DerivedReason`, whose
+ members are each asserted against what the `value_fn` actually reads.
- Set only when `field_path` is not: the two are alternatives, pinned by
- `test_every_description_declares_exactly_one`. Which reason applies is not
- a matter of opinion — see `DerivedReason`, whose members are each asserted
- against what the `value_fn` actually reads.
+ Its relationship to `field_path` is per reason, pinned by
+ `test_every_description_declares_exactly_one`: `NO_SOURCE_FIELD` and
+ `MULTIPLE_FIELDS` have no single field to name, so `field_path` stays
+ `None`; `SCHEMA_CONDITIONAL_FIELD` has exactly one and must name it.
A reason rather than a flag because `bool` conflated four situations, and
that conflation is how `evse_ev_connected` — one producible field, marked
@@ -188,6 +209,11 @@ class FieldPathDeclarationMixin:
"circuit.always_on": Producibility.SCHEMA_0_ONLY,
"circuit.is_sheddable": Producibility.SCHEMA_0_ONLY,
"panel.wifi_ssid": Producibility.SCHEMA_0_ONLY,
+ # The `grid_forming_entity` sensor's source field. schema_1 answers the
+ # same question through `resolve_dominant_power_source` over the MID's
+ # `grid/grid-forming-entity` instead of publishing a row of its own,
+ # which is what makes the sensor `SCHEMA_CONDITIONAL_FIELD`.
+ "panel.dominant_power_source": Producibility.SCHEMA_0_ONLY,
# util.py builds the EVSE DeviceInfo from these; entity_resolver.py and
# sensor.py resolve the fed circuit through `feed_circuit_id`.
"evse.vendor_name": Producibility.SCHEMA_0_ONLY,
@@ -227,29 +253,62 @@ class FieldPathDeclarationMixin:
"""
-def iter_field_path_declarations[DescriptionT: EntityDescription](
+def _iter_declared[DescriptionT: EntityDescription](
descriptions: Iterable[DescriptionT],
-) -> Iterator[tuple[str, DescriptionT]]:
- """Yield ``(field_path, description)`` for each description that declares one.
+) -> Iterator[tuple[str, FieldPathDeclarationMixin, DescriptionT]]:
+ """Yield ``(field_path, declaration, description)`` for each named source field.
- The single copy of the traversal rule — which descriptions declare a field,
- and which are exempt — so a caller that only wants the paths and a caller
- that wants the descriptions cannot drift apart on it.
+ The single copy of the traversal rule — which descriptions carry a
+ declaration at all — so no caller can drift from another on it.
Raises `TypeError` for a description that carries no
- `FieldPathDeclarationMixin` at all: such a description would be dropped
- silently, which is the drift this module exists to prevent. Descriptions
- that carry the mixin but declare nothing (`derived`, or `field_path is
- None`) are skipped, which is the declared-exempt case rather than drift.
+ `FieldPathDeclarationMixin`: such a description would be dropped silently,
+ which is the drift this module exists to prevent. A description that carries
+ the mixin and names no field is skipped, which is the declared-derived case
+ rather than drift.
+
+ `declaration` and `description` are the same object; they are yielded twice
+ because a type variable bounded on `EntityDescription` cannot also be known
+ to carry the mixin, and the alternative is a `cast` at every call site.
"""
for description in descriptions:
if not isinstance(description, FieldPathDeclarationMixin):
raise TypeError(
f"entity description '{description.key}' carries no field-path declaration"
)
- if description.derived or description.field_path is None:
+ if description.field_path is None:
continue
- yield description.field_path, description
+ yield description.field_path, description, description
+
+
+def iter_source_field_declarations[DescriptionT: EntityDescription](
+ descriptions: Iterable[DescriptionT],
+) -> Iterator[tuple[str, DescriptionT]]:
+ """Yield ``(field_path, description)`` for each description that names one.
+
+ Every entity that has a single source field, whatever `derived` says about
+ which adapters produce it.
+ """
+ for field_path, _, description in _iter_declared(descriptions):
+ yield field_path, description
+
+
+def iter_field_path_declarations[DescriptionT: EntityDescription](
+ descriptions: Iterable[DescriptionT],
+) -> Iterator[tuple[str, DescriptionT]]:
+ """Yield only the declarations the producible gate covers.
+
+ `derived` descriptions are skipped whether or not they name a field. That
+ is the whole content of the exemption: a `SCHEMA_CONDITIONAL_FIELD`
+ description does name its source field, and naming it is what gives the
+ entity its Repair mention and its unavailability — but the path is still
+ one adapter short of the both-adapters gate this function feeds, and
+ `RESIDUAL_EXEMPT_PATHS` is where it is enumerated instead.
+ """
+ for field_path, declaration, description in _iter_declared(descriptions):
+ if declaration.derived:
+ continue
+ yield field_path, description
def _walk_subclasses[EntityT](root: type[EntityT]) -> Iterator[type[EntityT]]:
@@ -300,12 +359,13 @@ def residual_field_paths() -> frozenset[str]:
)
-def declared_field_paths() -> frozenset[str]:
- """Field paths the integration reads that must be producible by an adapter.
+def platform_descriptions() -> tuple[EntityDescription, ...]:
+ """Every entity description this integration builds entities from.
- Derived entities are excluded: they have no single source field, so there is
- nothing for an adapter to produce. Residual readers that no adapter (or only
- one) produces are excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`.
+ One copy of the collection list. `declared_field_paths` and
+ `conditional_field_paths` ask different questions of the same descriptions,
+ and a second copy of the list is how a platform ends up answering one and
+ not the other.
"""
# Deferred: the platform modules import `FieldPathDeclarationMixin` from
# here, and `binary_sensor` reaches the package root for its config-entry
@@ -320,17 +380,50 @@ def declared_field_paths() -> frozenset[str]:
all_sensor_descriptions,
)
+ return (
+ *all_sensor_descriptions(),
+ *BINARY_SENSORS,
+ *EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ BESS_CONNECTED_SENSOR,
+ )
+
+
+def declared_field_paths() -> frozenset[str]:
+ """Field paths the integration reads that must be producible by an adapter.
+
+ Derived entities are excluded: they have no single source field, or the one
+ they have is not on both schemas, so there is nothing for both adapters to
+ produce. Residual readers that no adapter (or only one) produces are
+ excluded too, and are listed in `RESIDUAL_EXEMPT_PATHS`.
+ """
paths: set[str] = set(residual_field_paths())
paths.update(
- field_path
- for field_path, _ in iter_field_path_declarations(
- (
- *all_sensor_descriptions(),
- *BINARY_SENSORS,
- *EVSE_BINARY_SENSORS,
- GRID_ISLANDABLE_SENSOR,
- BESS_CONNECTED_SENSOR,
- )
- )
+ field_path for field_path, _ in iter_field_path_declarations(platform_descriptions())
)
return frozenset(paths)
+
+
+def conditional_field_paths() -> frozenset[str]:
+ """Source fields of entities only one adapter produces the field for.
+
+ The producible gate cannot cover these -- that is what
+ `DerivedReason.SCHEMA_CONDITIONAL_FIELD` says -- but the adapter that *does*
+ produce the field still publishes a metadata row for it, and that row can
+ come back `resolved=False` when the panel drops the property. So the
+ degradation half of the apparatus applies to them exactly as it does to a
+ plain declaration, and `schema_validation.evaluate_field_metadata` asks
+ about these paths alongside `declared_field_paths()`.
+
+ Read off the descriptions rather than filtered out of
+ `RESIDUAL_EXEMPT_PATHS`: most of that map is decoration -- device_info
+ fields, circuit attributes, entity-creation gates -- whose loss does not
+ make an entity's *reading* wrong, and which is deliberately excluded from
+ the availability probe. What belongs here is the narrower thing the
+ description states: the field this entity's value comes from.
+ """
+ return frozenset(
+ field_path
+ for field_path, declaration, _ in _iter_declared(platform_descriptions())
+ if declaration.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD
+ )
diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py
index d7fc945c..0ae2955a 100644
--- a/custom_components/span_panel/schema_validation.py
+++ b/custom_components/span_panel/schema_validation.py
@@ -19,7 +19,7 @@
from homeassistant.components.sensor import SensorEntityDescription
from span_panel_api.models import FieldMetadata
-from .field_paths import RESIDUAL_EXEMPT_PATHS, declared_field_paths
+from .field_paths import RESIDUAL_EXEMPT_PATHS, conditional_field_paths, declared_field_paths
_LOGGER = logging.getLogger(__name__)
@@ -77,12 +77,20 @@ def evaluate_field_metadata(
`SpanPanelCoordinator._run_schema_validation`.
"""
declared = declared_field_paths()
+ # Schema-conditional entities read a real field off a real metadata row;
+ # what they cannot do is satisfy a gate that demands *both* adapters
+ # produce it. Resolution is a property of the adapter that is running, so
+ # asking about these paths alongside the declared ones is what gives such
+ # an entity its unavailability and its Repair — the apparatus every other
+ # entity already has. Leaving them out is how `panel.wifi_ssid` stayed
+ # invisible: exempt from the gate read as exempt from everything.
+ resolvable = declared | conditional_field_paths()
sensor_defs = sensor_defs or {}
unresolved: set[str] = set()
mismatches: list[UnitMismatch] = []
- for field_path in declared:
+ for field_path in resolvable:
entry = field_metadata.get(field_path)
if entry is None:
# Hardware not present. Not a defect, and deliberately silent.
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 38e14688..a1329eb2 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -39,7 +39,7 @@
from .field_paths import (
DerivedReason,
FieldPathDeclarationMixin,
- iter_field_path_declarations,
+ iter_source_field_declarations,
)
@@ -145,6 +145,7 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="grid_forming_entity",
+ field_path="panel.dominant_power_source",
derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
translation_key="grid_forming_entity",
device_class=SensorDeviceClass.ENUM,
@@ -379,6 +380,7 @@ class SpanShedForecastSensorEntityDescription(
] = (
SpanShedForecastSensorEntityDescription(
key="time_to_priority_shed",
+ field_path="panel.shed_time_to_priority_shed_min",
derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
translation_key="time_to_priority_shed",
device_class=SensorDeviceClass.DURATION,
@@ -391,6 +393,7 @@ class SpanShedForecastSensorEntityDescription(
),
SpanShedForecastSensorEntityDescription(
key="shed_total_time_remaining",
+ field_path="panel.shed_total_time_remaining_min",
derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
translation_key="shed_total_time_remaining",
device_class=SensorDeviceClass.DURATION,
@@ -946,12 +949,18 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]:
def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]:
- """Every non-derived sensor description, keyed by the field path it reads.
+ """Every sensor description with a source field, keyed by that field path.
Keyed by field path because that is how the adapter keys its metadata;
- `description.key` is a different namespace and would not line up. Derived
- descriptions are excluded — they read several fields, or none, so no single
- path identifies them.
+ `description.key` is a different namespace and would not line up.
+ Descriptions that name no field are excluded — they read several fields, or
+ none, so no single path identifies them.
+
+ A `SCHEMA_CONDITIONAL_FIELD` description does name one and is included. Its
+ exemption is from the *producible* gate, and the unit its schema declares
+ for the field is checkable exactly as any other's: the adapter that
+ produces the row publishes a unit, and this integration's sensor declares
+ one, and they can disagree.
A few field paths are read by two descriptions (an unmapped-circuit raw key
and its named-circuit twin), and only the first is kept. That is safe only
@@ -960,10 +969,10 @@ def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]:
pins that rather than leaving it to chance.
Lives here rather than at the call site so no consumer has to know how a
- description declares its field; `field_paths.iter_field_path_declarations`
+ description declares its field; `field_paths.iter_source_field_declarations`
holds that rule.
"""
by_field_path: dict[str, SensorEntityDescription] = {}
- for field_path, description in iter_field_path_declarations(all_sensor_descriptions()):
+ for field_path, description in iter_source_field_declarations(all_sensor_descriptions()):
by_field_path.setdefault(field_path, description)
return by_field_path
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index e3598eb7..aeed159d 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -87,8 +87,15 @@ def test_gate_covers_every_declaration_in_the_source() -> None:
when a collection is emptied rather than unreferenced.
"""
declared = declared_field_paths()
+ # A `SCHEMA_CONDITIONAL_FIELD` description names its source field too, and
+ # that path is by definition one adapter short of this gate. It is covered
+ # instead by `RESIDUAL_EXEMPT_PATHS`, whose annotation is checked against
+ # both adapters below — so being enumerated there is the alternative to
+ # being in `declared`, not an escape from being checked.
uncovered = sorted(
- (path, module) for path, module in _source_declared_paths().items() if path not in declared
+ (path, module)
+ for path, module in _source_declared_paths().items()
+ if path not in declared and path not in RESIDUAL_EXEMPT_PATHS
)
assert not uncovered, (
"declared_field_paths() does not cover field paths declared in the source: "
@@ -257,7 +264,11 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# confidence enum, read as attributes on the two forecast sensors and
# carried by no adapter's metadata map.
Producibility.NEITHER: 18,
- Producibility.SCHEMA_0_ONLY: 10,
+ # +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's
+ # source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and
+ # enumerated nowhere, so `evaluate_field_metadata` counted it as produced-
+ # but-unread while an entity was reading it.
+ Producibility.SCHEMA_0_ONLY: 11,
# +2 with the shed forecast: the two live estimates, which schema_1 maps and
# flat firmware does not publish at all.
Producibility.SCHEMA_1_ONLY: 3,
diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py
index 56ac4a1f..993a31b4 100644
--- a/tests/test_field_path_introspection.py
+++ b/tests/test_field_path_introspection.py
@@ -217,10 +217,16 @@ def _record_reads(description: _DeclaringDescription) -> set[str]:
def test_declared_paths_match_what_value_fns_read() -> None:
+ """Every named source field must be one the `value_fn` actually reads.
+
+ `derived` is not consulted: a `SCHEMA_CONDITIONAL_FIELD` description names
+ its source field too, and that name is what the Repair and the availability
+ probe act on. An unverified one would send both at the wrong path.
+ """
mismatches: list[str] = []
for description in _declaring_descriptions():
- if description.derived or description.field_path is None:
+ if description.field_path is None:
continue
try:
sink = _record_reads(description)
@@ -340,13 +346,20 @@ def test_derived_reasons_match_what_value_fns_read() -> None:
f"{description.key}: claims MULTIPLE_FIELDS but reads {read} — "
"one field or none is a different reason"
)
- elif reason is DerivedReason.SCHEMA_CONDITIONAL_FIELD and (
- len(read) != 1 or read[0] in schema_0 & schema_1
- ):
- offenders.append(
- f"{description.key}: claims SCHEMA_CONDITIONAL_FIELD but reads {read}, "
- f"of which {sorted(set(read) & schema_0 & schema_1)} are produced by both "
- "adapters"
- )
+ elif reason is DerivedReason.SCHEMA_CONDITIONAL_FIELD:
+ if len(read) != 1 or read[0] in schema_0 & schema_1:
+ offenders.append(
+ f"{description.key}: claims SCHEMA_CONDITIONAL_FIELD but reads {read}, "
+ f"of which {sorted(set(read) & schema_0 & schema_1)} are produced by both "
+ "adapters"
+ )
+ elif description.field_path != read[0]:
+ # The one reason that still names a field names the right one.
+ # That name is what the Repair and the availability probe act
+ # on, so a stale one degrades the wrong entity or none.
+ offenders.append(
+ f"{description.key}: declares field_path={description.field_path!r} "
+ f"but reads {read[0]!r}"
+ )
assert not offenders, "Derived reasons disagree with readers:\n" + "\n".join(offenders)
diff --git a/tests/test_field_paths.py b/tests/test_field_paths.py
index 19830353..1656583e 100644
--- a/tests/test_field_paths.py
+++ b/tests/test_field_paths.py
@@ -2,22 +2,17 @@
from __future__ import annotations
-from custom_components.span_panel.binary_sensor import (
- BESS_CONNECTED_SENSOR,
- BINARY_SENSORS,
- EVSE_BINARY_SENSORS,
- GRID_ISLANDABLE_SENSOR,
-)
from custom_components.span_panel.field_paths import (
RESIDUAL_EXEMPT_PATHS,
DerivedReason,
+ FieldPathDeclarationMixin,
+ Producibility,
+ conditional_field_paths,
declared_field_paths,
+ platform_descriptions,
residual_field_paths,
)
-from custom_components.span_panel.sensor_definitions import (
- CIRCUIT_SENSORS,
- all_sensor_descriptions,
-)
+from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS
def test_circuit_power_declares_its_field_path() -> None:
@@ -50,21 +45,73 @@ def test_every_description_declares_exactly_one() -> None:
so the likelier mistake is inheriting it and declaring nothing — which
drops the entity from every gate with no signal. This is that signal.
- Must enumerate exactly what `declared_field_paths` iterates.
+ "Exactly one" is per `DerivedReason`, because the reasons differ on whether
+ a single source field exists at all. `NO_SOURCE_FIELD` and
+ `MULTIPLE_FIELDS` have none to name. `SCHEMA_CONDITIONAL_FIELD` has exactly
+ one and must name it: `derived` excuses the path from the *producible* gate,
+ which is a claim about the other adapter, and saying nothing about the field
+ would additionally excuse the entity from the Repair count and the
+ availability probe — the invisibility this whole module exists to prevent.
"""
- for description in (
- *all_sensor_descriptions(),
- *BINARY_SENSORS,
- *EVSE_BINARY_SENSORS,
- GRID_ISLANDABLE_SENSOR,
- BESS_CONNECTED_SENSOR,
- ):
- declares_path = description.field_path is not None
- assert declares_path != (description.derived is not None), (
+ for description in platform_descriptions():
+ assert isinstance(description, FieldPathDeclarationMixin), description
+ names_field = description.field_path is not None
+ if description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD:
+ assert names_field, (
+ f"{description.key} is SCHEMA_CONDITIONAL_FIELD, which reads exactly one "
+ "field, and must declare it as field_path= so the Repair and the "
+ "availability probe can see it"
+ )
+ continue
+ assert names_field != (description.derived is not None), (
f"{description.key} must declare exactly one of field_path / a DerivedReason"
)
+def test_schema_conditional_descriptions_name_an_exempt_field() -> None:
+ """The field a schema-conditional description names must be enumerated.
+
+ Its source path cannot enter `declared_field_paths()` — one adapter does not
+ produce it — so `RESIDUAL_EXEMPT_PATHS` is the only place it is written down
+ and the only place its producibility is checked against the adapters. A
+ schema-conditional description naming a path absent from there would be read
+ by an entity, gated by nothing, and reported by `evaluate_field_metadata` as
+ produced-but-unread. `panel.dominant_power_source` was exactly that.
+
+ `NEITHER` is excluded on purpose: a path no adapter publishes a metadata row
+ for has nothing to resolve, so a description reading one is
+ `NO_SOURCE_FIELD`, not schema-conditional.
+ """
+ for description in platform_descriptions():
+ assert isinstance(description, FieldPathDeclarationMixin), description
+ if description.derived is not DerivedReason.SCHEMA_CONDITIONAL_FIELD:
+ continue
+ path = description.field_path
+ assert path in RESIDUAL_EXEMPT_PATHS, (
+ f"{description.key} reads {path!r}, which no adapter pair produces and "
+ "RESIDUAL_EXEMPT_PATHS does not enumerate"
+ )
+ assert RESIDUAL_EXEMPT_PATHS[path] is not Producibility.NEITHER, (
+ f"{description.key} claims SCHEMA_CONDITIONAL_FIELD but {path!r} is "
+ "annotated NEITHER — no adapter produces a row for it, so there is "
+ "nothing schema-conditional about it"
+ )
+
+
+def test_conditional_paths_are_exactly_the_unresolvable_reads() -> None:
+ """`conditional_field_paths()` must be disjoint from the declared set.
+
+ The two together are what `evaluate_field_metadata` asks the adapter about.
+ An overlap would mean a path was both gated and exempted, which is the
+ contradiction `test_residual_buckets_are_disjoint` rules out one level down;
+ asserting it here keeps the union honest as the two functions change.
+ """
+ conditional = conditional_field_paths()
+ assert conditional
+ assert not (conditional & declared_field_paths())
+ assert conditional <= RESIDUAL_EXEMPT_PATHS.keys()
+
+
def test_residual_buckets_are_disjoint() -> None:
"""A residual path is either producible or exempt, never both."""
assert not (residual_field_paths() & RESIDUAL_EXEMPT_PATHS.keys())
diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py
index 2d62aaf8..9a69c347 100644
--- a/tests/test_schema_availability.py
+++ b/tests/test_schema_availability.py
@@ -201,19 +201,55 @@ async def test_panel_status_binary_sensor_is_never_probed(hass: HomeAssistant) -
assert entity.available is True
-async def test_derived_entity_is_never_probed(hass: HomeAssistant) -> None:
- """A derived entity declares no source field, so nothing can unresolve it.
-
- `bess_connected` reads `battery.connected`, which only one adapter
- publishes -- exactly why the description is `derived`. Probing a derived
- description would make availability depend on a path it never declared.
+async def test_schema_conditional_entity_goes_unavailable(hass: HomeAssistant) -> None:
+ """A schema-conditional entity is probed like any other.
+
+ `bess_connected` reads exactly one field, `battery.connected`, which only
+ the schema_0 adapter publishes -- that, and only that, is what `derived`
+ says here. The adapter that does publish it still reports whether the panel
+ resolved it, and an unresolved field reaches this entity as a default it
+ would present as a reading.
+
+ Excluding these from the probe was the `evse_ev_connected` failure reached
+ by a second route: an entity exempt from the producible gate silently
+ exempt from degradation too.
"""
coordinator = _make_coordinator(hass)
coordinator._findings = SchemaFindings(frozenset({"battery.connected"}), (), frozenset())
assert BESS_CONNECTED_SENSOR.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD
+ assert BESS_CONNECTED_SENSOR.field_path == "battery.connected"
entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR)
+ assert entity.available is False
+
+
+async def test_schema_conditional_entity_stays_available_when_resolved(
+ hass: HomeAssistant,
+) -> None:
+ """The probe must key on the entity's own field, not on being conditional."""
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset({_ETHERNET_LINK_PATH}), (), frozenset())
+
+ entity = SpanPanelBinarySensor(coordinator, BESS_CONNECTED_SENSOR)
+
+ assert entity.available is True
+
+
+async def test_residual_reads_still_do_not_probe(hass: HomeAssistant) -> None:
+ """`circuit.name` and `circuit.tabs` decorate a reading; they are not it.
+
+ The deliberate exclusion the schema-conditional change must not regress: a
+ circuit power sensor reads its name and tabs for naming and attributes, and
+ its power reading is still true when those are gone.
+ """
+ coordinator = _make_coordinator(hass)
+ coordinator._findings = SchemaFindings(frozenset({"circuit.name"}), (), frozenset())
+
+ entity = _circuit_power_entity(coordinator)
+
+ assert "circuit.name" in type(entity)._residual_field_paths
+ assert "circuit.name" in entity._declared_field_paths()
assert entity.available is True
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index c78e18dd..354a5744 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -674,12 +674,48 @@ async def test_removing_an_entity_drops_it_from_the_map(hass) -> None:
await _stop_scheduling(coordinator)
-async def test_derived_entities_are_not_tracked(hass) -> None:
- """A derived entity must not be blamed for one of the fields it combines.
+async def test_entities_with_no_source_field_are_not_tracked(hass) -> None:
+ """An entity must not be blamed for one of the fields it combines.
- Derived entities compute from several fields or none, so no single field's
- loss can be said to have taken them down. They declare no `field_path` and
- no residual reads, and must therefore land in no bucket at all.
+ A `MULTIPLE_FIELDS` or `NO_SOURCE_FIELD` description computes from several
+ fields or none, so no single field's loss can be said to have taken it
+ down. It names no `field_path` and carries no residual reads, and must
+ therefore land in no bucket at all.
+ """
+ from unittest.mock import MagicMock
+
+ from custom_components.span_panel.binary_sensor import async_setup_entry as binary_setup
+ from custom_components.span_panel.const import PANEL_STATUS
+ from custom_components.span_panel.field_paths import DerivedReason
+
+ coordinator, config_entry, _ = await _entities_by_declared_path(hass)
+ try:
+ added = MagicMock()
+ await binary_setup(hass, config_entry, added)
+ sourceless = [
+ entity
+ for entity in added.call_args.args[0]
+ if getattr(getattr(entity, "entity_description", None), "key", None) == PANEL_STATUS
+ ]
+ assert sourceless
+ assert sourceless[0].entity_description.derived is DerivedReason.NO_SOURCE_FIELD
+ assert sourceless[0].entity_description.field_path is None
+
+ await _add_to_platform(hass, config_entry, sourceless, "binary_sensor")
+
+ assert coordinator.entity_ids_by_field_path == {}
+ finally:
+ await _stop_scheduling(coordinator)
+
+
+async def test_schema_conditional_entities_are_tracked(hass) -> None:
+ """A schema-conditional entity must be nameable by its field's Repair.
+
+ `bess_connected` reads exactly one field. That the *other* adapter does not
+ publish it is why the description is `derived`, and is no reason for the
+ Repair against a dead `battery.connected` to report "0 entities affected" --
+ the suppression rule then throws the finding away entirely, so the user is
+ told nothing at all about a sensor that has gone to a default.
"""
from unittest.mock import MagicMock
@@ -690,20 +726,20 @@ async def test_derived_entities_are_not_tracked(hass) -> None:
coordinator, config_entry, _ = await _entities_by_declared_path(hass)
try:
- assert BESS_CONNECTED_SENSOR.derived, "fixture assumes a derived description"
+ assert BESS_CONNECTED_SENSOR.field_path == "battery.connected"
added = MagicMock()
await binary_setup(hass, config_entry, added)
- derived = [
+ conditional = [
entity
for entity in added.call_args.args[0]
if getattr(entity, "entity_description", None) is BESS_CONNECTED_SENSOR
]
- assert derived
+ assert conditional
- await _add_to_platform(hass, config_entry, derived, "binary_sensor")
+ await _add_to_platform(hass, config_entry, conditional, "binary_sensor")
- assert coordinator.entity_ids_by_field_path == {}
+ assert coordinator.entity_ids_by_field_path["battery.connected"]
finally:
await _stop_scheduling(coordinator)
diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py
index cba9cd63..1bfab80b 100644
--- a/tests/test_schema_validation.py
+++ b/tests/test_schema_validation.py
@@ -8,7 +8,10 @@
from custom_components.span_panel import sensor_definitions
from custom_components.span_panel.field_paths import (
RESIDUAL_EXEMPT_PATHS,
+ DerivedReason,
FieldPathDeclarationMixin,
+ Producibility,
+ conditional_field_paths,
declared_field_paths,
)
from custom_components.span_panel.schema_validation import (
@@ -148,11 +151,14 @@ def test_every_declared_field_path_keys_a_sensor_or_a_residual_reader() -> None:
dict keyed on `description.key` would silently collapse them.
"""
by_path = sensor_descriptions_by_field_path()
- assert by_path.keys() <= declared_field_paths()
+ assert by_path.keys() <= declared_field_paths() | conditional_field_paths()
assert {"battery.model", "pv.model"} <= by_path.keys()
for field_path, description in by_path.items():
assert description.field_path == field_path
- assert not description.derived
+ # A schema-conditional description is included: its unit is as
+ # checkable as any other's. What it must never be is a description
+ # that names no field at all.
+ assert description.derived in (None, DerivedReason.SCHEMA_CONDITIONAL_FIELD)
def test_resolved_unitless_sensor_yields_no_mismatch() -> None:
@@ -207,6 +213,51 @@ def test_real_adapter_metadata_produces_no_findings(metadata_fn: MetadataFn) ->
assert findings.unit_mismatches == ()
+def test_a_schema_conditional_field_can_be_unresolved() -> None:
+ """The evaluator must ask about the paths the producible gate cannot.
+
+ A `SCHEMA_CONDITIONAL_FIELD` entity reads a field that only one adapter
+ publishes a metadata row for. That row still carries the adapter's
+ three-way answer, so it can come back `resolved=False` -- the panel
+ declares the node and omits the property. Iterating `declared_field_paths()`
+ alone never asked, so such an entity got no Repair and no unavailability,
+ however dead its field was.
+ """
+ path = "panel.shed_time_to_priority_shed_min"
+ assert path in conditional_field_paths()
+ assert path not in declared_field_paths()
+
+ findings = evaluate_field_metadata({path: FieldMetadata(None, "unknown", resolved=False)})
+
+ assert findings.unresolved == frozenset({path})
+
+
+def test_a_schema_conditional_unit_is_still_compared() -> None:
+ """Being one adapter short of the gate does not excuse a wrong unit."""
+ findings = evaluate_field_metadata(
+ {"panel.shed_total_time_remaining_min": FieldMetadata("h", "integer")},
+ sensor_descriptions_by_field_path(),
+ )
+ assert [m.field_path for m in findings.unit_mismatches] == [
+ "panel.shed_total_time_remaining_min"
+ ]
+
+
+def test_a_decorating_exempt_path_is_not_probed() -> None:
+ """Exempt is not the same as read-as-a-value.
+
+ `evse.vendor_name` builds a DeviceInfo and `circuit.always_on` is an
+ attribute; neither is any entity's reading, so neither belongs in the
+ unresolved set. Widening the evaluator to every non-`NEITHER` exemption
+ would have swept both in.
+ """
+ for path in ("evse.vendor_name", "circuit.always_on"):
+ assert RESIDUAL_EXEMPT_PATHS[path] is not Producibility.NEITHER
+ assert path not in conditional_field_paths()
+ findings = evaluate_field_metadata({path: FieldMetadata(None, "unknown", resolved=False)})
+ assert findings.unresolved == frozenset()
+
+
def test_known_bad_schema_unit_exception_is_narrow() -> None:
"""Only the exact known-bad unit is excused; anything else is new information."""
from homeassistant.components.sensor import SensorEntityDescription
From a14d0999e8f7c69389daa915a846ca31cf6764a0 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 18:07:19 -0700
Subject: [PATCH 048/116] test(declarations): fail when the panel declares what
nothing reads
The producible gate asks whether an adapter produces what we declare we
read. It cannot see the converse: a property nobody reads is absent from
every list the gate consults, so it is invisible by construction.
`panel.wifi_ssid` is what that costs -- flat surfaces it, v1.0 declares it,
schema_1 maps nothing to it, and its exemption said SCHEMA_0_ONLY, which
was true. Every check agreed and a user upgrading lost an attribute.
Consumption is decided by experiment rather than by a list, because a list
is what made a new property invisible to begin with: republish one declared
property with a legal different value, rebuild the snapshot through the
real schema_1 mapper, and see which snapshot fields moved. Nothing here
restates `_PROPERTY_FIELD_MAP`, the lugs direction tables, the topology
readers or the device_info builders -- the probe sees all of them, and sees
them the way a user does.
Stricter than "mapped by `_PROPERTY_FIELD_MAP`" on purpose. A property the
library reads into a snapshot field no entity, attribute or device card
touches has reached nobody, so `circuit.is_240v`, `evse.part_number` and
`pv.software_version` are baselined rather than counted as read.
Two enumerations, both exact sets so neither can absorb a new property
silently. `_INTERNAL_ROUTES` has three entries for what the snapshot cannot
express -- adapter dispatch, a fallback tier this fixture shadows, a
topology branch no producer reaches -- and fails the moment one of them
becomes observable after all. The baseline has 39 lines, one reason each,
and fails in both directions: an untriaged declaration and a resolved one
are equally loud.
39 is 44 from the gap inventory minus the five shed-forecast properties the
forecast sensors and their attributes just took. Each catch-up task now
deletes the lines it surfaces, so the burn-down is a diff rather than a
claim.
`mid.grid_state` joins RESIDUAL_EXEMPT_PATHS: the probe found the
`mid_grid_state` sensor reading a field enumerated nowhere, the same hole
`panel.dominant_power_source` was in.
---
custom_components/span_panel/field_paths.py | 4 +
.../unread_declarations_baseline.json | 41 ++
tests/test_declared_but_unread.py | 380 ++++++++++++++++++
tests/test_field_path_conformance.py | 6 +-
4 files changed, 430 insertions(+), 1 deletion(-)
create mode 100644 tests/fixtures/unread_declarations_baseline.json
create mode 100644 tests/test_declared_but_unread.py
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 57d1cda9..1d8b7e7b 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -183,6 +183,10 @@ class FieldPathDeclarationMixin:
"mid.model": Producibility.NEITHER,
"mid.serial_number": Producibility.NEITHER,
"mid.grid_forming_device_name": Producibility.NEITHER,
+ # The `mid_grid_state` sensor's source field — utility-supply health,
+ # the one non-metadata entity the MID brings. Neither adapter maps the
+ # MID at all, which is why the description is `NO_SOURCE_FIELD`.
+ "mid.grid_state": Producibility.NEITHER,
# The EVSE's Homie node id — an addressing handle used to build the
# sub-device identifier, not a published field.
"evse.node_id": Producibility.NEITHER,
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
new file mode 100644
index 00000000..b452133d
--- /dev/null
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -0,0 +1,41 @@
+{
+ "bess/meter/active-power": "the battery's own charge/discharge meter; SpanBatterySnapshot carries no power field, and the panel's power_flow_battery is a different property (Task 5)",
+ "bess/status/communication-state": "the publisher's self-report, deliberately distinct from the enclosure's bess_connected view; no snapshot field carries it (Task 5)",
+ "circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field",
+ "circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against",
+ "circuit/connection/feeds-device-status": "the enclosure's link-health view of a circuit-fed DER; only the lugs fed-by-* route is read today, so PV and EVSE get no connected sensor (Task 3)",
+ "circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type",
+ "circuit/pcs/managed": "per-circuit PCS participation; an attribute on the circuit power sensor rather than a 32x entity, and unbuilt (Task 4)",
+ "circuit/pcs/priority": "per-circuit PCS ranking; same attribute treatment as pcs/managed, and unbuilt (Task 4)",
+ "distribution-enclosure/info/hardware-version": "no panel.hardware_version snapshot field, and the panel DeviceInfo declares no hw_version (Task 5)",
+ "distribution-enclosure/info/vendor-name": "DeviceInfo.manufacturer is hardcoded to \"Span\"; the declared value is never consulted, even as a fallback (Task 5)",
+ "distribution-enclosure/pcs/active": "\"a limit is being enforced right now\" - the automation trigger of the PCS family, and the one binary sensor it wants (Task 4)",
+ "distribution-enclosure/pcs/binding-constraint": "names which limit is winning the min() arbitration; the enum sensor that explains pcs/import-limit (Task 4)",
+ "distribution-enclosure/pcs/enabled": "whether the panel enforces UL 3141 import limiting at all; subsumed by pcs/active for entity purposes (Task 4)",
+ "distribution-enclosure/pcs/feed-import-limit": "the standing FSR floor; an attribute on the effective-limit sensor rather than an entity (Task 4)",
+ "distribution-enclosure/pcs/feed-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
+ "distribution-enclosure/pcs/feed-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
+ "distribution-enclosure/pcs/import-limit": "the effective enforced import limit - the single number that summarizes the PCS family, and the headline entity of Task 4",
+ "distribution-enclosure/pcs/off-grid-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)",
+ "distribution-enclosure/pcs/off-grid-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
+ "distribution-enclosure/pcs/off-grid-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
+ "distribution-enclosure/pcs/operator-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)",
+ "distribution-enclosure/pcs/operator-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
+ "distribution-enclosure/pcs/operator-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
+ "distribution-enclosure/pcs/requested-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)",
+ "distribution-enclosure/pcs/requested-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
+ "distribution-enclosure/pcs/requested-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
+ "distribution-enclosure/shed/policy": "a JSON policy document (soc-priority.v1 plus the two SOC thresholds); nothing parses it, and the thresholds belong as attributes beside the forecast (Task 4)",
+ "distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history",
+ "distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity",
+ "distribution-enclosure/status/wifi-ssid": "declared and never published; PanelFields.wifi_ssid is hardcoded None on schema_1, so a flat panel's wifi_ssid attribute is lost on upgrade (Task 2)",
+ "evse/config/max-charge-current": "the installer ceiling; it becomes native_max_value of the charge-current number entity rather than an entity of its own, and is deferred with it (Task 6)",
+ "evse/config/user-max-charge-current": "the only settable unread property; the eBus catalog spells this capability charge-limit with different property names, so the write topic waits on a real-firmware capture (Task 6)",
+ "evse/info/part-number": "reaches evse.part_number and stops there; only battery.part_number has a sensor, so no EVSE device card shows it (Task 5)",
+ "lugs/connection/count": "the lugs counterpart of circuit/connection/count, with no fed-device story upstream and no producer publishing it",
+ "lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
+ "lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it",
+ "lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
+ "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version (Task 5)",
+ "pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)"
+}
diff --git a/tests/test_declared_but_unread.py b/tests/test_declared_but_unread.py
new file mode 100644
index 00000000..d17cef66
--- /dev/null
+++ b/tests/test_declared_but_unread.py
@@ -0,0 +1,380 @@
+"""Every property the panel declares must reach a user, or say why it does not.
+
+The consumer-side mirror of panelbench's `test_declared_but_unvalued`, which
+asks the producer whether it ever publishes what it declares. This asks whether
+anything ever reads what arrives.
+
+**Why this and not the producible gate.** `test_field_path_conformance` starts
+from what the integration declares it reads and checks an adapter produces it.
+That direction cannot see a property nobody reads: an unread declaration is
+absent from every list the gate consults, so it is invisible by construction.
+`panel.wifi_ssid` is the worked example — flat surfaces it, v1.0 declares it,
+schema_1 maps nothing to it, and its `RESIDUAL_EXEMPT_PATHS` annotation said
+`SCHEMA_0_ONLY`, which was *true* and still sanctioned a silent regression.
+Every check in the codebase agreed, and a user upgrading lost an attribute.
+
+**How consumption is decided, and why not from a list.** A hand-kept map of
+"properties we read" is what made a new property invisible in the first place,
+so this derives the answer by experiment instead: republish one declared
+property with a different value, rebuild the snapshot through the real schema_1
+mapper, and see which snapshot fields moved. A property that moves a field the
+integration reads is surfaced; one that moves nothing reaches nobody. Nothing
+here restates `_PROPERTY_FIELD_MAP`, the lugs direction tables, the topology
+readers or the device_info builders — the experiment sees all of them, and sees
+them the way a user does, through what the panel actually renders.
+
+That is deliberately stricter than "mapped by `_PROPERTY_FIELD_MAP`". A property
+the library reads into a snapshot field no entity, attribute or device card ever
+touches has not reached anybody: `circuit.is_240v`, `evse.part_number` and
+`pv.software_version` are each one library line and no user-visible effect. They
+are baselined here rather than counted as read.
+
+**The two enumerations, and why neither can absorb a new property.**
+`_INTERNAL_ROUTES` holds what the snapshot cannot express — adapter dispatch, a
+shadowed fallback tier, a topology branch no producer reaches. The baseline file
+holds what is genuinely unread, one reason per line. Both are compared as exact
+sets, so an entry that stops being true fails just as loudly as a declaration
+that arrives untriaged. A new property can only ever land in either by somebody
+writing the line.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+import dataclasses
+import json
+import pathlib
+from typing import NamedTuple
+
+from span_panel_api import (
+ SpanBatterySnapshot,
+ SpanCircuitSnapshot,
+ SpanEvseSnapshot,
+ SpanMidSnapshot,
+ SpanPanelSnapshot,
+ SpanPVSnapshot,
+)
+
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ declared_field_paths,
+)
+from tests.adapter_fixtures import schema_one_snapshot, schema_one_tree
+
+BASELINE = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json"
+
+
+class Declaration(NamedTuple):
+ """One ``(device type, node, property)`` the fixture's `$description` declares.
+
+ Keyed by device *type* rather than device id, matching the granularity of
+ the capability catalogs and of the gap inventory: five circuits declare the
+ same properties, and three of them going unread is one gap, not three.
+ """
+
+ device_type: str
+ node: str
+ property_id: str
+
+ def __str__(self) -> str:
+ """Render as the baseline file's key: ``device-type/node/property``."""
+ return f"{self.device_type}/{self.node}/{self.property_id}"
+
+
+_INTERNAL_ROUTES: Mapping[Declaration, str] = {
+ Declaration("distribution-enclosure", "info", "data-model-version"): (
+ "tier-1 adapter dispatch (span_panel_api/dispatch.py) — it chooses which "
+ "adapter parses the tree, so it is consumed before any snapshot exists"
+ ),
+ Declaration("distribution-enclosure", "shed", "asserted-islanding-state"): (
+ "tier 2 of resolve_islanding_state (schema_1 panel.py), shadowed in this "
+ "fixture by the MID's tier-1 answer, and the write target of the existing "
+ "dominant-power-source control (schema_1 adapter.py)"
+ ),
+ Declaration("lugs", "connection", "feeds-device-id"): (
+ "the downstream-lugs feedthrough branch of resolve_relative_position "
+ "(schema_1 devices.py), which no producer currently reaches"
+ ),
+}
+"""Declarations consumed by a route no snapshot field can show.
+
+Three, and each names the code that reads it. This is the category the
+experiment cannot measure, so it is the category most at risk of becoming the
+allowlist that swallowed the problem — hence
+`test_no_internal_route_is_observable_after_all`, which fails the moment an
+entry stops being needed.
+"""
+
+
+def _mapping(value: object) -> Mapping[str, object]:
+ """Return `value` as a string-keyed mapping, or an empty one.
+
+ `json.loads` answers `object`, and every level of a Homie `$description` is
+ optional. Narrowing here rather than at each call site keeps the walk below
+ readable and keeps `Any` out of the module.
+ """
+ if not isinstance(value, Mapping):
+ return {}
+ return {str(key): item for key, item in value.items()}
+
+
+def _text(value: object) -> str:
+ return value if isinstance(value, str) else ""
+
+
+class _Property(NamedTuple):
+ """One declared property, and the device that declares it."""
+
+ device_id: str
+ topic: str
+ datatype: str
+ format_spec: str
+
+
+def _declared(tree: Mapping[str, Mapping[str, str]]) -> dict[Declaration, list[_Property]]:
+ """Every property declared anywhere in the tree, by declaration.
+
+ The authoritative property set is the `$description`, per the enclosure data
+ model: what a device publishes is a subset of what it declares, and the gap
+ between the two is precisely what this module is about.
+ """
+ found: dict[Declaration, list[_Property]] = {}
+ for device_id, topics in tree.items():
+ description = _mapping(json.loads(topics["$description"]))
+ device_type = _text(description.get("type")).rsplit(".", 1)[-1]
+ for node_id, node in _mapping(description.get("nodes")).items():
+ for property_id, definition in _mapping(_mapping(node).get("properties")).items():
+ body = _mapping(definition)
+ found.setdefault(Declaration(device_type, node_id, property_id), []).append(
+ _Property(
+ device_id=device_id,
+ topic=f"{node_id}/{property_id}",
+ datatype=_text(body.get("datatype")),
+ format_spec=_text(body.get("format")),
+ )
+ )
+ return found
+
+
+def _perturbed(declared: _Property, current: str | None) -> str:
+ """Return a legal value for this property that differs from `current`.
+
+ Legal matters: a parser that rejects the probe value would leave the field
+ unchanged and the property would read as unconsumed. So the replacement is
+ built from the declared `datatype` and `format`, which is the same
+ information the adapter parses against.
+
+ `current` is `None` for a property the fixture declares and never publishes
+ — 19 of the 203 instances. Publishing one is the right probe for exactly
+ those: it asks whether a value arriving would change anything, which is the
+ question `status/wifi-ssid` needed answering.
+ """
+ if declared.datatype in {"float", "integer"}:
+ try:
+ number = float(current or "")
+ except ValueError:
+ return "7" if declared.datatype == "integer" else "7.5"
+ return str(int(number) + 7) if declared.datatype == "integer" else str(number + 7.5)
+ if declared.datatype == "boolean":
+ return "false" if (current or "").lower() == "true" else "true"
+ if declared.datatype == "enum":
+ for option in declared.format_spec.split(","):
+ if option and option != current:
+ return option
+ return "probe-value" if current != "probe-value" else "probe-value-2"
+
+
+_SubSnapshot = (
+ SpanCircuitSnapshot | SpanEvseSnapshot | SpanBatterySnapshot | SpanPVSnapshot | SpanMidSnapshot
+)
+
+_COLLECTIONS = frozenset({"circuits", "evse", "battery", "pv", "mid"})
+"""Panel-snapshot attributes that hold sub-snapshots rather than a reading.
+
+Their fields are addressed by their own prefix — `circuit.x`, not
+`panel.circuits.x` — matching the field-path convention `field_paths` documents
+and `RESIDUAL_EXEMPT_PATHS` is written in.
+"""
+
+
+def _record(fields: dict[str, str], prefix: str, obj: _SubSnapshot, suffix: str = "") -> None:
+ for field in dataclasses.fields(obj):
+ fields[f"{prefix}.{field.name}{suffix}"] = repr(getattr(obj, field.name))
+
+
+def _snapshot_fields(snapshot: SpanPanelSnapshot) -> dict[str, str]:
+ """Flatten a snapshot to ``{field path: value}``.
+
+ The per-instance collections are keyed by circuit and EVSE id so two
+ circuits cannot mask each other's change; `_bare` strips the key again for
+ the reader lookup, which is per field and not per instance.
+
+ Values are held as `repr` rather than compared by equality so the diff is a
+ plain set operation over strings, whatever a field happens to hold.
+ """
+ fields: dict[str, str] = {}
+ for field in dataclasses.fields(snapshot):
+ if field.name not in _COLLECTIONS:
+ fields[f"panel.{field.name}"] = repr(getattr(snapshot, field.name))
+ for circuit_id, circuit in snapshot.circuits.items():
+ _record(fields, "circuit", circuit, f"@{circuit_id}")
+ for evse_key, evse in snapshot.evse.items():
+ _record(fields, "evse", evse, f"@{evse_key}")
+ _record(fields, "battery", snapshot.battery)
+ _record(fields, "pv", snapshot.pv)
+ if snapshot.mid is not None:
+ _record(fields, "mid", snapshot.mid)
+ return fields
+
+
+def _bare(field_path: str) -> str:
+ return field_path.split("@", 1)[0]
+
+
+def _moved_fields() -> dict[Declaration, frozenset[str]]:
+ """Republish each declared property once; return the snapshot fields it moved.
+
+ One rebuild per declaring device, so the two lugs devices and the five
+ circuits are probed separately and their results unioned: only the upstream
+ lugs' `fed-by-*` properties are read, and a single probe against whichever
+ came first would answer for both.
+ """
+ tree = schema_one_tree()
+ baseline = _snapshot_fields(schema_one_snapshot(tree))
+ moved: dict[Declaration, frozenset[str]] = {}
+
+ for declaration, instances in _declared(tree).items():
+ changed: set[str] = set()
+ for instance in instances:
+ current = tree[instance.device_id].get(instance.topic)
+ replacement = _perturbed(instance, current)
+ assert replacement != current, (
+ f"{declaration} on {instance.device_id}: the probe value equals the "
+ f"published one ({current!r}), so this property is not being tested"
+ )
+ mutated = {device_id: dict(topics) for device_id, topics in tree.items()}
+ mutated[instance.device_id][instance.topic] = replacement
+ after = _snapshot_fields(schema_one_snapshot(mutated))
+ changed.update(path for path, value in after.items() if baseline.get(path) != value)
+ moved[declaration] = frozenset(changed)
+ return moved
+
+
+def _read_field_paths() -> frozenset[str]:
+ """Every snapshot field the integration reads, by any route.
+
+ `declared_field_paths()` is descriptions plus residual entity-code reads;
+ `RESIDUAL_EXEMPT_PATHS` is the rest — device_info fields, circuit
+ attributes, entity-creation gates. Together they are the integration's
+ complete enumeration of its own reads, which is the claim
+ `test_every_exempt_path_still_has_a_reader` and the conformance suite hold
+ it to. A field in neither is one nothing renders.
+ """
+ return declared_field_paths() | frozenset(RESIDUAL_EXEMPT_PATHS)
+
+
+def _classified() -> tuple[dict[Declaration, frozenset[str]], dict[Declaration, frozenset[str]]]:
+ """Split every declaration into (surfaced, unread)."""
+ read = _read_field_paths()
+ surfaced: dict[Declaration, frozenset[str]] = {}
+ unread: dict[Declaration, frozenset[str]] = {}
+ for declaration, moved in _moved_fields().items():
+ if any(_bare(path) in read for path in moved):
+ surfaced[declaration] = moved
+ else:
+ unread[declaration] = moved
+ return surfaced, unread
+
+
+def _baseline() -> dict[Declaration, str]:
+ loaded: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8"))
+ entries: dict[Declaration, str] = {}
+ for key, reason in loaded.items():
+ device_type, node, property_id = key.split("/", 2)
+ entries[Declaration(device_type, node, property_id)] = reason
+ return entries
+
+
+def _lines(declarations: Iterable[Declaration]) -> str:
+ return "\n".join(f" {declaration}" for declaration in sorted(declarations)) or " (none)"
+
+
+def test_the_unread_declarations_match_the_recorded_baseline() -> None:
+ """Fails in both directions, so neither a gap nor a fix can land unnoticed.
+
+ A declaration nothing reads must be triaged: surfaced by a catch-up task, or
+ written into the baseline with the reason it stays unread. A declaration that
+ starts being read must lose its baseline line in the same commit — otherwise
+ the file drifts into a description of an older codebase and the count it
+ reports stops meaning anything.
+ """
+ _, unread = _classified()
+ expected = _baseline()
+
+ appeared = sorted(set(unread) - set(expected) - set(_INTERNAL_ROUTES))
+ resolved = sorted(set(expected) - set(unread))
+
+ assert set(unread) - set(_INTERNAL_ROUTES) == set(expected), (
+ "the set of declarations nothing reads moved.\n"
+ f" newly unread (nothing renders these):\n{_lines(appeared)}\n"
+ f" now read (delete their lines from {BASELINE.name}):\n{_lines(resolved)}\n\n"
+ "A newly unread property is a declaration that reaches no entity, attribute "
+ "or device card. Surface it, or record why it stays unread."
+ )
+
+
+def test_every_baseline_entry_carries_a_reason() -> None:
+ """A line with no reason is an allowlist entry wearing a baseline's clothes."""
+ empty = sorted(str(key) for key, reason in _baseline().items() if len(reason.split()) < 4)
+ assert not empty, (
+ f"baseline entries with no usable reason: {empty}. Each line says why the "
+ "property is not surfaced, so a reader can tell a deliberate skip from a backlog item."
+ )
+
+
+def test_every_baseline_entry_is_still_declared() -> None:
+ """A baseline outlives its declaration silently; the file only ever grows."""
+ declared = set(_declared(schema_one_tree()))
+ stale = sorted(str(key) for key in _baseline() if key not in declared)
+ assert not stale, (
+ f"baseline entries the fixture no longer declares: {stale}. The property "
+ "went away; drop its line with it."
+ )
+
+
+def test_no_internal_route_is_observable_after_all() -> None:
+ """An internal-route entry must be the only thing keeping its property out.
+
+ This is the entry that could quietly become an allowlist: unlike the
+ baseline it claims the property *is* consumed, and a claim the experiment
+ could check is one it should. So the moment a route's property does move a
+ field the integration reads, the entry has to go — otherwise the next
+ property added beside it inherits an exemption nobody re-examined.
+ """
+ surfaced, _ = _classified()
+ redundant = sorted(str(key) for key in _INTERNAL_ROUTES if key in surfaced)
+ assert not redundant, (
+ f"internal-route entries whose property now reaches a reader: {redundant}. "
+ "The route is no longer the only thing consuming it; delete the entry."
+ )
+
+
+def test_every_internal_route_is_still_declared() -> None:
+ declared = set(_declared(schema_one_tree()))
+ stale = sorted(str(key) for key in _INTERNAL_ROUTES if key not in declared)
+ assert not stale, f"internal-route entries the fixture no longer declares: {stale}"
+
+
+def test_the_probe_moves_something_for_a_known_reading() -> None:
+ """The experiment must be able to observe a change at all.
+
+ Every assertion above is satisfied by a probe that changes nothing, ever:
+ the unread set would simply be every declaration, matched by a baseline
+ somebody had grown to fit. This is the floor under that — a property whose
+ reading is unarguably rendered has to come back surfaced, and has to name
+ the field it moved.
+ """
+ surfaced, _ = _classified()
+ power = Declaration("circuit", "meter", "active-power")
+ assert power in surfaced
+ assert any(_bare(path) == "circuit.instant_power_w" for path in surfaced[power])
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index aeed159d..0be0126e 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -263,7 +263,11 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# +3 with the shed forecast: the two full-charge refinements and the
# confidence enum, read as attributes on the two forecast sensors and
# carried by no adapter's metadata map.
- Producibility.NEITHER: 18,
+ # +1 for `mid.grid_state`, the `mid_grid_state` sensor's source field. Like
+ # `panel.dominant_power_source` below it was read by a description and
+ # enumerated nowhere, so nothing held it against the adapters and
+ # `evaluate_field_metadata` had no way to tell it from an unread field.
+ Producibility.NEITHER: 19,
# +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's
# source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and
# enumerated nowhere, so `evaluate_field_metadata` counted it as produced-
From 1178243149130876ed4ba819e15c180d5184dc4b Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 18:37:24 -0700
Subject: [PATCH 049/116] feat(sensor): surface what the BESS reports about
itself
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`meter/active-power` and `status/communication-state` have been declared and
published on the BESS device since r202633 and nothing read either. The battery's
own charge/discharge figure lands as a sensor enabled by default; its own link
health lands as a diagnostic, off by default, because a fault signal is
interesting when something is wrong and noise on a device card the rest of the
time.
**The sign was the whole problem, and it is now normalised in one direction.**
The two properties arrive in opposite conventions: the enclosure's
`power-flows/battery` is discharge-positive per the capability catalog, while its
meter of the BESS is charge-positive-negated — it meters the battery the way it
meters a circuit it feeds. `BATTERY_POWER_SENSOR` has always negated the first;
the library now negates the second into the snapshot's charge-positive frame, and
`bess_meter_power` passes that through. Both sensors sit on the same device card
and both read **positive when charging**. A sensor whose sign contradicted the
one beside it would be worse than no sensor, so the agreement is asserted on the
two states rather than inferred from the two definitions, at two operating points.
The charging premise itself is derived, not assumed: the four power flows balance
(`pv + battery + grid == site`, grid positive when importing), and solving that
identity says which way the battery is going without appealing to any convention
this codebase chose. Reading the direction off the sign would be circular, since
the sign is the claim under test.
**The README said the opposite** — "Battery Power ... (+discharge, -charge)" —
and had since before the v1.0 work. The shipped behaviour is charge-positive;
only the documentation was wrong, so the documentation is what changed.
`bess_meter_power` deliberately does not replace `battery_power`. One is the
enclosure's arbitrated figure and one is the battery's own meter; on a healthy
panel they agree, and where they disagree that is a fact worth seeing.
`bess_communication_state` likewise does not replace `bess_connected`: the binary
sensor is the panel's view of the link and the new sensor is the battery's, and a
BESS can report its own link lost while the enclosure still claims it.
`BESS_TELEMETRY_SENSORS` is a separate tuple from `BESS_METADATA_SENSORS` because
the creation rule differs. Every metadata sensor exists on any commissioned BESS,
filled or empty; these two come from capability nodes a BESS may not have, so
absence has to mean no entity rather than a permanently unknown one. Gated twice
like the shed forecast — `has_bess_telemetry` for the capability (a new
`detect_capabilities` member, so a firmware upgrade that adds the nodes reaches
the reload), then the description's own `value_fn` for the individual reading.
Both descriptions are `SCHEMA_CONDITIONAL_FIELD` *and* name their `field_path`,
per the rule Task 1b established: flat's BESS device class declares neither
property, so the both-adapters gate cannot be satisfied, but the entity still
needs its Repair mention and its unavailability. The paths join
`RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`, which schema_1's metadata rows earn
them and which buys unit validation against the BESS's own `$description`.
Their two lines leave the unread baseline, which is what the burn-down looks
like: 39 to 37.
Tests read every expected value out of the capture and prove it by mutation.
Flipping the new sensor's sign fails five; removing the per-description creation
gate fails four; dropping either exemption fails seven across four modules.
---
CHANGELOG.md | 17 +
README.md | 34 +-
custom_components/span_panel/field_paths.py | 15 +
custom_components/span_panel/helpers.py | 23 +
custom_components/span_panel/icons.json | 6 +
custom_components/span_panel/sensor.py | 19 +
.../span_panel/sensor_definitions.py | 65 ++
custom_components/span_panel/strings.json | 12 +
.../span_panel/translations/en.json | 12 +
.../span_panel/translations/es.json | 12 +
.../span_panel/translations/fr.json | 12 +
.../span_panel/translations/ja.json | 12 +
.../span_panel/translations/pt.json | 12 +
.../unread_declarations_baseline.json | 2 -
tests/test_bess_telemetry.py | 568 ++++++++++++++++++
tests/test_enum_sensor_options.py | 2 +
tests/test_field_path_conformance.py | 7 +-
17 files changed, 811 insertions(+), 19 deletions(-)
create mode 100644 tests/test_bess_telemetry.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17e1ab7b..d78e7535 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,23 @@ All notable changes to this project will be documented in this file.
### Added
+- **Your battery's own meter and its own link health, on panels running the v1.0 data model.** **Meter Power** is what the BESS itself reports it is charging or
+ discharging at, as distinct from the panel's **Battery Power**, which is the enclosure's arbitrated figure. Both have been on the wire since firmware r202633
+ and nothing read either. Meter Power is enabled by default; **Communication State** — the BESS's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` report on its link
+ — is a diagnostic and is off by default, since it is only interesting when something is wrong.
+- **Both battery power sensors read positive when the battery is charging.** They come off the wire in opposite sign conventions and are normalised to one, so
+ the two sitting side by side on the BESS device can never point opposite ways.
+- **Communication State is not the same thing as BESS Connected.** The binary sensor is the _panel's_ view of the link, from the enclosure's connection record;
+ the new sensor is the _battery's_ view of it. A BESS can report its own link lost while the panel still claims it, and now you can see that.
+- Both sensors are created only where the BESS publishes the reading behind them — a battery on the older data model, or one whose firmware publishes only one
+ of the two, gets no entity for what it cannot report rather than one permanently unknown, and a BESS that gains the capability on a firmware upgrade picks the
+ sensors up on the reload the integration already performs.
+
+### Fixed
+
+- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation
+ was wrong. No entity changed.
+
- **Backup planning, in minutes: two new sensors on panels running the v1.0 data model.** **Time to Priority Shed** is how long before the panel starts shedding
circuits, and **Backup Time Remaining** is how long before the battery is spent. Your panel has been publishing both since firmware r202633 and nothing read
them; they are the numbers you would actually set an alarm on, so they are enabled by default and sit beside the power and energy sensors rather than under
diff --git a/README.md b/README.md
index 85689dcb..8b751fb3 100644
--- a/README.md
+++ b/README.md
@@ -170,12 +170,12 @@ Present only when the panel publishes them.
### Power Flow Sensors (v2 only)
-| Sensor | Device Class | Unit | Notes |
-| ------------- | ------------ | ---- | --------------------------------------------------------------------------- |
-| Grid Power | Power | W | Grid power flow |
-| Site Power | Power | W | Total site power (grid + PV + battery) |
-| Battery Power | Power | W | Battery charge/discharge (+discharge, -charge). Only when BESS commissioned |
-| PV Power | Power | W | PV generation (+producing). Only when PV commissioned |
+| Sensor | Device Class | Unit | Notes |
+| ------------- | ------------ | ---- | ------------------------------------------------------------------------------- |
+| Grid Power | Power | W | Grid power flow |
+| Site Power | Power | W | Total site power (grid + PV + battery) |
+| Battery Power | Power | W | Battery charge/discharge (+charging, -discharging). Only when BESS commissioned |
+| PV Power | Power | W | PV generation (+producing). Only when PV commissioned |
### PV Metadata Sensors (v2 only, on main panel device)
@@ -258,16 +258,18 @@ device uses manufacturer, model, serial number, and software version from batter
#### BESS Sensors
-| Sensor | Device Class | Unit | Notes |
-| ------------------ | -------------- | ---- | ----------------------------------------------------------------- |
-| Battery Level | Battery | % | State of energy as percentage |
-| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device |
-| BESS Vendor | — | — | Battery system vendor (diagnostic) |
-| BESS Model | — | — | Battery system model (diagnostic) |
-| BESS Serial Number | — | — | Battery system serial number (diagnostic) |
-| BESS Firmware | — | — | Battery system firmware (diagnostic) |
-| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic) |
-| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) |
+| Sensor | Device Class | Unit | Notes |
+| ------------------- | -------------- | ---- | ------------------------------------------------------------------------------------------------ |
+| Battery Level | Battery | % | State of energy as percentage |
+| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device |
+| Meter Power | Power | W | The BESS's own meter (+charging, -discharging). v1.0 data model only |
+| Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default). v1.0 data model only |
+| BESS Vendor | — | — | Battery system vendor (diagnostic) |
+| BESS Model | — | — | Battery system model (diagnostic) |
+| BESS Serial Number | — | — | Battery system serial number (diagnostic) |
+| BESS Firmware | — | — | Battery system firmware (diagnostic) |
+| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic) |
+| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) |
#### BESS Binary Sensors
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 1d8b7e7b..eb55441c 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -210,6 +210,21 @@ class FieldPathDeclarationMixin:
# and datatype validation against the panel's own `$description`.
"panel.shed_time_to_priority_shed_min": Producibility.SCHEMA_1_ONLY,
"panel.shed_total_time_remaining_min": Producibility.SCHEMA_1_ONLY,
+ # The BESS's own meter and its own link health, behind `bess_meter_power`
+ # and `bess_communication_state`, whose descriptions are
+ # `SCHEMA_CONDITIONAL_FIELD` for the usual reason: flat's BESS device
+ # class declares neither property, so the both-adapters gate cannot be
+ # satisfied. schema_1 carries a `_PROPERTY_FIELD_MAP` row for each, which
+ # is what makes these SCHEMA_1_ONLY rather than NEITHER and buys them unit
+ # and datatype validation against the BESS's own `$description`.
+ #
+ # `battery.power_w` is charge-positive in the snapshot -- the library
+ # negates the enclosure's meter frame -- which is the same direction
+ # `battery_power` shows after negating `panel.power_flow_battery`. The two
+ # sensors sit on one device card and must not disagree about which way is
+ # charging.
+ "battery.power_w": Producibility.SCHEMA_1_ONLY,
+ "battery.communication_state": Producibility.SCHEMA_1_ONLY,
"circuit.always_on": Producibility.SCHEMA_0_ONLY,
"circuit.is_sheddable": Producibility.SCHEMA_0_ONLY,
"panel.wifi_ssid": Producibility.SCHEMA_0_ONLY,
diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py
index f4d1d240..482e415f 100644
--- a/custom_components/span_panel/helpers.py
+++ b/custom_components/span_panel/helpers.py
@@ -305,6 +305,27 @@ def has_shed_forecast(snapshot: SpanPanelSnapshot) -> bool:
)
+def has_bess_telemetry(snapshot: SpanPanelSnapshot) -> bool:
+ """Detect whether the BESS publishes anything about itself beyond its state of charge.
+
+ Presence of the BESS's own `meter` and `status` capability nodes, from
+ presence of the fields they fill. A BESS may be commissioned and publish
+ neither: `has_bess` reads `soc/soc`, which is a different node, and every flat
+ panel's BESS has no such properties at all.
+
+ Separate from `has_bess` rather than folded into it, because the two answer
+ different questions and the wrong one is silently wrong. `has_bess` decides
+ whether the sub-device exists; this decides whether two of its sensors can be
+ created. Merging them would either delete the metadata sensors from a BESS
+ with no meter node or invent two permanently-unknown ones on it.
+
+ DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema
+ version, so a BESS that gains these nodes on a firmware upgrade reaches
+ `detect_capabilities`, the coordinator reloads, and the sensors appear.
+ """
+ return snapshot.battery.power_w is not None or snapshot.battery.communication_state is not None
+
+
def has_evse(snapshot: SpanPanelSnapshot) -> bool:
"""Detect whether an EVSE (EV charger) is commissioned."""
return len(snapshot.evse) > 0
@@ -332,4 +353,6 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]:
caps.add("mid")
if has_shed_forecast(snapshot):
caps.add("shed_forecast")
+ if has_bess_telemetry(snapshot):
+ caps.add("bess_telemetry")
return frozenset(caps)
diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json
index 43ca46f4..c3f0f3a6 100644
--- a/custom_components/span_panel/icons.json
+++ b/custom_components/span_panel/icons.json
@@ -57,6 +57,12 @@
"bess_model": {
"default": "mdi:battery-outline"
},
+ "bess_communication_state": {
+ "default": "mdi:lan-connect"
+ },
+ "bess_meter_power": {
+ "default": "mdi:battery-charging-outline"
+ },
"bess_nameplate_capacity": {
"default": "mdi:battery-high"
},
diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py
index cc327b49..52ef105e 100644
--- a/custom_components/span_panel/sensor.py
+++ b/custom_components/span_panel/sensor.py
@@ -21,6 +21,7 @@
from .coordinator import SpanPanelCoordinator
from .helpers import (
has_bess,
+ has_bess_telemetry,
has_evse,
has_mid,
has_power_flows,
@@ -38,6 +39,7 @@
BATTERY_POWER_SENSOR,
BATTERY_SENSOR,
BESS_METADATA_SENSORS,
+ BESS_TELEMETRY_SENSORS,
CIRCUIT_BREAKER_RATING_SENSOR,
CIRCUIT_CURRENT_SENSOR,
CIRCUIT_SENSORS,
@@ -422,6 +424,23 @@ def create_battery_sensors(
for desc in BESS_METADATA_SENSORS
)
+ # What the BESS reports about itself, gated per description because it comes
+ # from capability nodes a BESS may not have. `has_bess_telemetry` answers
+ # whether it publishes either node at all -- false on every flat panel, and
+ # the reason a reload creates these when a BESS gains them mid-life. The
+ # per-description check then asks whether *this* reading is among what it
+ # publishes: a BESS with a `meter` node and no `status` node is legal, and the
+ # half it omits must produce no entity rather than one permanently unknown.
+ #
+ # The presence test is the description's own `value_fn`, so the gate cannot
+ # drift away from the read it is gating.
+ if has_bess_telemetry(snapshot):
+ entities.extend(
+ SpanBessMetadataSensor(coordinator, desc, snapshot, bess_info)
+ for desc in BESS_TELEMETRY_SENSORS
+ if desc.value_fn(snapshot.battery) is not None
+ )
+
return entities
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index a1329eb2..391fc9c6 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -582,6 +582,70 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
),
)
+BESS_TELEMETRY_SENSORS: tuple[
+ SpanBessMetadataSensorEntityDescription,
+ SpanBessMetadataSensorEntityDescription,
+] = (
+ SpanBessMetadataSensorEntityDescription(
+ key="meter_power",
+ field_path="battery.power_w",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="bess_meter_power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ suggested_display_precision=0,
+ value_fn=lambda b: b.power_w,
+ ),
+ SpanBessMetadataSensorEntityDescription(
+ key="communication_state",
+ field_path="battery.communication_state",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="bess_communication_state",
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ options=["ok", "degraded", "lost", "unknown"],
+ value_fn=lambda b: None if b.communication_state is None else b.communication_state.lower(),
+ ),
+)
+"""What the BESS reports about *itself*, as opposed to what the panel reports about it.
+
+Separate from `BESS_METADATA_SENSORS` because these are created conditionally and
+those are not. Every metadata sensor exists on any commissioned BESS, filled or
+empty — `bess_part_number` sits at `None` on this fixture and has since it
+shipped. These two come from capability nodes a BESS may simply not have, so
+absence has to mean no entity rather than a permanently unknown one, and mixing
+the two rules into one tuple would mean deciding per description which applied.
+
+**Power is enabled by default and not diagnostic; communication state is
+neither.** The battery's own charge/discharge figure is a reading a user graphs
+and automates on. Its link health is a fault signal — interesting when something
+is wrong, noise on a device card the rest of the time — so it lands the way the
+other diagnostics do, off by default and available to anyone who wants it.
+
+**`bess_meter_power` is not `battery_power`, and the names say so.** The existing
+`battery_power` sensor reads `panel.power_flow_battery`, the enclosure's own
+arbitrated flow figure; this one reads the BESS's `meter/active-power`, the
+battery's own meter. On a healthy panel they agree, and where they disagree that
+is a fact worth being able to see rather than one to hide behind a single entity.
+
+**Both read charge-positive, which is what makes them agree.** The library
+negates the BESS meter into the snapshot's frame (`SpanBatterySnapshot.power_w`
+is documented charge-positive), and `BATTERY_POWER_SENSOR` negates
+`power_flow_battery`, which the capability catalog defines as
+discharge-positive. Two negations for two opposite wire conventions, landing on
+one convention in the UI: **positive means the battery is charging**. A sensor
+whose sign contradicted the one beside it would be worse than no sensor.
+
+**`derived` as well as `field_path`, by the producible rule.** The gate wants a
+path both adapters produce, and flat's BESS device class declares neither
+property — so `SCHEMA_CONDITIONAL_FIELD`, with the paths enumerated in
+`RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`. `field_path` still names the source,
+which is what gives each sensor its Repair mention and its unavailability when
+the panel stops resolving the property.
+"""
+
# ---------------------------------------------------------------------------
# PV metadata sensors (on main panel device)
# ---------------------------------------------------------------------------
@@ -925,6 +989,7 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]:
*UNMAPPED_SENSORS,
*MID_SENSORS,
*BESS_METADATA_SENSORS,
+ *BESS_TELEMETRY_SENSORS,
*PV_METADATA_SENSORS,
*PANEL_POWER_SENSORS,
*PANEL_ENERGY_SENSORS,
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 11908f34..14d6d1ab 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -177,6 +177,18 @@
"bess_model": {
"name": "Model"
},
+ "bess_communication_state": {
+ "name": "Communication State",
+ "state": {
+ "ok": "OK",
+ "degraded": "Degraded",
+ "lost": "Lost",
+ "unknown": "Unknown"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Meter Power"
+ },
"bess_nameplate_capacity": {
"name": "Nameplate Capacity"
},
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 11908f34..14d6d1ab 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -177,6 +177,18 @@
"bess_model": {
"name": "Model"
},
+ "bess_communication_state": {
+ "name": "Communication State",
+ "state": {
+ "ok": "OK",
+ "degraded": "Degraded",
+ "lost": "Lost",
+ "unknown": "Unknown"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Meter Power"
+ },
"bess_nameplate_capacity": {
"name": "Nameplate Capacity"
},
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 603c749b..5cbfa878 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -177,6 +177,18 @@
"bess_model": {
"name": "Modelo"
},
+ "bess_communication_state": {
+ "name": "Estado de Comunicación",
+ "state": {
+ "ok": "Correcto",
+ "degraded": "Degradado",
+ "lost": "Perdido",
+ "unknown": "Desconocido"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Potencia del Medidor"
+ },
"bess_nameplate_capacity": {
"name": "Capacidad Nominal"
},
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 096f209d..55fc0cf7 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -177,6 +177,18 @@
"bess_model": {
"name": "Modèle"
},
+ "bess_communication_state": {
+ "name": "État de Communication",
+ "state": {
+ "ok": "Correct",
+ "degraded": "Dégradé",
+ "lost": "Perdu",
+ "unknown": "Inconnu"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Puissance du Compteur"
+ },
"bess_nameplate_capacity": {
"name": "Capacité Nominale"
},
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 6b1e7530..234bcc9a 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -177,6 +177,18 @@
"bess_model": {
"name": "モデル"
},
+ "bess_communication_state": {
+ "name": "通信状態",
+ "state": {
+ "ok": "正常",
+ "degraded": "劣化",
+ "lost": "喪失",
+ "unknown": "不明"
+ }
+ },
+ "bess_meter_power": {
+ "name": "メーター電力"
+ },
"bess_nameplate_capacity": {
"name": "定格容量"
},
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index b793f4e7..ff7c298a 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -177,6 +177,18 @@
"bess_model": {
"name": "Modelo"
},
+ "bess_communication_state": {
+ "name": "Estado de Comunicação",
+ "state": {
+ "ok": "Correto",
+ "degraded": "Degradado",
+ "lost": "Perdido",
+ "unknown": "Desconhecido"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Potência do Medidor"
+ },
"bess_nameplate_capacity": {
"name": "Capacidade Nominal"
},
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
index b452133d..b731ff8b 100644
--- a/tests/fixtures/unread_declarations_baseline.json
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -1,6 +1,4 @@
{
- "bess/meter/active-power": "the battery's own charge/discharge meter; SpanBatterySnapshot carries no power field, and the panel's power_flow_battery is a different property (Task 5)",
- "bess/status/communication-state": "the publisher's self-report, deliberately distinct from the enclosure's bess_connected view; no snapshot field carries it (Task 5)",
"circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field",
"circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against",
"circuit/connection/feeds-device-status": "the enclosure's link-health view of a circuit-fed DER; only the lugs fed-by-* route is read today, so PV and EVSE get no connected sensor (Task 3)",
diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py
new file mode 100644
index 00000000..7d1a206a
--- /dev/null
+++ b/tests/test_bess_telemetry.py
@@ -0,0 +1,568 @@
+"""What the BESS reports about itself, surfaced as two sensors on its own device.
+
+`meter/active-power` and `status/communication-state` were declared, published
+and read by nobody. They are the battery's own view of its power and its own view
+of its link, as opposed to the enclosure's view of both.
+
+Every assertion runs against a real snapshot built by the real schema_1 adapter
+over the vendored capture, and every expected value is read out of that capture
+rather than written as a literal — a test that pins the same constant the code
+pins passes whether or not the wire is ever read. Each reading is proved by
+republishing it, deleting it, or dropping the node that carries it.
+
+**The sign is the hard part, and it is what most of this module is about.** The
+capture is of a *charging* battery, published as a negative `meter/active-power`
+because the enclosure meters the BESS the way it meters a circuit it feeds. The
+snapshot negates that into its charge-positive frame, and `battery_power` — the
+sensor already on this device, reading the enclosure's discharge-positive
+`power-flows/battery` — negates too. Two negations of two opposite wire
+conventions, landing on one convention in the UI. A sensor whose sign
+contradicted the one beside it would be worse than no sensor, so the agreement is
+asserted directly rather than inferred from the two definitions.
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+from decimal import Decimal
+import json
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+from span_panel_api import SpanPanelSnapshot
+
+from custom_components.span_panel import SpanPanelRuntimeData
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ DerivedReason,
+ Producibility,
+)
+from custom_components.span_panel.helpers import detect_capabilities, has_bess_telemetry
+from custom_components.span_panel.sensor import create_battery_sensors
+from custom_components.span_panel.sensor_definitions import (
+ BATTERY_POWER_SENSOR,
+ BESS_TELEMETRY_SENSORS,
+)
+from custom_components.span_panel.sensor_panel import (
+ SpanBessMetadataSensor,
+ SpanPanelBattery,
+ SpanPanelPowerSensor,
+)
+from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
+from homeassistant.const import CONF_HOST, UnitOfPower
+from homeassistant.helpers.entity import EntityCategory
+from homeassistant.helpers.typing import StateType
+
+from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree
+from .factories import SpanPanelSnapshotFactory
+
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+BESS = "bess"
+
+POWER_TOPIC = "meter/active-power"
+COMMS_TOPIC = "status/communication-state"
+ENCLOSURE_FLOW_TOPIC = "power-flows/battery"
+
+POWER_KEY = "meter_power"
+COMMS_KEY = "communication_state"
+ENCLOSURE_FLOW_KEY = BATTERY_POWER_SENSOR.key
+
+
+@pytest.fixture(autouse=True)
+def _mock_entity_registry() -> Any:
+ """Patch entity registry lookups used during sensor construction."""
+ registry = MagicMock()
+ registry.async_get_entity_id.return_value = None
+ with patch(
+ "custom_components.span_panel.sensor_base.er.async_get",
+ return_value=registry,
+ ):
+ yield registry
+
+
+def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock:
+ """A coordinator-like mock carrying one snapshot."""
+ coordinator = MagicMock()
+ coordinator.data = snapshot
+ coordinator.hass = MagicMock()
+ coordinator.panel_offline = False
+ coordinator.unresolved_paths = frozenset()
+ coordinator.config_entry = MockConfigEntry(
+ domain="span_panel",
+ data={CONF_HOST: "192.168.1.50"},
+ options={},
+ title="SPAN Panel",
+ unique_id=snapshot.serial_number,
+ )
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+ return coordinator
+
+
+def _published(device_id: str, topic: str) -> str:
+ """What the capture publishes on one topic, or fail saying it does not."""
+ value = schema_one_tree()[device_id].get(topic)
+ assert value is not None, f"{device_id} publishes no {topic} in the capture"
+ return value
+
+
+def _republishing(**topics: str) -> SpanPanelSnapshot:
+ """A snapshot from the capture with some BESS topics rewritten."""
+ tree = schema_one_tree()
+ for name, value in topics.items():
+ tree[BESS][name.replace("__", "/").replace("_", "-")] = value
+ return schema_one_snapshot(tree)
+
+
+def _without(topic: str) -> SpanPanelSnapshot:
+ """A snapshot from a BESS that stopped publishing (and declaring) a property."""
+ tree = schema_one_tree()
+ node, _, property_id = topic.partition("/")
+ description = json.loads(tree[BESS]["$description"])
+ del tree[BESS][topic]
+ del description["nodes"][node]["properties"][property_id]
+ tree[BESS]["$description"] = json.dumps(description)
+ return schema_one_snapshot(tree)
+
+
+def _without_node(node: str) -> SpanPanelSnapshot:
+ """A snapshot from a BESS with no such capability node at all."""
+ tree = schema_one_tree()
+ for topic in [t for t in tree[BESS] if t.startswith(f"{node}/")]:
+ del tree[BESS][topic]
+ description = json.loads(tree[BESS]["$description"])
+ del description["nodes"][node]
+ tree[BESS]["$description"] = json.dumps(description)
+ return schema_one_snapshot(tree)
+
+
+def _without_bess() -> SpanPanelSnapshot:
+ """A snapshot from a capture with no BESS device in the tree at all."""
+ tree = {device: topics for device, topics in schema_one_tree().items() if device != BESS}
+ return schema_one_snapshot(tree)
+
+
+BessSensor = SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor
+"""What `create_battery_sensors` returns: everything on the BESS sub-device."""
+
+
+def _sensors(snapshot: SpanPanelSnapshot) -> dict[str, BessSensor]:
+ """Whatever the platform creates for this snapshot, keyed by description key."""
+ created = create_battery_sensors(_coordinator(snapshot), snapshot)
+ return {sensor.entity_description.key: sensor for sensor in created}
+
+
+def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime | Decimal:
+ """The state one BESS sensor reports for a snapshot.
+
+ Typed as `SensorEntity.native_value` is, rather than narrowed to what these
+ two sensors happen to report: narrowing here would be the test asserting its
+ own expectation twice, once in the annotation and once in the body.
+ """
+ sensor = _sensors(snapshot)[key]
+ sensor._update_native_value()
+ return sensor.native_value
+
+
+# ---------------------------------------------------------------------------
+# The premise: the capture publishes both properties, on a charging battery
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_publishes_both_properties() -> None:
+ """Guard the premise for every test below, all of which read the capture for
+ their expected value: a capture that stopped publishing one would make them
+ vacuously true rather than failing."""
+ assert _published(BESS, POWER_TOPIC)
+ assert _published(BESS, COMMS_TOPIC)
+
+
+def test_the_capture_is_a_charging_battery() -> None:
+ """The premise of every sign assertion below, derived rather than assumed.
+
+ A sign convention can only be tested against a known physical state, and
+ "negative means charging" is the very claim under test, so reading the state
+ off the sign would be circular. The enclosure's four power flows balance
+ instead — `pv + battery + grid == site`, with `grid` positive when importing —
+ and solving that identity says which way the battery is going without
+ appealing to any convention this codebase chose.
+
+ The capture: 8500 W of PV meets 2653 W of site load and exports 2347 W, and
+ the 3500 W left over is going into the battery. So this battery is charging,
+ and the enclosure publishes that as a *negative* number.
+
+ Were the capture ever retaken with the battery discharging, this fails first
+ and says so, rather than the sign tests failing and reading as a wiring bug.
+ """
+ flows = {
+ name: float(_published(SCHEMA_ONE_PANEL, f"power-flows/{name}"))
+ for name in ("pv", "battery", "grid", "site")
+ }
+
+ assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"])
+ # PV alone exceeds the site load, so the surplus has nowhere to go but the
+ # battery and the grid — and the grid term is an export.
+ assert flows["pv"] > flows["site"]
+ assert flows["grid"] < 0
+ assert flows["battery"] < 0
+
+
+def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None:
+ """The two properties describing this battery must not disagree on the wire.
+
+ `battery_power` negates the enclosure's flow and `bess_meter_power` negates
+ the BESS's own meter; that is only coherent because the two are published in
+ the same frame. Pinned here rather than assumed, because "negate exactly one
+ of them" would be the wrong rule if a firmware ever published them opposed.
+ """
+ bess_meter = float(_published(BESS, POWER_TOPIC))
+ enclosure_flow = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC))
+
+ assert (bess_meter < 0) == (enclosure_flow < 0)
+
+
+# ---------------------------------------------------------------------------
+# The sign, and the agreement with the sensor beside it
+# ---------------------------------------------------------------------------
+
+
+def test_charging_reads_positive() -> None:
+ """The convention, asserted on the state a user sees rather than on the field.
+
+ The wire is charge-negative and the snapshot is charge-positive, so the
+ sensor's state is the negation of the published value. Magnitude and sign are
+ asserted separately on purpose: losing the negation keeps the magnitude, so
+ only the sign check catches it.
+ """
+ published = float(_published(BESS, POWER_TOPIC))
+ state = _state(schema_one_snapshot(), POWER_KEY)
+
+ assert state == -published
+ assert isinstance(state, float) and state > 0
+
+
+def test_it_agrees_with_the_battery_power_sensor_beside_it() -> None:
+ """The two battery-power sensors on this device must not contradict each other.
+
+ `battery_power` reads the enclosure's arbitrated `power-flows/battery`, which
+ the capability catalog defines as discharge-positive; this one reads the
+ BESS's own meter, which the enclosure publishes charge-negative. Two opposite
+ wire conventions, and the UI shows one — so the check is on the states, not
+ on either definition. A flip on either side fails here even if the side that
+ flipped still looks self-consistent.
+ """
+ snapshot = schema_one_snapshot()
+
+ own_meter = _state(snapshot, POWER_KEY)
+ enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY)
+
+ assert isinstance(own_meter, float) and isinstance(enclosure_flow, float)
+ assert (own_meter > 0) == (enclosure_flow > 0)
+
+
+def test_they_agree_when_the_battery_discharges_too() -> None:
+ """Agreement at one operating point could be coincidence; this is the other.
+
+ Both properties are republished with the battery discharging — the capture's
+ two values negated — and both sensors must go negative together.
+ """
+ snapshot = _republishing_both(
+ power=-float(_published(BESS, POWER_TOPIC)),
+ enclosure_flow=-float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)),
+ )
+
+ own_meter = _state(snapshot, POWER_KEY)
+ enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY)
+
+ assert isinstance(own_meter, float) and own_meter < 0
+ assert isinstance(enclosure_flow, float) and enclosure_flow < 0
+
+
+def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnapshot:
+ """A snapshot with the BESS meter and the enclosure's flow both rewritten."""
+ tree = schema_one_tree()
+ tree[BESS][POWER_TOPIC] = str(power)
+ tree[SCHEMA_ONE_PANEL][ENCLOSURE_FLOW_TOPIC] = str(enclosure_flow)
+ return schema_one_snapshot(tree)
+
+
+# ---------------------------------------------------------------------------
+# States follow the wire
+# ---------------------------------------------------------------------------
+
+
+def test_republishing_the_meter_moves_the_sensor() -> None:
+ """The mutation proof. The republished value differs in magnitude and in sign
+ from what the capture carries, so a sensor pinned to a constant — or wired to
+ the enclosure's flow instead — cannot report it."""
+ published = float(_published(BESS, POWER_TOPIC))
+ discharging = -published / 2
+
+ snapshot = _republishing(meter__active_power=str(discharging))
+
+ assert _state(snapshot, POWER_KEY) == -discharging
+ assert _state(snapshot, POWER_KEY) != -published
+
+
+def test_a_battery_at_rest_reports_zero_and_not_negative_zero() -> None:
+ """`-0.0` compares equal to `0.0` and renders as "-0.0" beside it, so a
+ negation added without a guard produces a reading that looks broken exactly
+ when nothing is happening."""
+ snapshot = _republishing(meter__active_power="0.0")
+
+ assert _state(snapshot, POWER_KEY) == 0.0
+ assert str(_state(snapshot, POWER_KEY)) == "0.0"
+
+
+def test_zero_watts_is_a_state_and_not_an_absence() -> None:
+ """An idle battery is a reading. A gate that treated zero as absence would
+ delete the entity whenever the battery stopped moving power."""
+ snapshot = _republishing(meter__active_power="0.0")
+
+ assert POWER_KEY in _sensors(snapshot)
+
+
+def test_the_communication_state_is_the_published_enum_lowercased() -> None:
+ """Lowercase because HA looks the state up as a translation key, which its own
+ contract restricts to `[a-z0-9-_]+`."""
+ published = _published(BESS, COMMS_TOPIC)
+
+ assert _state(schema_one_snapshot(), COMMS_KEY) == published.lower()
+
+
+@pytest.mark.parametrize("republished", ["DEGRADED", "LOST", "UNKNOWN"])
+def test_republishing_the_communication_state_moves_the_sensor(republished: str) -> None:
+ """Every other member of the enum the BESS's own `$description` declares, so
+ a sensor pinned to the captured OK cannot report any of them."""
+ snapshot = _republishing(status__communication_state=republished)
+
+ assert _state(snapshot, COMMS_KEY) == republished.lower()
+ assert _state(snapshot, COMMS_KEY) != _published(BESS, COMMS_TOPIC).lower()
+
+
+def test_the_declared_options_are_the_enum_the_bess_declares() -> None:
+ """The sensor's "Possible states" against the wire's `format`, so a firmware
+ that widens the enum is caught here rather than by the runtime append."""
+ description = json.loads(schema_one_tree()[BESS]["$description"])
+ declared = description["nodes"]["status"]["properties"]["communication-state"]["format"]
+
+ options = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY).options
+
+ assert options is not None
+ assert set(options) == {value.lower() for value in declared.split(",")}
+
+
+def test_communication_state_is_not_the_connected_binary_sensor() -> None:
+ """The two link facts this task deliberately keeps apart.
+
+ `bess_connected` is the enclosure's `connection/fed-by-device-status` view;
+ this sensor is the BESS's report about itself. A BESS can report its own link
+ LOST while the enclosure still claims it as OK, and a mapping that conflated
+ them could not express that.
+ """
+ snapshot = _republishing(status__communication_state="LOST")
+
+ assert _state(snapshot, COMMS_KEY) == "lost"
+ assert snapshot.battery.connected is True
+
+
+# ---------------------------------------------------------------------------
+# Absence: deleted property, dropped node, no BESS, flat panel
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_creates_both_sensors() -> None:
+ created = _sensors(schema_one_snapshot())
+
+ assert POWER_KEY in created
+ assert COMMS_KEY in created
+
+
+def test_a_bess_with_no_meter_node_gets_no_power_sensor() -> None:
+ """A dead entity stuck at unknown is worse than no entity: it occupies the
+ entity list, breaks a dashboard card, and cannot be told apart from a battery
+ whose meter has failed."""
+ snapshot = _without_node("meter")
+
+ assert POWER_KEY not in _sensors(snapshot)
+ # The other half of the pair is unaffected — a partial BESS is legal firmware.
+ assert COMMS_KEY in _sensors(snapshot)
+
+
+def test_a_bess_with_no_status_node_gets_no_communication_sensor() -> None:
+ snapshot = _without_node("status")
+
+ assert COMMS_KEY not in _sensors(snapshot)
+ assert POWER_KEY in _sensors(snapshot)
+
+
+@pytest.mark.parametrize(
+ ("key", "topic", "unknown"),
+ [(POWER_KEY, POWER_TOPIC, None), (COMMS_KEY, COMMS_TOPIC, "unknown")],
+)
+def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale(
+ key: str, topic: str, unknown: str | None
+) -> None:
+ """Absence after setup is a different event from absence at setup.
+
+ Creation is decided once, from what the panel was publishing when the entry
+ loaded; a property that stops arriving afterwards cannot delete an entity a
+ user already has on a dashboard, so it has to degrade instead. The last value
+ persisting would be the worse outcome — a battery reading 3500 W forever is
+ indistinguishable from one that is actually charging.
+
+ Driven through the coordinator rather than by rebuilding the entity, because
+ that is the path a live update takes.
+ """
+ sensor = _sensors(schema_one_snapshot())[key]
+
+ sensor.coordinator.data = _without(topic)
+ sensor._update_native_value()
+
+ assert sensor.native_value == unknown
+
+
+@pytest.mark.parametrize(("key", "topic"), [(POWER_KEY, POWER_TOPIC), (COMMS_KEY, COMMS_TOPIC)])
+def test_a_property_declared_and_never_published_creates_no_entity(key: str, topic: str) -> None:
+ """The gate is the value, not the declaration.
+
+ A BESS may declare a property in its `$description` and publish nothing on it
+ — 19 instances in this capture do. An entity created from a declaration alone
+ would be permanently unknown, which is the outcome the per-description gate
+ exists to prevent, so this is the same answer as a missing node reached by a
+ different route.
+ """
+ tree = schema_one_tree()
+ del tree[BESS][topic]
+
+ created = _sensors(schema_one_snapshot(tree))
+
+ assert key not in created
+
+
+def test_a_bess_publishing_neither_gets_neither_sensor() -> None:
+ tree = schema_one_tree()
+ description = json.loads(tree[BESS]["$description"])
+ for node in ("meter", "status"):
+ for topic in [t for t in tree[BESS] if t.startswith(f"{node}/")]:
+ del tree[BESS][topic]
+ del description["nodes"][node]
+ tree[BESS]["$description"] = json.dumps(description)
+ snapshot = schema_one_snapshot(tree)
+
+ assert has_bess_telemetry(snapshot) is False
+ created = _sensors(snapshot)
+ assert POWER_KEY not in created
+ assert COMMS_KEY not in created
+ # The BESS itself is still commissioned, so its metadata sensors survive.
+ assert created
+
+
+def test_no_bess_device_creates_no_battery_sensors_at_all() -> None:
+ snapshot = _without_bess()
+
+ assert create_battery_sensors(_coordinator(snapshot), snapshot) == []
+
+
+def test_a_flat_panel_gets_neither_sensor() -> None:
+ """The same absence by the other route: flat's BESS device class declares
+ neither property, so the factory's default snapshot carries neither field."""
+ snapshot = SpanPanelSnapshotFactory.create()
+
+ assert has_bess_telemetry(snapshot) is False
+ created = _sensors(snapshot)
+ assert POWER_KEY not in created
+ assert COMMS_KEY not in created
+
+
+def test_the_telemetry_appearing_is_a_capability_change() -> None:
+ """Which is how a BESS that gains these nodes mid-life gets the sensors: the
+ coordinator reloads on a new capability."""
+ assert "bess_telemetry" not in detect_capabilities(SpanPanelSnapshotFactory.create())
+ assert "bess_telemetry" in detect_capabilities(schema_one_snapshot())
+ assert "bess_telemetry" in detect_capabilities(_without_node("meter"))
+ assert "bess_telemetry" not in detect_capabilities(_without_bess())
+
+
+# ---------------------------------------------------------------------------
+# Shape of the entities
+# ---------------------------------------------------------------------------
+
+
+def test_the_power_sensor_is_a_watt_measurement_enabled_by_default() -> None:
+ """The battery's own charge/discharge figure is a reading a user graphs and
+ automates on, so it belongs beside the other power sensors rather than under
+ the diagnostics fold."""
+ description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY)
+
+ assert description.device_class is SensorDeviceClass.POWER
+ assert description.state_class is SensorStateClass.MEASUREMENT
+ assert description.native_unit_of_measurement == UnitOfPower.WATT
+ assert description.entity_registry_enabled_default is True
+ assert description.entity_category is not EntityCategory.DIAGNOSTIC
+
+
+def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None:
+ """A fault signal: interesting when something is wrong, noise on a device card
+ the rest of the time."""
+ description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY)
+
+ assert description.device_class is SensorDeviceClass.ENUM
+ assert description.entity_category is EntityCategory.DIAGNOSTIC
+ assert description.entity_registry_enabled_default is False
+
+
+def test_the_declared_unit_matches_what_the_bess_declares() -> None:
+ """HA's unit against the tree's, for the path schema_1 carries metadata for. A
+ disagreement here is what the unit-mismatch Repair reports at runtime."""
+ from .adapter_fixtures import schema_one_metadata
+
+ description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY)
+
+ assert schema_one_metadata()["battery.power_w"].unit == (description.native_unit_of_measurement)
+
+
+def test_both_sensors_live_on_the_bess_sub_device() -> None:
+ """Beside the metadata sensors and the battery level, not on the panel."""
+ created = _sensors(schema_one_snapshot())
+ bess_device = created["vendor"].device_info
+
+ assert created[POWER_KEY].device_info == bess_device
+ assert created[COMMS_KEY].device_info == bess_device
+
+
+def test_every_bess_sensor_gets_a_distinct_unique_id() -> None:
+ """They live on one device and differ only by description key, so a key reused
+ from the metadata group would silently collide."""
+ created = _sensors(schema_one_snapshot())
+ unique_ids = {sensor.unique_id for sensor in created.values()}
+
+ assert len(unique_ids) == len(created)
+
+
+# ---------------------------------------------------------------------------
+# Conformance annotations
+# ---------------------------------------------------------------------------
+
+
+def test_both_paths_are_exempt_as_schema_1_only() -> None:
+ """Pinned here as well as in the conformance suite, because the reason is
+ specific to these properties: flat's BESS device class declares neither, so
+ the producible gate cannot be satisfied and the descriptions must stay
+ derived. schema_1 does map both, which is what makes the annotation
+ SCHEMA_1_ONLY rather than NEITHER."""
+ assert RESIDUAL_EXEMPT_PATHS["battery.power_w"] is Producibility.SCHEMA_1_ONLY
+ assert RESIDUAL_EXEMPT_PATHS["battery.communication_state"] is Producibility.SCHEMA_1_ONLY
+
+
+@pytest.mark.parametrize("description", BESS_TELEMETRY_SENSORS, ids=lambda d: d.key)
+def test_each_description_names_its_field_as_well_as_its_reason(description: Any) -> None:
+ """`field_path` says what the entity's value is and `derived` says why that
+ path is outside the both-adapters gate. Leaving the first unset excuses the
+ entity from its Repair mention and from going unavailable when the panel stops
+ resolving the property."""
+ assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD
+ assert description.field_path in RESIDUAL_EXEMPT_PATHS
diff --git a/tests/test_enum_sensor_options.py b/tests/test_enum_sensor_options.py
index 82941ab7..9546466c 100644
--- a/tests/test_enum_sensor_options.py
+++ b/tests/test_enum_sensor_options.py
@@ -28,6 +28,7 @@
from custom_components.span_panel.sensor_definitions import (
BESS_METADATA_SENSORS,
+ BESS_TELEMETRY_SENSORS,
CIRCUIT_SENSORS,
EVSE_SENSORS,
MID_SENSORS,
@@ -56,6 +57,7 @@ def _enum_descriptions() -> list[Any]:
UNMAPPED_SENSORS,
MID_SENSORS,
BESS_METADATA_SENSORS,
+ BESS_TELEMETRY_SENSORS,
PV_METADATA_SENSORS,
CIRCUIT_SENSORS,
EVSE_SENSORS,
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 0be0126e..beb3c220 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -275,7 +275,12 @@ def test_no_exempt_path_is_producible_by_both() -> None:
Producibility.SCHEMA_0_ONLY: 11,
# +2 with the shed forecast: the two live estimates, which schema_1 maps and
# flat firmware does not publish at all.
- Producibility.SCHEMA_1_ONLY: 3,
+ # +2 for `battery.power_w` and `battery.communication_state`, the BESS's own
+ # meter and link health behind `bess_meter_power` and
+ # `bess_communication_state`. schema_1 maps both; flat's BESS device class
+ # declares neither property, so neither can ever satisfy the both-adapters
+ # gate.
+ Producibility.SCHEMA_1_ONLY: 5,
}
"""The exemption inventory, by reason. See `test_exempt_inventory_is_complete`."""
From 0cbcb4af498a9d64b8e8e0d8dc9491d4f4613493 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 19:07:52 -0700
Subject: [PATCH 050/116] feat(sensor): surface the panel's power control
system
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`energy.ebus.capability.pcs` 0.3 is the largest capability the enclosure
publishes — sixteen properties on the panel and two on every circuit — and
nothing read a byte of it. It is UL 3141 import limiting: the panel's Firm
Service Rating, plus the arbitration that reconciles every active import
constraint to one enforced current limit.
**The capability decides the entity/attribute split, not taste.** It says
outright that `pcs` does not re-publish the other regimes' constraints and that
"what `pcs` publishes is the **result**: the effective `import-limit` and the
`binding-constraint`". So the result is the entities: `pcs_import_limit` in amps,
enabled by default because a panel throttling the user's supply is a fact about
their electricity rather than about the integration's health, and
`pcs_binding_constraint` beside it, which is what makes the number
interpretable — "limited to 40 A" versus "limited to 40 A *because the utility
sent an envelope*". `pcs_active` is the third, a binary sensor, because it is the
only property here that changes on its own and therefore the one an automation
triggers on.
The twelve amps-native inputs and `pcs_enabled` ride as attributes on the limit
they explain. They are working, not readings: eleven of the thirteen move only
when somebody reconfigures the panel, and a dashboard with twelve near-constant
amperages on it is not a dashboard. Circuit-side `pcs/managed` and `pcs/priority`
land the same way, as two attributes on the circuit's power sensor — a 40-space
panel would otherwise gain eighty entities carrying two facts. `pcs_priority` is
deliberately not the existing `shed_priority`: two policies on one relay, and the
catalog keeps them apart because a circuit may take part in one, both, or
neither.
**Creation is gated once, on the node, and that is the one place this capability
differs from the shed forecast and the BESS telemetry.** Both of those gate a
second time on the reading, because a declared-but-unpublished property would
become a permanently-unknown entity. That reasoning does not transfer: every
`pcs` property is legally `0.0`, `false` or `UNCONFIGURED`, and the reference
capture is exactly that — a PCS which exists and is switched off. A per-reading
gate would create the entities on a configured panel and delete them the moment
somebody turned the PCS off, which is the state most panels are in and the state
a user most wants reported. The library carries the distinction as
`SpanPanelSnapshot.pcs is None`, from the capability's own rule that absence of
the node means the device runs no PCS, and `has_pcs` joins `detect_capabilities`
so a panel that gains the node reaches the reload.
All three descriptions are `SCHEMA_CONDITIONAL_FIELD` *and* name their
`field_path`: no flat panel declares the capability, so the both-adapters gate
cannot be satisfied, while the entities still need their Repair mention and their
unavailability. The three result paths join `RESIDUAL_EXEMPT_PATHS` as
`SCHEMA_1_ONLY`, which schema_1's metadata rows earn them and which buys the
limit unit validation against the panel's own `$description`; the fifteen
attribute reads join as `NEITHER`, matching the shed-forecast refinements.
Eighteen lines leave the unread baseline, 37 to 19 — the whole PCS block, panel
and circuit.
**The capture cannot prove a reading on its own, so no test asks it to.** All
sixteen panel values are zeros, `false` and `UNCONFIGURED`, which means an entity
wired to a neighbouring property reports the identical value and an entity
hardcoding a zero agrees with the wire by accident. Presence is asserted against
the capture; every reading is proved against a republished PCS where each value
differs from the captured one and from every sibling's, with the attribute
dictionary compared whole so a wrong wiring shows as a value under the wrong
name. On top of that, each of the sixteen is republished individually and exactly
one observable may move. Crossing two attribute families fails four tests; making
the presence gate value-based fails thirty; deleting the circuit attribute block
fails four; dropping one exemption fails three; pointing `pcs_active` at
`enabled` fails eight.
---
CHANGELOG.md | 24 +-
README.md | 59 +-
custom_components/span_panel/binary_sensor.py | 42 +
custom_components/span_panel/field_paths.py | 45 +-
custom_components/span_panel/helpers.py | 27 +
custom_components/span_panel/icons.json | 9 +
custom_components/span_panel/sensor.py | 37 +
.../span_panel/sensor_circuit.py | 21 +
.../span_panel/sensor_definitions.py | 218 +++++
custom_components/span_panel/sensor_panel.py | 94 +-
custom_components/span_panel/strings.json | 19 +
.../span_panel/translations/en.json | 19 +
.../span_panel/translations/es.json | 19 +
.../span_panel/translations/fr.json | 19 +
.../span_panel/translations/ja.json | 19 +
.../span_panel/translations/pt.json | 19 +
.../unread_declarations_baseline.json | 18 -
tests/test_declared_but_unread.py | 12 +-
tests/test_enum_sensor_options.py | 2 +
tests/test_field_path_conformance.py | 13 +-
tests/test_field_path_introspection.py | 6 +-
tests/test_pcs.py | 816 ++++++++++++++++++
22 files changed, 1510 insertions(+), 47 deletions(-)
create mode 100644 tests/test_pcs.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d78e7535..61076d69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,12 +17,6 @@ All notable changes to this project will be documented in this file.
- Both sensors are created only where the BESS publishes the reading behind them — a battery on the older data model, or one whose firmware publishes only one
of the two, gets no entity for what it cannot report rather than one permanently unknown, and a BESS that gains the capability on a firmware upgrade picks the
sensors up on the reload the integration already performs.
-
-### Fixed
-
-- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation
- was wrong. No entity changed.
-
- **Backup planning, in minutes: two new sensors on panels running the v1.0 data model.** **Time to Priority Shed** is how long before the panel starts shedding
circuits, and **Backup Time Remaining** is how long before the battery is spent. Your panel has been publishing both since firmware r202633 and nothing read
them; they are the numbers you would actually set an alarm on, so they are enabled by default and sit beside the power and energy sensors rather than under
@@ -33,6 +27,24 @@ All notable changes to this project will be documented in this file.
- Both sensors are created only where the panel publishes the estimate behind them. A panel on the older data model, or one whose firmware publishes only part
of the forecast, gets no entity for what it cannot report rather than one permanently unknown — and a panel that gains the capability on a firmware upgrade
picks the sensors up on the reload the integration already performs.
+- **What is limiting your import, in amps: three new entities on panels running the v1.0 data model.** **Import Limit** is the current limit your panel is
+ actually enforcing, **Binding Constraint** names which rule set it — your service rating, a utility envelope, an operator cap, a limit you asked for — and
+ **PCS Active** says whether anything is being throttled right now. Your panel has published all of this since firmware r202633 and nothing read it.
+- **Import Limit carries the whole arbitration as attributes**: the four constraint limits the panel reconciled (`feed_import_limit`, `operator_import_limit`,
+ `off_grid_import_limit`, `requested_import_limit`), each one's `_enablement` and `_active` flag, and `pcs_enabled`. They explain the enforced number rather
+ than being numbers to watch, and most of them change only when somebody reconfigures the panel — so they refine an entity you already have instead of adding
+ twelve to your entity list.
+- **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them: whether the Power Control System manages that circuit,
+ and where it sits in the shed order when an import limit binds. `pcs_priority` is a different thing from the existing `shed_priority`, which is the backup
+ tier — a circuit may take part in one policy, both, or neither.
+- All three entities are created wherever the panel publishes the capability, **including when the PCS is switched off**. A panel reporting a 0 A limit with
+ everything unconfigured is reporting a state, and that is the state most panels are in; entities that vanished until somebody configured a limit would be
+ entities nobody could build a dashboard on.
+
+### Fixed
+
+- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation
+ was wrong. No entity changed.
## [2.1.0] - 8/2026
diff --git a/README.md b/README.md
index 8b751fb3..93951535 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,30 @@ Present only when the panel publishes them.
| `full_charge_total_time_remaining` | int | Backup Time Remaining | The same estimate assuming the battery starts full |
| `forecast_confidence` | string | both | The panel's own assessment: `LOW`, `MEDIUM`, or `HIGH` |
+### Power Control System Sensors (v1.0 data model only)
+
+Created only when your panel publishes the `pcs` capability, and created whether or not the PCS is switched on — a PCS reporting a limit of 0 A is reporting a
+state, not an absence.
+
+| Sensor | Device Class | Unit | Notes |
+| ------------------ | ------------ | ---- | -------------------------------------------------------------------------------------------------------------------------- |
+| Import Limit | Current | A | The limit actually being enforced: the most restrictive of every active constraint |
+| Binding Constraint | Enum | — | Which constraint sets that limit: Firm Service Rating, Grid Envelope, Voltage Support, Off-Grid, Requested, Operator, None |
+
+#### Power Control System Sensor Attributes
+
+On **Import Limit**, and present only when the panel publishes them. These are the inputs the panel reconciled to produce the enforced limit above.
+
+| Attribute | Type | Notes |
+| ------------------------ | ------ | --------------------------------------------------------------- |
+| `pcs_enabled` | bool | Whether the panel's PCS is enabled at all |
+| `feed_import_limit` | float | The Firm Service Rating: the commissioned, always-on floor (A) |
+| `operator_import_limit` | float | A cap imposed by a fleet or aggregator operator (A) |
+| `off_grid_import_limit` | float | The import cap while islanded (A) |
+| `requested_import_limit` | float | A voluntary limit requested by the owner or installer (A) |
+| `_enablement` | string | Per limit: `UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED` |
+| `_active` | bool | Per limit: whether that constraint is currently enforcing |
+
### Power Flow Sensors (v2 only)
| Sensor | Device Class | Unit | Notes |
@@ -298,15 +322,17 @@ Applies to Main Meter and Feed Through energy sensors.
### Circuit Power Sensor Attributes
-| Attribute | Type | Notes |
-| ----------------- | ------ | ----------------------------------------------------- |
-| `tabs` | string | Breaker slot position(s) |
-| `voltage` | string | 120 or 240 (derived from tab count) |
-| `always_on` | bool | Whether circuit is always-on |
-| `relay_state` | string | OPEN / CLOSED / UNKNOWN |
-| `relay_requester` | string | Who requested relay state |
-| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN |
-| `is_sheddable` | bool | Whether circuit can be shed |
+| Attribute | Type | Notes |
+| ----------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- |
+| `tabs` | string | Breaker slot position(s) |
+| `voltage` | string | 120 or 240 (derived from tab count) |
+| `always_on` | bool | Whether circuit is always-on |
+| `relay_state` | string | OPEN / CLOSED / UNKNOWN |
+| `relay_requester` | string | Who requested relay state |
+| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN |
+| `is_sheddable` | bool | Whether circuit can be shed |
+| `pcs_managed` | bool | (v1.0) Whether the panel's Power Control System manages this circuit. Present only when the circuit reports it |
+| `pcs_priority` | int | (v1.0) This circuit's shed order under an active import limit — distinct from `shed_priority`, which is the backup tier |
### Circuit Energy Sensor Attributes
@@ -317,13 +343,14 @@ Applies to Main Meter and Feed Through energy sensors.
### Binary Sensors
-| Sensor | Device Class | Notes |
-| --------------- | ------------ | ------------------------------------------------------------------- |
-| Door State | Tamper | Panel door open/closed |
-| Ethernet Link | Connectivity | Wired network status |
-| Wi-Fi Link | Connectivity | Wireless network status |
-| Panel Status | Connectivity | Overall panel online/offline |
-| Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported |
+| Sensor | Device Class | Notes |
+| --------------- | ------------ | -------------------------------------------------------------------------------------------------- |
+| Door State | Tamper | Panel door open/closed |
+| Ethernet Link | Connectivity | Wired network status |
+| Wi-Fi Link | Connectivity | Wireless network status |
+| Panel Status | Connectivity | Overall panel online/offline |
+| Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported |
+| PCS Active | Running | (v1.0) Whether the Power Control System is limiting import right now. Only when the panel runs one |
**Removed from binary sensors:**
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 7c2d9f1c..65b72147 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -36,6 +36,7 @@
build_evse_unique_id_for_entry,
has_bess,
has_mid,
+ has_pcs,
resolve_evse_display_suffix,
)
from .util import bess_device_info, evse_device_info
@@ -160,6 +161,40 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
)
+PCS_ACTIVE_SENSOR = SpanPanelBinarySensorEntityDescription(
+ key="pcs_active",
+ field_path="pcs.active",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="pcs_active",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda s: None if s.pcs is None else s.pcs.active,
+)
+"""Is the Power Control System limiting import right now?
+
+The one property of this capability that changes on its own, and therefore the
+one an automation triggers on: `pcs/enabled` is a commissioning fact and the four
+constraint limits move only on reconfiguration, but `active` flips when the panel
+starts throttling. A binary sensor rather than a third enum, because the question
+is binary and `pcs_binding_constraint` already answers "which limit" for anyone
+who needs it.
+
+Diagnostic, and enabled by default. It reports the panel constraining the user's
+supply, which is worth seeing, but it belongs beside the other panel-state
+sensors rather than among the power readings.
+
+`None` when the panel runs no PCS, which is what a flat panel and any v1.0
+firmware without the node report — but the entity is not created there at all, so
+the branch is reached only if the node disappears mid-session, where unknown is
+the right answer.
+
+`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`, by the producible rule: flat
+declares no `pcs` capability, so the both-adapters gate cannot be satisfied,
+while the entity still needs its Repair mention and its unavailability when the
+panel stops resolving the property.
+"""
+
+
class SpanPanelBinarySensor[T: SpanPanelBinarySensorEntityDescription](
SpanPanelEntity, BinarySensorEntity
):
@@ -439,6 +474,13 @@ async def async_setup_entry(
)
)
+ # Add the PCS activity sensor where the panel runs a Power Control System.
+ # Gated on the node, not on a value: every property this capability publishes
+ # is legally zero or false, so a value gate would delete the entity of every
+ # panel whose PCS is merely switched off — see `has_pcs`.
+ if has_pcs(snapshot):
+ entities.append(SpanPanelBinarySensor(coordinator, PCS_ACTIVE_SENSOR))
+
# Add EVSE binary sensors for each commissioned charger
if snapshot.evse:
for evse_id in snapshot.evse:
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index eb55441c..211298bf 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -10,7 +10,7 @@
fields to `battery.model` / `pv.model`).
Field path convention: ``{snapshot_type}.{field_name}`` — ``panel``,
-``circuit``, ``battery``, ``pv``, ``evse`` and ``mid``.
+``circuit``, ``battery``, ``pv``, ``evse``, ``mid`` and ``pcs``.
"""
from __future__ import annotations
@@ -199,6 +199,36 @@ class FieldPathDeclarationMixin:
"panel.shed_full_charge_time_to_priority_shed_min": Producibility.NEITHER,
"panel.shed_full_charge_total_time_remaining_min": Producibility.NEITHER,
"panel.shed_forecast_confidence": Producibility.NEITHER,
+ # The PCS arbitration's *inputs*, read for the twelve attributes on
+ # `pcs_import_limit` plus its `pcs_enabled` (`pcs_arbitration_attributes`
+ # in sensor_definitions). schema_1 reads all thirteen into the snapshot
+ # and carries a `_PROPERTY_FIELD_MAP` row for none of them, deliberately:
+ # the capability calls `import-limit` and `binding-constraint` "the
+ # result", and these explain that result rather than being readings of
+ # their own, so there is no unit surface for a row to describe. Same
+ # shape as the shed-forecast refinements above.
+ "pcs.enabled": Producibility.NEITHER,
+ "pcs.feed_import_limit_a": Producibility.NEITHER,
+ "pcs.feed_import_limit_enablement": Producibility.NEITHER,
+ "pcs.feed_import_limit_active": Producibility.NEITHER,
+ "pcs.operator_import_limit_a": Producibility.NEITHER,
+ "pcs.operator_import_limit_enablement": Producibility.NEITHER,
+ "pcs.operator_import_limit_active": Producibility.NEITHER,
+ "pcs.off_grid_import_limit_a": Producibility.NEITHER,
+ "pcs.off_grid_import_limit_enablement": Producibility.NEITHER,
+ "pcs.off_grid_import_limit_active": Producibility.NEITHER,
+ "pcs.requested_import_limit_a": Producibility.NEITHER,
+ "pcs.requested_import_limit_enablement": Producibility.NEITHER,
+ "pcs.requested_import_limit_active": Producibility.NEITHER,
+ # A circuit's *participation* in that PCS — `managed` and `priority`,
+ # read as attributes on its power sensor (`sensor_circuit.py`). Not
+ # `_residual_field_paths` on the entity: that feeds
+ # `declared_field_paths()`, and no flat circuit declares a `pcs` node at
+ # all, so the producible gate would reject both. schema_1 maps neither
+ # for the reason above — they qualify a circuit's reading rather than
+ # being one.
+ "circuit.pcs_managed": Producibility.NEITHER,
+ "circuit.pcs_priority": Producibility.NEITHER,
"circuit.is_user_controllable": Producibility.SCHEMA_1_ONLY,
# The two backup-planning estimates behind `time_to_priority_shed` and
# `shed_total_time_remaining`, whose descriptions are
@@ -225,6 +255,17 @@ class FieldPathDeclarationMixin:
# charging.
"battery.power_w": Producibility.SCHEMA_1_ONLY,
"battery.communication_state": Producibility.SCHEMA_1_ONLY,
+ # The Power Control System's result, behind `pcs_import_limit`,
+ # `pcs_binding_constraint` and the `pcs_active` binary sensor. Their
+ # descriptions are `SCHEMA_CONDITIONAL_FIELD` for the usual reason: no
+ # flat panel declares `energy.ebus.capability.pcs`, so the both-adapters
+ # gate cannot be satisfied. schema_1 carries a `_PROPERTY_FIELD_MAP` row
+ # for each of these three, which is what makes them SCHEMA_1_ONLY rather
+ # than NEITHER and buys `pcs.import_limit_a` unit validation against the
+ # panel's own `$description`.
+ "pcs.import_limit_a": Producibility.SCHEMA_1_ONLY,
+ "pcs.binding_constraint": Producibility.SCHEMA_1_ONLY,
+ "pcs.active": Producibility.SCHEMA_1_ONLY,
"circuit.always_on": Producibility.SCHEMA_0_ONLY,
"circuit.is_sheddable": Producibility.SCHEMA_0_ONLY,
"panel.wifi_ssid": Producibility.SCHEMA_0_ONLY,
@@ -394,6 +435,7 @@ def platform_descriptions() -> tuple[EntityDescription, ...]:
BINARY_SENSORS,
EVSE_BINARY_SENSORS,
GRID_ISLANDABLE_SENSOR,
+ PCS_ACTIVE_SENSOR,
)
from .sensor_definitions import ( # pylint: disable=import-outside-toplevel
all_sensor_descriptions,
@@ -405,6 +447,7 @@ def platform_descriptions() -> tuple[EntityDescription, ...]:
*EVSE_BINARY_SENSORS,
GRID_ISLANDABLE_SENSOR,
BESS_CONNECTED_SENSOR,
+ PCS_ACTIVE_SENSOR,
)
diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py
index 482e415f..cdc51967 100644
--- a/custom_components/span_panel/helpers.py
+++ b/custom_components/span_panel/helpers.py
@@ -326,6 +326,31 @@ def has_bess_telemetry(snapshot: SpanPanelSnapshot) -> bool:
return snapshot.battery.power_w is not None or snapshot.battery.communication_state is not None
+def has_pcs(snapshot: SpanPanelSnapshot) -> bool:
+ """Detect whether the panel runs a Power Control System.
+
+ The one capability gate here that cannot be a value test, and the library is
+ where that is enforced: `SpanPanelSnapshot.pcs` is `None` exactly when the
+ enclosure declares no `pcs` node, per the capability's own rule that
+ "absence of the `pcs` node means the device does not run (or participate in)
+ a Power Control System".
+
+ A value test would be wrong rather than merely awkward. Every property this
+ capability publishes is legally zero — the reference capture is a PCS that
+ exists and is switched off, reporting `0.0` on every limit — so reading the
+ values would delete the entities of every panel whose PCS is unconfigured,
+ which is the state most panels are in and the state a user most wants to
+ see.
+
+ Always false on flat firmware, which publishes no such node at all.
+
+ DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema
+ version, so a panel that gains the node reaches `detect_capabilities`, the
+ coordinator reloads, and the entities appear.
+ """
+ return snapshot.pcs is not None
+
+
def has_evse(snapshot: SpanPanelSnapshot) -> bool:
"""Detect whether an EVSE (EV charger) is commissioned."""
return len(snapshot.evse) > 0
@@ -355,4 +380,6 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]:
caps.add("shed_forecast")
if has_bess_telemetry(snapshot):
caps.add("bess_telemetry")
+ if has_pcs(snapshot):
+ caps.add("pcs")
return frozenset(caps)
diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json
index c3f0f3a6..a6a5f1c4 100644
--- a/custom_components/span_panel/icons.json
+++ b/custom_components/span_panel/icons.json
@@ -32,6 +32,9 @@
},
"wifi_link": {
"default": "mdi:wifi"
+ },
+ "pcs_active": {
+ "default": "mdi:speedometer"
}
},
"button": {
@@ -210,6 +213,12 @@
"connected": "mdi:cloud-check",
"unconnected": "mdi:cloud-off-outline"
}
+ },
+ "pcs_import_limit": {
+ "default": "mdi:transmission-tower-import"
+ },
+ "pcs_binding_constraint": {
+ "default": "mdi:scale-balance"
}
}
},
diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py
index 52ef105e..cfbc5c77 100644
--- a/custom_components/span_panel/sensor.py
+++ b/custom_components/span_panel/sensor.py
@@ -24,6 +24,7 @@
has_bess_telemetry,
has_evse,
has_mid,
+ has_pcs,
has_power_flows,
has_pv,
has_shed_forecast,
@@ -54,6 +55,7 @@
PANEL_DATA_STATUS_SENSORS,
PANEL_ENERGY_SENSORS,
PANEL_POWER_SENSORS,
+ PCS_SENSORS,
PV_METADATA_SENSORS,
PV_POWER_SENSOR,
SHED_FORECAST_SENSORS,
@@ -72,6 +74,7 @@
SpanPanelPanelStatus,
SpanPanelPowerSensor,
SpanPanelStatus,
+ SpanPcsSensor,
SpanPVMetadataSensor,
SpanShedForecastSensor,
)
@@ -90,6 +93,7 @@
"SpanPanelPanelStatus",
"SpanPanelPowerSensor",
"SpanPanelStatus",
+ "SpanPcsSensor",
"SpanSensorBase",
"SpanShedForecastSensor",
"SpanUnmappedCircuitSensor",
@@ -398,6 +402,36 @@ def create_shed_forecast_sensors(
]
+def create_pcs_sensors(
+ coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
+) -> list[SpanPcsSensor]:
+ """Create the Power Control System sensors, where the panel runs one.
+
+ **One gate, not two, and that is the difference from every other capability
+ here.** The shed forecast and the BESS telemetry gate a second time on each
+ description's own `value_fn`, because a property that arrives unpublished
+ would otherwise become a permanently-unknown entity. That reasoning does not
+ transfer: `pcs` publishes properties that are legally `0.0`, `false` and
+ `UNCONFIGURED`, and the reference capture is exactly that — a PCS which
+ exists and is switched off. A per-reading gate would create the entities on a
+ configured panel and delete them the moment somebody turned the PCS off,
+ which is the state a user most wants to see reported.
+
+ So presence is the node, which `has_pcs` reads off the library's `None`
+ contract, and a property the node omits degrades to unknown on an entity that
+ stays. Both results the capability marks `SHOULD`, so a panel publishing the
+ node without them is unusual firmware rather than an expected shape.
+
+ DUAL-SCHEMA: gated on what the snapshot carries, never on a version. No flat
+ panel publishes the capability, and a v1.0 panel that gains it reaches
+ `detect_capabilities` and picks the entities up on the reload.
+ """
+ if not has_pcs(snapshot):
+ return []
+
+ return [SpanPcsSensor(coordinator, description, snapshot) for description in PCS_SENSORS]
+
+
def create_battery_sensors(
coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
) -> list[SpanPanelBattery | SpanPanelPowerSensor | SpanBessMetadataSensor]:
@@ -502,6 +536,7 @@ def create_native_sensors(
| SpanEvseSensor
| SpanMidSensor
| SpanShedForecastSensor
+ | SpanPcsSensor
]:
"""Create all native sensors for the platform."""
entities: list[
@@ -518,6 +553,7 @@ def create_native_sensors(
| SpanEvseSensor
| SpanMidSensor
| SpanShedForecastSensor
+ | SpanPcsSensor
] = []
# Create different sensor types
@@ -528,6 +564,7 @@ def create_native_sensors(
entities.extend(create_battery_sensors(coordinator, snapshot))
entities.extend(create_mid_sensors(coordinator, snapshot))
entities.extend(create_shed_forecast_sensors(coordinator, snapshot))
+ entities.extend(create_pcs_sensors(coordinator, snapshot))
entities.extend(create_power_flow_sensors(coordinator, snapshot))
entities.extend(create_evse_sensors(coordinator, snapshot))
diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py
index b519aa90..0e06c6c1 100644
--- a/custom_components/span_panel/sensor_circuit.py
+++ b/custom_components/span_panel/sensor_circuit.py
@@ -253,6 +253,27 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
attributes["shed_priority"] = circuit.priority
attributes["is_sheddable"] = circuit.is_sheddable
+ # This circuit's participation in the enclosure's Power Control System,
+ # beside its load-shed participation above. Two policies on the same
+ # relay, and the catalog keeps them apart because they answer different
+ # questions — limit site import, versus preserve backup runtime — so the
+ # attribute names do too: `pcs_priority` is an integer shed ordering
+ # under an import limit, `shed_priority` the backup tier.
+ #
+ # Attributes rather than entities: a 40-space panel would otherwise gain
+ # eighty entities carrying two facts that change only when somebody
+ # reconfigures the panel.
+ #
+ # Omitted when the circuit publishes neither, which is every flat
+ # circuit and any v1.0 circuit outside a PCS. Both properties are `MAY`,
+ # so absence is conformant firmware; an attribute present and empty would
+ # read as a reading that failed, and `False` / `0` would each be a claim
+ # the panel never made.
+ if circuit.pcs_managed is not None:
+ attributes["pcs_managed"] = circuit.pcs_managed
+ if circuit.pcs_priority is not None:
+ attributes["pcs_priority"] = circuit.pcs_priority
+
return attributes
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 391fc9c6..b254b899 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -13,6 +13,7 @@
from collections.abc import Callable
from dataclasses import dataclass
+from typing import NamedTuple
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -34,6 +35,7 @@
SpanEvseSnapshot,
SpanMidSnapshot,
SpanPanelSnapshot,
+ SpanPcsSnapshot,
)
from .field_paths import (
@@ -429,6 +431,221 @@ class SpanShedForecastSensorEntityDescription(
"""
+# ---------------------------------------------------------------------------
+# Power Control System (v1.0 `pcs`, conditionally created)
+# ---------------------------------------------------------------------------
+
+
+class PcsConstraintFamily(NamedTuple):
+ """One amps-native constraint class, and how to read its three properties.
+
+ `energy.ebus.capability.pcs` 0.3 publishes each arbitration input as an
+ identical `{-import-limit, -enablement, -active}` triplet, and says
+ so as a rule: a vendor "MAY publish further amps-native limits using the
+ same triplet". So the four families are one shape repeated, and the
+ attribute builder is written once over this table rather than four times
+ over twelve field names — where a copied line would put an enablement in an
+ active flag and still read plausibly.
+
+ `attribute` is the name the limit is published under, and the enablement and
+ active flags extend it. The names mirror the wire property ids so a user
+ reading the catalog and a user reading the attribute list see the same
+ words.
+ """
+
+ attribute: str
+ limit_fn: Callable[[SpanPcsSnapshot], float | None]
+ enablement_fn: Callable[[SpanPcsSnapshot], str | None]
+ active_fn: Callable[[SpanPcsSnapshot], bool | None]
+
+
+PCS_CONSTRAINT_FAMILIES: tuple[
+ PcsConstraintFamily,
+ PcsConstraintFamily,
+ PcsConstraintFamily,
+ PcsConstraintFamily,
+] = (
+ PcsConstraintFamily(
+ attribute="feed_import_limit",
+ limit_fn=lambda p: p.feed_import_limit_a,
+ enablement_fn=lambda p: p.feed_import_limit_enablement,
+ active_fn=lambda p: p.feed_import_limit_active,
+ ),
+ PcsConstraintFamily(
+ attribute="operator_import_limit",
+ limit_fn=lambda p: p.operator_import_limit_a,
+ enablement_fn=lambda p: p.operator_import_limit_enablement,
+ active_fn=lambda p: p.operator_import_limit_active,
+ ),
+ PcsConstraintFamily(
+ attribute="off_grid_import_limit",
+ limit_fn=lambda p: p.off_grid_import_limit_a,
+ enablement_fn=lambda p: p.off_grid_import_limit_enablement,
+ active_fn=lambda p: p.off_grid_import_limit_active,
+ ),
+ PcsConstraintFamily(
+ attribute="requested_import_limit",
+ limit_fn=lambda p: p.requested_import_limit_a,
+ enablement_fn=lambda p: p.requested_import_limit_enablement,
+ active_fn=lambda p: p.requested_import_limit_active,
+ ),
+)
+"""The four constraint classes the catalog names, in the order it names them.
+
+The FSR first because it is the only standing one: `feed_import_limit` is the
+commissioned floor that "cannot be lost", and the other three are conditional —
+an operator cap set over a fleet API, an islanded cap, and a limit the owner
+asked for. A reader scanning the attributes meets the permanent one first.
+"""
+
+
+def _no_pcs_attributes(pcs: SpanPcsSnapshot) -> dict[str, float | str | bool]:
+ """Return nothing — the default for a PCS sensor with no attributes of its own."""
+ return {}
+
+
+def pcs_arbitration_attributes(pcs: SpanPcsSnapshot) -> dict[str, float | str | bool]:
+ """Return the arbitration inputs behind the effective import limit.
+
+ They belong to the sensor that shows the limit itself.
+
+ `capabilities/pcs.md` is explicit that what a PCS publishes is "the
+ **result**: the effective `import-limit` and the `binding-constraint`". That
+ is the entity; these are the working. Twelve of them, which is why they are
+ attributes: a dashboard with twelve near-constant amperages on it is not a
+ dashboard, and eleven of these move only when somebody reconfigures the
+ panel.
+
+ Each is omitted when the panel does not publish it, rather than appearing as
+ `None`. Three of the four classes are `MAY`, so an absent family is
+ conformant firmware; an attribute present and empty would read as a reading
+ the panel failed to produce.
+
+ `pcs_enabled` rides here rather than as its own entity because it is
+ subsumed: the `pcs_active` binary sensor is the fact an automation triggers
+ on, and a PCS that is not enabled cannot be active.
+ """
+ attributes: dict[str, float | str | bool] = {}
+
+ if pcs.enabled is not None:
+ attributes["pcs_enabled"] = pcs.enabled
+
+ for family in PCS_CONSTRAINT_FAMILIES:
+ limit = family.limit_fn(pcs)
+ if limit is not None:
+ attributes[family.attribute] = limit
+ enablement = family.enablement_fn(pcs)
+ if enablement is not None:
+ attributes[f"{family.attribute}_enablement"] = enablement
+ active = family.active_fn(pcs)
+ if active is not None:
+ attributes[f"{family.attribute}_active"] = active
+
+ return attributes
+
+
+@dataclass(frozen=True, kw_only=True)
+class SpanPcsRequiredKeysMixin(FieldPathDeclarationMixin):
+ """Required keys mixin for the Power Control System sensors.
+
+ Keyword-only for the reason `FieldPathDeclarationMixin` is: a mixin's fields
+ flatten ahead of `EntityDescription.key`, which has no default, so a
+ defaulted positional field here would make every description
+ unconstructable.
+
+ `attributes_fn` is carried on the description rather than decided inside the
+ entity, for the reason the shed-forecast pairing is: the alternative is a
+ comparison against `description.key` in `extra_state_attributes`, which puts
+ a string match between an entity and the data it publishes and silently
+ stops matching after a rename.
+ """
+
+ value_fn: Callable[[SpanPcsSnapshot], float | str | None]
+
+ attributes_fn: Callable[[SpanPcsSnapshot], dict[str, float | str | bool]] = _no_pcs_attributes
+ """What this sensor publishes beside its state. Empty for most."""
+
+
+@dataclass(frozen=True, kw_only=True)
+class SpanPcsSensorEntityDescription(SensorEntityDescription, SpanPcsRequiredKeysMixin):
+ """Describes a Power Control System sensor entity."""
+
+
+PCS_BINDING_CONSTRAINT_OPTIONS: tuple[str, ...] = (
+ "fsr",
+ "doe",
+ "voltage",
+ "off_grid",
+ "requested",
+ "operator",
+ "none",
+ "unknown",
+)
+"""`binding-constraint`'s enum, lowercased for Home Assistant's state keys.
+
+The catalog's eight members, in its order. Publishers MAY extend the enum
+through the property's Homie `$format`, so this is the interoperable core rather
+than a closed set — a vendor value arrives as a state Home Assistant does not
+recognise, which is a visible gap rather than a silent re-labelling.
+"""
+
+
+PCS_SENSORS: tuple[SpanPcsSensorEntityDescription, SpanPcsSensorEntityDescription] = (
+ SpanPcsSensorEntityDescription(
+ key="pcs_import_limit",
+ field_path="pcs.import_limit_a",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="pcs_import_limit",
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ suggested_display_precision=1,
+ value_fn=lambda p: p.import_limit_a,
+ attributes_fn=pcs_arbitration_attributes,
+ ),
+ SpanPcsSensorEntityDescription(
+ key="pcs_binding_constraint",
+ field_path="pcs.binding_constraint",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="pcs_binding_constraint",
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options=list(PCS_BINDING_CONSTRAINT_OPTIONS),
+ value_fn=lambda p: None if p.binding_constraint is None else p.binding_constraint.lower(),
+ ),
+)
+"""What the enclosure's Power Control System publishes as a result.
+
+**The effective limit is the entity, and the arbitration is its attributes.**
+The capability says the PCS reconciles every active import constraint — some of
+them in watts on `doe`, some in volts on `voltage-response` — to one enforced
+current limit, and that "what `pcs` publishes is the **result**". So the result
+is what gets an entity a user can graph and alarm on. The twelve amps-native
+inputs behind it are on that entity as attributes: they explain a number rather
+than being numbers anyone watches, and eleven of them move only on
+reconfiguration.
+
+`pcs_binding_constraint` is the second half of that result and the reason the
+first is interpretable — it names which constraint class won the `min()`, which
+is the difference between "the panel is limiting me to 40 A" and "the panel is
+limiting me to 40 A *because the utility sent an envelope*". Diagnostic and
+enabled by default: it is short, it changes rarely, and it is useless filed
+where nobody finds it.
+
+**Enabled by default and not diagnostic, for the limit.** A PCS actively
+throttling import is a fact about the user's electricity supply, not about the
+integration's health.
+
+**`derived` as well as `field_path`, by the producible rule.** No flat panel
+publishes `energy.ebus.capability.pcs` at all, so the both-adapters gate cannot
+be satisfied; the paths are enumerated in `RESIDUAL_EXEMPT_PATHS` as
+`SCHEMA_1_ONLY`, which schema_1's metadata rows earn them and which buys
+`pcs_import_limit` unit validation against the panel's own `$description`.
+`field_path` still names the source, which is what gives each sensor its Repair
+mention and its unavailability when the panel stops resolving the property.
+"""
+
+
# ---------------------------------------------------------------------------
# Circuit diagnostic sensors (promoted from attributes)
# ---------------------------------------------------------------------------
@@ -990,6 +1207,7 @@ def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]:
*MID_SENSORS,
*BESS_METADATA_SENSORS,
*BESS_TELEMETRY_SENSORS,
+ *PCS_SENSORS,
*PV_METADATA_SENSORS,
*PANEL_POWER_SENSORS,
*PANEL_ENERGY_SENSORS,
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index 9f1bf38d..5cce73e8 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -7,7 +7,12 @@
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import UNDEFINED
-from span_panel_api import SpanBatterySnapshot, SpanMidSnapshot, SpanPanelSnapshot
+from span_panel_api import (
+ SpanBatterySnapshot,
+ SpanMidSnapshot,
+ SpanPanelSnapshot,
+ SpanPcsSnapshot,
+)
from .coordinator import SpanPanelCoordinator
from .helpers import (
@@ -24,6 +29,7 @@
SpanPanelBatterySensorEntityDescription,
SpanPanelDataSensorEntityDescription,
SpanPanelStatusSensorEntityDescription,
+ SpanPcsSensorEntityDescription,
SpanPVMetadataSensorEntityDescription,
SpanShedForecastSensorEntityDescription,
)
@@ -184,6 +190,92 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
return attributes or None
+class SpanPcsSensor(SpanSensorBase[SpanPcsSensorEntityDescription, SpanPcsSnapshot]):
+ """A reading from the enclosure's Power Control System.
+
+ Created only where the panel declares a `pcs` node, so a panel that runs no
+ PCS — every flat panel, and any v1.0 firmware without the capability — gets
+ no entity rather than one stuck at unknown. See `create_pcs_sensors`.
+ """
+
+ # `_residual_field_paths` stays empty on purpose. The thirteen fields
+ # `pcs_arbitration_attributes` reads are not declarable here: no adapter
+ # carries a metadata row for them, so declaring them would put them in
+ # `declared_field_paths()` where the producible gate rejects anything one
+ # adapter cannot emit. They are enumerated in `RESIDUAL_EXEMPT_PATHS` as
+ # `Producibility.NEITHER` instead, beside the shed-forecast refinements and
+ # the `mid.*` attribute reads, which are outside the gate for the same
+ # reason.
+
+ def __init__(
+ self,
+ data_coordinator: SpanPanelCoordinator,
+ description: SpanPcsSensorEntityDescription,
+ snapshot: SpanPanelSnapshot,
+ ) -> None:
+ """Initialize a PCS sensor, keeping a typed handle on its description.
+
+ `SensorEntity.entity_description` is annotated as the base
+ `SensorEntityDescription`, so reading `attributes_fn` off it would need a
+ narrowing override mypy rejects, or a `getattr` that removes the check.
+ The same move `SpanShedForecastSensor` makes for its twin readers.
+ """
+ super().__init__(data_coordinator, description, snapshot)
+ self._pcs = description
+
+ def _generate_unique_id(
+ self,
+ snapshot: SpanPanelSnapshot,
+ description: SpanPcsSensorEntityDescription,
+ ) -> str:
+ """Generate unique ID for a PCS sensor."""
+ return construct_panel_unique_id_for_entry(
+ self.coordinator, snapshot, description.key, self._device_name
+ )
+
+ def _generate_friendly_name(
+ self,
+ snapshot: SpanPanelSnapshot,
+ description: SpanPcsSensorEntityDescription,
+ ) -> str:
+ """Generate friendly name for a PCS sensor."""
+ if description.name is not None and description.name is not UNDEFINED:
+ return str(description.name)
+ return "Power Control System"
+
+ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPcsSnapshot:
+ """Get the data source for the PCS sensor.
+
+ The PCS is optional, so a snapshot without one has no data source.
+ Entities are created only when `has_pcs` is true, and a panel that stops
+ publishing the node makes them unknown rather than reaching this — the
+ same contract `SpanMidSensor` has.
+ """
+ pcs = snapshot.pcs
+ if pcs is None:
+ raise ValueError("PCS sensor asked for a data source on a snapshot with no PCS")
+ return pcs
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any] | None:
+ """The arbitration inputs behind this sensor's reading, where it has any.
+
+ Which attributes belong to which sensor comes from the description, not
+ from a comparison against `key`: `pcs_binding_constraint` publishes none
+ and `pcs_import_limit` publishes twelve, and stating that as data is what
+ keeps a rename from silently moving them.
+
+ Individually omitted when the panel does not publish them — three of the
+ four constraint classes are `MAY`, so an absent family is conformant
+ firmware rather than a reading that failed.
+ """
+ snapshot = self.coordinator.data
+ if snapshot is None or snapshot.pcs is None:
+ return None
+
+ return self._pcs.attributes_fn(snapshot.pcs) or None
+
+
class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]):
"""Span Panel hardware status sensor entity."""
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 14d6d1ab..311876e9 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -147,6 +147,9 @@
},
"wifi_link": {
"name": "Wi-Fi Link"
+ },
+ "pcs_active": {
+ "name": "PCS Active"
}
},
"button": {
@@ -362,6 +365,22 @@
"unconnected": "Disconnected",
"unknown": "Unknown"
}
+ },
+ "pcs_import_limit": {
+ "name": "Import Limit"
+ },
+ "pcs_binding_constraint": {
+ "name": "Binding Constraint",
+ "state": {
+ "fsr": "Firm Service Rating",
+ "doe": "Grid Envelope",
+ "voltage": "Voltage Support",
+ "off_grid": "Off-Grid",
+ "requested": "Requested",
+ "operator": "Operator",
+ "none": "None",
+ "unknown": "Unknown"
+ }
}
}
},
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 14d6d1ab..311876e9 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -147,6 +147,9 @@
},
"wifi_link": {
"name": "Wi-Fi Link"
+ },
+ "pcs_active": {
+ "name": "PCS Active"
}
},
"button": {
@@ -362,6 +365,22 @@
"unconnected": "Disconnected",
"unknown": "Unknown"
}
+ },
+ "pcs_import_limit": {
+ "name": "Import Limit"
+ },
+ "pcs_binding_constraint": {
+ "name": "Binding Constraint",
+ "state": {
+ "fsr": "Firm Service Rating",
+ "doe": "Grid Envelope",
+ "voltage": "Voltage Support",
+ "off_grid": "Off-Grid",
+ "requested": "Requested",
+ "operator": "Operator",
+ "none": "None",
+ "unknown": "Unknown"
+ }
}
}
},
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 5cbfa878..4180ef0b 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -147,6 +147,9 @@
},
"wifi_link": {
"name": "Enlace Wi-Fi"
+ },
+ "pcs_active": {
+ "name": "PCS Activo"
}
},
"button": {
@@ -362,6 +365,22 @@
"unconnected": "Desconectado",
"unknown": "Desconocido"
}
+ },
+ "pcs_import_limit": {
+ "name": "Límite de Importación"
+ },
+ "pcs_binding_constraint": {
+ "name": "Restricción Vinculante",
+ "state": {
+ "fsr": "Capacidad Firme del Servicio",
+ "doe": "Envolvente de Red",
+ "voltage": "Soporte de Tensión",
+ "off_grid": "Fuera de Red",
+ "requested": "Solicitado",
+ "operator": "Operador",
+ "none": "Ninguno",
+ "unknown": "Desconocido"
+ }
}
}
},
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 55fc0cf7..65e7c50b 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -147,6 +147,9 @@
},
"wifi_link": {
"name": "Liaison Wi-Fi"
+ },
+ "pcs_active": {
+ "name": "PCS Actif"
}
},
"button": {
@@ -362,6 +365,22 @@
"unconnected": "Déconnecté",
"unknown": "Inconnu"
}
+ },
+ "pcs_import_limit": {
+ "name": "Limite d'Importation"
+ },
+ "pcs_binding_constraint": {
+ "name": "Contrainte Contraignante",
+ "state": {
+ "fsr": "Capacité Ferme du Service",
+ "doe": "Enveloppe Réseau",
+ "voltage": "Support de Tension",
+ "off_grid": "Hors Réseau",
+ "requested": "Demandé",
+ "operator": "Opérateur",
+ "none": "Aucun",
+ "unknown": "Inconnu"
+ }
}
}
},
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 234bcc9a..babc29ba 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -147,6 +147,9 @@
},
"wifi_link": {
"name": "Wi-Fiリンク"
+ },
+ "pcs_active": {
+ "name": "PCS 動作中"
}
},
"button": {
@@ -362,6 +365,22 @@
"unconnected": "未接続",
"unknown": "不明"
}
+ },
+ "pcs_import_limit": {
+ "name": "インポート制限"
+ },
+ "pcs_binding_constraint": {
+ "name": "拘束制約",
+ "state": {
+ "fsr": "確定サービス容量",
+ "doe": "系統エンベロープ",
+ "voltage": "電圧サポート",
+ "off_grid": "オフグリッド",
+ "requested": "要求",
+ "operator": "オペレーター",
+ "none": "なし",
+ "unknown": "不明"
+ }
}
}
},
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index ff7c298a..aedf0e58 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -147,6 +147,9 @@
},
"wifi_link": {
"name": "Ligação Wi-Fi"
+ },
+ "pcs_active": {
+ "name": "PCS Ativo"
}
},
"button": {
@@ -362,6 +365,22 @@
"unconnected": "Desconectado",
"unknown": "Desconhecido"
}
+ },
+ "pcs_import_limit": {
+ "name": "Limite de Importação"
+ },
+ "pcs_binding_constraint": {
+ "name": "Restrição Vinculativa",
+ "state": {
+ "fsr": "Capacidade Firme do Serviço",
+ "doe": "Envelope da Rede",
+ "voltage": "Suporte de Tensão",
+ "off_grid": "Fora da Rede",
+ "requested": "Solicitado",
+ "operator": "Operador",
+ "none": "Nenhum",
+ "unknown": "Desconhecido"
+ }
}
}
},
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
index b731ff8b..69668cda 100644
--- a/tests/fixtures/unread_declarations_baseline.json
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -3,26 +3,8 @@
"circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against",
"circuit/connection/feeds-device-status": "the enclosure's link-health view of a circuit-fed DER; only the lugs fed-by-* route is read today, so PV and EVSE get no connected sensor (Task 3)",
"circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type",
- "circuit/pcs/managed": "per-circuit PCS participation; an attribute on the circuit power sensor rather than a 32x entity, and unbuilt (Task 4)",
- "circuit/pcs/priority": "per-circuit PCS ranking; same attribute treatment as pcs/managed, and unbuilt (Task 4)",
"distribution-enclosure/info/hardware-version": "no panel.hardware_version snapshot field, and the panel DeviceInfo declares no hw_version (Task 5)",
"distribution-enclosure/info/vendor-name": "DeviceInfo.manufacturer is hardcoded to \"Span\"; the declared value is never consulted, even as a fallback (Task 5)",
- "distribution-enclosure/pcs/active": "\"a limit is being enforced right now\" - the automation trigger of the PCS family, and the one binary sensor it wants (Task 4)",
- "distribution-enclosure/pcs/binding-constraint": "names which limit is winning the min() arbitration; the enum sensor that explains pcs/import-limit (Task 4)",
- "distribution-enclosure/pcs/enabled": "whether the panel enforces UL 3141 import limiting at all; subsumed by pcs/active for entity purposes (Task 4)",
- "distribution-enclosure/pcs/feed-import-limit": "the standing FSR floor; an attribute on the effective-limit sensor rather than an entity (Task 4)",
- "distribution-enclosure/pcs/feed-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
- "distribution-enclosure/pcs/feed-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
- "distribution-enclosure/pcs/import-limit": "the effective enforced import limit - the single number that summarizes the PCS family, and the headline entity of Task 4",
- "distribution-enclosure/pcs/off-grid-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)",
- "distribution-enclosure/pcs/off-grid-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
- "distribution-enclosure/pcs/off-grid-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
- "distribution-enclosure/pcs/operator-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)",
- "distribution-enclosure/pcs/operator-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
- "distribution-enclosure/pcs/operator-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
- "distribution-enclosure/pcs/requested-import-limit": "an arbitration input; an attribute on the effective-limit sensor rather than an entity (Task 4)",
- "distribution-enclosure/pcs/requested-import-limit-active": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
- "distribution-enclosure/pcs/requested-import-limit-enablement": "one of the four enablement/active pairs; binding-constraint already names the winner (Task 4)",
"distribution-enclosure/shed/policy": "a JSON policy document (soc-priority.v1 plus the two SOC thresholds); nothing parses it, and the thresholds belong as attributes beside the forecast (Task 4)",
"distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history",
"distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity",
diff --git a/tests/test_declared_but_unread.py b/tests/test_declared_but_unread.py
index d17cef66..891bd213 100644
--- a/tests/test_declared_but_unread.py
+++ b/tests/test_declared_but_unread.py
@@ -52,6 +52,7 @@
SpanEvseSnapshot,
SpanMidSnapshot,
SpanPanelSnapshot,
+ SpanPcsSnapshot,
SpanPVSnapshot,
)
@@ -185,10 +186,15 @@ def _perturbed(declared: _Property, current: str | None) -> str:
_SubSnapshot = (
- SpanCircuitSnapshot | SpanEvseSnapshot | SpanBatterySnapshot | SpanPVSnapshot | SpanMidSnapshot
+ SpanCircuitSnapshot
+ | SpanEvseSnapshot
+ | SpanBatterySnapshot
+ | SpanPVSnapshot
+ | SpanMidSnapshot
+ | SpanPcsSnapshot
)
-_COLLECTIONS = frozenset({"circuits", "evse", "battery", "pv", "mid"})
+_COLLECTIONS = frozenset({"circuits", "evse", "battery", "pv", "mid", "pcs"})
"""Panel-snapshot attributes that hold sub-snapshots rather than a reading.
Their fields are addressed by their own prefix — `circuit.x`, not
@@ -224,6 +230,8 @@ def _snapshot_fields(snapshot: SpanPanelSnapshot) -> dict[str, str]:
_record(fields, "pv", snapshot.pv)
if snapshot.mid is not None:
_record(fields, "mid", snapshot.mid)
+ if snapshot.pcs is not None:
+ _record(fields, "pcs", snapshot.pcs)
return fields
diff --git a/tests/test_enum_sensor_options.py b/tests/test_enum_sensor_options.py
index 9546466c..b9c80338 100644
--- a/tests/test_enum_sensor_options.py
+++ b/tests/test_enum_sensor_options.py
@@ -33,6 +33,7 @@
EVSE_SENSORS,
MID_SENSORS,
PANEL_DATA_STATUS_SENSORS,
+ PCS_SENSORS,
PV_METADATA_SENSORS,
STATUS_SENSORS,
UNMAPPED_SENSORS,
@@ -58,6 +59,7 @@ def _enum_descriptions() -> list[Any]:
MID_SENSORS,
BESS_METADATA_SENSORS,
BESS_TELEMETRY_SENSORS,
+ PCS_SENSORS,
PV_METADATA_SENSORS,
CIRCUIT_SENSORS,
EVSE_SENSORS,
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index beb3c220..754d9d61 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -267,7 +267,12 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# `panel.dominant_power_source` below it was read by a description and
# enumerated nowhere, so nothing held it against the adapters and
# `evaluate_field_metadata` had no way to tell it from an unread field.
- Producibility.NEITHER: 19,
+ # +15 with the PCS: the twelve arbitration inputs and `pcs.enabled` behind
+ # `pcs_import_limit`'s attributes, plus the two circuit participation fields
+ # read as attributes on the circuit power sensor. schema_1 reads all fifteen
+ # and maps none of them, deliberately — they explain the effective limit
+ # rather than being readings of their own.
+ Producibility.NEITHER: 34,
# +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's
# source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and
# enumerated nowhere, so `evaluate_field_metadata` counted it as produced-
@@ -280,7 +285,11 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# `bess_communication_state`. schema_1 maps both; flat's BESS device class
# declares neither property, so neither can ever satisfy the both-adapters
# gate.
- Producibility.SCHEMA_1_ONLY: 5,
+ # +3 for the PCS's result: `pcs.import_limit_a`, `pcs.binding_constraint`
+ # and `pcs.active`, behind the two PCS sensors and the `pcs_active` binary
+ # sensor. schema_1 maps all three; no flat panel declares the capability at
+ # all, so none can ever satisfy the both-adapters gate.
+ Producibility.SCHEMA_1_ONLY: 8,
}
"""The exemption inventory, by reason. See `test_exempt_inventory_is_complete`."""
diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py
index 993a31b4..b1182761 100644
--- a/tests/test_field_path_introspection.py
+++ b/tests/test_field_path_introspection.py
@@ -15,6 +15,7 @@
SpanEvseSnapshot,
SpanMidSnapshot,
SpanPanelSnapshot,
+ SpanPcsSnapshot,
)
from custom_components.span_panel.field_paths import (
@@ -27,7 +28,7 @@
# Attributes of the panel snapshot that are themselves sub-snapshots. Their
# fields are addressed as "battery.x", not "panel.battery.x".
-_SUB_SNAPSHOTS = {"battery", "pv", "evse", "mid"}
+_SUB_SNAPSHOTS = {"battery", "pv", "evse", "mid", "pcs"}
class _Recorder:
@@ -122,6 +123,7 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]:
BINARY_SENSORS,
EVSE_BINARY_SENSORS,
GRID_ISLANDABLE_SENSOR,
+ PCS_ACTIVE_SENSOR,
)
for description in (
@@ -130,6 +132,7 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]:
*EVSE_BINARY_SENSORS,
GRID_ISLANDABLE_SENSOR,
BESS_CONNECTED_SENSOR,
+ PCS_ACTIVE_SENSOR,
):
if not isinstance(description, _DeclaringDescription):
raise TypeError(
@@ -154,6 +157,7 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]:
SpanBatterySnapshot: "battery",
SpanEvseSnapshot: "evse",
SpanMidSnapshot: "mid",
+ SpanPcsSnapshot: "pcs",
}
diff --git a/tests/test_pcs.py b/tests/test_pcs.py
new file mode 100644
index 00000000..06608033
--- /dev/null
+++ b/tests/test_pcs.py
@@ -0,0 +1,816 @@
+"""The Power Control System, surfaced as three entities and fifteen attributes.
+
+Thirteen of the attributes are on the effective-limit sensor and two are on
+every circuit's power sensor.
+
+`energy.ebus.capability.pcs` 0.3 is the largest capability the enclosure
+publishes — sixteen properties on the panel, two on every circuit — and nothing
+read a byte of it. The capability itself says what to surface: it reconciles
+every active import constraint to one enforced current limit, and "what `pcs`
+publishes is the **result**: the effective `import-limit` and the
+`binding-constraint`". So the result is the entities, and the arbitration behind
+it is their attributes.
+
+**The capture is a PCS that is switched off, and every test here is written
+around that.** Every limit is `0.0`, every enablement `UNCONFIGURED`, every
+boolean `false`. Uniform data makes an assertion cheap to satisfy for the wrong
+reason — an entity wired to the neighbouring property reports the identical
+value, and an entity hardcoding a zero agrees with the wire by accident. So no
+state or attribute here is asserted against the captured values. Presence is
+checked against the capture; every *reading* is proved by republishing a value
+that differs from the captured one and from every sibling's, and the sensor's
+whole attribute dictionary is compared at once, so a wrong wiring shows up as a
+value under the wrong name rather than as a value that happens to match.
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+from decimal import Decimal
+import json
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+from span_panel_api import SpanPanelSnapshot
+
+from custom_components.span_panel import SpanPanelRuntimeData
+from custom_components.span_panel.binary_sensor import (
+ PCS_ACTIVE_SENSOR,
+ SpanPanelBinarySensor,
+ SpanPanelBinarySensorEntityDescription,
+)
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ DerivedReason,
+ Producibility,
+)
+from custom_components.span_panel.helpers import detect_capabilities, has_pcs
+from custom_components.span_panel.sensor import create_circuit_sensors, create_pcs_sensors
+from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor
+from custom_components.span_panel.sensor_definitions import (
+ PCS_BINDING_CONSTRAINT_OPTIONS,
+ PCS_CONSTRAINT_FAMILIES,
+ PCS_SENSORS,
+)
+from custom_components.span_panel.sensor_panel import SpanPcsSensor
+from homeassistant.components.binary_sensor import BinarySensorDeviceClass
+from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
+from homeassistant.const import CONF_HOST, UnitOfElectricCurrent
+from homeassistant.helpers.entity import EntityCategory
+from homeassistant.helpers.typing import StateType
+
+from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree
+from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory
+
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+NODE = "pcs"
+
+IMPORT_LIMIT_KEY = "pcs_import_limit"
+BINDING_CONSTRAINT_KEY = "pcs_binding_constraint"
+
+# A circuit the capture reports as PCS-managed, and one it reports as not.
+_SOURCES = ("feed", "operator", "off-grid", "requested")
+"""The catalog's four amps-native constraint classes, in the order it names them."""
+
+MANAGED_CIRCUIT = "0ab966b95f92a6a51ec548485aa85f54"
+UNMANAGED_CIRCUIT = "573066aaddd7b75114c4563ce3af18c4"
+
+# One republished value per panel property, every one different from the
+# captured value *and* from every sibling's. That distinctness is the whole
+# apparatus of this module: against a capture of zeros, `false` and
+# `UNCONFIGURED`, an attribute asserted to equal what was published is satisfied
+# by eleven wrong wirings as easily as by the right one.
+#
+# Two of the sixteen cannot be made unique, and saying why matters more than
+# hiding it. The enablement enum has four members and one of them is what the
+# capture already publishes, so four families can differ from the capture and
+# from each other in at most three ways. Every `-active` flag is worse: all four
+# are `false` in the capture, so all four must be republished `true` to be
+# testing anything at all.
+#
+# Telling those apart is what `test_republishing_any_property_moves_only_what_reads_it`
+# is for. It flips one property at a time on top of this state and requires
+# exactly one observable to move, which catches a cross-wiring the dictionary
+# comparison below cannot see.
+_CONFIGURED: dict[str, str] = {
+ "enabled": "true",
+ "active": "true",
+ "import-limit": "37.5",
+ "binding-constraint": "DOE",
+ "feed-import-limit": "100.0",
+ "feed-import-limit-enablement": "ENABLED",
+ "feed-import-limit-active": "true",
+ "operator-import-limit": "62.5",
+ "operator-import-limit-enablement": "DISABLED",
+ "operator-import-limit-active": "true",
+ "off-grid-import-limit": "25.0",
+ "off-grid-import-limit-enablement": "UNSPECIFIED",
+ "off-grid-import-limit-active": "true",
+ "requested-import-limit": "80.0",
+ "requested-import-limit-enablement": "ENABLED",
+ "requested-import-limit-active": "true",
+}
+
+# What `pcs_import_limit` must publish beside its state for `_CONFIGURED`. The
+# effective limit and the binding constraint are absent: those are the two
+# entities, and repeating an entity's state as its own attribute would be a
+# second copy to keep in step.
+_CONFIGURED_ATTRIBUTES: dict[str, float | str | bool] = {
+ "pcs_enabled": True,
+ "feed_import_limit": 100.0,
+ "feed_import_limit_enablement": "ENABLED",
+ "feed_import_limit_active": True,
+ "operator_import_limit": 62.5,
+ "operator_import_limit_enablement": "DISABLED",
+ "operator_import_limit_active": True,
+ "off_grid_import_limit": 25.0,
+ "off_grid_import_limit_enablement": "UNSPECIFIED",
+ "off_grid_import_limit_active": True,
+ "requested_import_limit": 80.0,
+ "requested_import_limit_enablement": "ENABLED",
+ "requested_import_limit_active": True,
+}
+
+
+@pytest.fixture(autouse=True)
+def _mock_entity_registry() -> Any:
+ """Patch entity registry lookups used during sensor construction."""
+ registry = MagicMock()
+ registry.async_get_entity_id.return_value = None
+ with patch(
+ "custom_components.span_panel.sensor_base.er.async_get",
+ return_value=registry,
+ ):
+ yield registry
+
+
+def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock:
+ """A coordinator-like mock carrying one snapshot."""
+ coordinator = MagicMock()
+ coordinator.data = snapshot
+ coordinator.hass = MagicMock()
+ coordinator.panel_offline = False
+ coordinator.unresolved_paths = frozenset()
+ coordinator.config_entry = MockConfigEntry(
+ domain="span_panel",
+ data={CONF_HOST: "192.168.1.50"},
+ options={},
+ title="SPAN Panel",
+ unique_id=snapshot.serial_number,
+ )
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+ return coordinator
+
+
+def _published(property_id: str, device_id: str = SCHEMA_ONE_PANEL) -> str:
+ """What the capture publishes on one PCS topic, or fail saying it does not."""
+ value = schema_one_tree()[device_id].get(f"{NODE}/{property_id}")
+ assert value is not None, f"{device_id} publishes no {NODE}/{property_id} in the capture"
+ return value
+
+
+def _republishing(device_id: str = SCHEMA_ONE_PANEL, **properties: str) -> SpanPanelSnapshot:
+ """A snapshot from the capture with some PCS topics rewritten."""
+ tree = schema_one_tree()
+ for property_id, value in properties.items():
+ tree[device_id][f"{NODE}/{property_id.replace('_', '-')}"] = value
+ return schema_one_snapshot(tree)
+
+
+def _configured() -> SpanPanelSnapshot:
+ """A snapshot of a PCS that is switched on, every property distinct.
+
+ The capture cannot be used for a reading test — see the module docstring —
+ so this is the state the reading tests run against.
+ """
+ tree = schema_one_tree()
+ for property_id, value in _CONFIGURED.items():
+ tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] = value
+ return schema_one_snapshot(tree)
+
+
+def _without(*property_ids: str, device_id: str = SCHEMA_ONE_PANEL) -> SpanPanelSnapshot:
+ """A snapshot from a panel that stopped publishing (and declaring) properties."""
+ tree = schema_one_tree()
+ description = json.loads(tree[device_id]["$description"])
+ for property_id in property_ids:
+ del tree[device_id][f"{NODE}/{property_id}"]
+ del description["nodes"][NODE]["properties"][property_id]
+ tree[device_id]["$description"] = json.dumps(description)
+ return schema_one_snapshot(tree)
+
+
+def _without_node(device_id: str = SCHEMA_ONE_PANEL) -> SpanPanelSnapshot:
+ """A snapshot from a capture with no `pcs` node on one device at all."""
+ tree = schema_one_tree()
+ for topic in [t for t in tree[device_id] if t.startswith(f"{NODE}/")]:
+ del tree[device_id][topic]
+ description = json.loads(tree[device_id]["$description"])
+ del description["nodes"][NODE]
+ tree[device_id]["$description"] = json.dumps(description)
+ return schema_one_snapshot(tree)
+
+
+def _sensors(snapshot: SpanPanelSnapshot) -> dict[str, SpanPcsSensor]:
+ """Whatever the platform creates for this snapshot, keyed by description key."""
+ created = create_pcs_sensors(_coordinator(snapshot), snapshot)
+ return {sensor.entity_description.key: sensor for sensor in created}
+
+
+def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime | Decimal:
+ """The state one PCS sensor reports for a snapshot.
+
+ Typed as `SensorEntity.native_value` is rather than narrowed to what these
+ two sensors happen to report: narrowing here would be the test asserting its
+ own expectation twice, once in the annotation and once in the body.
+ """
+ sensor = _sensors(snapshot)[key]
+ sensor._update_native_value()
+ return sensor.native_value
+
+
+def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]:
+ return _sensors(snapshot)[key].extra_state_attributes or {}
+
+
+def _binary(
+ snapshot: SpanPanelSnapshot,
+) -> SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]:
+ """The `pcs_active` binary sensor, updated from the snapshot."""
+ sensor: SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] = SpanPanelBinarySensor(
+ _coordinator(snapshot), PCS_ACTIVE_SENSOR
+ )
+ sensor.async_write_ha_state = MagicMock()
+ sensor._handle_coordinator_update()
+ return sensor
+
+
+def _circuit_power_sensor(snapshot: SpanPanelSnapshot, circuit_id: str) -> SpanCircuitPowerSensor:
+ """The power sensor for one circuit, which is where PCS participation lands."""
+ coordinator = _coordinator(snapshot)
+ created = create_circuit_sensors(coordinator, snapshot, coordinator.config_entry)
+ for sensor in created:
+ if isinstance(sensor, SpanCircuitPowerSensor) and sensor.circuit_id == circuit_id:
+ return sensor
+ raise AssertionError(f"no power sensor created for circuit {circuit_id}")
+
+
+def _circuit_attributes(snapshot: SpanPanelSnapshot, circuit_id: str) -> dict[str, Any]:
+ return _circuit_power_sensor(snapshot, circuit_id).extra_state_attributes or {}
+
+
+# ---------------------------------------------------------------------------
+# The premise: what the capture carries, and why it cannot prove a reading
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_publishes_the_whole_system_surface() -> None:
+ """Guard the premise for every test below. Sixteen properties, each declared
+ and published; a capture that dropped one would make its absence test
+ vacuous rather than failing."""
+ declared = json.loads(schema_one_tree()[SCHEMA_ONE_PANEL]["$description"])["nodes"][NODE]
+
+ assert set(declared["properties"]) == set(_CONFIGURED)
+ for property_id in _CONFIGURED:
+ assert _published(property_id)
+
+
+def test_the_capture_is_a_pcs_that_is_switched_off() -> None:
+ """The fact this module is written around, asserted rather than assumed.
+
+ Every limit zero, every enablement `UNCONFIGURED`, every boolean false. That
+ is why no reading below is proved by comparing an entity against the
+ capture: fifteen wrong wirings report the same value as the right one. Were
+ the capture ever retaken with a configured PCS, this fails first and says
+ so, rather than the reading tests silently becoming redundant.
+ """
+ assert _published("enabled") == "false"
+ assert _published("active") == "false"
+ assert _published("binding-constraint") == "NONE"
+ assert {float(_published(f"{source}-import-limit")) for source in _SOURCES} == {0.0}
+ assert {_published(f"{source}-import-limit-enablement") for source in _SOURCES} == {
+ "UNCONFIGURED"
+ }
+ assert float(_published("import-limit")) == 0.0
+
+
+def test_the_republished_values_are_all_different_from_each_other() -> None:
+ """Guard the apparatus itself.
+
+ Every reading test below rests on `_CONFIGURED` giving each property a value
+ no sibling shares — that is what turns "the attribute equals what was
+ published" into a statement about which property it came from. Two entries
+ accidentally made equal would silently weaken every one of them.
+ """
+ numeric = [value for value in _CONFIGURED.values() if value.replace(".", "").isdigit()]
+ assert len(set(numeric)) == len(numeric)
+
+ # As distinct as the enum allows: four families, four members, one of which
+ # is the value the capture already publishes.
+ declared = json.loads(schema_one_tree()[SCHEMA_ONE_PANEL]["$description"])["nodes"][NODE][
+ "properties"
+ ]["feed-import-limit-enablement"]["format"]
+ enablements = {_CONFIGURED[f"{source}-import-limit-enablement"] for source in _SOURCES}
+ assert len(enablements) == len(declared.split(",")) - 1
+
+ for property_id, value in _CONFIGURED.items():
+ assert value != _published(property_id), f"{property_id} is not being changed"
+
+
+# ---------------------------------------------------------------------------
+# The effective limit, and the arbitration it carries as attributes
+# ---------------------------------------------------------------------------
+
+
+def test_the_import_limit_sensor_reports_the_effective_limit() -> None:
+ """The headline reading, on a PCS that is switched on."""
+ assert _state(_configured(), IMPORT_LIMIT_KEY) == float(_CONFIGURED["import-limit"])
+
+
+def test_the_effective_limit_is_not_any_of_its_inputs() -> None:
+ """The capability calls `import-limit` the arbitration *result*. A sensor
+ wired to the FSR would be plausible and wrong, so the four inputs are
+ republished to one shared value the result does not share."""
+ snapshot = _republishing(
+ **{
+ "import_limit": "12.5",
+ "feed_import_limit": "99.0",
+ "operator_import_limit": "99.0",
+ "off_grid_import_limit": "99.0",
+ "requested_import_limit": "99.0",
+ }
+ )
+
+ assert _state(snapshot, IMPORT_LIMIT_KEY) == 12.5
+
+
+def test_republishing_the_effective_limit_moves_the_sensor() -> None:
+ """The mutation proof, twice over: two values, neither the captured zero."""
+ assert _state(_republishing(import_limit="15.0"), IMPORT_LIMIT_KEY) == 15.0
+ assert _state(_republishing(import_limit="16.5"), IMPORT_LIMIT_KEY) == 16.5
+
+
+def test_zero_amps_is_a_reading_and_not_an_absence() -> None:
+ """The captured state, and a real one: the PCS is permitting no import at
+ all. An entity that treated it as missing would go blank exactly when the
+ panel is most restrictive."""
+ assert _state(schema_one_snapshot(), IMPORT_LIMIT_KEY) == 0.0
+
+
+def test_the_arbitration_rides_as_attributes_on_the_limit() -> None:
+ """The whole attribute dictionary at once, against a PCS where every value
+ is distinct.
+
+ Compared as a dictionary rather than key by key, deliberately. Twelve of
+ these are the same shape and the capture makes them identical, so the
+ failure worth catching is a value landing under the wrong name — which a
+ per-key assertion on a matching value cannot see and this does.
+ """
+ assert _attributes(_configured(), IMPORT_LIMIT_KEY) == _CONFIGURED_ATTRIBUTES
+
+
+def test_the_attributes_are_the_inputs_and_not_the_result() -> None:
+ """`import-limit` and `binding-constraint` are the two entities. Repeating
+ either as an attribute of the other would be a second copy to keep in
+ step."""
+ attributes = _attributes(_configured(), IMPORT_LIMIT_KEY)
+
+ assert "import_limit" not in attributes
+ assert "binding_constraint" not in attributes
+
+
+@pytest.mark.parametrize("family", PCS_CONSTRAINT_FAMILIES, ids=lambda f: f.attribute)
+def test_each_constraint_family_publishes_its_own_three_attributes(family: Any) -> None:
+ """Every family contributes a limit, an enablement and an active flag, and
+ the names extend the limit's. Asserted per family so a copied line that left
+ one family reading another's fields fails naming the family."""
+ snapshot = _configured()
+ pcs = snapshot.pcs
+ assert pcs is not None
+ attributes = _attributes(snapshot, IMPORT_LIMIT_KEY)
+
+ assert attributes[family.attribute] == family.limit_fn(pcs)
+ assert attributes[f"{family.attribute}_enablement"] == family.enablement_fn(pcs)
+ assert attributes[f"{family.attribute}_active"] == family.active_fn(pcs)
+
+
+@pytest.mark.parametrize("property_id", sorted(_CONFIGURED))
+def test_republishing_any_property_moves_only_what_reads_it(property_id: str) -> None:
+ """The strongest statement this module makes, and the one the uniform
+ capture demands.
+
+ One property is republished on top of the fully-configured PCS, and the
+ entity states plus the whole attribute dictionary are compared against the
+ unmodified configured baseline. Exactly one thing may move. An attribute
+ wired to a neighbouring property moves when it should not, which no
+ assertion against the captured zeros could ever detect — every sibling
+ already holds the value a wrong wiring would report.
+ """
+ tree = schema_one_tree()
+ for name, value in _CONFIGURED.items():
+ tree[SCHEMA_ONE_PANEL][f"{NODE}/{name}"] = value
+
+ def observe(snapshot: SpanPanelSnapshot) -> dict[str, object]:
+ observed: dict[str, object] = {
+ f"state:{IMPORT_LIMIT_KEY}": _state(snapshot, IMPORT_LIMIT_KEY),
+ f"state:{BINDING_CONSTRAINT_KEY}": _state(snapshot, BINDING_CONSTRAINT_KEY),
+ "state:pcs_active": _binary(snapshot).is_on,
+ }
+ observed.update(_attributes(snapshot, IMPORT_LIMIT_KEY))
+ return observed
+
+ baseline = observe(schema_one_snapshot(tree))
+
+ # A second value for this property, again unlike anything else published.
+ republished = "false" if _CONFIGURED[property_id] == "true" else "true"
+ if property_id == "binding-constraint":
+ republished = "OPERATOR"
+ elif property_id.endswith("-enablement"):
+ republished = "DISABLED" if _CONFIGURED[property_id] != "DISABLED" else "ENABLED"
+ elif property_id.endswith("import-limit"):
+ republished = "7.25"
+
+ tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"] = republished
+ after = observe(schema_one_snapshot(tree))
+
+ moved = {name for name, value in after.items() if baseline[name] != value}
+ assert len(moved) == 1, f"republishing {property_id} moved {sorted(moved)}"
+
+
+@pytest.mark.parametrize("property_id", sorted(_CONFIGURED))
+def test_an_unpublished_property_is_omitted_rather_than_shown_empty(property_id: str) -> None:
+ """Three of the four constraint classes are `MAY`, so an absent family is
+ conformant firmware. An attribute present and holding `None` would read as a
+ reading the panel failed to produce, which is a different claim."""
+ attributes = _attributes(_without(property_id), IMPORT_LIMIT_KEY)
+
+ assert None not in attributes.values()
+
+
+def test_a_panel_publishing_no_constraint_families_still_shows_its_limit() -> None:
+ """The minimum conformant PCS: the two results, and none of the working."""
+ snapshot = _without(
+ *[
+ f"{source}-import-limit{suffix}"
+ for source in _SOURCES
+ for suffix in ("", "-enablement", "-active")
+ ],
+ "enabled",
+ )
+
+ assert _state(snapshot, IMPORT_LIMIT_KEY) == 0.0
+ assert _attributes(snapshot, IMPORT_LIMIT_KEY) == {}
+
+
+# ---------------------------------------------------------------------------
+# The binding constraint
+# ---------------------------------------------------------------------------
+
+
+def test_the_binding_constraint_is_the_published_enum_lowercased() -> None:
+ """Lowercase because Home Assistant looks the state up as a translation key,
+ which its own contract restricts to `[a-z0-9-_]+`."""
+ assert (
+ _state(_configured(), BINDING_CONSTRAINT_KEY) == _CONFIGURED["binding-constraint"].lower()
+ )
+
+
+@pytest.mark.parametrize("republished", ["FSR", "DOE", "VOLTAGE", "OFF_GRID", "OPERATOR"])
+def test_republishing_the_binding_constraint_moves_the_sensor(republished: str) -> None:
+ """Members of the enum the panel's own `$description` declares, none of them
+ the captured `NONE`, so a sensor pinned to the capture cannot report any."""
+ assert _state(_republishing(binding_constraint=republished), BINDING_CONSTRAINT_KEY) == (
+ republished.lower()
+ )
+
+
+def test_the_declared_options_are_the_enum_the_panel_declares() -> None:
+ """The sensor's "Possible states" against the wire's `format`, so a firmware
+ that widens the enum is caught here rather than by a runtime append.
+
+ This is the assertion against the catalog: the fixture's `$format` is a
+ verbatim copy of the eight members `capabilities/pcs.md` lists, so checking
+ the options against it checks them against the catalog without a third
+ hand-written copy in this file.
+ """
+ declared = json.loads(schema_one_tree()[SCHEMA_ONE_PANEL]["$description"])["nodes"][NODE][
+ "properties"
+ ]["binding-constraint"]["format"]
+
+ assert set(PCS_BINDING_CONSTRAINT_OPTIONS) == {value.lower() for value in declared.split(",")}
+
+
+def test_none_is_a_binding_constraint_and_not_an_absence() -> None:
+ """The captured value. `NONE` means nothing is constraining import, which is
+ a state the catalog defines; reporting it as unknown would lose that."""
+ assert _state(schema_one_snapshot(), BINDING_CONSTRAINT_KEY) == "none"
+
+
+def test_a_binding_constraint_that_stops_arriving_goes_unknown() -> None:
+ """Absence after setup is a different event from absence at setup: an entity
+ a user already has cannot be deleted, so it degrades instead."""
+ sensor = _sensors(schema_one_snapshot())[BINDING_CONSTRAINT_KEY]
+
+ sensor.coordinator.data = _without("binding-constraint")
+ sensor._update_native_value()
+
+ assert sensor.native_value == "unknown"
+
+
+# ---------------------------------------------------------------------------
+# The activity binary sensor
+# ---------------------------------------------------------------------------
+
+
+def test_the_activity_sensor_follows_the_published_flag() -> None:
+ """Both directions, because the capture only shows one of them."""
+ assert _binary(_republishing(active="true")).is_on is True
+ assert _binary(_republishing(active="false")).is_on is False
+
+
+def test_activity_is_not_enablement() -> None:
+ """A configured PCS spends most of its life enabled and inactive, which is
+ exactly the state a sensor reading the wrong flag would misreport. Both are
+ `false` in the capture, so crossing them there is invisible."""
+ snapshot = _republishing(enabled="true", active="false")
+
+ assert _binary(snapshot).is_on is False
+ assert _attributes(snapshot, IMPORT_LIMIT_KEY)["pcs_enabled"] is True
+
+
+def test_the_activity_sensor_goes_unknown_when_the_flag_stops_arriving() -> None:
+ """`None` reaches Home Assistant as unknown, not as unavailable: the panel
+ is reachable and the entity is fine, the fact simply is not being stated."""
+ assert _binary(_without("active")).is_on is None
+
+
+def test_the_activity_sensor_is_a_running_diagnostic() -> None:
+ """A panel throttling the user's supply is a state worth seeing, but it
+ belongs beside the other panel-state sensors rather than the power
+ readings."""
+ assert PCS_ACTIVE_SENSOR.device_class is BinarySensorDeviceClass.RUNNING
+ assert PCS_ACTIVE_SENSOR.entity_category is EntityCategory.DIAGNOSTIC
+ assert PCS_ACTIVE_SENSOR.entity_registry_enabled_default is True
+
+
+# ---------------------------------------------------------------------------
+# Absence: no node, no PCS, a flat panel
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_creates_all_three_entities() -> None:
+ created = _sensors(schema_one_snapshot())
+
+ assert set(created) == {IMPORT_LIMIT_KEY, BINDING_CONSTRAINT_KEY}
+ assert has_pcs(schema_one_snapshot()) is True
+
+
+def test_a_panel_with_no_pcs_node_gets_no_entities() -> None:
+ """The presence gate, from the tree end."""
+ snapshot = _without_node()
+
+ assert has_pcs(snapshot) is False
+ assert _sensors(snapshot) == {}
+
+
+def test_a_flat_panel_gets_no_entities() -> None:
+ """The same absence by the other route: no flat panel declares the
+ capability, so the factory's default snapshot carries no PCS at all."""
+ snapshot = SpanPanelSnapshotFactory.create()
+
+ assert has_pcs(snapshot) is False
+ assert _sensors(snapshot) == {}
+
+
+def test_a_switched_off_pcs_still_gets_its_entities() -> None:
+ """The reason the gate is the node and not a value, stated as a test.
+
+ The capture publishes `0.0` on every limit and `false` on every flag. A
+ creation rule that read those as absence would delete the entities of every
+ panel whose PCS is merely unconfigured — which is the state most panels are
+ in, and the state a user most wants reported.
+ """
+ snapshot = schema_one_snapshot()
+
+ assert snapshot.pcs is not None
+ assert snapshot.pcs.import_limit_a == 0.0
+ assert set(_sensors(snapshot)) == {IMPORT_LIMIT_KEY, BINDING_CONSTRAINT_KEY}
+ assert _binary(snapshot).is_on is False
+
+
+def test_a_declared_node_that_publishes_nothing_still_gets_its_entities() -> None:
+ """Mid-discovery, and the same rule: the panel has announced the capability
+ and not yet retained its topics, so the entities exist and read unknown."""
+ tree = schema_one_tree()
+ for property_id in _CONFIGURED:
+ del tree[SCHEMA_ONE_PANEL][f"{NODE}/{property_id}"]
+ snapshot = schema_one_snapshot(tree)
+
+ assert set(_sensors(snapshot)) == {IMPORT_LIMIT_KEY, BINDING_CONSTRAINT_KEY}
+ assert _state(snapshot, IMPORT_LIMIT_KEY) is None
+ assert _attributes(snapshot, IMPORT_LIMIT_KEY) == {}
+
+
+def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale() -> None:
+ """The last value persisting would be worse than unknown: a panel reporting
+ a 40 A limit forever is indistinguishable from one still enforcing it."""
+ sensor = _sensors(_configured())[IMPORT_LIMIT_KEY]
+
+ sensor.coordinator.data = _without("import-limit")
+ sensor._update_native_value()
+
+ assert sensor.native_value is None
+
+
+def test_the_pcs_appearing_is_a_capability_change() -> None:
+ """Which is how a panel that gains the node mid-life gets the entities: the
+ coordinator reloads on a new capability."""
+ assert "pcs" not in detect_capabilities(SpanPanelSnapshotFactory.create())
+ assert "pcs" in detect_capabilities(schema_one_snapshot())
+ assert "pcs" not in detect_capabilities(_without_node())
+
+
+# ---------------------------------------------------------------------------
+# Circuit participation
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_publishes_participation_on_two_disagreeing_circuits() -> None:
+ """Guard the premise for the circuit tests: an attribute wired to a constant
+ would satisfy either circuit alone."""
+ assert _published("managed", MANAGED_CIRCUIT) == "true"
+ assert _published("managed", UNMANAGED_CIRCUIT) == "false"
+ assert _published("priority", MANAGED_CIRCUIT) != _published("priority", UNMANAGED_CIRCUIT)
+
+
+def test_a_circuit_sensor_carries_its_pcs_participation() -> None:
+ """Read against the capture rather than against literals, on both circuits."""
+ snapshot = schema_one_snapshot()
+
+ managed = _circuit_attributes(snapshot, MANAGED_CIRCUIT)
+ unmanaged = _circuit_attributes(snapshot, UNMANAGED_CIRCUIT)
+
+ assert managed["pcs_managed"] is True
+ assert unmanaged["pcs_managed"] is False
+ assert managed["pcs_priority"] == int(_published("priority", MANAGED_CIRCUIT))
+ assert unmanaged["pcs_priority"] == int(_published("priority", UNMANAGED_CIRCUIT))
+
+
+def test_republishing_participation_moves_the_circuit_attributes() -> None:
+ """The attribute-mutation proof. The republished priority is outside the
+ range any circuit uses in the capture, so an attribute wired to another
+ circuit — or to the load-shed priority beside it — cannot report it."""
+ snapshot = _republishing(device_id=MANAGED_CIRCUIT, managed="false", priority="42")
+
+ attributes = _circuit_attributes(snapshot, MANAGED_CIRCUIT)
+
+ assert attributes["pcs_managed"] is False
+ assert attributes["pcs_priority"] == 42
+ # The other circuit is untouched, so a shared read would show here.
+ assert _circuit_attributes(snapshot, UNMANAGED_CIRCUIT)["pcs_priority"] == int(
+ _published("priority", UNMANAGED_CIRCUIT)
+ )
+
+
+def test_pcs_priority_is_not_the_shed_priority_beside_it() -> None:
+ """Two policies on one relay, and two attributes on one sensor. The catalog
+ keeps them apart because they answer different questions, and they do not
+ even share a value space."""
+ attributes = _circuit_attributes(schema_one_snapshot(), MANAGED_CIRCUIT)
+
+ assert isinstance(attributes["pcs_priority"], int)
+ assert isinstance(attributes["shed_priority"], str)
+
+
+@pytest.mark.parametrize("property_id", ["managed", "priority"])
+def test_a_circuit_that_does_not_publish_participation_omits_the_attribute(
+ property_id: str,
+) -> None:
+ """Both properties are `MAY`. `False` and `0` would each be a claim the
+ panel never made, and an attribute holding `None` reads as a failed
+ reading."""
+ snapshot = _without(property_id, device_id=MANAGED_CIRCUIT)
+
+ assert f"pcs_{property_id}" not in _circuit_attributes(snapshot, MANAGED_CIRCUIT)
+
+
+def test_a_circuit_outside_any_pcs_shows_neither_attribute() -> None:
+ snapshot = _without_node(MANAGED_CIRCUIT)
+ attributes = _circuit_attributes(snapshot, MANAGED_CIRCUIT)
+
+ assert "pcs_managed" not in attributes
+ assert "pcs_priority" not in attributes
+ # The circuit's own readings are unaffected.
+ assert "shed_priority" in attributes
+
+
+def test_a_flat_circuit_shows_neither_attribute() -> None:
+ """No flat circuit declares a `pcs` node, so the attributes simply do not
+ appear rather than appearing empty."""
+ circuit = SpanCircuitSnapshotFactory.create(circuit_id="1", name="Kitchen")
+ snapshot = SpanPanelSnapshotFactory.create(circuits={circuit.circuit_id: circuit})
+
+ assert circuit.pcs_managed is None
+ attributes = _circuit_attributes(snapshot, circuit.circuit_id)
+
+ assert "pcs_managed" not in attributes
+ assert "pcs_priority" not in attributes
+
+
+# ---------------------------------------------------------------------------
+# Shape of the entities, and the conformance annotations
+# ---------------------------------------------------------------------------
+
+
+def test_the_import_limit_is_an_ampere_measurement_enabled_by_default() -> None:
+ """A PCS throttling import is a fact about the user's electricity supply,
+ not about the integration's health, so it is not filed as a diagnostic."""
+ description = next(d for d in PCS_SENSORS if d.key == IMPORT_LIMIT_KEY)
+
+ assert description.device_class is SensorDeviceClass.CURRENT
+ assert description.state_class is SensorStateClass.MEASUREMENT
+ assert description.native_unit_of_measurement == UnitOfElectricCurrent.AMPERE
+ assert description.entity_registry_enabled_default is True
+ assert description.entity_category is not EntityCategory.DIAGNOSTIC
+
+
+def test_the_binding_constraint_is_an_enum_diagnostic_enabled_by_default() -> None:
+ """It explains a number already on screen: short, rarely changing, and
+ useless filed where nobody finds it."""
+ description = next(d for d in PCS_SENSORS if d.key == BINDING_CONSTRAINT_KEY)
+
+ assert description.device_class is SensorDeviceClass.ENUM
+ assert description.entity_category is EntityCategory.DIAGNOSTIC
+ assert description.entity_registry_enabled_default is True
+
+
+def test_the_declared_unit_matches_what_the_panel_declares() -> None:
+ """Home Assistant's unit against the tree's, for the one PCS path schema_1
+ carries metadata for. A disagreement here is what the unit-mismatch Repair
+ reports at runtime."""
+ from .adapter_fixtures import schema_one_metadata
+
+ description = next(d for d in PCS_SENSORS if d.key == IMPORT_LIMIT_KEY)
+
+ assert schema_one_metadata()["pcs.import_limit_a"].unit == (
+ description.native_unit_of_measurement
+ )
+
+
+def test_the_entities_live_on_the_main_panel_device() -> None:
+ """The PCS is the enclosure's own capability, not a sub-device."""
+ created = list(_sensors(schema_one_snapshot()).values())
+ panel_device = _binary(schema_one_snapshot()).device_info
+
+ assert panel_device is not None
+ for sensor in created:
+ assert sensor.device_info == panel_device
+
+
+def test_every_pcs_entity_gets_a_distinct_unique_id() -> None:
+ """They live on one device and differ only by description key, so a key
+ reused from another panel sensor would silently collide."""
+ created = _sensors(schema_one_snapshot())
+ unique_ids = {sensor.unique_id for sensor in created.values()} | {
+ _binary(schema_one_snapshot()).unique_id
+ }
+
+ assert len(unique_ids) == len(created) + 1
+
+
+def test_the_three_result_paths_are_exempt_as_schema_1_only() -> None:
+ """Pinned here as well as in the conformance suite because the reason is
+ specific to this capability: no flat panel declares `pcs` at all, so the
+ producible gate cannot be satisfied and the descriptions must stay derived.
+ schema_1 maps all three, which is what makes the annotation SCHEMA_1_ONLY
+ rather than NEITHER."""
+ for path in ("pcs.import_limit_a", "pcs.binding_constraint", "pcs.active"):
+ assert RESIDUAL_EXEMPT_PATHS[path] is Producibility.SCHEMA_1_ONLY
+
+
+def test_every_attribute_read_is_enumerated_as_neither() -> None:
+ """The fifteen fields nothing renders as a reading are still reads, and an
+ unenumerated read is invisible to the Repair machinery — which is the exact
+ hole `panel.wifi_ssid` sat in. Derived from the families rather than listed
+ again, so a fifth constraint class cannot be added without one."""
+ expected = {"pcs.enabled", "circuit.pcs_managed", "circuit.pcs_priority"}
+ for family in PCS_CONSTRAINT_FAMILIES:
+ field = f"pcs.{family.attribute}"
+ expected |= {f"{field}_a", f"{field}_enablement", f"{field}_active"}
+
+ for path in expected:
+ assert RESIDUAL_EXEMPT_PATHS[path] is Producibility.NEITHER
+
+
+@pytest.mark.parametrize("description", [*PCS_SENSORS, PCS_ACTIVE_SENSOR], ids=lambda d: str(d.key))
+def test_each_description_names_its_field_as_well_as_its_reason(description: Any) -> None:
+ """`field_path` says what the entity's value is and `derived` says why that
+ path is outside the both-adapters gate. Leaving the first unset excuses the
+ entity from its Repair mention and from going unavailable when the panel
+ stops resolving the property."""
+ assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD
+ assert description.field_path in RESIDUAL_EXEMPT_PATHS
From d42a18e8c50aac03dfd50a9106dd53f2a96a1bee Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 19:08:38 -0700
Subject: [PATCH 051/116] test(pcs): pin that only one PCS sensor carries the
arbitration
The attribute set is carried on the description rather than decided from
`description.key` inside the entity, and nothing asserted the half of that claim
that matters: `pcs_binding_constraint` must publish no attributes at all while
`pcs_import_limit` publishes thirteen. Without it the default reader was the one
line of this feature no test reached.
---
tests/test_pcs.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/tests/test_pcs.py b/tests/test_pcs.py
index 06608033..53755d61 100644
--- a/tests/test_pcs.py
+++ b/tests/test_pcs.py
@@ -504,6 +504,13 @@ def test_the_declared_options_are_the_enum_the_panel_declares() -> None:
assert set(PCS_BINDING_CONSTRAINT_OPTIONS) == {value.lower() for value in declared.split(",")}
+def test_the_binding_constraint_publishes_no_attributes_of_its_own() -> None:
+ """The other half of putting the pairing on the description: one PCS sensor
+ carries thirteen attributes and the other carries none, and which is which is
+ data rather than a comparison against `key` inside the entity."""
+ assert _sensors(_configured())[BINDING_CONSTRAINT_KEY].extra_state_attributes is None
+
+
def test_none_is_a_binding_constraint_and_not_an_absence() -> None:
"""The captured value. `NONE` means nothing is constraining import, which is
a state the catalog defines; reporting it as unknown would lose that."""
From 78989f13367b251b7ede9e23a2dd6006349e4074 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 19:36:45 -0700
Subject: [PATCH 052/116] feat(binary_sensor): surface the panel's link to each
DER it feeds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`bess_connected` has shown the enclosure's view of the link to the battery since
v1.0 landed. The identical fact for a solar inverter or a charger reached
nothing, and the reason is where the panel publishes it: `connection` 0.1 states
the enclosure/DER relationship on the **circuit**, so the battery's record
arrives on the upstream lugs — already read — while the inverter's and each
charger's arrive on the circuits that feed them, and that half of the capability
had no reader. One of three device classes had a link sensor because of where its
record happened to sit.
`pv_panel_link` and `evse_panel_link` close it. Connectivity, diagnostic, on the
device the link describes: the charger's on its own sub-device, the inverter's on
the panel beside `pv_vendor` and `pv_product` until the PV becomes a sub-device
of its own.
**The names are the point, not a detail.** `evse_ev_connected` sits two
definitions above the new charger sensor and reads `evse.status`: a vehicle is
plugged in. `evse_panel_link` reads the feeding circuit's connection record: the
enclosure can reach the charger. Those diverge exactly when it matters — a
charger mid-session behind a lost link reports a plugged-in vehicle and a dead
link at once, because the last session state the panel heard is still the last
session state the panel heard — and a test produces that state rather than
asserting the distinction in prose. `PLUG` against `CONNECTIVITY`, primary
against diagnostic, and neither key is a prefix of the other.
**Creation is gated on the record existing, never on the circuit.** The spec is
unambiguous: `connection.md` makes an unpublished property the "unknown" signal,
`distribution-enclosure.md` makes a mixed-load circuit publishing no `feeds-*`
normal, and the enum is `OK,LOST,DEGRADED` with no UNKNOWN member — so absence
is the only way a panel can say it does not know, and reporting it as a broken
link would invent the one reading a user would act on. Two of the capture's five
circuits publish nothing, and they create nothing. The gate is per DER, not per
panel: two chargers can be fed by two circuits of which only one publishes a
record, and only one sensor may exist then. `has_der_link_health` joins
`detect_capabilities` so a panel that starts publishing reaches the reload.
Both descriptions are `SCHEMA_CONDITIONAL_FIELD` *and* name their `field_path`:
flat firmware publishes `connected` on the BESS and on no other device class, so
the both-adapters gate cannot be satisfied, while the entities still need their
Repair mention and their unavailability. `pv.connected` and `evse.connected` join
`RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`, which schema_1's metadata rows earn
them — `battery.connected` sits beside them as `SCHEMA_0_ONLY`, which is the
whole shape of the gap in one place.
One line leaves the unread baseline, 19 to 18:
`circuit/connection/feeds-device-status`. `feeds-device-type` stays, and its
recorded reason is still true — the library resolves a DER's class by
dereferencing `feeds-device-id` against the tree's declared `$type`, so the echo
is read by nothing and would be redundant if it were.
**The capture publishes `OK` on all three records, so no test rests on it.** That
value is satisfied by an implementation returning a constant, one reading the
wrong circuit, and one handing every DER the first record it finds. Every
expectation is read out of the capture, the enum's members come from the
circuit's own `$format`, and each reading is proved by republishing values that
differ per DER and then swapping them — two chargers are in the capture precisely
so cross-wiring is falsifiable. Conflating the charger's link with the EV plug
fails nine tests; removing the record gate fails four; making it a value gate
rather than a record gate fails five; landing both chargers' records on one
charger fails four; dropping one exemption fails three; restoring the baseline
line fails one.
---
CHANGELOG.md | 11 +
README.md | 14 +-
custom_components/span_panel/binary_sensor.py | 68 ++-
custom_components/span_panel/field_paths.py | 14 +
custom_components/span_panel/helpers.py | 33 ++
custom_components/span_panel/icons.json | 6 +
custom_components/span_panel/strings.json | 6 +
.../span_panel/translations/en.json | 6 +
.../span_panel/translations/es.json | 6 +
.../span_panel/translations/fr.json | 6 +
.../span_panel/translations/ja.json | 6 +
.../span_panel/translations/pt.json | 6 +
.../unread_declarations_baseline.json | 1 -
tests/test_der_link_health.py | 519 ++++++++++++++++++
tests/test_field_path_conformance.py | 8 +-
tests/test_field_path_introspection.py | 4 +
16 files changed, 707 insertions(+), 7 deletions(-)
create mode 100644 tests/test_der_link_health.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61076d69..83f58d74 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file.
### Added
+- **Whether your panel can reach your solar inverter and each of your chargers, on panels running the v1.0 data model.** **PV Panel Link** and **EVSE Panel
+ Link** are the same fact **BESS Connected** has always shown for the battery: the panel's own report of the link to a device it feeds. The battery's version
+ worked because the panel publishes it on the main lugs; the inverter's and each charger's are published by the circuit that feeds them, and nothing read that
+ half — so one of your three device classes had a link sensor and the others did not.
+- **EVSE Panel Link is not EV Connected.** EV Connected is the charger reporting that a vehicle is plugged in. EVSE Panel Link is the panel reporting that it
+ can reach the charger at all. A charger part-way through a session behind a link the panel has lost shows a plugged-in vehicle and a dead link at the same
+ time, which is exactly the case where you want to know which of the two you are looking at. The new sensors are diagnostics; EV Connected is unchanged.
+- Each sensor is created only where a circuit publishes the link record for that device, and per charger rather than per panel — two chargers whose circuits
+ report differently get two sensors that say different things. A circuit that feeds ordinary loads publishes no such record, which is normal and is not
+ reported as a fault, and a panel that starts publishing one picks the sensors up on the reload the integration already performs.
+
- **Your battery's own meter and its own link health, on panels running the v1.0 data model.** **Meter Power** is what the BESS itself reports it is charging or
discharging at, as distinct from the panel's **Battery Power**, which is the enclosure's arbitrated figure. Both have been on the wire since firmware r202633
and nothing read either. Meter Power is enabled by default; **Communication State** — the BESS's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` report on its link
diff --git a/README.md b/README.md
index 93951535..6bf2aa28 100644
--- a/README.md
+++ b/README.md
@@ -261,10 +261,15 @@ feature. A display suffix differentiates multiple chargers on the same panel:
#### EVSE Binary Sensors (per charger)
-| Sensor | Device Class | Notes |
-| ------------ | ---------------- | ------------------------------------------------------------------ |
-| Charging | Battery Charging | ON when status is CHARGING |
-| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING |
+| Sensor | Device Class | Notes |
+| --------------- | ---------------- | ------------------------------------------------------------------------------------------------------- |
+| Charging | Battery Charging | ON when status is CHARGING |
+| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING — a vehicle is plugged in |
+| EVSE Panel Link | Connectivity | (v1.0) Whether the panel can reach the charger. A different fact from EV Connected, and it can disagree |
+
+**EVSE Panel Link is not EV Connected.** EV Connected is what the charger says about the cable in front of it; EVSE Panel Link is what the panel says about
+whether it can reach the charger at all. A charger part-way through a session behind a lost link reports a plugged-in vehicle and a dead link at the same time.
+EVSE Panel Link is a diagnostic and appears only where the circuit feeding that charger publishes the link record.
#### EVSE Device Info Attributes
@@ -351,6 +356,7 @@ Applies to Main Meter and Feed Through energy sensors.
| Panel Status | Connectivity | Overall panel online/offline |
| Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported |
| PCS Active | Running | (v1.0) Whether the Power Control System is limiting import right now. Only when the panel runs one |
+| PV Panel Link | Connectivity | (v1.0) Whether the panel can reach the solar inverter. Only when the feeding circuit reports it |
**Removed from binary sensors:**
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 65b72147..0b2e6bda 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -160,6 +160,33 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
value_fn=lambda s: s.battery.connected,
)
+PV_PANEL_LINK_SENSOR = SpanPanelBinarySensorEntityDescription(
+ key="pv_panel_link",
+ field_path="pv.connected",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="pv_panel_link",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda s: s.pv.connected,
+)
+"""Can the enclosure talk to the solar inverter?
+
+`bess_connected`'s counterpart for the other DER the panel feeds through a
+circuit. The BESS got one because the upstream lugs' `connection/fed-by-*`
+record was already read; the PV's and the charger's live on the *circuit* that
+feeds them, as `connection` 0.1 specifies, and nothing read that half — so the
+one device class whose link the panel happened to report through the lugs was
+the only one a user could see.
+
+On the panel device, beside `pv_vendor` and `pv_product`, because the PV is not
+yet a sub-device of its own. It moves with them when it becomes one.
+
+`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`: flat firmware publishes
+`connected` on the BESS and on nothing else, so the both-adapters gate cannot be
+satisfied, while the entity still needs its Repair mention and its
+unavailability when the panel stops resolving the property.
+"""
+
PCS_ACTIVE_SENSOR = SpanPanelBinarySensorEntityDescription(
key="pcs_active",
@@ -371,6 +398,32 @@ class SpanEvseBinarySensorEntityDescription(
),
)
+EVSE_PANEL_LINK_SENSOR = SpanEvseBinarySensorEntityDescription(
+ key="evse_panel_link",
+ field_path="evse.connected",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="evse_panel_link",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda e: e.connected,
+)
+"""Can the enclosure talk to this charger?
+
+**Not `evse_ev_connected`, which sits two definitions above it.** That one reads
+the charger's own `status/status` and answers "is a vehicle plugged in" — a
+`PLUG` device class, enabled by default, the fact a user builds a charging
+automation on. This reads the *feeding circuit's* `connection/feeds-device-status`
+and answers "can the panel reach the charger at all" — a `CONNECTIVITY` device
+class, diagnostic. The two disagree exactly when it matters: a charger
+mid-session over a lost link reports a plugged-in vehicle and a dead link at the
+same time, because the last session state the panel heard is still the last
+session state the panel heard.
+
+Deliberately outside `EVSE_BINARY_SENSORS`, which is the unconditional pair.
+This one is created per charger and only where the record exists, following
+`bess_connected` and `pcs_active` rather than its two neighbours.
+"""
+
# Fallback EVSE snapshot used when the EVSE disappears mid-session
_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="")
@@ -481,12 +534,25 @@ async def async_setup_entry(
if has_pcs(snapshot):
entities.append(SpanPanelBinarySensor(coordinator, PCS_ACTIVE_SENSOR))
+ # The enclosure's view of the link to the solar inverter, where a circuit
+ # publishes one. Gated on the record existing and never on what kind of
+ # circuit publishes it — `distribution-enclosure.md` makes a mixed-load
+ # circuit publishing no `feeds-*` the normal case, so absence is the panel
+ # saying it does not know rather than a fault, and the enum it does publish
+ # has no UNKNOWN member for it to say that with. See `PV_PANEL_LINK_SENSOR`.
+ if snapshot.pv.connected is not None:
+ entities.append(SpanPanelBinarySensor(coordinator, PV_PANEL_LINK_SENSOR))
+
# Add EVSE binary sensors for each commissioned charger
if snapshot.evse:
- for evse_id in snapshot.evse:
+ for evse_id, evse in snapshot.evse.items():
entities.extend(
SpanEvseBinarySensor(coordinator, evse_desc, evse_id)
for evse_desc in EVSE_BINARY_SENSORS
)
+ # Per charger, not per panel: two chargers can be fed by two
+ # circuits of which only one publishes the record.
+ if evse.connected is not None:
+ entities.append(SpanEvseBinarySensor(coordinator, EVSE_PANEL_LINK_SENSOR, evse_id))
async_add_entities(entities)
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 211298bf..f20fa5fd 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -266,6 +266,20 @@ class FieldPathDeclarationMixin:
"pcs.import_limit_a": Producibility.SCHEMA_1_ONLY,
"pcs.binding_constraint": Producibility.SCHEMA_1_ONLY,
"pcs.active": Producibility.SCHEMA_1_ONLY,
+ # The enclosure's view of the link to each circuit-fed DER, behind the
+ # `pv_panel_link` and `evse_panel_link` binary sensors. Both descriptions
+ # are `SCHEMA_CONDITIONAL_FIELD` for the usual reason: flat firmware
+ # publishes `connected` on the BESS and on no other device class, so the
+ # both-adapters gate cannot be satisfied. schema_1 maps both — from one
+ # property, the feeding circuit's `connection/feeds-device-status` —
+ # which is what makes these SCHEMA_1_ONLY rather than NEITHER.
+ #
+ # `battery.connected` is the same fact for the third DER and sits below
+ # as SCHEMA_0_ONLY, which is the whole shape of this gap: the link the
+ # enclosure reported through the lugs was read, and the one it reports
+ # through a circuit was not.
+ "pv.connected": Producibility.SCHEMA_1_ONLY,
+ "evse.connected": Producibility.SCHEMA_1_ONLY,
"circuit.always_on": Producibility.SCHEMA_0_ONLY,
"circuit.is_sheddable": Producibility.SCHEMA_0_ONLY,
"panel.wifi_ssid": Producibility.SCHEMA_0_ONLY,
diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py
index cdc51967..aeb57d53 100644
--- a/custom_components/span_panel/helpers.py
+++ b/custom_components/span_panel/helpers.py
@@ -356,6 +356,37 @@ def has_evse(snapshot: SpanPanelSnapshot) -> bool:
return len(snapshot.evse) > 0
+def has_der_link_health(snapshot: SpanPanelSnapshot) -> bool:
+ """Detect whether the panel reports the link to any circuit-fed DER.
+
+ Presence of the *record*, from presence of the field it fills. The library
+ models `connected` as `None` for a DER no circuit claims, and the enum a
+ circuit does publish is `OK,LOST,DEGRADED` with no UNKNOWN member — so an
+ absent property is the only way the panel can say it does not know, and
+ `None` is the only reading that can mean it.
+
+ A value gate would be wrong here in a way it is not for the PCS: the
+ question is not what the link is doing but whether the panel says anything
+ about it, and `distribution-enclosure.md` makes silence the normal state for
+ a circuit that feeds an ordinary load rather than a DER.
+
+ Coarse on purpose. This decides whether a *reload* is worth requesting, not
+ which entities exist — the per-DER gate in `binary_sensor.async_setup_entry`
+ does that, because two chargers can be fed by two circuits of which only one
+ publishes the record.
+
+ Always false on flat firmware, which publishes this only for the BESS, and
+ reaches `battery.connected` rather than either field here.
+
+ DUAL-SCHEMA: gated on what the snapshot carries rather than on a schema
+ version, so a panel that starts publishing the record reaches
+ `detect_capabilities`, the coordinator reloads, and the sensors appear.
+ """
+ return snapshot.pv.connected is not None or any(
+ evse.connected is not None for evse in snapshot.evse.values()
+ )
+
+
def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]:
"""Derive the set of optional capabilities present in the snapshot.
@@ -382,4 +413,6 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]:
caps.add("bess_telemetry")
if has_pcs(snapshot):
caps.add("pcs")
+ if has_der_link_health(snapshot):
+ caps.add("der_link_health")
return frozenset(caps)
diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json
index a6a5f1c4..0b790506 100644
--- a/custom_components/span_panel/icons.json
+++ b/custom_components/span_panel/icons.json
@@ -35,6 +35,12 @@
},
"pcs_active": {
"default": "mdi:speedometer"
+ },
+ "pv_panel_link": {
+ "default": "mdi:solar-power-variant"
+ },
+ "evse_panel_link": {
+ "default": "mdi:lan-connect"
}
},
"button": {
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 311876e9..76667302 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -150,6 +150,12 @@
},
"pcs_active": {
"name": "PCS Active"
+ },
+ "pv_panel_link": {
+ "name": "PV Panel Link"
+ },
+ "evse_panel_link": {
+ "name": "EVSE Panel Link"
}
},
"button": {
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 311876e9..76667302 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -150,6 +150,12 @@
},
"pcs_active": {
"name": "PCS Active"
+ },
+ "pv_panel_link": {
+ "name": "PV Panel Link"
+ },
+ "evse_panel_link": {
+ "name": "EVSE Panel Link"
}
},
"button": {
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 4180ef0b..aef041fb 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -150,6 +150,12 @@
},
"pcs_active": {
"name": "PCS Activo"
+ },
+ "pv_panel_link": {
+ "name": "Enlace del Panel FV"
+ },
+ "evse_panel_link": {
+ "name": "Enlace del Panel EVSE"
}
},
"button": {
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 65e7c50b..80a4ab40 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -150,6 +150,12 @@
},
"pcs_active": {
"name": "PCS Actif"
+ },
+ "pv_panel_link": {
+ "name": "Liaison du panneau PV"
+ },
+ "evse_panel_link": {
+ "name": "Liaison du panneau EVSE"
}
},
"button": {
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index babc29ba..d0dc23f9 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -150,6 +150,12 @@
},
"pcs_active": {
"name": "PCS 動作中"
+ },
+ "pv_panel_link": {
+ "name": "PVパネルリンク"
+ },
+ "evse_panel_link": {
+ "name": "EVSEパネルリンク"
}
},
"button": {
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index aedf0e58..4b1d6112 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -150,6 +150,12 @@
},
"pcs_active": {
"name": "PCS Ativo"
+ },
+ "pv_panel_link": {
+ "name": "Ligação do Painel FV"
+ },
+ "evse_panel_link": {
+ "name": "Ligação do Painel EVSE"
}
},
"button": {
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
index 69668cda..ea1e6914 100644
--- a/tests/fixtures/unread_declarations_baseline.json
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -1,7 +1,6 @@
{
"circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field",
"circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against",
- "circuit/connection/feeds-device-status": "the enclosure's link-health view of a circuit-fed DER; only the lugs fed-by-* route is read today, so PV and EVSE get no connected sensor (Task 3)",
"circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type",
"distribution-enclosure/info/hardware-version": "no panel.hardware_version snapshot field, and the panel DeviceInfo declares no hw_version (Task 5)",
"distribution-enclosure/info/vendor-name": "DeviceInfo.manufacturer is hardcoded to \"Span\"; the declared value is never consulted, even as a fallback (Task 5)",
diff --git a/tests/test_der_link_health.py b/tests/test_der_link_health.py
new file mode 100644
index 00000000..8d6f49f8
--- /dev/null
+++ b/tests/test_der_link_health.py
@@ -0,0 +1,519 @@
+"""The enclosure's link to each circuit-fed DER, as two diagnostic binary sensors.
+
+`bess_connected` has shown the panel's view of the link to the battery since v1.0
+landed, because the upstream lugs' `connection/fed-by-device-status` was already
+read. The identical fact for a PV or a charger is published by the **circuit that
+feeds it** — `connection/feeds-device-status`, the other half of the same
+capability — and reached nothing, so one DER class had a link sensor and the
+other two did not.
+
+`pv_panel_link` and `evse_panel_link` close that.
+
+**The naming is load-bearing.** `evse_ev_connected` already exists on the same
+device and reads the charger's own `status/status`: *a vehicle is plugged in*.
+The new one reads the feeding circuit's connection record: *the enclosure can
+reach the charger*. Those are different questions with different answers, and
+`test_the_charger_link_is_not_the_ev_plug` produces the state where they
+disagree rather than asserting the distinction in prose.
+
+**Every expectation is read out of the capture**, including the enum's members,
+which come from the circuit's own `$description` `format` rather than from a list
+written here. And the capture publishes `OK` on all three records, so no reading
+is proved by the baseline alone: each is proved by republishing values that
+differ per DER, and by swapping them, because two chargers fed by two circuits is
+what makes cross-wiring falsifiable.
+
+**Absence is a reading too.** Two of the capture's five circuits publish no
+connection record at all — the enclosure data model calls that normal for a
+mixed-load circuit, and the enum has no UNKNOWN member, so an unpublished
+property is the only way a panel can say it does not know. The entity is gated on
+the record existing, never on the circuit's type, and never treats silence as a
+fault.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+import json
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from span_panel_api import SpanPanelSnapshot
+
+from custom_components.span_panel import SpanPanelRuntimeData
+from custom_components.span_panel.binary_sensor import (
+ EVSE_BINARY_SENSORS,
+ EVSE_PANEL_LINK_SENSOR,
+ PV_PANEL_LINK_SENSOR,
+ SpanEvseBinarySensor,
+ SpanPanelBinarySensor,
+ SpanPanelBinarySensorEntityDescription,
+ async_setup_entry,
+)
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ DerivedReason,
+ Producibility,
+)
+from custom_components.span_panel.helpers import detect_capabilities, has_der_link_health
+from homeassistant.components.binary_sensor import BinarySensorDeviceClass
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity import EntityCategory
+
+from .adapter_fixtures import schema_one_snapshot, schema_one_tree
+from .factories import SpanPanelSnapshotFactory
+
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+CONNECTION_NODE = "connection"
+FEEDS_ID_TOPIC = f"{CONNECTION_NODE}/feeds-device-id"
+FEEDS_STATUS_TOPIC = f"{CONNECTION_NODE}/feeds-device-status"
+
+# The DER the capture commissions: one inverter and two chargers, each fed by its
+# own circuit. Two chargers is what makes the wiring falsifiable, so a capture
+# that lost one has to fail rather than quietly halve the evidence.
+PV = "pv"
+EVSE = "evse"
+EVSE_2 = "evse-2"
+
+STATUS_OK = "OK"
+
+PanelBinarySensor = SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]
+LinkEntity = PanelBinarySensor | SpanEvseBinarySensor
+"""Everything `binary_sensor.async_setup_entry` can add, and nothing wider.
+
+Narrowed at the boundary rather than carried as `object`, so every lookup below
+reads a real attribute instead of one the type checker had to be told to ignore.
+"""
+
+
+# ---------------------------------------------------------------------------
+# Reading the capture
+# ---------------------------------------------------------------------------
+
+
+def _feeding_circuit(tree: dict[str, dict[str, str]], device_id: str) -> str:
+ """The circuit the capture says feeds `device_id`, or fail saying none does."""
+ feeders = [
+ circuit for circuit, topics in tree.items() if topics.get(FEEDS_ID_TOPIC) == device_id
+ ]
+ assert len(feeders) == 1, f"{len(feeders)} circuits feed {device_id} in the capture, expected 1"
+ return feeders[0]
+
+
+def _status_options() -> list[str]:
+ """The enum as the feeding circuit's own `$description` declares it.
+
+ Read off the wire because the legal values are the panel's claim, not this
+ module's — and because the absence of an `UNKNOWN` member is the premise
+ that makes `None` the only way to report an unknown link.
+ """
+ tree = schema_one_tree()
+ description = json.loads(tree[_feeding_circuit(tree, PV)]["$description"])
+ declared = description["nodes"][CONNECTION_NODE]["properties"]["feeds-device-status"]
+ assert declared["datatype"] == "enum"
+ return str(declared["format"]).split(",")
+
+
+def _not_ok() -> list[str]:
+ return [option for option in _status_options() if option != STATUS_OK]
+
+
+def _republishing(**statuses: str) -> SpanPanelSnapshot:
+ """A snapshot from the capture with each named DER's link status rewritten.
+
+ Keyed by DER rather than by circuit so a test says what it means — "the
+ charger's link is down" — while the indirection through `_feeding_circuit`
+ keeps it reading the capture's own topology rather than a circuit id copied
+ into the test.
+ """
+ tree = schema_one_tree()
+ for der, status in statuses.items():
+ tree[_feeding_circuit(tree, der.replace("_", "-"))][FEEDS_STATUS_TOPIC] = status
+ return schema_one_snapshot(tree)
+
+
+def _without_status(der: str) -> SpanPanelSnapshot:
+ """A snapshot whose feeding circuit stopped publishing the status half."""
+ tree = schema_one_tree()
+ del tree[_feeding_circuit(tree, der)][FEEDS_STATUS_TOPIC]
+ return schema_one_snapshot(tree)
+
+
+def _without_record(der: str) -> SpanPanelSnapshot:
+ """A snapshot whose feeding circuit publishes no connection record at all.
+
+ What a circuit feeding an ordinary load looks like, applied to a circuit
+ that used to feed a DER.
+ """
+ tree = schema_one_tree()
+ circuit = _feeding_circuit(tree, der)
+ del tree[circuit][FEEDS_ID_TOPIC]
+ del tree[circuit][FEEDS_STATUS_TOPIC]
+ return schema_one_snapshot(tree)
+
+
+# ---------------------------------------------------------------------------
+# Building the entities
+# ---------------------------------------------------------------------------
+
+
+def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock:
+ coordinator = MagicMock()
+ coordinator.data = snapshot
+ coordinator.panel_offline = False
+ coordinator.last_update_success = True
+ coordinator.unresolved_paths = frozenset()
+ coordinator.config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={},
+ options={},
+ title="SPAN Panel",
+ unique_id=snapshot.serial_number,
+ )
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+ coordinator.async_request_refresh = AsyncMock()
+ return coordinator
+
+
+async def _created(hass: HomeAssistant, snapshot: SpanPanelSnapshot) -> list[LinkEntity]:
+ """Everything `binary_sensor.async_setup_entry` creates for one snapshot.
+
+ Driven through the platform rather than by constructing descriptions,
+ because the gate under test is a creation gate: an entity that must not
+ exist cannot be observed by asking an entity for its state.
+ """
+ coordinator = _coordinator(snapshot)
+ config_entry = coordinator.config_entry
+ async_add_entities = MagicMock()
+
+ await async_setup_entry(hass, config_entry, async_add_entities)
+
+ added: Sequence[LinkEntity] = async_add_entities.call_args.args[0]
+ for entity in added:
+ assert isinstance(entity, SpanPanelBinarySensor | SpanEvseBinarySensor)
+ return list(added)
+
+
+def _keys(created: Sequence[LinkEntity]) -> list[str]:
+ return [entity.entity_description.key for entity in created]
+
+
+def _state(entity: LinkEntity) -> bool | None:
+ entity.async_write_ha_state = MagicMock()
+ entity._handle_coordinator_update()
+ return entity.is_on
+
+
+def _pv_link(created: Sequence[LinkEntity]) -> PanelBinarySensor:
+ matches = [
+ entity
+ for entity in created
+ if isinstance(entity, SpanPanelBinarySensor)
+ and entity.entity_description.key == PV_PANEL_LINK_SENSOR.key
+ ]
+ assert len(matches) == 1, f"{len(matches)} PV link sensors created, expected 1"
+ return matches[0]
+
+
+def _evse_links_by_feed(
+ created: Sequence[LinkEntity], snapshot: SpanPanelSnapshot
+) -> dict[str, SpanEvseBinarySensor]:
+ """The charger link sensors, keyed by the circuit that feeds each charger.
+
+ Keyed by feed rather than by the snapshot's EVSE key, which is a harmonised
+ serial: a test that named one would still pass if every record landed on the
+ same charger, which is the failure this module exists to rule out.
+ """
+ links: dict[str, SpanEvseBinarySensor] = {}
+ for entity in created:
+ if (
+ isinstance(entity, SpanEvseBinarySensor)
+ and entity.entity_description.key == EVSE_PANEL_LINK_SENSOR.key
+ ):
+ links[snapshot.evse[entity._evse_id].feed_circuit_id] = entity
+ return links
+
+
+# ---------------------------------------------------------------------------
+# The premise
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_publishes_a_link_record_for_three_ders_and_two_bare_circuits() -> None:
+ """Guard every expectation below, and the negative case with them.
+
+ Three DER-feeding circuits publish the record; the remaining circuits
+ declare the node and publish neither half of it. That second group is the
+ absence case, and it is in the capture rather than manufactured here.
+ """
+ tree = schema_one_tree()
+ circuits = {
+ device_id
+ for device_id, topics in tree.items()
+ if json.loads(topics["$description"])["type"].endswith(".circuit")
+ }
+ feeding = {_feeding_circuit(tree, der) for der in (PV, EVSE, EVSE_2)}
+
+ assert feeding <= circuits
+ for circuit in feeding:
+ assert tree[circuit][FEEDS_STATUS_TOPIC] in _status_options()
+
+ bare = circuits - feeding
+ assert len(bare) == 2, f"expected two circuits feeding no DER, found {len(bare)}"
+ for circuit in bare:
+ declared = json.loads(tree[circuit]["$description"])["nodes"]
+ assert CONNECTION_NODE in declared, (
+ f"{circuit} does not declare the node, so its silence proves nothing about "
+ "a circuit that declares the record and publishes none of it"
+ )
+ assert not [topic for topic in tree[circuit] if topic.startswith(f"{CONNECTION_NODE}/")]
+
+
+def test_the_status_enum_has_no_unknown_member() -> None:
+ """Why absence has to mean unknown: the enum cannot say it."""
+ options = _status_options()
+
+ assert STATUS_OK in options
+ assert "UNKNOWN" not in options
+ assert _not_ok(), "the enum offers no bad status, so nothing below can observe a broken link"
+
+
+# ---------------------------------------------------------------------------
+# The readings
+# ---------------------------------------------------------------------------
+
+
+async def test_each_der_reports_the_link_its_own_circuit_publishes(hass: HomeAssistant) -> None:
+ """Baseline, with every expectation computed from the capture."""
+ tree = schema_one_tree()
+ snapshot = schema_one_snapshot(tree)
+ created = await _created(hass, snapshot)
+
+ assert _state(_pv_link(created)) is (
+ tree[_feeding_circuit(tree, PV)][FEEDS_STATUS_TOPIC] == STATUS_OK
+ )
+
+ links = _evse_links_by_feed(created, snapshot)
+ assert len(links) == 2, "the capture commissions two chargers; both should carry a link sensor"
+ for der in (EVSE, EVSE_2):
+ circuit = _feeding_circuit(tree, der)
+ assert _state(links[circuit]) is (tree[circuit][FEEDS_STATUS_TOPIC] == STATUS_OK)
+
+
+@pytest.mark.parametrize("status", _not_ok())
+async def test_a_bad_status_flips_the_sensor(hass: HomeAssistant, status: str) -> None:
+ """Both non-OK members, so a check written as `!= "LOST"` fails on DEGRADED."""
+ snapshot = _republishing(pv=status, evse=status)
+ created = await _created(hass, snapshot)
+
+ assert _state(_pv_link(created)) is False
+ links = _evse_links_by_feed(created, snapshot)
+ assert _state(links[_feeding_circuit(schema_one_tree(), EVSE)]) is False
+
+
+async def test_two_chargers_do_not_share_one_link(hass: HomeAssistant) -> None:
+ """The cross-wiring case, and the reason the capture carries two chargers.
+
+ Both read `OK` as captured, so the baseline test above is satisfied by an
+ implementation that hands every charger the first record it finds. Here the
+ two are republished differing and then swapped: getting one arrangement
+ right by luck is possible, both is not.
+ """
+ down, degraded = _not_ok()[0], _not_ok()[-1]
+ tree = schema_one_tree()
+ first_circuit, second_circuit = _feeding_circuit(tree, EVSE), _feeding_circuit(tree, EVSE_2)
+
+ for first, second in ((down, STATUS_OK), (STATUS_OK, down), (degraded, STATUS_OK)):
+ snapshot = _republishing(evse=first, **{"evse_2": second})
+ links = _evse_links_by_feed(await _created(hass, snapshot), snapshot)
+
+ assert _state(links[first_circuit]) is (first == STATUS_OK)
+ assert _state(links[second_circuit]) is (second == STATUS_OK)
+
+
+async def test_the_inverters_link_is_not_a_chargers(hass: HomeAssistant) -> None:
+ """The third DER, held apart from the two chargers the same way."""
+ snapshot = _republishing(pv=_not_ok()[0])
+ created = await _created(hass, snapshot)
+
+ assert _state(_pv_link(created)) is False
+ for link in _evse_links_by_feed(created, snapshot).values():
+ assert _state(link) is True
+
+
+# ---------------------------------------------------------------------------
+# Absence is not a fault
+# ---------------------------------------------------------------------------
+
+
+async def test_a_der_whose_circuit_publishes_no_status_gets_no_entity(
+ hass: HomeAssistant,
+) -> None:
+ """The gate is the record, and a retained topic can simply go away.
+
+ Not "unavailable" and not `False`: the panel has said nothing, the enum has
+ no way to say it, and an entity reporting a broken link on that basis would
+ be inventing the one reading a user would act on.
+ """
+ snapshot = _without_status(PV)
+ created = await _created(hass, snapshot)
+
+ assert PV_PANEL_LINK_SENSOR.key not in _keys(created)
+ assert len(_evse_links_by_feed(created, snapshot)) == 2, (
+ "removing the inverter's status removed a charger's sensor too"
+ )
+
+
+async def test_a_charger_whose_circuit_publishes_no_record_gets_no_entity(
+ hass: HomeAssistant,
+) -> None:
+ """Per charger, not per panel.
+
+ One of two chargers losing its record must remove one of two sensors. A gate
+ that asked "does any DER have a record" would keep both, and the surviving
+ one would report a link nothing publishes.
+ """
+ snapshot = _without_record(EVSE)
+ created = await _created(hass, snapshot)
+
+ links = _evse_links_by_feed(created, snapshot)
+ assert len(links) == 1
+ assert _feeding_circuit(schema_one_tree(), EVSE_2) in links
+ assert PV_PANEL_LINK_SENSOR.key in _keys(created), "the inverter's sensor went with it"
+
+
+async def test_the_circuits_that_feed_no_der_create_nothing(hass: HomeAssistant) -> None:
+ """The capture's two bare circuits, asserted as producing no entity.
+
+ The `feeds-*` triple is absent from a mixed-load circuit by design, so the
+ count of link sensors must equal the count of DER the capture claims — three
+ — and not the count of circuits, five.
+ """
+ snapshot = schema_one_snapshot()
+ created = await _created(hass, snapshot)
+
+ link_keys = [key for key in _keys(created) if key.endswith("_panel_link")]
+ assert len(link_keys) == 3
+
+
+async def test_a_panel_with_no_connection_records_creates_neither_sensor(
+ hass: HomeAssistant,
+) -> None:
+ """A flat panel, and any v1.0 panel whose circuits publish no record.
+
+ The factory snapshot leaves both fields `None`, which is what every flat
+ panel produces: flat publishes `connected` on the BESS and on no other
+ device class.
+ """
+ snapshot = SpanPanelSnapshotFactory.create()
+ created = await _created(hass, snapshot)
+
+ assert [key for key in _keys(created) if key.endswith("_panel_link")] == []
+
+
+# ---------------------------------------------------------------------------
+# The fact this must not be confused with
+# ---------------------------------------------------------------------------
+
+
+async def test_the_charger_link_is_not_the_ev_plug(hass: HomeAssistant) -> None:
+ """`evse_panel_link` and `evse_ev_connected` disagree, and both are right.
+
+ The state that separates them: a charger mid-session behind a link the
+ enclosure has lost. `status/status` still says a vehicle is plugged in —
+ that is the last thing the panel heard — while the feeding circuit reports
+ the link as down. One entity for both facts would have to pick, and would be
+ wrong about one of them every time they diverge.
+
+ The two sensors are also told apart at a glance: `CONNECTIVITY` against
+ `PLUG`, diagnostic against primary, and neither key is a prefix of the
+ other, so no automation can select one meaning to get the other.
+ """
+ tree = schema_one_tree()
+ circuit = _feeding_circuit(tree, EVSE)
+ plugged_in = tree[EVSE]["status/status"]
+ snapshot = _republishing(evse=_not_ok()[0])
+ created = await _created(hass, snapshot)
+
+ link = _evse_links_by_feed(created, snapshot)[circuit]
+ plug = next(
+ entity
+ for entity in created
+ if isinstance(entity, SpanEvseBinarySensor)
+ and entity.entity_description.key == "evse_ev_connected"
+ and snapshot.evse[entity._evse_id].feed_circuit_id == circuit
+ )
+
+ assert snapshot.evse[link._evse_id].status == plugged_in
+ assert _state(link) is False
+ assert _state(plug) is True
+
+ ev_connected = next(desc for desc in EVSE_BINARY_SENSORS if desc.key == "evse_ev_connected")
+ assert EVSE_PANEL_LINK_SENSOR.device_class is BinarySensorDeviceClass.CONNECTIVITY
+ assert ev_connected.device_class is BinarySensorDeviceClass.PLUG
+ assert EVSE_PANEL_LINK_SENSOR.entity_category is EntityCategory.DIAGNOSTIC
+ assert ev_connected.entity_category is None
+ assert EVSE_PANEL_LINK_SENSOR.field_path != ev_connected.field_path
+ assert not EVSE_PANEL_LINK_SENSOR.key.startswith(ev_connected.key)
+ assert not ev_connected.key.startswith(EVSE_PANEL_LINK_SENSOR.key)
+
+
+async def test_the_inverter_link_does_not_displace_the_batterys(hass: HomeAssistant) -> None:
+ """`bess_connected` reads the lugs; these read a circuit. Both survive.
+
+ Breaking every circuit-side record must leave the battery's sensor reporting
+ what the upstream lugs say, or the new route has quietly taken over a field
+ that was already right.
+ """
+ down = _not_ok()[0]
+ snapshot = _republishing(pv=down, evse=down, **{"evse_2": down})
+ created = await _created(hass, snapshot)
+
+ bess = next(
+ entity
+ for entity in created
+ if isinstance(entity, SpanPanelBinarySensor)
+ and entity.entity_description.key == "bess_connected"
+ )
+ assert _state(bess) is True
+
+
+# ---------------------------------------------------------------------------
+# Declarations and gating
+# ---------------------------------------------------------------------------
+
+
+def test_both_descriptions_name_their_field_and_say_why_it_is_exempt() -> None:
+ """`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`, per the established rule.
+
+ Flat firmware publishes `connected` on the BESS alone, so neither path can
+ satisfy the both-adapters gate — while both entities still need their Repair
+ mention and their unavailability when the panel stops resolving the property.
+ """
+ for description in (PV_PANEL_LINK_SENSOR, EVSE_PANEL_LINK_SENSOR):
+ assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD
+ assert description.field_path is not None
+ assert RESIDUAL_EXEMPT_PATHS[description.field_path] is Producibility.SCHEMA_1_ONLY
+
+ assert PV_PANEL_LINK_SENSOR.field_path == "pv.connected"
+ assert EVSE_PANEL_LINK_SENSOR.field_path == "evse.connected"
+
+
+def test_the_capability_gate_follows_the_record_and_reaches_the_reload() -> None:
+ """A panel that starts publishing the record must be able to gain the entities.
+
+ Entities are created at setup, so a capability that appears later reaches a
+ user only through `detect_capabilities` and the reload it triggers.
+ """
+ assert has_der_link_health(schema_one_snapshot()) is True
+ assert has_der_link_health(SpanPanelSnapshotFactory.create()) is False
+ assert has_der_link_health(_without_record(PV)) is True, (
+ "the chargers still publish records; the gate must not be all-or-nothing"
+ )
+
+ assert "der_link_health" in detect_capabilities(schema_one_snapshot())
+ assert "der_link_health" not in detect_capabilities(SpanPanelSnapshotFactory.create())
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 754d9d61..35c6810b 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -289,7 +289,13 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# and `pcs.active`, behind the two PCS sensors and the `pcs_active` binary
# sensor. schema_1 maps all three; no flat panel declares the capability at
# all, so none can ever satisfy the both-adapters gate.
- Producibility.SCHEMA_1_ONLY: 8,
+ # +2 for `pv.connected` and `evse.connected`, the enclosure's view of the
+ # link to each circuit-fed DER, behind `pv_panel_link` and
+ # `evse_panel_link`. schema_1 maps both from one property — the feeding
+ # circuit's `connection/feeds-device-status` — while flat publishes
+ # `connected` on the BESS and on no other device class, so neither can
+ # satisfy the both-adapters gate.
+ Producibility.SCHEMA_1_ONLY: 10,
}
"""The exemption inventory, by reason. See `test_exempt_inventory_is_complete`."""
diff --git a/tests/test_field_path_introspection.py b/tests/test_field_path_introspection.py
index b1182761..53b56160 100644
--- a/tests/test_field_path_introspection.py
+++ b/tests/test_field_path_introspection.py
@@ -122,8 +122,10 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]:
BESS_CONNECTED_SENSOR,
BINARY_SENSORS,
EVSE_BINARY_SENSORS,
+ EVSE_PANEL_LINK_SENSOR,
GRID_ISLANDABLE_SENSOR,
PCS_ACTIVE_SENSOR,
+ PV_PANEL_LINK_SENSOR,
)
for description in (
@@ -133,6 +135,8 @@ def _declaring_descriptions() -> Iterator[_DeclaringDescription]:
GRID_ISLANDABLE_SENSOR,
BESS_CONNECTED_SENSOR,
PCS_ACTIVE_SENSOR,
+ PV_PANEL_LINK_SENSOR,
+ EVSE_PANEL_LINK_SENSOR,
):
if not isinstance(description, _DeclaringDescription):
raise TypeError(
From e1552a9fc91ac5c8ddb5519085f929fbb3fa486d Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 19:41:57 -0700
Subject: [PATCH 053/116] fix(coordinator): derive capabilities once, so an
upgrade actually reloads
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`_detect_capabilities` was a second, hand-rolled copy of the derivation the
platforms gate on. It knew four capabilities — bess, pv, power_flows, evse —
and `helpers.detect_capabilities` knows nine. `mid` had been missing since it
was added; this catch-up took the count to five, since `shed_forecast`,
`bess_telemetry`, `pcs` and `der_link_health` all joined the helper and none
joined the copy.
The copy is what `_check_capability_change` reads to decide whether to request
a reload. So a panel that gained any of the five on a firmware upgrade
published the properties, matched a creation gate that was never re-run, and
asked for no reload — the entities appeared only when the user next restarted
Home Assistant. Two of the commits in this branch claim the opposite in their
own messages, because the helper is where the capability was registered and
the copy is not visible from there.
Delegating removes the class of bug rather than the instance. `has_evse` gains
the circuit-typed-evse signal the copy carried and the helper lacked, so
converging loses nothing: creation still iterates `snapshot.evse`, so a
commissioned-but-not-yet-published charger adds no entities and only brings the
reload forward to the moment the panel first admits it exists.
The test asserts the two sets are equal on a capture that exercises the five
missing capabilities, rather than asserting the two names are the same object —
the latter would pass again the moment someone re-inlined the logic.
---
custom_components/span_panel/coordinator.py | 28 +++++++++------------
custom_components/span_panel/helpers.py | 13 ++++++++--
tests/test_coordinator.py | 28 +++++++++++++++++++++
3 files changed, 51 insertions(+), 18 deletions(-)
diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py
index 8542b49a..cc7824dc 100644
--- a/custom_components/span_panel/coordinator.py
+++ b/custom_components/span_panel/coordinator.py
@@ -27,6 +27,7 @@
from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError
from .const import DOMAIN
+from .helpers import detect_capabilities
from .id_builder import build_circuit_unique_id
from .schema_repairs import async_sync_schema_issues
from .schema_validation import SchemaFindings, evaluate_field_metadata
@@ -483,22 +484,17 @@ def schema_findings(self) -> SchemaFindings | None:
@staticmethod
def _detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]:
- """Derive optional hardware capabilities present in the snapshot."""
- caps: set[str] = set()
- if snapshot.battery.soe_percentage is not None:
- caps.add("bess")
- if snapshot.power_flow_pv is not None or any(
- c.device_type == "pv" for c in snapshot.circuits.values()
- ):
- caps.add("pv")
- if snapshot.power_flow_site is not None:
- caps.add("power_flows")
- if (
- any(c.device_type == "evse" for c in snapshot.circuits.values())
- or len(snapshot.evse) > 0
- ):
- caps.add("evse")
- return frozenset(caps)
+ """Derive optional hardware capabilities present in the snapshot.
+
+ Delegates to `helpers.detect_capabilities` rather than deriving its
+ own set. This was a second, hand-rolled copy that never learned about
+ `mid`, `shed_forecast`, `bess_telemetry`, `pcs` or `der_link_health` --
+ so a panel that gained any of them on a firmware upgrade published the
+ properties, grew no entities, and requested no reload. The platforms
+ gate creation on the helper; the reload trigger has to read the same
+ set or the two silently disagree about what the panel can do.
+ """
+ return detect_capabilities(snapshot)
def _check_capability_change(self, snapshot: SpanPanelSnapshot) -> None:
"""Check if hardware capabilities changed and request reload if expanded."""
diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py
index aeb57d53..8609a208 100644
--- a/custom_components/span_panel/helpers.py
+++ b/custom_components/span_panel/helpers.py
@@ -352,8 +352,17 @@ def has_pcs(snapshot: SpanPanelSnapshot) -> bool:
def has_evse(snapshot: SpanPanelSnapshot) -> bool:
- """Detect whether an EVSE (EV charger) is commissioned."""
- return len(snapshot.evse) > 0
+ """Detect whether an EVSE (EV charger) is commissioned.
+
+ A circuit typed `evse` counts even before the charger appears in
+ `snapshot.evse`: the panel has commissioned it and the device usually
+ arrives on a later snapshot. Creation still iterates `snapshot.evse`, so
+ the wider signal adds no entities -- it makes the coordinator ask for a
+ reload at the moment the panel first admits the charger exists.
+ """
+ return len(snapshot.evse) > 0 or any(
+ circuit.device_type == "evse" for circuit in snapshot.circuits.values()
+ )
def has_der_link_health(snapshot: SpanPanelSnapshot) -> bool:
diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py
index eda1c34c..b449bb95 100644
--- a/tests/test_coordinator.py
+++ b/tests/test_coordinator.py
@@ -26,6 +26,7 @@
HomeAssistantError,
)
+from .adapter_fixtures import schema_one_snapshot
from .factories import (
SpanBatterySnapshotFactory,
SpanEvseSnapshotFactory,
@@ -692,3 +693,30 @@ async def test_validation_runs_at_most_once_successfully(hass: HomeAssistant) ->
await coordinator._run_post_update_tasks(snapshot)
assert coordinator.unresolved_paths == frozenset({"circuit.instant_power_w"})
+
+
+def test_the_reload_trigger_sees_every_capability_the_platforms_gate_on() -> None:
+ """The coordinator must not derive its own, narrower capability set.
+
+ It used to. `_detect_capabilities` was a hand-rolled copy that knew only
+ bess/pv/power_flows/evse, so a panel that gained `mid`, `shed_forecast`,
+ `bess_telemetry`, `pcs` or `der_link_health` on a firmware upgrade
+ published the properties, created no entities, and asked for no reload --
+ the user saw nothing until they restarted Home Assistant.
+
+ Asserting equality on a snapshot that exercises the capabilities is what
+ catches a re-divergence; asserting the two names are the same object would
+ pass the moment someone re-inlined the logic.
+ """
+ snapshot = schema_one_snapshot()
+
+ from custom_components.span_panel.helpers import detect_capabilities
+
+ assert SpanPanelCoordinator._detect_capabilities(snapshot) == detect_capabilities(
+ snapshot
+ )
+ # The capabilities the old copy was blind to are present in this capture,
+ # so the assertion above is not comparing two empty sets.
+ assert {"shed_forecast", "pcs", "bess_telemetry", "der_link_health"} <= (
+ detect_capabilities(snapshot)
+ )
From 3d18abb940d9e0fb3dac0a5683581d3ba76ab95c Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 20:12:51 -0700
Subject: [PATCH 054/116] feat(sensor): give the panel its own identity, and
give the SSID back
The metadata sweep: no new entity classes, four surfaces, and one of them is a
regression rather than a feature.
`wifi_ssid` is the regression. Flat published it and this integration has shown
it as an attribute since; on v1.0 the library read nothing into the field, so an
upgrading user's attribute quietly emptied. With the library reading it, both
adapters produce `panel.wifi_ssid` and the producible gate refuses to let it
stay exempt -- `test_no_exempt_path_is_producible_by_both` named it and demanded
the promotion, which is the ratchet working as designed. It is declared on
`SpanPanelStatus`, the entity that reads it, so a Repair can name what a dead
field takes down.
The panel's device card now shows the manufacturer, model and hardware revision
the enclosure publishes. The fallbacks are the compatibility guarantee, not a
courtesy: flat declares none of the three, so every existing installation lands
on "Span" / "SPAN Panel" exactly as before, and `hw_version` is absent rather
than blank where no revision is published.
`shed/policy` reaches `dsm_state` as `shed_algorithm` plus the two SoC
thresholds -- the numbers that make the panel's shed behaviour predictable. An
algorithm this integration does not recognise keeps its name and carries the raw
document beside it, because the policy schema is versioned in its own `$id` and
a user can read a document a parser could not.
`evse.part_number` becomes a diagnostic sensor shaped like `bess_part_number`,
promoted out of the unread baseline by the schema_1 metadata row the library
gained.
Baseline: 18 -> 13. `pv/info/firmware-version` stays, reworded to name Task 7 --
the library reads it, but PV has no sub-device for a `sw_version` to land on
until that device exists. MID firmware needed nothing: `mid_device_info` has
carried `sw_version` and `hw_version` since the MID landed.
Fixtures resynced from the library, whose reference tree caught up with the
producer: eight identity properties it had never published are published now,
including the SSID this all turns on.
---
custom_components/span_panel/field_paths.py | 21 +-
.../span_panel/sensor_definitions.py | 17 +-
custom_components/span_panel/sensor_panel.py | 74 ++-
custom_components/span_panel/strings.json | 3 +
.../span_panel/translations/en.json | 3 +
.../span_panel/translations/es.json | 3 +
.../span_panel/translations/fr.json | 3 +
.../span_panel/translations/ja.json | 3 +
.../span_panel/translations/pt.json | 3 +
custom_components/span_panel/util.py | 23 +-
tests/fixtures/README.md | 11 +-
tests/fixtures/schema_one_tree.json | 11 +-
.../fixtures/schema_one_tree_batteryless.json | 8 +-
tests/fixtures/schema_one_tree_no_pv.json | 10 +-
.../unread_declarations_baseline.json | 7 +-
tests/test_evse_entities.py | 2 +-
tests/test_field_path_conformance.py | 20 +-
tests/test_metadata_sweep.py | 472 ++++++++++++++++++
tests/test_promoted_sensors.py | 2 +-
tests/test_sensor_platform.py | 3 +-
20 files changed, 675 insertions(+), 24 deletions(-)
create mode 100644 tests/test_metadata_sweep.py
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index f20fa5fd..6fa11d11 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -166,6 +166,25 @@ class FieldPathDeclarationMixin:
"circuit.relative_position": Producibility.NEITHER,
# The panel reports it outside the typed field surface.
"panel.panel_size": Producibility.NEITHER,
+ # The enclosure's build identity, read by `snapshot_to_device_info` for
+ # the panel's device card. No row on either adapter, and deliberately:
+ # flat declares none of the three, and schema_1 rows exist to carry a
+ # unit and a datatype for a *reading*, which an identity string is not.
+ # Same shape as the `mid.*` device-card reads below.
+ "panel.vendor_name": Producibility.NEITHER,
+ "panel.model": Producibility.NEITHER,
+ "panel.hardware_version": Producibility.NEITHER,
+ # `shed/policy` parsed, read for the attributes on `dsm_state`
+ # (`SpanPanelPanelStatus.extra_state_attributes`). The raw document is
+ # kept beside the parsed members because the policy schema is versioned
+ # in its own `$id`: an algorithm this library does not recognise still
+ # reaches a user as the string the panel published. No row on either
+ # adapter -- flat has no `shed` node, and a JSON document has no unit
+ # surface for a schema_1 row to describe.
+ "panel.shed_policy": Producibility.NEITHER,
+ "panel.shed_policy_algorithm": Producibility.NEITHER,
+ "panel.shed_soc_threshold_shed_percent": Producibility.NEITHER,
+ "panel.shed_soc_threshold_release_percent": Producibility.NEITHER,
# The panel identity key behind every unique_id and the panel DeviceInfo
# (~30 read sites).
"panel.serial_number": Producibility.NEITHER,
@@ -282,7 +301,6 @@ class FieldPathDeclarationMixin:
"evse.connected": Producibility.SCHEMA_1_ONLY,
"circuit.always_on": Producibility.SCHEMA_0_ONLY,
"circuit.is_sheddable": Producibility.SCHEMA_0_ONLY,
- "panel.wifi_ssid": Producibility.SCHEMA_0_ONLY,
# The `grid_forming_entity` sensor's source field. schema_1 answers the
# same question through `resolve_dominant_power_source` over the MID's
# `grid/grid-forming-entity` instead of publishing a row of its own,
@@ -422,6 +440,7 @@ def residual_field_paths() -> frozenset[str]:
from . import ( # noqa: F401 pylint: disable=import-outside-toplevel,unused-import
select,
sensor_circuit,
+ sensor_panel,
switch,
)
from .entity import SpanPanelEntity # pylint: disable=import-outside-toplevel
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index b254b899..e1a406f5 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -830,8 +830,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
Separate from `BESS_METADATA_SENSORS` because these are created conditionally and
those are not. Every metadata sensor exists on any commissioned BESS, filled or
-empty — `bess_part_number` sits at `None` on this fixture and has since it
-shipped. These two come from capability nodes a BESS may simply not have, so
+empty. These two come from capability nodes a BESS may simply not have, so
absence has to mean no entity rather than a permanently unknown one, and mixing
the two rules into one tuple would mean deciding per description which applied.
@@ -1150,6 +1149,7 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK
SpanEvseSensorEntityDescription,
SpanEvseSensorEntityDescription,
SpanEvseSensorEntityDescription,
+ SpanEvseSensorEntityDescription,
] = (
SpanEvseSensorEntityDescription(
key="evse_status",
@@ -1188,6 +1188,19 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK
options=["locked", "unlocked", "unknown"],
value_fn=lambda e: e.lock_state or "unknown",
),
+ # The charger's SKU, shaped like `bess_part_number`: build metadata, so
+ # diagnostic and off by default, and a plain `field_path` because both
+ # adapters map the property (`evse/part-number` on flat, `info/part-number`
+ # on v1.0). It was the one unread declaration whose promotion the producible
+ # gate could demand, and adding the schema_1 metadata row is what demanded it.
+ SpanEvseSensorEntityDescription(
+ key="evse_part_number",
+ field_path="evse.part_number",
+ translation_key="evse_part_number",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ value_fn=lambda e: e.part_number,
+ ),
)
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index 5cce73e8..161fcc47 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
-from typing import Any
+from typing import Any, ClassVar
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import UNDEFINED
@@ -61,9 +61,53 @@ def _grid_forming_device_name(snapshot: SpanPanelSnapshot) -> str | None:
return mid.grid_forming_device_name
+def _shed_policy_attributes(snapshot: SpanPanelSnapshot) -> dict[str, Any]:
+ """Render the shed policy for a person rather than as a JSON blob.
+
+ `shed/policy` is one `json` property carrying an algorithm name and its
+ parameters, and the two SoC thresholds inside it are the numbers that make
+ the panel's shedding behaviour predictable -- what state of charge sheds the
+ SOC_THRESHOLD circuits, and what state of charge brings them back.
+
+ **The raw document survives whenever the parse did not fully succeed.** The
+ property's `$format` schema is versioned in its own `$id`, which is the
+ publisher saying a different algorithm may arrive; when one does, the
+ library reports its name and no thresholds, and showing the document beside
+ the name is strictly more than showing nothing. A user can read it; an
+ exception would have taken the sensor down instead.
+
+ Absent members are omitted rather than rendered as `None`, matching the
+ forecast sensors: an empty attribute reads as a value the panel failed to
+ produce, a missing one as firmware that does not carry it.
+ """
+ attributes: dict[str, Any] = {}
+ if snapshot.shed_policy_algorithm is not None:
+ attributes["shed_algorithm"] = snapshot.shed_policy_algorithm
+ if snapshot.shed_soc_threshold_shed_percent is not None:
+ attributes["soc_threshold_shed"] = snapshot.shed_soc_threshold_shed_percent
+ if snapshot.shed_soc_threshold_release_percent is not None:
+ attributes["soc_threshold_release"] = snapshot.shed_soc_threshold_release_percent
+ thresholds_complete = (
+ snapshot.shed_soc_threshold_shed_percent is not None
+ and snapshot.shed_soc_threshold_release_percent is not None
+ )
+ if not thresholds_complete and snapshot.shed_policy is not None:
+ attributes["shed_policy"] = snapshot.shed_policy
+ return attributes
+
+
class SpanPanelPanelStatus(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]):
"""Span Panel data status sensor entity."""
+ # `_residual_field_paths` stays empty on purpose. The four `panel.shed_*`
+ # policy fields read for `dsm_state`'s attributes are not declarable here:
+ # everything declared on an entity flows into `declared_field_paths()`,
+ # where the producible gate demands both adapters emit it, and no adapter
+ # carries a row for any of them -- flat has no `shed` node at all, and a
+ # JSON policy document has no unit surface for a schema_1 row to describe.
+ # They are enumerated in `RESIDUAL_EXEMPT_PATHS` as `Producibility.NEITHER`
+ # instead, beside the shed-forecast refinements and the PCS attributes.
+
def __init__(
self,
data_coordinator: SpanPanelCoordinator,
@@ -97,6 +141,23 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot:
"""Get the data source for the panel data status sensor."""
return snapshot
+ @property
+ def extra_state_attributes(self) -> dict[str, Any] | None:
+ """The shed policy, on the sensor that says whether shedding is in force.
+
+ `dsm_state` is the entity a user already looks at to know whether the
+ panel is on grid or off it, and the policy is what says what happens
+ next. Attached to that one description rather than to every sensor this
+ class renders, the same way `SpanPanelStatus` attaches the grid-forming
+ device name to `grid_forming_entity` alone.
+ """
+ if self.entity_description.key != "dsm_state":
+ return None
+ snapshot = self.coordinator.data
+ if snapshot is None:
+ return None
+ return _shed_policy_attributes(snapshot) or None
+
class SpanShedForecastSensor(
SpanSensorBase[SpanShedForecastSensorEntityDescription, SpanPanelSnapshot]
@@ -279,6 +340,17 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]):
"""Span Panel hardware status sensor entity."""
+ _residual_field_paths: ClassVar[tuple[str, ...]] = ("panel.wifi_ssid",)
+ """The SSID, read for an attribute rather than by a `value_fn`.
+
+ A plain residual and not an exemption: both adapters map the property
+ (`core/wifi-ssid` on flat, `status/wifi-ssid` on v1.0), so the producible
+ gate covers it, and `test_no_exempt_path_is_producible_by_both` is what
+ demanded the move the moment schema_1 grew its row. Until then the path sat
+ in `RESIDUAL_EXEMPT_PATHS` annotated `SCHEMA_0_ONLY` -- true, and the reason
+ a v1.0 panel silently stopped filling an attribute a flat panel filled.
+ """
+
def __init__(
self,
data_coordinator: SpanPanelCoordinator,
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 76667302..4d79c150 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -255,6 +255,9 @@
"unlocked": "Unlocked"
}
},
+ "evse_part_number": {
+ "name": "Part Number"
+ },
"evse_status": {
"name": "Charger Status",
"state": {
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 76667302..4d79c150 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -255,6 +255,9 @@
"unlocked": "Unlocked"
}
},
+ "evse_part_number": {
+ "name": "Part Number"
+ },
"evse_status": {
"name": "Charger Status",
"state": {
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index aef041fb..8e895a6f 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -255,6 +255,9 @@
"unlocked": "Desbloqueado"
}
},
+ "evse_part_number": {
+ "name": "Número de Pieza"
+ },
"evse_status": {
"name": "Estado del Cargador",
"state": {
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 80a4ab40..ad1d7f3b 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -255,6 +255,9 @@
"unlocked": "Déverrouillé"
}
},
+ "evse_part_number": {
+ "name": "Référence"
+ },
"evse_status": {
"name": "État du Chargeur",
"state": {
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index d0dc23f9..36a228cc 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -255,6 +255,9 @@
"unlocked": "アンロック"
}
},
+ "evse_part_number": {
+ "name": "型番"
+ },
"evse_status": {
"name": "充電器ステータス",
"state": {
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index 4b1d6112..7a508f23 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -255,6 +255,9 @@
"unlocked": "Desbloqueado"
}
},
+ "evse_part_number": {
+ "name": "Número de Peça"
+ },
"evse_status": {
"name": "Estado do Carregador",
"state": {
diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py
index 9462180e..85024db4 100644
--- a/custom_components/span_panel/util.py
+++ b/custom_components/span_panel/util.py
@@ -60,15 +60,32 @@ def snapshot_to_device_info(
device_name: str | None = None,
host: str | None = None,
) -> DeviceInfo:
- """Convert a SpanPanelSnapshot to a Home Assistant device info object."""
+ """Convert a SpanPanelSnapshot to a Home Assistant device info object.
+
+ Manufacturer, model and hardware revision come from the enclosure's own
+ `info` node where it publishes them, and fall back to the strings this
+ integration has always shown where it does not.
+
+ **The fallbacks are the point, not a courtesy.** Flat firmware declares none
+ of the three, so every existing installation lands on them; a panel that
+ omits one must keep the card it has rather than losing a row. `hw_version`
+ has no such string to fall back to and so is simply absent on flat --
+ `DeviceInfo` omits a `None` field, which is the difference between "this
+ panel does not report a revision" and "this panel reports a blank one".
+ """
configuration_url = f"http://{host}" if host else None
return DeviceInfo(
identifiers={(DOMAIN, snapshot.serial_number)},
- manufacturer="Span",
- model="SPAN Panel",
+ manufacturer=snapshot.vendor_name or "Span",
+ # The published designation is the panel's own model code (`MAIN_40`),
+ # which is also what `panel_size` is derived from. Showing it beats
+ # "SPAN Panel" on a card whose whole job is saying which hardware this
+ # is -- and the generic string remains for anything that publishes none.
+ model=snapshot.model or "SPAN Panel",
name=device_name or "Span Panel",
sw_version=snapshot.firmware_version,
+ hw_version=snapshot.hardware_version,
configuration_url=configuration_url,
)
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
index 498828d2..dea51ed7 100644
--- a/tests/fixtures/README.md
+++ b/tests/fixtures/README.md
@@ -12,9 +12,14 @@ Both are byte-identical copies from the `span-panel-api` repository:
| File here | Source in `span-panel-api` |
|---|---|
| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` |
-| `schema_one_tree.json` | `tests/fixtures/parent_child_tree.json` |
+| `schema_one_tree.json` | `packages/schema-1/src/.../reference_payloads/parent_child_tree.json` |
-Refresh by copying them again. If a copy changes shape rather than content, the
+Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to
+its source: the library pins what that capture leaves unvalued against
+panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a
+copy that has drifted puts these tests on a wire no producer sends.
+
+If a copy changes shape rather than content, the
loader in `tests/adapter_fixtures.py` is what needs updating — note that
`schema_one_tree.json` is a **dict keyed by device id**, whose `$description`
value is a **JSON string**, not a parsed object.
@@ -36,7 +41,7 @@ def drop(marker: str, out_name: str) -> None:
for device_id, topics in tree.items()
if marker not in json.loads(topics.get("$description", "{}")).get("type", "")
}
- pathlib.Path(f"tests/fixtures/{out_name}").write_text(json.dumps(kept, indent=2))
+ pathlib.Path(f"tests/fixtures/{out_name}").write_text(json.dumps(kept, indent=2) + "\n")
print(f"{out_name}: {len(tree)} -> {len(kept)} devices")
drop(".bess", "schema_one_tree_batteryless.json")
diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json
index f6c25916..0aee1b83 100644
--- a/tests/fixtures/schema_one_tree.json
+++ b/tests/fixtures/schema_one_tree.json
@@ -62,8 +62,11 @@
"bess": {
"$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
"$state": "ready",
+ "info/firmware-version": "example-bess/v0.1.0",
"info/model": "Example BESS",
"info/nameplate-capacity": "13.5",
+ "info/part-number": "SPN-BESS-001",
+ "info/serial-number": "EXAMPLE-BESS-40T-001",
"info/vendor-name": "Span",
"meter/active-power": "-3500.0",
"soc/soc": "50.410493827160494",
@@ -76,6 +79,10 @@
"grid/grid-forming-entity": "GRID",
"grid/grid-state": "UP",
"grid/islanding-state": "ON_GRID",
+ "info/firmware-version": "example-mid/v0.1.0",
+ "info/hardware-version": "rev1",
+ "info/model": "SPAN MID",
+ "info/serial-number": "EXAMPLE-BESS-40T-001-mid",
"info/vendor-name": "Span"
},
"d3724e0d660ba506aa79c1cafe5d1181": {
@@ -169,7 +176,8 @@
"status/postal-code": "94103",
"status/relay": "CLOSED",
"status/time-zone": "America/Los_Angeles",
- "status/wifi": "true"
+ "status/wifi": "true",
+ "status/wifi-ssid": "example-wifi"
},
"fe8b85c15bc9610c1b8b4ebc6f82488d": {
"$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
@@ -218,6 +226,7 @@
"pv": {
"$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
"$state": "ready",
+ "info/firmware-version": "example-pv/v0.1.0",
"info/model": "IQ8PLUS-72-2-US",
"info/nominal-power": "10000.0",
"info/vendor-name": "Enphase"
diff --git a/tests/fixtures/schema_one_tree_batteryless.json b/tests/fixtures/schema_one_tree_batteryless.json
index 6baeda2a..5e67af74 100644
--- a/tests/fixtures/schema_one_tree_batteryless.json
+++ b/tests/fixtures/schema_one_tree_batteryless.json
@@ -65,6 +65,10 @@
"grid/grid-forming-entity": "GRID",
"grid/grid-state": "UP",
"grid/islanding-state": "ON_GRID",
+ "info/firmware-version": "example-mid/v0.1.0",
+ "info/hardware-version": "rev1",
+ "info/model": "SPAN MID",
+ "info/serial-number": "EXAMPLE-BESS-40T-001-mid",
"info/vendor-name": "Span"
},
"d3724e0d660ba506aa79c1cafe5d1181": {
@@ -158,7 +162,8 @@
"status/postal-code": "94103",
"status/relay": "CLOSED",
"status/time-zone": "America/Los_Angeles",
- "status/wifi": "true"
+ "status/wifi": "true",
+ "status/wifi-ssid": "example-wifi"
},
"fe8b85c15bc9610c1b8b4ebc6f82488d": {
"$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
@@ -207,6 +212,7 @@
"pv": {
"$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
"$state": "ready",
+ "info/firmware-version": "example-pv/v0.1.0",
"info/model": "IQ8PLUS-72-2-US",
"info/nominal-power": "10000.0",
"info/vendor-name": "Enphase"
diff --git a/tests/fixtures/schema_one_tree_no_pv.json b/tests/fixtures/schema_one_tree_no_pv.json
index 9b0483ab..ac92a366 100644
--- a/tests/fixtures/schema_one_tree_no_pv.json
+++ b/tests/fixtures/schema_one_tree_no_pv.json
@@ -62,8 +62,11 @@
"bess": {
"$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
"$state": "ready",
+ "info/firmware-version": "example-bess/v0.1.0",
"info/model": "Example BESS",
"info/nameplate-capacity": "13.5",
+ "info/part-number": "SPN-BESS-001",
+ "info/serial-number": "EXAMPLE-BESS-40T-001",
"info/vendor-name": "Span",
"meter/active-power": "-3500.0",
"soc/soc": "50.410493827160494",
@@ -76,6 +79,10 @@
"grid/grid-forming-entity": "GRID",
"grid/grid-state": "UP",
"grid/islanding-state": "ON_GRID",
+ "info/firmware-version": "example-mid/v0.1.0",
+ "info/hardware-version": "rev1",
+ "info/model": "SPAN MID",
+ "info/serial-number": "EXAMPLE-BESS-40T-001-mid",
"info/vendor-name": "Span"
},
"d3724e0d660ba506aa79c1cafe5d1181": {
@@ -169,7 +176,8 @@
"status/postal-code": "94103",
"status/relay": "CLOSED",
"status/time-zone": "America/Los_Angeles",
- "status/wifi": "true"
+ "status/wifi": "true",
+ "status/wifi-ssid": "example-wifi"
},
"fe8b85c15bc9610c1b8b4ebc6f82488d": {
"$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
index ea1e6914..1ca96e9d 100644
--- a/tests/fixtures/unread_declarations_baseline.json
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -2,19 +2,14 @@
"circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field",
"circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against",
"circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type",
- "distribution-enclosure/info/hardware-version": "no panel.hardware_version snapshot field, and the panel DeviceInfo declares no hw_version (Task 5)",
- "distribution-enclosure/info/vendor-name": "DeviceInfo.manufacturer is hardcoded to \"Span\"; the declared value is never consulted, even as a fallback (Task 5)",
- "distribution-enclosure/shed/policy": "a JSON policy document (soc-priority.v1 plus the two SOC thresholds); nothing parses it, and the thresholds belong as attributes beside the forecast (Task 4)",
"distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history",
"distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity",
- "distribution-enclosure/status/wifi-ssid": "declared and never published; PanelFields.wifi_ssid is hardcoded None on schema_1, so a flat panel's wifi_ssid attribute is lost on upgrade (Task 2)",
"evse/config/max-charge-current": "the installer ceiling; it becomes native_max_value of the charge-current number entity rather than an entity of its own, and is deferred with it (Task 6)",
"evse/config/user-max-charge-current": "the only settable unread property; the eBus catalog spells this capability charge-limit with different property names, so the write topic waits on a real-firmware capture (Task 6)",
- "evse/info/part-number": "reaches evse.part_number and stops there; only battery.part_number has a sensor, so no EVSE device card shows it (Task 5)",
"lugs/connection/count": "the lugs counterpart of circuit/connection/count, with no fed-device story upstream and no producer publishing it",
"lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
"lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it",
"lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
- "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version (Task 5)",
+ "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version - it lands with the PV sub-device (Task 7)",
"pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)"
}
diff --git a/tests/test_evse_entities.py b/tests/test_evse_entities.py
index 87de36b2..06c9e36a 100644
--- a/tests/test_evse_entities.py
+++ b/tests/test_evse_entities.py
@@ -77,7 +77,7 @@ class TestEvseSensorDefinitions:
"""Test EVSE sensor definition structure."""
def test_evse_sensors_count(self):
- assert len(EVSE_SENSORS) == 3
+ assert len(EVSE_SENSORS) == 4
def test_evse_status_sensor_is_enum(self):
status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status")
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 35c6810b..ee480d5f 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -272,12 +272,28 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# read as attributes on the circuit power sensor. schema_1 reads all fifteen
# and maps none of them, deliberately — they explain the effective limit
# rather than being readings of their own.
- Producibility.NEITHER: 34,
+ # +3 for the enclosure's own build identity -- `panel.vendor_name`,
+ # `panel.model`, `panel.hardware_version` -- read by `snapshot_to_device_info`
+ # for the panel's device card. Flat declares none of the three, and a
+ # schema_1 row exists to carry a unit and a datatype for a reading, which an
+ # identity string is not; the `mid.*` device-card reads sit here for the
+ # same reason.
+ # +4 for the shed policy -- the raw `shed/policy` document plus the
+ # algorithm and the two SoC thresholds parsed out of it -- read as
+ # attributes on `dsm_state`. Flat has no `shed` node, and a JSON document
+ # has no unit surface for a schema_1 row to describe.
+ Producibility.NEITHER: 41,
# +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's
# source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and
# enumerated nowhere, so `evaluate_field_metadata` counted it as produced-
# but-unread while an entity was reading it.
- Producibility.SCHEMA_0_ONLY: 11,
+ # -1 for `panel.wifi_ssid`, which left this map entirely: schema_1 grew the
+ # `status/wifi-ssid` row, both adapters produce the path, and
+ # `test_no_exempt_path_is_producible_by_both` demanded it become a
+ # declaration -- `SpanPanelStatus._residual_field_paths`. Its time here as a
+ # true `SCHEMA_0_ONLY` annotation is what sanctioned a flat -> v1.0
+ # regression: the attribute a flat panel filled, a v1.0 panel did not.
+ Producibility.SCHEMA_0_ONLY: 10,
# +2 with the shed forecast: the two live estimates, which schema_1 maps and
# flat firmware does not publish at all.
# +2 for `battery.power_w` and `battery.communication_state`, the BESS's own
diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py
new file mode 100644
index 00000000..2fb4ecf7
--- /dev/null
+++ b/tests/test_metadata_sweep.py
@@ -0,0 +1,472 @@
+"""Identity that reaches a device card, an attribute or a diagnostic sensor.
+
+Four surfaces, no new entity classes, and one of them is a regression rather
+than a feature. Grouped because they share a proof obligation: each is a value
+the panel has published all along that nothing rendered, so a test asserting a
+constant the code also holds would pass whether or not the wire is ever read.
+
+Every expectation below is therefore read out of the vendored capture, and every
+reading is proved by republishing it, unpublishing it, or both. The device-card
+assertions go through the real device registry after a real registration rather
+than through the `DeviceInfo` dict, because the dict is what the code returns and
+the registry is what a user sees.
+
+**`panel.wifi_ssid` is the regression.** Flat published `core/wifi-ssid` and the
+integration has surfaced it as an attribute since; v1.0 declares
+`status/wifi-ssid`, schema_1 mapped nothing to it, and the path's exemption
+annotation said `SCHEMA_0_ONLY` -- which was true, and sanctioned a user losing
+an attribute on upgrade. With the library reading it, both adapters produce the
+path, so it is a declaration now and the producible gate covers it.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+from span_panel_api import SpanPanelSnapshot
+
+from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ declared_field_paths,
+)
+from custom_components.span_panel.sensor import create_evse_sensors, create_panel_sensors
+from custom_components.span_panel.sensor_definitions import EVSE_SENSORS
+from custom_components.span_panel.sensor_panel import SpanPanelPanelStatus, SpanPanelStatus
+from homeassistant.const import CONF_HOST, STATE_UNKNOWN
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+
+from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree
+
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+EVSE_PART_NUMBER_KEY = "evse_part_number"
+DSM_STATE_KEY = "dsm_state"
+SOFTWARE_VERSION_KEY = "software_version"
+
+WIFI_SSID_TOPIC = "status/wifi-ssid"
+POLICY_TOPIC = "shed/policy"
+VENDOR_TOPIC = "info/vendor-name"
+MODEL_TOPIC = "info/model"
+HARDWARE_TOPIC = "info/hardware-version"
+FIRMWARE_TOPIC = "info/firmware-version"
+PART_NUMBER_TOPIC = "info/part-number"
+
+EVSE = "evse"
+
+# What the panel's device card showed before any of this was readable, and what
+# a panel publishing nothing must go on showing.
+FALLBACK_MANUFACTURER = "Span"
+FALLBACK_MODEL = "SPAN Panel"
+
+
+@pytest.fixture(autouse=True)
+def _mock_entity_registry() -> Any:
+ """Patch the entity-registry lookup sensor construction performs."""
+ registry = MagicMock()
+ registry.async_get_entity_id.return_value = None
+ with patch(
+ "custom_components.span_panel.sensor_base.er.async_get",
+ return_value=registry,
+ ):
+ yield registry
+
+
+def _published(device_id: str, topic: str) -> str:
+ """What the capture publishes on one topic, or fail saying it does not."""
+ value = schema_one_tree()[device_id].get(topic)
+ assert value is not None, f"{device_id} publishes no {topic} in the capture"
+ return value
+
+
+def _snapshot(**rewrites: str | None) -> SpanPanelSnapshot:
+ """A snapshot from the capture with panel topics rewritten or unpublished.
+
+ Keyword spelling is `node__property_name`. `None` removes the topic, which is
+ what a panel whose firmware omits a property looks like -- a different event
+ from publishing an empty string, and the one the fallbacks exist for.
+ """
+ tree = schema_one_tree()
+ for path, value in rewrites.items():
+ node, _, prop = path.partition("__")
+ topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}"
+ if value is None:
+ tree[SCHEMA_ONE_PANEL].pop(topic, None)
+ else:
+ tree[SCHEMA_ONE_PANEL][topic] = value
+ return schema_one_snapshot(tree)
+
+
+def _evse_snapshot(**rewrites: str | None) -> SpanPanelSnapshot:
+ """The same, against the first EVSE in the capture."""
+ tree = schema_one_tree()
+ for path, value in rewrites.items():
+ node, _, prop = path.partition("__")
+ topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}"
+ if value is None:
+ tree[EVSE].pop(topic, None)
+ else:
+ tree[EVSE][topic] = value
+ return schema_one_snapshot(tree)
+
+
+def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock:
+ """A coordinator-like mock carrying one snapshot."""
+ coordinator = MagicMock()
+ coordinator.data = snapshot
+ coordinator.hass = MagicMock()
+ coordinator.panel_offline = False
+ coordinator.unresolved_paths = frozenset()
+ coordinator.config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "192.168.1.50"},
+ options={},
+ title="SPAN Panel",
+ unique_id=snapshot.serial_number,
+ )
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+ return coordinator
+
+
+def _panel_sensors(snapshot: SpanPanelSnapshot) -> dict[str, Any]:
+ """Every panel-level sensor the platform creates, keyed by description key."""
+ coordinator = _coordinator(snapshot)
+ created = create_panel_sensors(coordinator, snapshot, coordinator.config_entry)
+ return {sensor.entity_description.key: sensor for sensor in created}
+
+
+def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]:
+ """The attributes one panel sensor reports, or an empty dict for none."""
+ sensor = _panel_sensors(snapshot)[key]
+ return sensor.extra_state_attributes or {}
+
+
+async def _registered_panel(
+ hass: HomeAssistant, snapshot: SpanPanelSnapshot, entry_id: str
+) -> dr.DeviceEntry:
+ """Register the panel the way setup does, and read its card back.
+
+ Through the registry rather than through `snapshot_to_device_info`'s return
+ value: the dict is this integration's claim, the registry entry is the device
+ a user opens. A field the registry drops or overwrites is invisible to an
+ assertion on the dict.
+ """
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "192.168.1.30"},
+ entry_id=entry_id,
+ unique_id=snapshot.serial_number,
+ )
+ entry.add_to_hass(hass)
+ await ensure_device_registered(hass, entry, snapshot, "SPAN Panel")
+ device = dr.async_get(hass).async_get_device_by_identifier(
+ (DOMAIN, snapshot.serial_number), entry.entry_id
+ )
+ assert device is not None
+ return device
+
+
+# ---------------------------------------------------------------------------
+# The premise
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_publishes_everything_this_module_reads() -> None:
+ """Guard the premise, since every expectation below is read from the capture.
+
+ The capture was eight identity properties behind the producer until it was
+ refreshed, and the shape of that gap is exactly what makes this guard worth
+ having: a test whose expected value comes from an unpublished topic does not
+ fail, it stops asserting anything.
+ """
+ assert _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)
+ assert _published(SCHEMA_ONE_PANEL, POLICY_TOPIC)
+ assert _published(SCHEMA_ONE_PANEL, VENDOR_TOPIC)
+ assert _published(SCHEMA_ONE_PANEL, MODEL_TOPIC)
+ assert _published(SCHEMA_ONE_PANEL, HARDWARE_TOPIC)
+ assert _published(EVSE, PART_NUMBER_TOPIC)
+
+
+# ---------------------------------------------------------------------------
+# The panel's device card
+# ---------------------------------------------------------------------------
+
+
+async def test_the_panel_card_shows_the_identity_the_panel_publishes(
+ hass: HomeAssistant,
+) -> None:
+ """Manufacturer, model and hardware revision come off the wire, not a constant.
+
+ Read back from the registry, and compared against the capture rather than
+ against literals: the model in particular is `MAIN_40`, which is also the
+ string `panel_size` is derived from, so an assertion written as a literal
+ would agree with the size lookup rather than with the panel.
+ """
+ device = await _registered_panel(hass, _snapshot(), "entry-card-published")
+
+ assert device.manufacturer == _published(SCHEMA_ONE_PANEL, VENDOR_TOPIC)
+ assert device.model == _published(SCHEMA_ONE_PANEL, MODEL_TOPIC)
+ assert device.hw_version == _published(SCHEMA_ONE_PANEL, HARDWARE_TOPIC)
+ assert device.sw_version == _published(SCHEMA_ONE_PANEL, FIRMWARE_TOPIC)
+
+
+async def test_the_panel_card_follows_a_republished_identity(hass: HomeAssistant) -> None:
+ """The card tracks the wire, so nothing here is passing on a coincidence.
+
+ `MAIN_32` is deliberately another real model: it keeps `panel_size` resolvable,
+ so the only thing the rewrite changes is the string on the card.
+ """
+ rewritten = _snapshot(
+ info__vendor_name="Another Vendor",
+ info__model="MAIN_32",
+ info__hardware_version="rev9",
+ )
+
+ device = await _registered_panel(hass, rewritten, "entry-card-rewritten")
+
+ assert device.manufacturer == "Another Vendor"
+ assert device.model == "MAIN_32"
+ assert device.hw_version == "rev9"
+
+
+async def test_a_panel_publishing_no_identity_keeps_the_card_it_has_always_had(
+ hass: HomeAssistant,
+) -> None:
+ """The fallbacks are the compatibility guarantee, not a courtesy.
+
+ Flat firmware declares none of these three, so every existing installation
+ lands here. A panel that omits one must keep the row it has rather than
+ losing it -- and `hw_version`, which never had a string to fall back to, must
+ be absent rather than blank: `DeviceInfo` omits a `None` and renders an empty
+ string as a present-but-empty row.
+ """
+ bare = _snapshot(info__vendor_name=None, info__model=None, info__hardware_version=None)
+
+ device = await _registered_panel(hass, bare, "entry-card-bare")
+
+ assert device.manufacturer == FALLBACK_MANUFACTURER
+ assert device.model == FALLBACK_MODEL
+ assert device.hw_version is None
+
+
+def test_the_panel_identity_paths_are_enumerated_as_device_card_reads() -> None:
+ """`snapshot_to_device_info` is not an entity, so its reads are exempt residuals.
+
+ Annotated `NEITHER` beside the `mid.*` device-card reads: flat declares none
+ of the three, and a schema_1 metadata row exists to carry a unit and a
+ datatype for a reading, which an identity string is not. Asserted here so the
+ three cannot quietly leave the inventory that is the only record of them.
+ """
+ for path in ("panel.vendor_name", "panel.model", "panel.hardware_version"):
+ assert path in RESIDUAL_EXEMPT_PATHS, path
+ assert path not in declared_field_paths(), path
+
+
+# ---------------------------------------------------------------------------
+# `panel.wifi_ssid` -- the flat -> v1.0 regression
+# ---------------------------------------------------------------------------
+
+
+def test_the_ssid_attribute_carries_what_the_panel_publishes() -> None:
+ """The attribute a flat panel filled, filled again on v1.0.
+
+ Asserted on a sensor built by the platform over a snapshot the real schema_1
+ adapter produced from the capture, so what is under test is the whole route:
+ published topic, mapper, snapshot field, attribute.
+ """
+ attributes = _attributes(_snapshot(), SOFTWARE_VERSION_KEY)
+
+ assert attributes["wifi_ssid"] == _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)
+
+
+def test_the_ssid_attribute_follows_a_republished_ssid() -> None:
+ """A panel that joins another network says so, which a hardcoded `None` never could."""
+ attributes = _attributes(_snapshot(status__wifi_ssid="another-network"), SOFTWARE_VERSION_KEY)
+
+ assert attributes["wifi_ssid"] == "another-network"
+
+
+def test_an_unpublished_ssid_leaves_the_attribute_off_entirely() -> None:
+ """Absent, not empty. An attribute present and blank reads as a failed reading."""
+ attributes = _attributes(_snapshot(status__wifi_ssid=None), SOFTWARE_VERSION_KEY)
+
+ assert "wifi_ssid" not in attributes
+ # The sensor still reports its other attributes, so this is the SSID going
+ # missing rather than the attribute block collapsing.
+ assert "panel_size" in attributes
+
+
+def test_the_ssid_is_a_declaration_now_rather_than_an_exemption() -> None:
+ """The gate's own ratchet, asserted where a reader will find it.
+
+ Both adapters map `wifi_ssid`, so the path satisfies the producible gate and
+ `test_no_exempt_path_is_producible_by_both` refuses to let it stay exempt.
+ It is declared on the entity that reads it, which is what lets a Repair name
+ the entities a dead field takes with it.
+ """
+ assert "panel.wifi_ssid" not in RESIDUAL_EXEMPT_PATHS
+ assert "panel.wifi_ssid" in declared_field_paths()
+ assert "panel.wifi_ssid" in SpanPanelStatus._residual_field_paths
+
+
+# ---------------------------------------------------------------------------
+# `shed/policy` -- attributes on `dsm_state`
+# ---------------------------------------------------------------------------
+
+
+def test_the_shed_policy_reaches_dsm_state_as_its_two_thresholds() -> None:
+ """The numbers that make the panel's shed behaviour predictable.
+
+ Compared against the document the capture publishes rather than against
+ literals, so the parse is checked against the producer's own encoding of it.
+ """
+ document = json.loads(_published(SCHEMA_ONE_PANEL, POLICY_TOPIC))
+ attributes = _attributes(_snapshot(), DSM_STATE_KEY)
+
+ assert attributes["shed_algorithm"] == document["algorithm"]
+ assert attributes["soc_threshold_shed"] == document["parameters"]["soc-threshold-shed"]
+ assert attributes["soc_threshold_release"] == document["parameters"]["soc-threshold-release"]
+ # Fully parsed, so the raw document adds nothing a user could act on.
+ assert "shed_policy" not in attributes
+
+
+def test_the_thresholds_follow_a_republished_policy() -> None:
+ """A panel reconfigured to shed later says so."""
+ rewritten = json.dumps(
+ {
+ "algorithm": "soc-priority.v1",
+ "parameters": {"soc-threshold-shed": 5, "soc-threshold-release": 15},
+ }
+ )
+
+ attributes = _attributes(_snapshot(shed__policy=rewritten), DSM_STATE_KEY)
+
+ assert attributes["soc_threshold_shed"] == 5
+ assert attributes["soc_threshold_release"] == 15
+
+
+def test_an_unknown_algorithm_degrades_to_the_raw_document() -> None:
+ """The policy schema is versioned in its own `$id`, so another algorithm may arrive.
+
+ Reporting `soc-priority.v1`'s thresholds for a document that never had them
+ would be worse than reporting nothing, and raising would take the sensor
+ down. Naming the algorithm and showing the document is what a user can act on.
+ """
+ other = json.dumps({"algorithm": "runtime-priority.v2", "parameters": {"minutes-shed": 30}})
+
+ attributes = _attributes(_snapshot(shed__policy=other), DSM_STATE_KEY)
+
+ assert attributes["shed_algorithm"] == "runtime-priority.v2"
+ assert attributes["shed_policy"] == other
+ assert "soc_threshold_shed" not in attributes
+ assert "soc_threshold_release" not in attributes
+
+
+def test_an_unparseable_policy_still_leaves_the_sensor_standing() -> None:
+ """A panel is a publisher this integration does not control.
+
+ One malformed string must not take `dsm_state` -- the sensor a button reads
+ to decide whether the panel is already on grid -- down with it.
+ """
+ sensor = _panel_sensors(_snapshot(shed__policy="{not json"))[DSM_STATE_KEY]
+ sensor._update_native_value()
+
+ assert sensor.native_value is not None
+ assert (sensor.extra_state_attributes or {}) == {"shed_policy": "{not json"}
+
+
+def test_a_panel_publishing_no_policy_reports_no_policy_attributes() -> None:
+ """`dsm_state` keeps its state and simply carries nothing extra."""
+ sensor = _panel_sensors(_snapshot(shed__policy=None))[DSM_STATE_KEY]
+ sensor._update_native_value()
+
+ assert sensor.native_value is not None
+ assert sensor.extra_state_attributes is None
+
+
+def test_the_policy_attributes_hang_off_dsm_state_and_nothing_else() -> None:
+ """One sensor's attributes, not every sensor this class renders.
+
+ `SpanPanelPanelStatus` renders the relay state and the run config too, and a
+ shed policy repeated on each of them is noise on three cards.
+ """
+ sensors = _panel_sensors(_snapshot())
+ carrying = {
+ key
+ for key, sensor in sensors.items()
+ if isinstance(sensor, SpanPanelPanelStatus)
+ and "shed_algorithm" in (sensor.extra_state_attributes or {})
+ }
+
+ assert carrying == {DSM_STATE_KEY}
+
+
+# ---------------------------------------------------------------------------
+# `evse.part_number` -- the promotion the producible gate demanded
+# ---------------------------------------------------------------------------
+
+
+def _evse_states(snapshot: SpanPanelSnapshot, key: str) -> set[Any]:
+ """What every charger's sensor of this key reports."""
+ states: set[Any] = set()
+ for sensor in create_evse_sensors(_coordinator(snapshot), snapshot):
+ if sensor.entity_description.key != key:
+ continue
+ sensor._update_native_value()
+ states.add(sensor.native_value)
+ return states
+
+
+def test_the_charger_reports_the_part_number_the_panel_publishes() -> None:
+ """The BESS has shown its SKU since it shipped; the charger beside it had none."""
+ assert _evse_states(_snapshot(), EVSE_PART_NUMBER_KEY) == {_published(EVSE, PART_NUMBER_TOPIC)}
+
+
+def test_the_part_number_follows_a_republished_value() -> None:
+ """Two chargers, one rewritten, so the sensor cannot be reading a constant.
+
+ The capture publishes the same SKU on both, which is what makes this the
+ mutation worth running: a rewrite of one has to show up as two distinct
+ states rather than as one.
+ """
+ states = _evse_states(_evse_snapshot(info__part_number="SPN-DRV-999"), EVSE_PART_NUMBER_KEY)
+
+ assert states == {"SPN-DRV-999", _published(EVSE, PART_NUMBER_TOPIC)}
+
+
+def test_a_charger_publishing_no_part_number_reports_unknown_rather_than_a_default() -> None:
+ """An unpublished SKU is unknown, and no charger invents one for another.
+
+ `STATE_UNKNOWN` rather than `None` because the platform renders a
+ non-numeric sensor's absent value that way, which is how `bess_part_number`
+ has always behaved on a BESS that publishes none. The other charger keeps
+ its value in the same breath, so this is one charger going quiet rather than
+ the sensor failing.
+ """
+ states = _evse_states(_evse_snapshot(info__part_number=None), EVSE_PART_NUMBER_KEY)
+
+ assert states == {STATE_UNKNOWN, _published(EVSE, PART_NUMBER_TOPIC)}
+
+
+def test_the_part_number_is_a_plain_declaration_on_both_adapters() -> None:
+ """The promotion this task's schema_1 metadata row demanded.
+
+ Flat has mapped `evse/part-number` all along; adding the v1.0 row made the
+ path producible by both, and a both-producible path is a declaration rather
+ than an exemption. Diagnostic and off by default, matching `bess_part_number`
+ -- build metadata is not something a user wants on a card by default.
+ """
+ (description,) = [d for d in EVSE_SENSORS if d.key == EVSE_PART_NUMBER_KEY]
+
+ assert description.field_path == "evse.part_number"
+ assert description.derived is None
+ assert description.entity_registry_enabled_default is False
+ assert "evse.part_number" in declared_field_paths()
+ assert "evse.part_number" not in RESIDUAL_EXEMPT_PATHS
diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py
index 866d2eeb..b7ec0b6d 100644
--- a/tests/test_promoted_sensors.py
+++ b/tests/test_promoted_sensors.py
@@ -401,7 +401,7 @@ class TestEvseSensorDefinitions:
"""Test EVSE sensor definitions."""
def test_sensor_count(self):
- assert len(EVSE_SENSORS) == 3
+ assert len(EVSE_SENSORS) == 4
def test_all_have_translation_keys(self):
for desc in EVSE_SENSORS:
diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py
index 05c8ff95..4370eb20 100644
--- a/tests/test_sensor_platform.py
+++ b/tests/test_sensor_platform.py
@@ -26,6 +26,7 @@
create_power_flow_sensors,
create_unmapped_circuit_sensors,
)
+from custom_components.span_panel.sensor_definitions import EVSE_SENSORS
from homeassistant.core import HomeAssistant
from .factories import (
@@ -399,5 +400,5 @@ def test_create_evse_sensors_creates_all_descriptions_for_each_charger() -> None
entities = create_evse_sensors(coordinator, snapshot)
- assert len(entities) == 6
+ assert len(entities) == len(EVSE_SENSORS) * 2
assert {entity._evse_id for entity in entities} == {"evse-0", "evse-1"}
From bc3e19aee0d441dbbd1dcf046d5ffe0e333713e6 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 20:53:13 -0700
Subject: [PATCH 055/116] feat(number): give each charger the charge-current
limit its panel declares
The catch-up's only settable property, and the integration's first `number`
platform. One entity per commissioned EVSE, on the charger's own device card
beside the sensors it already has.
Every part of the control is described by the panel rather than by this
integration. The value and the maximum come from whichever node the charger's
`$description` declares -- `config/{max,user-max}-charge-current` in the
reference tree, `charge-limit/{installer-max,owner-limit}` in the eBus catalog,
resolved in the library so nothing here names a wire property. A charger
publishing the catalogued spelling produces an identical entity, asserted field
by field against one publishing SPAN's.
`native_max_value` is the installer-commissioned ceiling, read off the wire.
That is the whole reason this is not a hardcoded slider: the maximum is a
derating from the breaker and J1772, it moves when a charger is recommissioned,
and Home Assistant checks a service call against it before this platform sees
one. The library checks again against what the panel is publishing *now* and
refuses rather than clamping -- a clamp would report a limit the charger is not
enforcing.
Two gates, both from the declaration and neither from a value:
- `$settable` creates the entity. A charger publishing a perfectly readable
limit it does not declare writable gets no control, the same distinction
`never-backup` already draws for a circuit's priority. Asserted through
`async_setup_entry`, because an entity that must not exist cannot be observed
by asking an entity for its state.
- An unpublished ceiling makes the entity unavailable. A number must report some
maximum and Home Assistant's default is 100, so the alternative is putting a
plausible-looking amperage in front of a user that no installer commissioned.
A write the panel has acknowledged and not yet applied shows as a
`charge_current_limit_target` attribute while the state stays the limit the
charger is still enforcing -- the rendering the priority select already gives
`circuit.priority_target`, rather than a second pending-command mechanism.
Field paths: the limit and the ceiling are `SCHEMA_1_ONLY` -- schema_1 resolves
a metadata row for each from the charger's own `$description`, and flat's `evse`
type declares no settable ceiling at all -- and the description is
`SCHEMA_CONDITIONAL_FIELD` naming the limit, so the entity keeps its Repair
mention and its unavailability. `$settable` and the `$target` echo are `NEITHER`:
facts about a command rather than readings.
The two `evse/config/*` lines leave the unread baseline, 13 -> 11.
Proof, beyond the assertions: nine deliberate breakages of this platform and its
gates -- the creation gate ignoring `$settable`, a hardcoded maximum, the value
reading the ceiling, the write addressing the wrong charger, the refusal
swallowed, the platform unforwarded -- each failed the tests that name it, and
dropping either read from `RESIDUAL_EXEMPT_PATHS` put its property straight back
into the unread baseline.
---
CHANGELOG.md | 11 +
README.md | 11 +
custom_components/span_panel/__init__.py | 5 +
custom_components/span_panel/field_paths.py | 28 +
custom_components/span_panel/icons.json | 5 +
custom_components/span_panel/number.py | 295 ++++++++
custom_components/span_panel/strings.json | 11 +
.../span_panel/translations/en.json | 11 +
.../span_panel/translations/es.json | 11 +
.../span_panel/translations/fr.json | 11 +
.../span_panel/translations/ja.json | 11 +
.../span_panel/translations/pt.json | 11 +
.../unread_declarations_baseline.json | 2 -
tests/test_evse_charge_limit.py | 640 ++++++++++++++++++
tests/test_field_path_conformance.py | 16 +-
15 files changed, 1075 insertions(+), 4 deletions(-)
create mode 100644 custom_components/span_panel/number.py
create mode 100644 tests/test_evse_charge_limit.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83f58d74..43b78e1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file.
### Added
+- **A charge-current limit you can set, on panels running the v1.0 data model.** Each commissioned SPAN Drive gets an **EVSE Charge Current Limit** number on
+ its own device — the ceiling the charger offers your vehicle, which you can lower to charge more slowly and raise back. It is the first control this
+ integration has that changes something on a charger rather than on the panel.
+- The maximum you can ask for is the one your installer commissioned, read from the panel rather than assumed: the box will not accept a value above the
+ charger's rated current, and neither will anything else — a value beyond it is refused before it is sent, not quietly rounded down to something you did not
+ ask for. If the panel has not yet published what the charger is rated for, the control reports unavailable instead of offering an invented range.
+- The control appears only where the panel says the limit can be changed. A charger that publishes its limit as read-only gets no control, which is the same
+ distinction **Circuit Priority** already makes for a circuit commissioned never-backup.
+- While the panel is acknowledging a change it has not yet applied, the requested value shows as a `charge_current_limit_target` attribute and the state stays
+ the limit the charger is still enforcing — the same way Circuit Priority reports a priority change in flight.
+
- **Whether your panel can reach your solar inverter and each of your chargers, on panels running the v1.0 data model.** **PV Panel Link** and **EVSE Panel
Link** are the same fact **BESS Connected** has always shown for the battery: the panel's own report of the link to a device it feeds. The battery's version
worked because the panel publishes it on the main lugs; the inverter's and each charger's are published by the circuit that feeds them, and nothing read that
diff --git a/README.md b/README.md
index 6bf2aa28..7bf49a5a 100644
--- a/README.md
+++ b/README.md
@@ -271,6 +271,17 @@ feature. A display suffix differentiates multiple chargers on the same panel:
whether it can reach the charger at all. A charger part-way through a session behind a lost link reports a plugged-in vehicle and a dead link at the same time.
EVSE Panel Link is a diagnostic and appears only where the circuit feeding that charger publishes the link record.
+#### EVSE Controls (per charger, v1.0)
+
+| Control | Platform | Unit | Notes |
+| ------------------------- | -------- | ---- | --------------------------------------------------------------------------------------- |
+| EVSE Charge Current Limit | Number | A | The charge-current ceiling you can lower. Bounded by the installer-commissioned maximum |
+
+The maximum is read from the panel, never assumed: it is the current the charger was commissioned for, and a value above it is refused rather than clamped. The
+control is created only where the panel declares the limit settable, and reports unavailable while the panel has not published the commissioned maximum that
+bounds it. A change the panel has acknowledged but not yet applied appears as a `charge_current_limit_target` attribute while the state stays the limit the
+charger is still enforcing.
+
#### EVSE Device Info Attributes
| Attribute | Source |
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index 4d221be2..741e2d71 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -92,6 +92,11 @@ class SpanPanelRuntimeData:
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
+ # Added with the EVSE charge-current control -- the first number this
+ # integration has ever had, and forwarded unconditionally like every other
+ # platform: `number.async_setup_entry` creates nothing on a panel with no
+ # charger that declares a settable limit.
+ Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 6fa11d11..ba874ffd 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -209,6 +209,16 @@ class FieldPathDeclarationMixin:
# The EVSE's Homie node id — an addressing handle used to build the
# sub-device identifier, not a published field.
"evse.node_id": Producibility.NEITHER,
+ # The charge-current control's two non-readings, read by
+ # `SpanEvseNumber`: `$settable` on the limit's declaration, which is the
+ # entity-creation gate, and the Homie `$target` echo of a write the
+ # panel has accepted but not yet applied, rendered as an attribute. Both
+ # are facts about a command rather than readings, so no adapter carries
+ # a metadata row for either — the same shape as the `circuit.*_target`
+ # pair at the top of this map, and the same reason `panel.grid_islandable`
+ # sits here as a creation gate.
+ "evse.charge_current_limit_settable": Producibility.NEITHER,
+ "evse.charge_current_limit_target_a": Producibility.NEITHER,
# The shed-forecast refinements, read for attributes on the two forecast
# sensors (`SpanShedForecastSensor.extra_state_attributes`). schema_1
# reads all three into the snapshot but carries a `_PROPERTY_FIELD_MAP`
@@ -299,6 +309,22 @@ class FieldPathDeclarationMixin:
# through a circuit was not.
"pv.connected": Producibility.SCHEMA_1_ONLY,
"evse.connected": Producibility.SCHEMA_1_ONLY,
+ # The EVSE charge-current pair behind the `evse_charge_current_limit`
+ # number: the settable limit the entity's value comes from — its
+ # description is `SCHEMA_CONDITIONAL_FIELD` and names it — and the
+ # commissioned ceiling the entity reads for `native_max_value`. Flat
+ # firmware's `evse` device type publishes `advertised-current` and no
+ # settable ceiling at all, so neither can ever satisfy the both-adapters
+ # gate. schema_1 carries a metadata row for each, resolved from the
+ # charger's own `$description` rather than from a table, which is what
+ # makes these SCHEMA_1_ONLY rather than NEITHER and buys the entity unit
+ # validation against the property the panel actually declares.
+ #
+ # The ceiling is deliberately not on `SpanEvseNumber._residual_field_paths`:
+ # that feeds `declared_field_paths()`, which is the both-adapters gate,
+ # and flat produces neither path.
+ "evse.charge_current_limit_a": Producibility.SCHEMA_1_ONLY,
+ "evse.charge_current_ceiling_a": Producibility.SCHEMA_1_ONLY,
"circuit.always_on": Producibility.SCHEMA_0_ONLY,
"circuit.is_sheddable": Producibility.SCHEMA_0_ONLY,
# The `grid_forming_entity` sensor's source field. schema_1 answers the
@@ -470,6 +496,7 @@ def platform_descriptions() -> tuple[EntityDescription, ...]:
GRID_ISLANDABLE_SENSOR,
PCS_ACTIVE_SENSOR,
)
+ from .number import EVSE_NUMBERS # pylint: disable=import-outside-toplevel
from .sensor_definitions import ( # pylint: disable=import-outside-toplevel
all_sensor_descriptions,
)
@@ -478,6 +505,7 @@ def platform_descriptions() -> tuple[EntityDescription, ...]:
*all_sensor_descriptions(),
*BINARY_SENSORS,
*EVSE_BINARY_SENSORS,
+ *EVSE_NUMBERS,
GRID_ISLANDABLE_SENSOR,
BESS_CONNECTED_SENSOR,
PCS_ACTIVE_SENSOR,
diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json
index 0b790506..d96c4534 100644
--- a/custom_components/span_panel/icons.json
+++ b/custom_components/span_panel/icons.json
@@ -48,6 +48,11 @@
"default": "mdi:transmission-tower"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "default": "mdi:ev-station"
+ }
+ },
"select": {
"circuit_priority": {
"default": "mdi:priority-high"
diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py
new file mode 100644
index 00000000..ab62620d
--- /dev/null
+++ b/custom_components/span_panel/number.py
@@ -0,0 +1,295 @@
+"""Number entities for the Span Panel — today, one per commissioned EV charger.
+
+The charge-current ceiling is the only settable property v1.0 puts outside the
+panel and its circuits, and the only control this integration has ever offered
+that carries a physical bound: the installer commissions a maximum from the
+breaker rating and J1772 derating, and nothing a user does may exceed it.
+
+So every number this platform builds is described by the panel rather than by
+this module. The value, the maximum, and the fact that a control exists at all
+come from the charger's own `$description`, resolved in the library
+(`span_panel_api_schema_1.charge_limit`) because the node carrying the limit has
+two spellings in circulation and the `$description` is the specification's
+authority on which one a charger publishes. Nothing here names a wire property.
+
+**A control is offered only where the panel declares one.** `$settable` on the
+limit is what creates the entity; the commissioned ceiling is what bounds it.
+A charger that declares neither gets no entity, which is the honest rendering of
+`charge-limit.md`'s absence semantics — "the EVSE has no adjustable
+charge-current ceiling (it charges at a fixed rate)".
+
+**A pending write shows as an attribute, not as a state.** The panel echoes an
+accepted command on the Homie `$target` topic and republishes the property when
+it takes effect, and the priority select already renders that pair exactly this
+way (`circuit.priority_target`). Reporting the requested value as the state
+instead would show a limit the charger may never have accepted.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable, Mapping
+from dataclasses import dataclass
+import logging
+from typing import Any, Final
+
+from homeassistant.components.number import (
+ NumberDeviceClass,
+ NumberEntity,
+ NumberEntityDescription,
+ NumberMode,
+)
+from homeassistant.const import UnitOfElectricCurrent
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity import EntityCategory
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from span_panel_api import EvseControlProtocol, SpanEvseSnapshot, SpanPanelSnapshot
+from span_panel_api.exceptions import SpanPanelServerError
+
+from . import SpanPanelConfigEntry
+from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS
+from .coordinator import SpanPanelCoordinator
+from .entity import SpanPanelEntity
+from .field_paths import DerivedReason, FieldPathDeclarationMixin
+from .helpers import build_evse_unique_id_for_entry, resolve_evse_display_suffix
+from .util import evse_device_info
+
+_LOGGER: logging.Logger = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 1
+
+_EMPTY_EVSE = SpanEvseSnapshot(node_id="", feed_circuit_id="")
+"""Fallback for a charger that disappears mid-session, as the other EVSE platforms use."""
+
+
+@dataclass(frozen=True)
+class SpanEvseNumberRequiredKeysMixin(FieldPathDeclarationMixin):
+ """Required keys mixin for EVSE number entities."""
+
+ value_fn: Callable[[SpanEvseSnapshot], int | None]
+ maximum_fn: Callable[[SpanEvseSnapshot], int | None]
+ target_fn: Callable[[SpanEvseSnapshot], int | None]
+ settable_fn: Callable[[SpanEvseSnapshot], bool]
+ set_fn: Callable[[EvseControlProtocol, str, int], Awaitable[None]]
+
+
+@dataclass(frozen=True, kw_only=True)
+class SpanEvseNumberEntityDescription(NumberEntityDescription, SpanEvseNumberRequiredKeysMixin):
+ """Describes an EVSE number entity."""
+
+
+EVSE_CHARGE_CURRENT_LIMIT: Final = SpanEvseNumberEntityDescription(
+ key="evse_charge_current_limit",
+ field_path="evse.charge_current_limit_a",
+ # One field, produced by schema_1 alone: flat firmware's `evse` device type
+ # carries `advertised-current` — what the charger is offering the vehicle,
+ # read-only — and no settable ceiling anywhere, so the both-adapters gate
+ # cannot be satisfied. schema_1 carries a metadata row for the field, which
+ # is what makes the path SCHEMA_1_ONLY rather than NEITHER and buys the
+ # entity unit validation against the charger's own `$description`.
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="evse_charge_current_limit",
+ device_class=NumberDeviceClass.CURRENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ # Zero, because a charge-current ceiling has no meaning below it: this is a
+ # charge-only EVSE, so lowering the ceiling can stop charging and can never
+ # reverse it. Not read from the wire because there is nothing on the wire to
+ # read — Homie expresses a bounded numeric as a `min:max` `$format`, and
+ # neither the capture nor the `charge-limit` catalog declares one for this
+ # property. See `native_max_value`, which *is* published and is read.
+ native_min_value=0,
+ # The declared datatype is `integer`, and a step is the granularity of the
+ # quantity rather than a policy: a charger that accepts 16 A and 17 A does
+ # not accept 16.5. Asserted against the declaration in
+ # `test_evse_charge_limit.py` rather than assumed here.
+ native_step=1,
+ mode=NumberMode.BOX,
+ entity_category=EntityCategory.CONFIG,
+ value_fn=lambda evse: evse.charge_current_limit_a,
+ maximum_fn=lambda evse: evse.charge_current_ceiling_a,
+ target_fn=lambda evse: evse.charge_current_limit_target_a,
+ settable_fn=lambda evse: evse.charge_current_limit_settable,
+ set_fn=lambda client, node_id, amps: client.set_evse_charge_limit(node_id, amps),
+)
+"""The owner's charge-current ceiling.
+
+`mode=BOX` rather than a slider: the useful values are a handful of amperages an
+installer or an owner knows by name (16, 24, 32, 40), and a slider over an
+installer-set range invites dragging past the value someone meant. The range is
+still enforced — Home Assistant checks the service call against `min_value` and
+`max_value`, and the library refuses anything above the commissioned ceiling
+before it reaches the wire.
+
+Deliberately `EntityCategory.CONFIG`, beside the circuit priority select: this
+changes how the panel behaves rather than reporting how it is behaving.
+"""
+
+EVSE_NUMBERS: tuple[SpanEvseNumberEntityDescription, ...] = (EVSE_CHARGE_CURRENT_LIMIT,)
+
+
+class SpanEvseNumber(SpanPanelEntity, NumberEntity):
+ """One settable amperage on one commissioned EV charger."""
+
+ def __init__(
+ self,
+ data_coordinator: SpanPanelCoordinator,
+ description: SpanEvseNumberEntityDescription,
+ evse_id: str,
+ ) -> None:
+ """Initialize the EVSE number."""
+ super().__init__(data_coordinator, context=description)
+ snapshot: SpanPanelSnapshot = data_coordinator.data
+ self._evse_id = evse_id
+ # The same object under two names. `entity_description` is what Home
+ # Assistant and `SpanPanelEntity._source_field_path` read, and its
+ # declared type is `NumberEntityDescription`; `_description` is the same
+ # instance at the type this platform actually declared, so the readers
+ # below stay on the description rather than being copied off it onto the
+ # entity. Copying is what splits a declaration from its reader, which is
+ # the drift `field_paths` exists to prevent.
+ self.entity_description = description
+ self._description = description
+
+ panel_name = (
+ data_coordinator.config_entry.data.get(
+ CONF_DEVICE_NAME, data_coordinator.config_entry.title
+ )
+ or "Span Panel"
+ )
+ evse = snapshot.evse.get(evse_id, _EMPTY_EVSE)
+ use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False)
+ self._attr_device_info = evse_device_info(
+ snapshot.serial_number,
+ evse,
+ panel_name,
+ resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers),
+ panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id,
+ )
+ self._attr_unique_id = build_evse_unique_id_for_entry(
+ data_coordinator,
+ snapshot,
+ evse_id,
+ description.key,
+ data_coordinator.config_entry.data.get(
+ CONF_DEVICE_NAME, data_coordinator.config_entry.title
+ ),
+ )
+ self._apply(evse)
+
+ def _evse(self) -> SpanEvseSnapshot:
+ snapshot: SpanPanelSnapshot | None = self.coordinator.data
+ if snapshot is None:
+ return _EMPTY_EVSE
+ return snapshot.evse.get(self._evse_id, _EMPTY_EVSE)
+
+ def _apply(self, evse: SpanEvseSnapshot) -> None:
+ """Take the reading and the bound the panel currently publishes.
+
+ The maximum moves with the panel because it can: an installer
+ recommissioning a charger republishes `installer-max`, and a control
+ still offering the old range would let a user ask for a current the
+ hardware is no longer rated for. Left at whatever was last published
+ when the value goes away, so the entity reports unavailable with its
+ last known bound rather than briefly widening.
+ """
+ self._attr_native_value = self._description.value_fn(evse)
+ maximum = self._description.maximum_fn(evse)
+ if maximum is not None:
+ self._attr_native_max_value = maximum
+
+ @property
+ def available(self) -> bool:
+ """False while the panel is offline or the bound is unknown.
+
+ Offline follows the priority select: a control that cannot reach the
+ panel is not a control.
+
+ The bound is the addition, and it is this entity's own hazard. A number
+ must report *some* maximum, so an unpublished ceiling would otherwise be
+ rendered as Home Assistant's default of 100 — a plausible-looking
+ amperage that no installer commissioned. Reporting unavailable says the
+ panel has not told us what the charger is rated for, which is the true
+ statement; the entity comes back when the ceiling does.
+ """
+ if self.coordinator.panel_offline:
+ return False
+ if self._description.maximum_fn(self._evse()) is None:
+ return False
+ return super().available
+
+ @property
+ def extra_state_attributes(self) -> Mapping[str, Any] | None:
+ """The pending write, while the panel is echoing one.
+
+ The same rendering the priority select gives `priority_target`, and for
+ the same reason: `$target` is a command in flight, and the state stays
+ the value the charger is actually enforcing until it republishes.
+ """
+ target = self._description.target_fn(self._evse())
+ if target is None:
+ return None
+ return {"charge_current_limit_target": target}
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Ask the panel to lower (or restore) this charger's ceiling.
+
+ Home Assistant has already rejected anything outside `min_value` /
+ `max_value` by the time this runs, and the library refuses anything above
+ the commissioned ceiling again before publishing. That is not redundancy:
+ the first check is against the range this entity last reported, the second
+ against what the panel is publishing now, and a recommissioning between
+ the two is exactly when they differ.
+
+ A fractional request truncates rather than rounds. The property is
+ declared `integer`, so some whole number has to be chosen, and for a
+ ceiling the safe direction is down: asking for 16.7 A and getting 16 is
+ a slower charge, asking for it and getting 17 is a current the user did
+ not request.
+ """
+ client = self.coordinator.client
+ if not isinstance(client, EvseControlProtocol):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="evse_charge_limit_unsupported",
+ translation_placeholders={"charger": self._evse_id},
+ )
+ try:
+ await self._description.set_fn(client, self._evse_id, int(value))
+ except SpanPanelServerError as err:
+ _LOGGER.warning("SPAN panel refused a charge-current limit: %s", err)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="evse_charge_limit_failed",
+ translation_placeholders={"charger": self._evse_id, "reason": str(err)},
+ ) from err
+ await self.coordinator.async_request_refresh()
+
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._apply(self._evse())
+ super()._handle_coordinator_update()
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: SpanPanelConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up number entities for Span Panel."""
+ _LOGGER.debug("ASYNC SETUP ENTRY NUMBER")
+
+ coordinator = config_entry.runtime_data.coordinator
+ snapshot: SpanPanelSnapshot = coordinator.data
+
+ async_add_entities(
+ [
+ SpanEvseNumber(coordinator, description, evse_id)
+ for evse_id, evse in snapshot.evse.items()
+ for description in EVSE_NUMBERS
+ # The declaration is the gate, never the value: a charger that
+ # declares the property settable and has not published one yet still
+ # has the control, and a charger that publishes a value it does not
+ # declare settable does not.
+ if description.settable_fn(evse)
+ ]
+ )
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 4d79c150..9ecc5d28 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -163,6 +163,11 @@
"name": "GFE Override: Grid Connected"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "name": "EVSE Charge Current Limit"
+ }
+ },
"select": {
"circuit_priority": {
"name": "Circuit Priority",
@@ -400,6 +405,12 @@
"circuit_relay_failed": {
"message": "Failed to set circuit relay state for {circuit}."
},
+ "evse_charge_limit_failed": {
+ "message": "Failed to set the charge-current limit for charger {charger}: {reason}"
+ },
+ "evse_charge_limit_unsupported": {
+ "message": "This SPAN panel offers no charge-current control for charger {charger}."
+ },
"export_manifest_no_entries": {
"message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service."
},
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 4d79c150..9ecc5d28 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -163,6 +163,11 @@
"name": "GFE Override: Grid Connected"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "name": "EVSE Charge Current Limit"
+ }
+ },
"select": {
"circuit_priority": {
"name": "Circuit Priority",
@@ -400,6 +405,12 @@
"circuit_relay_failed": {
"message": "Failed to set circuit relay state for {circuit}."
},
+ "evse_charge_limit_failed": {
+ "message": "Failed to set the charge-current limit for charger {charger}: {reason}"
+ },
+ "evse_charge_limit_unsupported": {
+ "message": "This SPAN panel offers no charge-current control for charger {charger}."
+ },
"export_manifest_no_entries": {
"message": "No SPAN panel configuration entries are loaded. Add and configure a SPAN panel before calling this service."
},
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 8e895a6f..34bb52a7 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -163,6 +163,11 @@
"name": "Anulación GFE: Conectado a Red"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "name": "Límite de Corriente de Carga EVSE"
+ }
+ },
"select": {
"circuit_priority": {
"name": "Prioridad del Circuito",
@@ -400,6 +405,12 @@
"circuit_relay_failed": {
"message": "Error al establecer el estado del relé del circuito para {circuit}."
},
+ "evse_charge_limit_failed": {
+ "message": "No se pudo establecer el límite de corriente de carga para el cargador {charger}: {reason}"
+ },
+ "evse_charge_limit_unsupported": {
+ "message": "Este panel SPAN no ofrece control de corriente de carga para el cargador {charger}."
+ },
"export_manifest_no_entries": {
"message": "No hay entradas de configuración de SPAN Panel cargadas. Agregue y configure un SPAN Panel antes de llamar a este servicio."
},
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index ad1d7f3b..dea10a79 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -163,6 +163,11 @@
"name": "Forçage GFE : Connecté au Réseau"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "name": "Limite de Courant de Charge EVSE"
+ }
+ },
"select": {
"circuit_priority": {
"name": "Priorité du Circuit",
@@ -400,6 +405,12 @@
"circuit_relay_failed": {
"message": "Échec de la définition de l'état du relais du circuit pour {circuit}."
},
+ "evse_charge_limit_failed": {
+ "message": "Échec du réglage de la limite de courant de charge pour le chargeur {charger} : {reason}"
+ },
+ "evse_charge_limit_unsupported": {
+ "message": "Ce panneau SPAN n'offre aucun contrôle du courant de charge pour le chargeur {charger}."
+ },
"export_manifest_no_entries": {
"message": "Aucune entrée de configuration SPAN Panel n'est chargée. Ajoutez et configurez un panneau SPAN avant d'appeler ce service."
},
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 36a228cc..5309c41d 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -163,6 +163,11 @@
"name": "GFEオーバーライド:系統接続"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "name": "EVSE充電電流上限"
+ }
+ },
"select": {
"circuit_priority": {
"name": "回路優先度",
@@ -400,6 +405,12 @@
"circuit_relay_failed": {
"message": "{circuit}の回路リレー状態の設定に失敗しました。"
},
+ "evse_charge_limit_failed": {
+ "message": "充電器 {charger} の充電電流上限を設定できませんでした: {reason}"
+ },
+ "evse_charge_limit_unsupported": {
+ "message": "このSPANパネルは充電器 {charger} の充電電流制御に対応していません。"
+ },
"export_manifest_no_entries": {
"message": "SPANパネルの設定エントリが読み込まれていません。このサービスを呼び出す前に、SPANパネルを追加して設定してください。"
},
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index 7a508f23..e67c4436 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -163,6 +163,11 @@
"name": "Substituição GFE: Conectado à Rede"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "name": "Limite de Corrente de Carga EVSE"
+ }
+ },
"select": {
"circuit_priority": {
"name": "Prioridade do Circuito",
@@ -400,6 +405,12 @@
"circuit_relay_failed": {
"message": "Falha ao definir o estado do relé do circuito para {circuit}."
},
+ "evse_charge_limit_failed": {
+ "message": "Falha ao definir o limite de corrente de carga do carregador {charger}: {reason}"
+ },
+ "evse_charge_limit_unsupported": {
+ "message": "Este painel SPAN não oferece controlo da corrente de carga para o carregador {charger}."
+ },
"export_manifest_no_entries": {
"message": "Nenhuma entrada de configuração do painel SPAN está carregada. Adicione e configure um painel SPAN antes de chamar este serviço."
},
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
index 1ca96e9d..b9767d52 100644
--- a/tests/fixtures/unread_declarations_baseline.json
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -4,8 +4,6 @@
"circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type",
"distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history",
"distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity",
- "evse/config/max-charge-current": "the installer ceiling; it becomes native_max_value of the charge-current number entity rather than an entity of its own, and is deferred with it (Task 6)",
- "evse/config/user-max-charge-current": "the only settable unread property; the eBus catalog spells this capability charge-limit with different property names, so the write topic waits on a real-firmware capture (Task 6)",
"lugs/connection/count": "the lugs counterpart of circuit/connection/count, with no fed-device story upstream and no producer publishing it",
"lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
"lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it",
diff --git a/tests/test_evse_charge_limit.py b/tests/test_evse_charge_limit.py
new file mode 100644
index 00000000..186d2542
--- /dev/null
+++ b/tests/test_evse_charge_limit.py
@@ -0,0 +1,640 @@
+"""The EVSE charge-current limit, as the integration's first number entity.
+
+The only settable property the v1.0 catch-up surfaces, so this is the one place
+in the catch-up where being wrong reaches the panel rather than the dashboard.
+The tests are shaped around that.
+
+**Nothing here names an amperage.** Every expectation is computed from the
+capture, and where the capture publishes the same value on both chargers — it
+publishes 32 on each — the test republishes differing values first, because an
+assertion satisfied by reading one charger twice proves nothing about two.
+
+**The write is proved to the wire, not to a mock.** `test_the_write_reaches_the
+_wire_as_one_publish` drives the entity through the real client and the real
+schema_1 adapter and asserts the exact topic and payload the transport hands the
+broker — including that the topic is addressed by the charger's *device id*
+while the entity holds its serial-harmonised snapshot key, which are different
+strings and would both look plausible in a log.
+
+**The control exists only where the panel declares it.** `$settable` is the
+gate, so a charger whose limit is not declared settable gets no entity at all —
+asserted through `async_setup_entry`, because an entity that must not exist
+cannot be observed by asking an entity for its state.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+import json
+from unittest.mock import AsyncMock, MagicMock
+
+from homeassistant.components.number import NumberDeviceClass, NumberMode
+from homeassistant.const import Platform, UnitOfElectricCurrent
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity import EntityCategory
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from span_panel_api import SpanPanelSnapshot
+
+from custom_components.span_panel import PLATFORMS, SpanPanelRuntimeData
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ DerivedReason,
+ Producibility,
+ platform_descriptions,
+)
+from custom_components.span_panel.number import (
+ EVSE_CHARGE_CURRENT_LIMIT,
+ SpanEvseNumber,
+ async_setup_entry,
+)
+
+from .adapter_fixtures import (
+ SCHEMA_ONE_PANEL,
+ schema_one_metadata,
+ schema_one_snapshot,
+ schema_one_tree,
+)
+
+EVSE = "evse"
+EVSE_2 = "evse-2"
+"""The two chargers the capture commissions. Two is what makes cross-wiring falsifiable."""
+
+CONFIG_NODE = "config"
+CEILING_TOPIC = f"{CONFIG_NODE}/max-charge-current"
+LIMIT_TOPIC = f"{CONFIG_NODE}/user-max-charge-current"
+
+FIELD_LIMIT = "evse.charge_current_limit_a"
+FIELD_CEILING = "evse.charge_current_ceiling_a"
+FIELD_TARGET = "evse.charge_current_limit_target_a"
+FIELD_SETTABLE = "evse.charge_current_limit_settable"
+
+
+# ---------------------------------------------------------------------------
+# Reading the capture
+# ---------------------------------------------------------------------------
+
+
+def _published(tree: dict[str, dict[str, str]], device_id: str, topic: str) -> int:
+ """What the capture publishes on this topic, or fail saying it does not."""
+ value = tree[device_id].get(topic)
+ assert value is not None, f"{device_id} publishes no {topic} in the capture"
+ return int(value)
+
+
+def _declaration(tree: dict[str, dict[str, str]], device_id: str, property_id: str) -> dict[str, object]:
+ """One property's declaration out of the charger's own `$description`."""
+ description = json.loads(tree[device_id]["$description"])
+ declared = description["nodes"][CONFIG_NODE]["properties"][property_id]
+ assert isinstance(declared, dict)
+ return declared
+
+
+def _snapshot(**overrides: dict[str, int | None]) -> SpanPanelSnapshot:
+ """A snapshot from the capture with each charger's topics rewritten or removed."""
+ tree = schema_one_tree()
+ for device_id, topics in overrides.items():
+ for topic, value in topics.items():
+ if value is None:
+ tree[device_id].pop(topic, None)
+ else:
+ tree[device_id][topic] = str(value)
+ return schema_one_snapshot(tree)
+
+
+def _mutated_description(
+ tree: dict[str, dict[str, str]], device_id: str, mutate: object
+) -> dict[str, dict[str, str]]:
+ """Rewrite one charger's `$description` through `mutate`, in place on `tree`."""
+ description = json.loads(tree[device_id]["$description"])
+ assert callable(mutate)
+ mutate(description)
+ tree[device_id]["$description"] = json.dumps(description)
+ return tree
+
+
+def _not_settable(device_id: str) -> SpanPanelSnapshot:
+ """A charger that publishes a limit and does not declare it writable."""
+
+ def drop(description: dict[str, dict[str, dict[str, dict[str, dict[str, object]]]]]) -> None:
+ description["nodes"][CONFIG_NODE]["properties"]["user-max-charge-current"].pop("settable")
+
+ return schema_one_snapshot(_mutated_description(schema_one_tree(), device_id, drop))
+
+
+def _renamed_to_catalog(device_id: str) -> dict[str, dict[str, str]]:
+ """The capture with one charger publishing the eBus `charge-limit` spelling.
+
+ The naming the catalog specifies and no producer we have publishes. The
+ entity must not be able to tell: the library resolves the node from the
+ `$description`, so the rename is a rename and nothing else.
+ """
+ tree = schema_one_tree()
+ description = json.loads(tree[device_id]["$description"])
+ properties = description["nodes"].pop(CONFIG_NODE)["properties"]
+ description["nodes"]["charge-limit"] = {
+ "name": "charge-limit",
+ "type": "energy.ebus.capability.charge-limit",
+ "properties": {
+ "installer-max": properties["max-charge-current"],
+ "owner-limit": properties["user-max-charge-current"],
+ },
+ }
+ tree[device_id]["$description"] = json.dumps(description)
+ tree[device_id]["charge-limit/installer-max"] = tree[device_id].pop(CEILING_TOPIC)
+ tree[device_id]["charge-limit/owner-limit"] = tree[device_id].pop(LIMIT_TOPIC)
+ return tree
+
+
+def _fed_adapter(*extra: tuple[str, str]) -> object:
+ """A real schema_1 adapter fed the capture the way the broker replays it.
+
+ `extra` appends messages the retained-topic fixture cannot express — a
+ Homie `$target` is published on `//$target`, which the
+ fixture's flat `{topic: value}` shape has no room for.
+ """
+ from span_panel_api.models import V2HomieSchema
+ from span_panel_api_schema_1 import SchemaOneAdapter
+
+ tree = schema_one_tree()
+ adapter = SchemaOneAdapter(
+ SCHEMA_ONE_PANEL,
+ V2HomieSchema(
+ firmware_version="spanos2/r202633/01",
+ types_schema_hash="sha256:test",
+ types={},
+ data_model_version="1.0",
+ ),
+ )
+ for device_id in [SCHEMA_ONE_PANEL, *[d for d in tree if d != SCHEMA_ONE_PANEL]]:
+ topics = tree[device_id]
+ prefix = f"ebus/5/{device_id}"
+ adapter.handle_message(f"{prefix}/$description", topics["$description"])
+ adapter.handle_message(f"{prefix}/$state", topics["$state"])
+ for topic, value in topics.items():
+ if not topic.startswith("$"):
+ adapter.handle_message(f"{prefix}/{topic}", value)
+ for topic, value in extra:
+ adapter.handle_message(topic, value)
+ return adapter
+
+
+# ---------------------------------------------------------------------------
+# Building the entities
+# ---------------------------------------------------------------------------
+
+
+def _coordinator(snapshot: SpanPanelSnapshot, client: object | None = None) -> MagicMock:
+ coordinator = MagicMock()
+ coordinator.data = snapshot
+ coordinator.panel_offline = False
+ coordinator.last_update_success = True
+ coordinator.unresolved_paths = frozenset()
+ coordinator.client = MagicMock() if client is None else client
+ coordinator.config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={},
+ options={},
+ title="SPAN Panel",
+ unique_id=snapshot.serial_number,
+ )
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+ coordinator.async_request_refresh = AsyncMock()
+ return coordinator
+
+
+async def _created(
+ hass: HomeAssistant, snapshot: SpanPanelSnapshot, client: object | None = None
+) -> list[SpanEvseNumber]:
+ """Everything `number.async_setup_entry` creates for one snapshot."""
+ coordinator = _coordinator(snapshot, client)
+ async_add_entities = MagicMock()
+
+ await async_setup_entry(hass, coordinator.config_entry, async_add_entities)
+
+ added: Sequence[SpanEvseNumber] = async_add_entities.call_args.args[0]
+ for entity in added:
+ assert isinstance(entity, SpanEvseNumber)
+ return list(added)
+
+
+def _serial(tree: dict[str, dict[str, str]], device_id: str) -> str:
+ serial = tree[device_id].get("info/serial-number")
+ assert serial, f"{device_id} publishes no serial in the capture"
+ return serial
+
+
+def _for(created: Sequence[SpanEvseNumber], tree: dict[str, dict[str, str]], device_id: str) -> SpanEvseNumber:
+ """The number belonging to one charger, found by the serial the snapshot keys it on."""
+ serial = _serial(tree, device_id)
+ matches = [entity for entity in created if entity._evse_id == serial]
+ assert len(matches) == 1, f"{len(matches)} numbers created for {device_id}, expected 1"
+ return matches[0]
+
+
+def _refreshed(entity: SpanEvseNumber, snapshot: SpanPanelSnapshot | None) -> SpanEvseNumber:
+ """Push a new snapshot through the coordinator update the way HA does."""
+ entity.coordinator.data = snapshot
+ entity.async_write_ha_state = MagicMock()
+ entity._handle_coordinator_update()
+ return entity
+
+
+# ---------------------------------------------------------------------------
+# The platform exists
+# ---------------------------------------------------------------------------
+
+
+def test_the_number_platform_is_forwarded() -> None:
+ """A platform module nothing forwards creates nothing, silently."""
+ assert Platform.NUMBER in PLATFORMS
+
+
+async def test_one_number_per_charger_that_declares_a_settable_limit(hass: HomeAssistant) -> None:
+ tree = schema_one_tree()
+ created = await _created(hass, schema_one_snapshot(tree))
+
+ assert [entity._evse_id for entity in created] == [_serial(tree, EVSE), _serial(tree, EVSE_2)]
+ assert {entity.entity_description.key for entity in created} == {EVSE_CHARGE_CURRENT_LIMIT.key}
+ assert len({entity.unique_id for entity in created}) == 2
+
+
+async def test_no_number_where_the_limit_is_not_declared_settable(hass: HomeAssistant) -> None:
+ """The refusal, at the point where it costs a user nothing.
+
+ A charger publishes a perfectly readable limit and does not declare it
+ writable. Offering the control anyway would put a write on the wire the
+ panel never offered, and the user would find out by it not working.
+ """
+ tree = schema_one_tree()
+ created = await _created(hass, _not_settable(EVSE))
+
+ assert [entity._evse_id for entity in created] == [_serial(tree, EVSE_2)]
+
+
+async def test_no_number_where_the_charger_declares_no_limit_at_all(hass: HomeAssistant) -> None:
+ """`charge-limit.md`: absence means the charger charges at a fixed rate."""
+ tree = schema_one_tree()
+
+ def drop(description: dict[str, dict[str, object]]) -> None:
+ description["nodes"].pop(CONFIG_NODE)
+
+ _mutated_description(tree, EVSE, drop)
+ del tree[EVSE][CEILING_TOPIC]
+ del tree[EVSE][LIMIT_TOPIC]
+
+ created = await _created(hass, schema_one_snapshot(tree))
+
+ assert [entity._evse_id for entity in created] == [_serial(tree, EVSE_2)]
+
+
+# ---------------------------------------------------------------------------
+# Reading — per charger, from the wire
+# ---------------------------------------------------------------------------
+
+
+async def test_each_number_reads_its_own_charger(hass: HomeAssistant) -> None:
+ """Read each charger's own limit, from values made to differ first.
+
+ The capture publishes 32 on both, so an assertion against it as-published is
+ satisfied by a platform that reads one charger twice.
+ """
+ tree = schema_one_tree()
+ first = _published(tree, EVSE, LIMIT_TOPIC) - 8
+ second = _published(tree, EVSE_2, LIMIT_TOPIC) - 16
+ assert first != second
+
+ created = await _created(hass, _snapshot(evse={LIMIT_TOPIC: first}, **{"evse-2": {LIMIT_TOPIC: second}}))
+
+ assert _for(created, tree, EVSE).native_value == first
+ assert _for(created, tree, EVSE_2).native_value == second
+
+
+async def test_each_number_is_bounded_by_its_own_ceiling(hass: HomeAssistant) -> None:
+ """`native_max_value` is the commissioned ceiling, and per charger."""
+ tree = schema_one_tree()
+ first = _published(tree, EVSE, CEILING_TOPIC) - 8
+ second = _published(tree, EVSE_2, CEILING_TOPIC) - 16
+ assert first != second
+
+ created = await _created(hass, _snapshot(evse={CEILING_TOPIC: first}, **{"evse-2": {CEILING_TOPIC: second}}))
+
+ assert _for(created, tree, EVSE).native_max_value == first
+ assert _for(created, tree, EVSE_2).native_max_value == second
+
+
+async def test_republishing_moves_the_state_and_the_bound(hass: HomeAssistant) -> None:
+ tree = schema_one_tree()
+ created = await _created(hass, schema_one_snapshot(tree))
+ entity = _for(created, tree, EVSE)
+ assert entity.native_value == _published(tree, EVSE, LIMIT_TOPIC)
+
+ lowered = _published(tree, EVSE, LIMIT_TOPIC) - 16
+ recommissioned = _published(tree, EVSE, CEILING_TOPIC) - 8
+ _refreshed(entity, _snapshot(evse={LIMIT_TOPIC: lowered, CEILING_TOPIC: recommissioned}))
+
+ assert entity.native_value == lowered
+ assert entity.native_max_value == recommissioned
+
+
+async def test_an_unpublished_limit_is_unknown_rather_than_zero(hass: HomeAssistant) -> None:
+ """The control is still offered — the property is declared, the value is late."""
+ tree = schema_one_tree()
+ created = await _created(hass, _snapshot(evse={LIMIT_TOPIC: None}))
+ entity = _for(created, tree, EVSE)
+
+ assert entity.native_value is None
+ assert entity.available is True
+
+
+async def test_an_unpublished_ceiling_makes_the_control_unavailable(hass: HomeAssistant) -> None:
+ """A number must report some maximum, and Home Assistant's default is 100.
+
+ Rendering an uncommissioned charger as a 0-100 A control would put a
+ plausible-looking amperage in front of a user that no installer ever set.
+ Unavailable says the panel has not told us what the charger is rated for.
+ """
+ tree = schema_one_tree()
+ created = await _created(hass, _snapshot(evse={CEILING_TOPIC: None}))
+
+ assert _for(created, tree, EVSE).available is False
+ assert _for(created, tree, EVSE_2).available is True
+
+
+async def test_a_coordinator_with_no_snapshot_yet_takes_the_control_down(hass: HomeAssistant) -> None:
+ """A failed first refresh leaves `coordinator.data` unset.
+
+ Every other read here goes through the snapshot, so the entity has to answer
+ without one rather than raising inside a property Home Assistant polls.
+ """
+ tree = schema_one_tree()
+ created = await _created(hass, schema_one_snapshot(tree))
+ entity = _for(created, tree, EVSE)
+
+ _refreshed(entity, None)
+
+ assert entity.available is False
+ assert entity.native_value is None
+ assert entity.extra_state_attributes is None
+
+
+async def test_the_control_is_unavailable_while_the_panel_is_offline(hass: HomeAssistant) -> None:
+ tree = schema_one_tree()
+ coordinator = _coordinator(schema_one_snapshot(tree))
+ async_add_entities = MagicMock()
+ await async_setup_entry(hass, coordinator.config_entry, async_add_entities)
+ entity = _for(async_add_entities.call_args.args[0], tree, EVSE)
+ assert entity.available is True
+
+ coordinator.panel_offline = True
+
+ assert entity.available is False
+
+
+# ---------------------------------------------------------------------------
+# The pending write
+# ---------------------------------------------------------------------------
+
+
+async def test_a_pending_write_is_an_attribute_and_not_the_state(hass: HomeAssistant) -> None:
+ """The `$target` echo, rendered the way the priority select renders its own.
+
+ Reporting the requested value as the state would show a limit the charger
+ may never have accepted.
+ """
+ tree = schema_one_tree()
+ pending = _published(tree, EVSE, LIMIT_TOPIC) - 8
+ adapter = _fed_adapter((f"ebus/5/{EVSE}/{LIMIT_TOPIC}/$target", str(pending)))
+ snapshot = adapter.build_snapshot()
+
+ created = await _created(hass, snapshot)
+ entity = _for(created, tree, EVSE)
+
+ assert entity.extra_state_attributes == {"charge_current_limit_target": pending}
+ assert entity.native_value == _published(tree, EVSE, LIMIT_TOPIC)
+ assert _for(created, tree, EVSE_2).extra_state_attributes is None
+
+
+# ---------------------------------------------------------------------------
+# The other spelling
+# ---------------------------------------------------------------------------
+
+
+async def test_the_catalogued_spelling_produces_the_same_control(hass: HomeAssistant) -> None:
+ """`charge-limit/{installer-max,owner-limit}` — the eBus 0.1 naming.
+
+ Nothing in this integration names either spelling, so a charger publishing
+ the specified one has to produce an identical entity. Asserted against the
+ unrenamed capture rather than against literals, so the two are held to each
+ other.
+ """
+ tree = schema_one_tree()
+ published = _for(await _created(hass, schema_one_snapshot(tree)), tree, EVSE)
+ catalogued = _for(await _created(hass, schema_one_snapshot(_renamed_to_catalog(EVSE))), tree, EVSE)
+
+ assert catalogued.unique_id == published.unique_id
+ assert catalogued.native_value == published.native_value
+ assert catalogued.native_max_value == published.native_max_value
+ assert catalogued.available == published.available
+
+
+# ---------------------------------------------------------------------------
+# The control's shape, taken from the declaration
+# ---------------------------------------------------------------------------
+
+
+def test_the_unit_is_the_one_the_charger_declares() -> None:
+ """The entity's unit against the panel's, through the adapter's metadata row.
+
+ The same check `evaluate_field_metadata` makes for every sensor, made here
+ because a number carries a unit and this platform is not a sensor platform.
+ """
+ declared = schema_one_metadata()[FIELD_LIMIT]
+
+ assert declared.resolved is True
+ assert declared.unit == EVSE_CHARGE_CURRENT_LIMIT.native_unit_of_measurement
+ assert EVSE_CHARGE_CURRENT_LIMIT.native_unit_of_measurement == UnitOfElectricCurrent.AMPERE
+
+
+def test_the_step_is_the_granularity_the_charger_declares() -> None:
+ """A step of 1 is a claim about the datatype, so it is checked against it."""
+ tree = schema_one_tree()
+
+ assert _declaration(tree, EVSE, "user-max-charge-current")["datatype"] == "integer"
+ assert schema_one_metadata()[FIELD_LIMIT].datatype == "integer"
+ assert EVSE_CHARGE_CURRENT_LIMIT.native_step == 1
+
+
+def test_the_control_is_configuration_rather_than_measurement() -> None:
+ assert EVSE_CHARGE_CURRENT_LIMIT.entity_category is EntityCategory.CONFIG
+ assert EVSE_CHARGE_CURRENT_LIMIT.device_class is NumberDeviceClass.CURRENT
+ assert EVSE_CHARGE_CURRENT_LIMIT.mode is NumberMode.BOX
+ assert EVSE_CHARGE_CURRENT_LIMIT.native_min_value == 0
+
+
+# ---------------------------------------------------------------------------
+# Writing
+# ---------------------------------------------------------------------------
+
+
+def _live_client() -> tuple[object, MagicMock]:
+ """A real transport over a real schema_1 adapter fed the capture.
+
+ Not a mock: the point of the write tests is the topic and the payload, and a
+ mocked client asserts only that the integration called the method it was
+ written to call.
+ """
+ from span_panel_api.mqtt.client import MqttClientConfig, SpanMqttClient
+
+ client = SpanMqttClient(
+ host="192.168.1.1",
+ serial_number=SCHEMA_ONE_PANEL,
+ broker_config=MqttClientConfig(broker_host="h", username="u", password="p"),
+ )
+ client._adapter = _fed_adapter()
+ bridge = MagicMock()
+ client._bridge = bridge
+ return client, bridge
+
+
+async def test_the_write_reaches_the_wire_as_one_publish(hass: HomeAssistant) -> None:
+ """Entity to broker, with the exact topic and payload asserted.
+
+ The topic is addressed by the charger's **device id** while the entity holds
+ its serial-harmonised snapshot key — two different strings, both plausible
+ in a log, and only one of which any panel subscribes to.
+ """
+ tree = schema_one_tree()
+ client, bridge = _live_client()
+ created = await _created(hass, schema_one_snapshot(tree), client)
+ entity = _for(created, tree, EVSE)
+ asked = _published(tree, EVSE, CEILING_TOPIC) - 8
+
+ await entity.async_set_native_value(float(asked))
+
+ assert entity._evse_id != EVSE
+ bridge.publish.assert_called_once_with(
+ f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(asked), qos=1
+ )
+ entity.coordinator.async_request_refresh.assert_awaited_once()
+
+
+async def test_the_write_goes_to_the_charger_the_entity_belongs_to(hass: HomeAssistant) -> None:
+ tree = schema_one_tree()
+ client, bridge = _live_client()
+ created = await _created(hass, schema_one_snapshot(tree), client)
+ asked = _published(tree, EVSE_2, CEILING_TOPIC) - 8
+
+ await _for(created, tree, EVSE_2).async_set_native_value(float(asked))
+
+ bridge.publish.assert_called_once_with(
+ f"ebus/5/{EVSE_2}/config/user-max-charge-current/set", str(asked), qos=1
+ )
+
+
+async def test_a_fractional_request_truncates_downward(hass: HomeAssistant) -> None:
+ """The property is declared `integer`, so some whole number has to be chosen.
+
+ Down, because this is a ceiling: asking for a fraction and getting the lower
+ whole number is a slower charge, getting the higher one is a current the
+ user did not request.
+ """
+ tree = schema_one_tree()
+ client, bridge = _live_client()
+ created = await _created(hass, schema_one_snapshot(tree), client)
+ whole = _published(tree, EVSE, CEILING_TOPIC) - 8
+
+ await _for(created, tree, EVSE).async_set_native_value(whole + 0.7)
+
+ bridge.publish.assert_called_once_with(
+ f"ebus/5/{EVSE}/config/user-max-charge-current/set", str(whole), qos=1
+ )
+
+
+async def test_a_value_above_the_ceiling_is_refused_before_it_reaches_the_wire(
+ hass: HomeAssistant,
+) -> None:
+ """The second of the two range checks, and the one that matters.
+
+ Home Assistant rejects a service call outside the range this entity last
+ reported. This is the library rejecting it against what the panel is
+ publishing now — which is the same check only until an installer
+ recommissions the charger between the two.
+ """
+ tree = schema_one_tree()
+ client, bridge = _live_client()
+ created = await _created(hass, schema_one_snapshot(tree), client)
+ over = _published(tree, EVSE, CEILING_TOPIC) + 1
+
+ with pytest.raises(HomeAssistantError) as raised:
+ await _for(created, tree, EVSE).async_set_native_value(float(over))
+
+ assert raised.value.translation_key == "evse_charge_limit_failed"
+ bridge.publish.assert_not_called()
+
+
+async def test_the_range_home_assistant_checks_is_the_commissioned_one(hass: HomeAssistant) -> None:
+ """The first of the two checks, asserted through the range the entity reports.
+
+ `number.async_set_value` refuses a service call outside `min_value` /
+ `max_value` before it ever reaches this platform, and for a non-temperature
+ device class those are `native_min_value` / `native_max_value` unconverted.
+ So the range the entity reports has to be the panel's, which is what reading
+ `native_max_value` off the wire buys — the state-side properties are not
+ read here because they raise until the entity is attached to a platform.
+ """
+ tree = schema_one_tree()
+ created = await _created(hass, schema_one_snapshot(tree))
+ entity = _for(created, tree, EVSE)
+
+ assert entity.native_max_value == _published(tree, EVSE, CEILING_TOPIC)
+ assert entity.native_min_value == 0
+
+
+async def test_a_client_with_no_evse_control_is_reported_rather_than_ignored(
+ hass: HomeAssistant,
+) -> None:
+ """A transport that does not implement the control at all — a flat panel's."""
+
+ class _NoEvseControl:
+ """Everything but `set_evse_charge_limit`."""
+
+ tree = schema_one_tree()
+ created = await _created(hass, schema_one_snapshot(tree), _NoEvseControl())
+
+ with pytest.raises(HomeAssistantError) as raised:
+ await _for(created, tree, EVSE).async_set_native_value(16.0)
+
+ assert raised.value.translation_key == "evse_charge_limit_unsupported"
+
+
+# ---------------------------------------------------------------------------
+# The declarations this entity makes about itself
+# ---------------------------------------------------------------------------
+
+
+def test_the_description_names_its_source_field_and_why_it_is_exempt() -> None:
+ """Both `field_path` and `derived`, as a schema-conditional description must.
+
+ The only settable entity to carry the pair, and the Repair naming a dead
+ field has to be able to name this entity too.
+ """
+ assert EVSE_CHARGE_CURRENT_LIMIT.field_path == FIELD_LIMIT
+ assert EVSE_CHARGE_CURRENT_LIMIT.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD
+ assert EVSE_CHARGE_CURRENT_LIMIT in platform_descriptions()
+
+
+def test_every_field_this_control_reads_is_enumerated() -> None:
+ """Four reads, four annotations, each checked against the adapters elsewhere.
+
+ The pair the panel publishes as readings carries a schema_1 metadata row; the
+ pair that describes a command carries none on either adapter.
+ """
+ assert RESIDUAL_EXEMPT_PATHS[FIELD_LIMIT] is Producibility.SCHEMA_1_ONLY
+ assert RESIDUAL_EXEMPT_PATHS[FIELD_CEILING] is Producibility.SCHEMA_1_ONLY
+ assert RESIDUAL_EXEMPT_PATHS[FIELD_TARGET] is Producibility.NEITHER
+ assert RESIDUAL_EXEMPT_PATHS[FIELD_SETTABLE] is Producibility.NEITHER
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index ee480d5f..37b272dc 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -282,7 +282,13 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# algorithm and the two SoC thresholds parsed out of it -- read as
# attributes on `dsm_state`. Flat has no `shed` node, and a JSON document
# has no unit surface for a schema_1 row to describe.
- Producibility.NEITHER: 41,
+ # +2 for the EVSE charge-current control's two non-readings:
+ # `charge_current_limit_settable`, the `$settable` flag `number.py` gates
+ # entity creation on, and `charge_current_limit_target_a`, the Homie
+ # `$target` echo it renders as an attribute. Facts about a command rather
+ # than readings, so no adapter carries a row for either -- the same shape as
+ # the `circuit.*_target` pair.
+ Producibility.NEITHER: 43,
# +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's
# source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and
# enumerated nowhere, so `evaluate_field_metadata` counted it as produced-
@@ -311,7 +317,13 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# circuit's `connection/feeds-device-status` — while flat publishes
# `connected` on the BESS and on no other device class, so neither can
# satisfy the both-adapters gate.
- Producibility.SCHEMA_1_ONLY: 10,
+ # +2 for the EVSE charge-current pair behind the
+ # `evse_charge_current_limit` number: the settable limit its description
+ # names, and the commissioned ceiling the entity reads for
+ # `native_max_value`. schema_1 resolves both from the charger's own
+ # `$description`; flat's `evse` type declares no settable ceiling at all, so
+ # neither can satisfy the both-adapters gate.
+ Producibility.SCHEMA_1_ONLY: 12,
}
"""The exemption inventory, by reason. See `test_exempt_inventory_is_complete`."""
From 85ca61997ba16e4257547426902a28f05edd59b1 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 21:22:17 -0700
Subject: [PATCH 056/116] feat(device): give the solar inverter a card of its
own, and move its entities to it
PV was the last DER on a v1.0 panel with no device. Its vendor, model and
nameplate capacity were rendered as three diagnostic sensors on the *panel's*
card -- beside the panel's own manufacturer and model, so the card whose job is
saying which enclosure this is read as if the enclosure were an Enphase
inverter. The firmware version the library has read all along reached nothing,
because a version has nowhere to go but a card; `pv/info/firmware-version` was
baselined saying exactly that, and that line goes.
`SUB_DEVICE_PV` and `pv_device_info` follow the BESS and the MID, and the four
kinds now sit under one classifier: `classify_sub_device_identifier` tests the
EVSE *infix* before the three suffixes, because a suffix rule cannot tell a
charger whose node id ends in `_pv` from an inverter.
The identifier is `{panel serial}_pv` and deliberately does not mention the
inverter's serial. Every PV description declares `info/serial-number` and no
producer publishes one; an identifier preferring it would change the day
firmware started publishing, and a device identifier is what a consumer keys its
registry on -- so that day would read as the inverter being replaced. The serial
stays baselined, with the reason reworded to name this second thing that would
have moved.
Five entities change device: `pv_power`, `pv_vendor`, `pv_product`,
`pv_nameplate_capacity` and `pv_panel_link`. Home Assistant re-homes an entity
itself when it re-registers with new `device_info`, so there is no bespoke
migration and deliberately none written. What it does not do is re-derive an
`entity_id`, and it derives a new one from the *device* name -- so a fresh
installation would have landed on `sensor.span_panel_solar_pv_vendor` where
every existing one has `sensor.span_panel_pv_vendor`, two shapes for the same
entity decided by install date. `construct_panel_scoped_entity_id` pins the
panel-scoped id on both, which is what `construct_single_circuit_entity_id`
already does for the circuit sensors that live on an EVSE.
`tests/test_pv_device.py` seeds a registry in the released shape, sets both
platforms up through real entity platforms, and reads the registry back: every
entity keeps its `entity_id` and its `unique_id`, lands on the inverter, and
leaves no duplicate or orphan behind. A fresh install is compared against a
migrated one on a second Home Assistant instance and must match entity for
entity. A user-renamed `entity_id` still wins over the pin.
---
custom_components/span_panel/binary_sensor.py | 43 +-
.../span_panel/entity_resolver.py | 34 +
custom_components/span_panel/field_paths.py | 13 +
custom_components/span_panel/helpers.py | 2 +
custom_components/span_panel/sensor.py | 53 +-
custom_components/span_panel/sensor_panel.py | 28 +-
custom_components/span_panel/services.py | 8 +-
custom_components/span_panel/util.py | 60 +-
custom_components/span_panel/websocket.py | 11 +-
.../unread_declarations_baseline.json | 3 +-
tests/test_device_links.py | 34 +-
tests/test_field_path_conformance.py | 6 +-
tests/test_pv_device.py | 739 ++++++++++++++++++
tests/test_sensor_entities.py | 16 +-
tests/test_websocket.py | 10 +-
15 files changed, 1024 insertions(+), 36 deletions(-)
create mode 100644 tests/test_pv_device.py
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 0b2e6bda..f914dee7 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -34,12 +34,13 @@
from .helpers import (
build_binary_sensor_unique_id_for_entry,
build_evse_unique_id_for_entry,
+ construct_panel_scoped_entity_id,
has_bess,
has_mid,
has_pcs,
resolve_evse_display_suffix,
)
-from .util import bess_device_info, evse_device_info
+from .util import bess_device_info, evse_device_info, pv_device_info
# pylint: disable=invalid-overridden-method
@@ -178,8 +179,9 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
one device class whose link the panel happened to report through the lugs was
the only one a user could see.
-On the panel device, beside `pv_vendor` and `pv_product`, because the PV is not
-yet a sub-device of its own. It moves with them when it becomes one.
+On the inverter's own sub-device, beside `pv_vendor` and `pv_product`, which is
+where it moved when the PV got a device of its own -- the same place
+`bess_connected` sits relative to the battery.
`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`: flat firmware publishes
`connected` on the BESS and on nothing else, so the both-adapters gate cannot be
@@ -232,8 +234,16 @@ def __init__(
data_coordinator: SpanPanelCoordinator,
description: T,
device_info_override: DeviceInfo | None = None,
+ entity_id_override: str | None = None,
) -> None:
- """Initialize Span Panel Circuit entity."""
+ """Initialize Span Panel Circuit entity.
+
+ `entity_id_override` is how a sensor that used to sit on the panel's own
+ card keeps the object id that card gave it after moving to a sub-device --
+ see `construct_panel_scoped_entity_id`. Home Assistant treats it as a
+ suggestion and ignores it for an entity already in the registry, so it
+ only ever decides what a *new* installation gets.
+ """
super().__init__(data_coordinator, context=description)
snapshot: SpanPanelSnapshot = data_coordinator.data
@@ -254,6 +264,9 @@ def __init__(
data_coordinator, snapshot, description.key
)
+ if entity_id_override is not None:
+ self.entity_id = entity_id_override
+
@property
def available(self) -> bool:
"""Return entity availability.
@@ -541,7 +554,27 @@ async def async_setup_entry(
# saying it does not know rather than a fault, and the enum it does publish
# has no UNKNOWN member for it to say that with. See `PV_PANEL_LINK_SENSOR`.
if snapshot.pv.connected is not None:
- entities.append(SpanPanelBinarySensor(coordinator, PV_PANEL_LINK_SENSOR))
+ configured_name = coordinator.config_entry.data.get(
+ CONF_DEVICE_NAME, coordinator.config_entry.title
+ )
+ entities.append(
+ SpanPanelBinarySensor(
+ coordinator,
+ PV_PANEL_LINK_SENSOR,
+ device_info_override=pv_device_info(
+ snapshot.serial_number,
+ snapshot.pv,
+ configured_name or "Span Panel",
+ panel_device_id=config_entry.runtime_data.panel_device_id,
+ ),
+ entity_id_override=construct_panel_scoped_entity_id(
+ snapshot,
+ "binary_sensor",
+ PV_PANEL_LINK_SENSOR.translation_key or "",
+ configured_name,
+ ),
+ )
+ )
# Add EVSE binary sensors for each commissioned charger
if snapshot.evse:
diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py
index 88458876..7ea29929 100644
--- a/custom_components/span_panel/entity_resolver.py
+++ b/custom_components/span_panel/entity_resolver.py
@@ -459,3 +459,37 @@ def construct_unmapped_friendly_name(
"""Construct friendly name for unmapped circuit sensors."""
# Format: "Unmapped Tab 32 Consumed Energy"
return f"Unmapped Tab {circuit_number} {sensor_description_name}"
+
+
+def construct_panel_scoped_entity_id(
+ snapshot: SpanPanelSnapshot,
+ platform: str,
+ translation_key: str,
+ device_name: str | None = None,
+) -> str:
+ """Return the entity_id a sub-device entity keeps from the panel's card.
+
+ Home Assistant builds an `has_entity_name` entity's object id from the name of
+ the device it belongs to, so moving an entity to a sub-device would give a
+ *new* installation `sensor.span_panel_solar_pv_vendor` where every existing
+ one has `sensor.span_panel_pv_vendor` -- the registry never renames an entity
+ that already exists. Two shapes for the same entity, decided by install date,
+ is the long-tail bug this pins shut: an entity that was born on the panel card
+ keeps the panel-scoped id on both.
+
+ The same thing `construct_single_circuit_entity_id` already does for the
+ circuit sensors that live on an EVSE sub-device, and for the same reason.
+
+ Suggestion only. Home Assistant honours an `entity_id` an entity sets for
+ itself when it registers, and ignores it for one that is already registered --
+ which is exactly the behaviour wanted here, since an id a user has renamed is
+ theirs.
+
+ `translation_key` rather than a name, because the name comes from the
+ translations and is not loaded when an entity is constructed.
+ `test_the_pinned_ids_are_what_the_panel_card_would_have_generated` holds the
+ two to each other.
+ """
+ device_info = snapshot_to_device_info(snapshot, device_name)
+ panel_name = device_info.get("name") or "Span Panel"
+ return f"{platform}.{slugify(panel_name)}_{translation_key}"
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index ba874ffd..e58dce2b 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -202,6 +202,19 @@ class FieldPathDeclarationMixin:
"mid.model": Producibility.NEITHER,
"mid.serial_number": Producibility.NEITHER,
"mid.grid_forming_device_name": Producibility.NEITHER,
+ # `pv_device_info` reads the inverter's firmware version for its device
+ # card. `pv.vendor_name` and `pv.model` are not here beside it because
+ # they are `field_path` declarations on the three PV metadata sensors
+ # already, and the card reads the same two fields those sensors do.
+ #
+ # `NEITHER` for the same reason as the `mid.*` and `panel.*` card reads
+ # above: flat's `pv` device class declares no firmware version at all,
+ # and a schema_1 metadata row carries a unit and a datatype for a
+ # *reading*, which a version string is not.
+ #
+ # `pv.serial_number` is deliberately absent -- from this table, from the
+ # card and from the snapshot. See `pv_device_info`.
+ "pv.software_version": Producibility.NEITHER,
# The `mid_grid_state` sensor's source field — utility-supply health,
# the one non-metadata entity the MID brings. Neither adapter maps the
# MID at all, which is why the description is `NO_SOURCE_FIELD`.
diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py
index 8609a208..3733020b 100644
--- a/custom_components/span_panel/helpers.py
+++ b/custom_components/span_panel/helpers.py
@@ -20,6 +20,7 @@
build_switch_unique_id_for_entry,
construct_circuit_unique_id_for_entry,
construct_multi_circuit_entity_id,
+ construct_panel_scoped_entity_id,
construct_panel_unique_id_for_entry,
construct_single_circuit_entity_id,
construct_synthetic_unique_id_for_entry,
@@ -79,6 +80,7 @@
"construct_circuit_unique_id",
"construct_circuit_unique_id_for_entry",
"construct_multi_circuit_entity_id",
+ "construct_panel_scoped_entity_id",
"construct_panel_unique_id",
"construct_panel_unique_id_for_entry",
"construct_select_unique_id",
diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py
index cfbc5c77..cdcdd6b9 100644
--- a/custom_components/span_panel/sensor.py
+++ b/custom_components/span_panel/sensor.py
@@ -20,6 +20,7 @@
)
from .coordinator import SpanPanelCoordinator
from .helpers import (
+ construct_panel_scoped_entity_id,
has_bess,
has_bess_telemetry,
has_evse,
@@ -78,7 +79,7 @@
SpanPVMetadataSensor,
SpanShedForecastSensor,
)
-from .util import bess_device_info, evse_device_info, mid_device_info
+from .util import bess_device_info, evse_device_info, mid_device_info, pv_device_info
# Export the sensor classes for backward compatibility with tests
__all__ = [
@@ -478,6 +479,22 @@ def create_battery_sensors(
return entities
+def _build_pv_device_info(
+ coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
+) -> DeviceInfo:
+ """DeviceInfo for the solar inverter sub-device."""
+ panel_name = (
+ coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title)
+ or "Span Panel"
+ )
+ return pv_device_info(
+ snapshot.serial_number,
+ snapshot.pv,
+ panel_name,
+ panel_device_id=coordinator.config_entry.runtime_data.panel_device_id,
+ )
+
+
def create_power_flow_sensors(
coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
) -> list[SpanPanelPowerSensor | SpanPVMetadataSensor]:
@@ -486,15 +503,43 @@ def create_power_flow_sensors(
PV Power — only when PV is commissioned.
Site Power — only when the power-flows node is publishing.
PV metadata sensors — only when PV is commissioned.
+
+ The PV sensors land on the inverter's own sub-device, matching what the BESS
+ has done since v1.0: `battery_power` is the enclosure's reading of the
+ battery and it sits on the battery's card, so `pv_power` -- the enclosure's
+ reading of the inverter -- belongs on the inverter's. Each keeps the
+ panel-scoped entity_id it already has; see `construct_panel_scoped_entity_id`.
"""
entities: list[SpanPanelPowerSensor | SpanPVMetadataSensor] = []
if has_pv(snapshot):
- entities.append(SpanPanelPowerSensor(coordinator, PV_POWER_SENSOR, snapshot))
+ pv_info = _build_pv_device_info(coordinator, snapshot)
+ panel_name = coordinator.config_entry.data.get(
+ CONF_DEVICE_NAME, coordinator.config_entry.title
+ )
+ entities.append(
+ SpanPanelPowerSensor(
+ coordinator,
+ PV_POWER_SENSOR,
+ snapshot,
+ device_info_override=pv_info,
+ entity_id_override=construct_panel_scoped_entity_id(
+ snapshot, "sensor", PV_POWER_SENSOR.translation_key or "", panel_name
+ ),
+ )
+ )
- # PV metadata sensors on the main panel device
entities.extend(
- SpanPVMetadataSensor(coordinator, desc, snapshot) for desc in PV_METADATA_SENSORS
+ SpanPVMetadataSensor(
+ coordinator,
+ desc,
+ snapshot,
+ pv_info,
+ construct_panel_scoped_entity_id(
+ snapshot, "sensor", desc.translation_key or "", panel_name
+ ),
+ )
+ for desc in PV_METADATA_SENSORS
)
if has_power_flows(snapshot):
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index 161fcc47..dba89837 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -457,13 +457,23 @@ def __init__(
description: SpanPanelDataSensorEntityDescription,
snapshot: SpanPanelSnapshot,
device_info_override: DeviceInfo | None = None,
+ entity_id_override: str | None = None,
) -> None:
- """Initialize the enhanced panel power sensor."""
+ """Initialize the enhanced panel power sensor.
+
+ `entity_id_override` is how a power sensor that used to sit on the panel's
+ own card keeps the object id that card gave it after moving to a
+ sub-device -- see `construct_panel_scoped_entity_id`. Home Assistant
+ treats it as a suggestion and ignores it for an entity already in the
+ registry, so it only ever decides what a *new* installation gets.
+ """
self._description_key = description.key
super().__init__(data_coordinator, description, snapshot)
if device_info_override is not None:
self._attr_device_info = device_info_override
+ if entity_id_override is not None:
+ self.entity_id = entity_id_override
def _generate_unique_id(
self,
@@ -657,16 +667,30 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot:
class SpanPVMetadataSensor(
SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPanelSnapshot]
):
- """PV metadata sensor entity on the main panel device."""
+ """PV metadata sensor entity on the PV sub-device.
+
+ On the panel's own card until the inverter got one of its own, which put the
+ inverter's vendor and model beside the *panel's* vendor and model on the card
+ whose job is saying which enclosure this is.
+
+ The unique_id stays the panel-scoped one `construct_panel_unique_id_for_entry`
+ has always built, because a unique_id is an identity and these are the same
+ three entities they were. Only the device they hang off changes, which is a
+ registry update Home Assistant performs itself when the entity re-registers.
+ """
def __init__(
self,
data_coordinator: SpanPanelCoordinator,
description: SpanPVMetadataSensorEntityDescription,
snapshot: SpanPanelSnapshot,
+ device_info_override: DeviceInfo,
+ entity_id_override: str,
) -> None:
"""Initialize the PV metadata sensor."""
super().__init__(data_coordinator, description, snapshot)
+ self._attr_device_info = device_info_override
+ self.entity_id = entity_id_override
def _generate_unique_id(
self,
diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py
index 0500424b..24581d91 100644
--- a/custom_components/span_panel/services.py
+++ b/custom_components/span_panel/services.py
@@ -513,8 +513,10 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin
``kind`` is ``"circuits"`` or ``"sub_devices"``. For circuits,
``target_id`` is the panel-local circuit uuid (extracted from the
- entity's unique_id). For sub-devices, ``target_id`` is the HA
- device id of the BESS/EVSE; the panel id walks up via ``via_device_id``.
+ entity's unique_id). For sub-devices, ``target_id`` is the HA device id
+ of the sub-device; the panel id walks up via ``via_device_id``. Nothing
+ here enumerates the kinds, so a new one -- the PV inverter most recently
+ -- is favouritable the day its device exists.
Failure paths use distinct translation keys so users see the
actual reason their pick was rejected.
@@ -551,7 +553,7 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin
# Resolve the panel device id. Sub-devices register with
# via_device_id; main panels never do, so via_device_id presence is a
- # reliable discriminator (BESS / EVSE today) and we walk up to the
+ # reliable discriminator whatever kinds exist, and we walk up to the
# parent SPAN Panel here.
if device_entry.via_device_id is not None:
parent = device_registry.async_get(device_entry.via_device_id)
diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py
index 85024db4..a821d391 100644
--- a/custom_components/span_panel/util.py
+++ b/custom_components/span_panel/util.py
@@ -9,6 +9,7 @@
SpanEvseSnapshot,
SpanMidSnapshot,
SpanPanelSnapshot,
+ SpanPVSnapshot,
)
from .const import DOMAIN
@@ -35,6 +36,7 @@
SUB_DEVICE_BESS: Final = "bess"
SUB_DEVICE_MID: Final = "mid"
SUB_DEVICE_EVSE: Final = "evse"
+SUB_DEVICE_PV: Final = "pv"
def classify_sub_device_identifier(identifier: str) -> str | None:
@@ -43,15 +45,23 @@ def classify_sub_device_identifier(identifier: str) -> str | None:
None rather than an "unknown" string: the caller knows whether it is looking
at something that must be a sub-device, and a sentinel that reads like a kind
is what let an unclassified device render as a device with no type.
+
+ **Most specific first.** EVSE is the one kind whose token is an infix rather
+ than a suffix, and a suffix test cannot tell `..._evse_inverter_pv` from a PV
+ identifier. Testing the infix first makes the charger's node id opaque to the
+ suffix rules below, which is the only ordering that stays right whatever a
+ panel names its nodes.
"""
- if identifier.endswith(f"_{SUB_DEVICE_BESS}"):
- return SUB_DEVICE_BESS
- if identifier.endswith(f"_{SUB_DEVICE_MID}"):
- return SUB_DEVICE_MID
# Infix, not suffix: the node id follows, and it is what distinguishes one
# charger from another on the same panel.
if f"_{SUB_DEVICE_EVSE}_" in identifier:
return SUB_DEVICE_EVSE
+ if identifier.endswith(f"_{SUB_DEVICE_BESS}"):
+ return SUB_DEVICE_BESS
+ if identifier.endswith(f"_{SUB_DEVICE_MID}"):
+ return SUB_DEVICE_MID
+ if identifier.endswith(f"_{SUB_DEVICE_PV}"):
+ return SUB_DEVICE_PV
return None
@@ -181,3 +191,45 @@ def evse_device_info(
sw_version=evse.software_version,
via_device_id=panel_device_id,
)
+
+
+def pv_device_info(
+ panel_identifier: str,
+ pv: SpanPVSnapshot,
+ panel_name: str,
+ *,
+ panel_device_id: str,
+) -> DeviceInfo:
+ """Create DeviceInfo for the solar inverter, linked to the parent panel.
+
+ The last DER to get a card of its own. Its vendor, model and nameplate
+ capacity have been readable all along and were shown as three diagnostic
+ sensors on the *panel's* card, beside the panel's own manufacturer and model,
+ which reads as if the enclosure were an Enphase inverter. The firmware
+ version the library also reads reached nothing at all, because a version has
+ no home but a device card.
+
+ **The identifier deliberately does not mention the inverter's serial.**
+ `info/serial-number` is declared by every PV `$description` and published by
+ no producer today, so an identifier preferring it would be `_pv` on
+ every panel now and `_` on the first panel whose firmware
+ starts publishing one -- and a device identifier is what a consumer keys its
+ registry on, so that day would read as the inverter being replaced rather
+ than as a value arriving. `{panel serial}_pv` answers the only question an
+ identifier has to answer, "which panel's inverter", and a panel has exactly
+ one `pv` node, so nothing distinguishes two of them. The serial is not on the
+ card either, for the same reason it is not in the identifier: nothing in this
+ integration should start depending on it before a producer publishes one.
+ """
+ return DeviceInfo(
+ identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_PV}")},
+ name=f"{panel_name} Solar",
+ manufacturer=pv.vendor_name or "Unknown",
+ model=pv.model or "Solar Inverter",
+ # Passed through unguarded, as on the BESS and the MID: `DeviceInfo`
+ # omits a `None` field and renders an empty string as a present-but-blank
+ # row, so `or ""` would invent a version row for an inverter that
+ # published none.
+ sw_version=pv.software_version,
+ via_device_id=panel_device_id,
+ )
diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py
index f2969a60..b969bf8b 100644
--- a/custom_components/span_panel/websocket.py
+++ b/custom_components/span_panel/websocket.py
@@ -75,8 +75,9 @@ async def handle_panel_topology(
"""Return the full panel topology with entity mappings.
Admin users must pass the HA device registry ID for the **main SPAN panel**
- device only (not BESS/EVSE sub-devices). Returns panel metadata, circuits
- with tabs/entity mappings, and sub-devices (BESS, EVSE).
+ device only (not a sub-device). Returns panel metadata, circuits with
+ tabs/entity mappings, and sub-devices -- every kind
+ `classify_sub_device_identifier` names, which is BESS, MID, EVSE and PV.
"""
device_id = msg["device_id"]
@@ -92,12 +93,12 @@ async def handle_panel_topology(
connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device")
return
- # Sub-devices (BESS, EVSE) register with via_device_id pointing at the panel.
+ # Every sub-device registers with via_device_id pointing at the panel.
if device_entry.via_device_id is not None:
connection.send_error(
msg["id"],
"not_panel_device",
- "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.",
+ "Use the SPAN panel device registry ID, not a sub-device.",
)
return
@@ -225,7 +226,7 @@ def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None:
def _classify_sub_device(device_entry: dr.DeviceEntry) -> str:
- """Classify a sub-device by its identifiers: 'bess', 'mid', 'evse' or 'unknown'.
+ """Classify a sub-device by its identifiers, or 'unknown'.
The grammar lives with the builders that write it, in `util`, rather than
being restated here. Restating it is how the MID went out as 'unknown' for a
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
index b9767d52..8cfe362e 100644
--- a/tests/fixtures/unread_declarations_baseline.json
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -8,6 +8,5 @@
"lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
"lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it",
"lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
- "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version - it lands with the PV sub-device (Task 7)",
- "pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)"
+ "pv/info/serial-number": "held, and the PV device identifier deliberately does not use it: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade. The Home Assistant sub-device is keyed _pv for the same reason, so a serial arriving on the wire moves nothing"
}
diff --git a/tests/test_device_links.py b/tests/test_device_links.py
index de91e06c..6ea08af2 100644
--- a/tests/test_device_links.py
+++ b/tests/test_device_links.py
@@ -21,7 +21,12 @@
from typing import Any
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from span_panel_api import SpanMidSnapshot, SpanPVSnapshot
from custom_components.span_panel import ensure_device_registered
from custom_components.span_panel.const import DOMAIN
@@ -30,11 +35,8 @@
classify_sub_device_identifier,
evse_device_info,
mid_device_info,
+ pv_device_info,
)
-from homeassistant.const import CONF_HOST
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr
-from span_panel_api import SpanMidSnapshot
from .factories import (
SpanBatterySnapshotFactory,
@@ -42,8 +44,6 @@
SpanPanelSnapshotFactory,
)
-from pytest_homeassistant_custom_component.common import MockConfigEntry
-
_PANEL_ID = "a-registry-id"
@@ -77,6 +77,15 @@ def _builders() -> list[tuple[str, Any]]:
"sp3-link-001", _mid(), "Panel", panel_device_id=_PANEL_ID
),
),
+ (
+ "pv",
+ lambda: pv_device_info(
+ "sp3-link-001",
+ SpanPVSnapshot(vendor_name="Enphase", model="IQ8", software_version="v1"),
+ "Panel",
+ panel_device_id=_PANEL_ID,
+ ),
+ ),
(
"evse",
lambda: evse_device_info(
@@ -118,6 +127,19 @@ def test_the_panel_is_not_mistaken_for_a_sub_device() -> None:
assert classify_sub_device_identifier("sim-40t-001") is None
+def test_a_charger_node_ending_in_a_kind_is_still_a_charger() -> None:
+ """The infix wins over the suffixes, which is why it is tested first.
+
+ A charger's identifier carries its Homie node id, and nothing stops a node id
+ from ending in `_pv` or `_bess`. Testing the suffix rules first would classify
+ such a charger as whatever its node id happened to end with -- a device
+ rendering as the wrong type on a dashboard, which is the failure the MID
+ already shipped once.
+ """
+ assert classify_sub_device_identifier("sp3-link-001_evse_inverter_pv") == "evse"
+ assert classify_sub_device_identifier("sp3-link-001_evse_garage_bess") == "evse"
+
+
@pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "")
def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None:
"""Both halves asserted: the new key is set and the old one is gone.
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 37b272dc..2e4e0bf2 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -288,7 +288,11 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# `$target` echo it renders as an attribute. Facts about a command rather
# than readings, so no adapter carries a row for either -- the same shape as
# the `circuit.*_target` pair.
- Producibility.NEITHER: 43,
+ # +1 for `pv.software_version`, the firmware row on the solar inverter's own
+ # device card. Flat's `pv` device class declares no firmware version, and a
+ # version string is identity rather than a reading -- the same argument as
+ # the `mid.*` and `panel.*` card reads above.
+ Producibility.NEITHER: 44,
# +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's
# source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and
# enumerated nowhere, so `evaluate_field_metadata` counted it as produced-
diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py
new file mode 100644
index 00000000..aa72b9df
--- /dev/null
+++ b/tests/test_pv_device.py
@@ -0,0 +1,739 @@
+"""The solar inverter gets a device of its own, and takes its entities with it.
+
+PV was the last DER on a v1.0 panel with no card. Its vendor, model and
+nameplate capacity were rendered as three diagnostic sensors on the *panel's*
+device — beside the panel's own manufacturer and model, so a panel card read as
+if the enclosure were an Enphase inverter — and the firmware version the library
+has always read reached nothing at all, because a version has nowhere to go but
+a card. `pv/info/firmware-version` was baselined naming exactly that.
+
+**The identifier is the decision worth writing down.** `{panel serial}_pv`, and
+deliberately not the inverter's serial. Every PV `$description` declares
+`info/serial-number` and no producer publishes one, so an identifier that
+preferred a serial would be `_pv` on every panel today and
+`_` on the first panel whose firmware starts publishing one — and
+a device identifier is what a consumer keys its registry on, so that day would
+read as the inverter being replaced. `test_a_serial_arriving_on_the_wire_does_not_move_the_device`
+produces that day and asserts nothing moves.
+
+**The migration is the risk.** Existing installations have these entities on the
+panel device with panel-scoped `unique_id`s and panel-scoped `entity_id`s. Moving
+an entity to another device must change *only* the device: a changed `entity_id`
+breaks a dashboard, a changed `unique_id` orphans the entity and mints a
+duplicate. Home Assistant re-homes an entity by itself when it re-registers with
+new `device_info`, which is the mechanism used here; what it does *not* do is
+re-derive an `entity_id`, so a fresh installation would otherwise get
+`sensor.span_panel_solar_pv_vendor` where every existing one has
+`sensor.span_panel_pv_vendor`. `construct_panel_scoped_entity_id` pins that shut
+and `test_a_fresh_install_and_a_migrated_one_are_the_same_shape` is the proof.
+
+Every expectation about what the card shows is read out of the vendored capture
+and proved by republishing or unpublishing the property, never by a literal. The
+registry-shape expectations *are* literals, on purpose: they record what a
+released installation carries, and deriving them from the code under test would
+make the migration assertions vacuous.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable
+from contextlib import asynccontextmanager
+import json
+import logging
+import pathlib
+from typing import Final
+from unittest.mock import AsyncMock, MagicMock
+
+from span_panel_api import SpanPanelSnapshot
+
+from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered
+from custom_components.span_panel.binary_sensor import (
+ PV_PANEL_LINK_SENSOR,
+ async_setup_entry as binary_sensor_setup_entry,
+)
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ Producibility,
+ declared_field_paths,
+)
+from custom_components.span_panel.sensor import async_setup_entry as sensor_setup_entry
+from custom_components.span_panel.sensor_definitions import PV_METADATA_SENSORS, PV_POWER_SENSOR
+from custom_components.span_panel.util import SUB_DEVICE_PV, classify_sub_device_identifier
+from custom_components.span_panel.websocket import _classify_sub_device
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.util import slugify
+
+from .adapter_fixtures import schema_one_snapshot, schema_one_tree
+
+from pytest_homeassistant_custom_component.common import (
+ MockConfigEntry,
+ MockEntityPlatform,
+ async_test_home_assistant,
+)
+
+PV_DEVICE: Final = "pv"
+"""The inverter's Homie device id in the capture."""
+
+VENDOR_TOPIC: Final = "info/vendor-name"
+MODEL_TOPIC: Final = "info/model"
+FIRMWARE_TOPIC: Final = "info/firmware-version"
+SERIAL_TOPIC: Final = "info/serial-number"
+
+PANEL_NAME: Final = "SPAN Panel"
+
+FALLBACK_MANUFACTURER: Final = "Unknown"
+FALLBACK_MODEL: Final = "Solar Inverter"
+
+BASELINE: Final = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json"
+
+_LEGACY_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = (
+ ("sensor", "pv_power", "sensor.span_panel_pv_power"),
+ ("sensor", "pv_vendor", "sensor.span_panel_pv_vendor"),
+ ("sensor", "pv_product", "sensor.span_panel_pv_product"),
+ ("sensor", "pv_nameplate_capacity", "sensor.span_panel_pv_nameplate_capacity"),
+ ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_pv_panel_link"),
+)
+"""``(platform, unique_id suffix, entity_id)`` as a released installation holds them.
+
+Literals, deliberately. This is the registry a user upgrading already has, which
+is a historical fact rather than something the current code gets to decide —
+deriving it from the builders under test would make every assertion below agree
+with itself. `test_a_fresh_install_still_builds_the_unique_ids_users_already_have`
+is what holds the literals to the code.
+"""
+
+
+# ---------------------------------------------------------------------------
+# Reading the capture
+# ---------------------------------------------------------------------------
+
+
+def _published(topic: str) -> str:
+ """What the capture publishes on one PV topic, or fail saying it does not."""
+ value = schema_one_tree()[PV_DEVICE].get(topic)
+ assert value is not None, f"the capture publishes no {topic} on the inverter"
+ return value
+
+
+def _declared(topic: str) -> bool:
+ """Whether the inverter's `$description` declares one `node/property`."""
+ description = json.loads(schema_one_tree()[PV_DEVICE]["$description"])
+ node, _, prop = topic.partition("/")
+ return prop in description["nodes"].get(node, {}).get("properties", {})
+
+
+def _pv_snapshot(**rewrites: str | None) -> SpanPanelSnapshot:
+ """A snapshot from the capture with the inverter's topics rewritten or removed.
+
+ Keyword spelling is `node__property_name`. `None` removes the topic, which is
+ what firmware omitting a property looks like — a different event from
+ publishing an empty string, and the one the card's fallbacks exist for.
+ """
+ tree = schema_one_tree()
+ for path, value in rewrites.items():
+ node, _, prop = path.partition("__")
+ topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}"
+ if value is None:
+ tree[PV_DEVICE].pop(topic, None)
+ else:
+ tree[PV_DEVICE][topic] = value
+ return schema_one_snapshot(tree)
+
+
+# ---------------------------------------------------------------------------
+# Installing for real
+# ---------------------------------------------------------------------------
+
+
+def _entry(hass: HomeAssistant, entry_id: str, serial: str) -> MockConfigEntry:
+ """A config entry keyed on the panel's serial, as the config flow makes one."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "192.168.1.40", "device_name": PANEL_NAME},
+ options={},
+ title=PANEL_NAME,
+ entry_id=entry_id,
+ unique_id=serial,
+ )
+ entry.add_to_hass(hass)
+ return entry
+
+
+def _coordinator(
+ hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot
+) -> MagicMock:
+ coordinator = MagicMock()
+ coordinator.data = snapshot
+ coordinator.hass = hass
+ coordinator.panel_offline = False
+ coordinator.last_update_success = True
+ coordinator.unresolved_paths = frozenset()
+ coordinator.config_entry = entry
+ coordinator.async_request_refresh = AsyncMock()
+ return coordinator
+
+
+async def _register(
+ hass: HomeAssistant,
+ entry: MockConfigEntry,
+ platform: str,
+ setup: Callable[..., object],
+) -> None:
+ """Run one platform's `async_setup_entry` through a real `EntityPlatform`.
+
+ Through the platform rather than by inspecting the entities the setup
+ function returns, because everything under test here happens *in* the
+ registry: which device an entity is filed under, and which `entity_id` it
+ keeps. Neither is observable on an entity object.
+ """
+ added: list[object] = []
+ await setup(hass, entry, lambda entities, **_: added.extend(entities))
+
+ entity_platform = MockEntityPlatform(
+ hass, domain=platform, platform_name=DOMAIN, logger=logging.getLogger(__name__)
+ )
+ entity_platform.config_entry = entry
+ # The translations decide the object id Home Assistant derives, so a harness
+ # that skipped them would generate `sensor.span_panel_2` and prove nothing
+ # about the ids a user sees.
+ await entity_platform.platform_data.async_load_translations()
+ await entity_platform.async_add_entities(added)
+
+
+async def _install(
+ hass: HomeAssistant,
+ snapshot: SpanPanelSnapshot,
+ entry_id: str,
+ *,
+ seed: Callable[[HomeAssistant, MockConfigEntry, str], None] | None = None,
+) -> MockConfigEntry:
+ """Set up both platforms the way the integration does, optionally over a seeded registry."""
+ entry = _entry(hass, entry_id, snapshot.serial_number)
+ panel_device_id = await ensure_device_registered(hass, entry, snapshot, PANEL_NAME)
+ if seed is not None:
+ seed(hass, entry, panel_device_id)
+
+ coordinator = _coordinator(hass, entry, snapshot)
+ entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id=panel_device_id
+ )
+ await _register(hass, entry, "sensor", sensor_setup_entry)
+ await _register(hass, entry, "binary_sensor", binary_sensor_setup_entry)
+ return entry
+
+
+@asynccontextmanager
+async def _a_second_home_assistant() -> AsyncIterator[HomeAssistant]:
+ """A second, empty Home Assistant, for comparing two installations.
+
+ Both halves of the fresh-versus-migrated comparison have to be *the* install
+ on their instance. Running them into one registry makes the second collide
+ with the first and land on `..._pv_vendor_2`, which is an artefact of the
+ harness and would mask or invent a divergence either way.
+ """
+ async with async_test_home_assistant() as second:
+ try:
+ yield second
+ finally:
+ await second.async_stop(force=True)
+
+
+def _seed_the_old_shape(hass: HomeAssistant, entry: MockConfigEntry, panel_device_id: str) -> None:
+ """Write the PV entities onto the panel device, as a released install holds them."""
+ registry = er.async_get(hass)
+ for platform, suffix, entity_id in _LEGACY_PV_ENTITIES:
+ created = registry.async_get_or_create(
+ platform,
+ DOMAIN,
+ f"span_{entry.unique_id}_{suffix}",
+ config_entry=entry,
+ device_id=panel_device_id,
+ suggested_object_id=entity_id.split(".", 1)[1],
+ )
+ assert created.entity_id == entity_id, (
+ f"the seed could not reproduce {entity_id}; it landed on {created.entity_id}"
+ )
+
+
+def _pv_device(hass: HomeAssistant, entry: MockConfigEntry) -> dr.DeviceEntry | None:
+ return dr.async_get(hass).async_get_device_by_identifier(
+ (DOMAIN, f"{entry.unique_id}_{SUB_DEVICE_PV}"), entry.entry_id
+ )
+
+
+def _registry_shape(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, tuple[str, ...]]:
+ """``{entity_id: (unique_id, *device identifiers)}`` for every entity of one entry.
+
+ Devices by identifier rather than by registry id, because two installations
+ mint different registry ids for the same device and the identifier is the
+ stable name for "which card is this on".
+ """
+ devices = dr.async_get(hass)
+ shape: dict[str, tuple[str, ...]] = {}
+ for entity in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id):
+ device = devices.async_get(entity.device_id) if entity.device_id else None
+ identifiers = sorted(name for _domain, name in device.identifiers) if device else []
+ shape[entity.entity_id] = (entity.unique_id, *identifiers)
+ return shape
+
+
+# ---------------------------------------------------------------------------
+# The premise
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_publishes_the_identity_the_card_shows() -> None:
+ """Guard the premise: every card expectation below is read from these topics.
+
+ A test whose expected value comes from an unpublished topic does not fail, it
+ stops asserting anything — which is how the fixture drifted eight identity
+ properties behind the producer without a single red test.
+ """
+ assert _published(VENDOR_TOPIC)
+ assert _published(MODEL_TOPIC)
+ assert _published(FIRMWARE_TOPIC)
+
+
+def test_the_capture_declares_a_serial_and_publishes_none() -> None:
+ """The premise of the identifier decision, held to the capture.
+
+ If a producer ever values this, the choice of identifier stops being
+ hypothetical and `test_a_serial_arriving_on_the_wire_does_not_move_the_device`
+ stops being a simulation. Either way the identifier must not move, which is
+ what the two together assert.
+ """
+ assert _declared(SERIAL_TOPIC), "the inverter no longer declares a serial number"
+ assert schema_one_tree()[PV_DEVICE].get(SERIAL_TOPIC) is None, (
+ "the capture now values PV info/serial-number; decision 3 says it stays unvalued "
+ "until the flat side's PV device id is confirmed"
+ )
+
+
+# ---------------------------------------------------------------------------
+# The card
+# ---------------------------------------------------------------------------
+
+
+async def test_the_inverter_gets_a_card_showing_what_it_publishes(
+ hass: HomeAssistant,
+) -> None:
+ """Manufacturer, model and firmware read off the wire, not out of a constant."""
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-card")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert device.manufacturer == _published(VENDOR_TOPIC)
+ assert device.model == _published(MODEL_TOPIC)
+ assert device.sw_version == _published(FIRMWARE_TOPIC)
+
+
+async def test_the_card_follows_a_republished_identity(hass: HomeAssistant) -> None:
+ """The card tracks the wire, so nothing above is passing on a coincidence."""
+ rewritten = _pv_snapshot(
+ info__vendor_name="Another Vendor",
+ info__model="ANOTHER-MODEL-1",
+ info__firmware_version="example-pv/v9.9.9",
+ )
+
+ entry = await _install(hass, rewritten, "entry-pv-card-rewritten")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert device.manufacturer == "Another Vendor"
+ assert device.model == "ANOTHER-MODEL-1"
+ assert device.sw_version == "example-pv/v9.9.9"
+
+
+async def test_an_inverter_publishing_no_identity_gets_a_card_with_no_blank_rows(
+ hass: HomeAssistant,
+) -> None:
+ """The fallbacks, and the difference between an absent row and a blank one.
+
+ Vendor and model fall back to strings because a card with no name at all is
+ worse than a generic one. The firmware version has no string to fall back to
+ and must be *absent* rather than empty: `DeviceInfo` omits a `None` field and
+ renders `""` as a present-but-blank row, which reads as an inverter reporting
+ a blank version rather than one reporting none.
+ """
+ bare = _pv_snapshot(
+ info__vendor_name=None,
+ info__model=None,
+ info__firmware_version=None,
+ )
+
+ entry = await _install(hass, bare, "entry-pv-card-bare")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert device.manufacturer == FALLBACK_MANUFACTURER
+ assert device.model == FALLBACK_MODEL
+ assert device.sw_version is None
+
+
+async def test_the_card_hangs_off_the_panel_like_every_other_sub_device(
+ hass: HomeAssistant,
+) -> None:
+ """By registry id, which is the link Home Assistant stops dropping in 2027.8."""
+ snapshot = _pv_snapshot()
+ entry = await _install(hass, snapshot, "entry-pv-link")
+
+ device = _pv_device(hass, entry)
+ panel = dr.async_get(hass).async_get_device_by_identifier(
+ (DOMAIN, snapshot.serial_number), entry.entry_id
+ )
+ assert device is not None
+ assert panel is not None
+ assert device.via_device_id == panel.id
+
+
+async def test_a_panel_with_no_inverter_gets_no_card(hass: HomeAssistant) -> None:
+ """Absence is a reading. No PV node, no device, and no entities to re-home."""
+ tree = schema_one_tree()
+ del tree[PV_DEVICE]
+ for topics in tree.values():
+ topics.pop("power-flows/pv", None)
+ for circuit, topics in tree.items():
+ if topics.get("connection/feeds-device-id") == PV_DEVICE:
+ topics.pop("connection/feeds-device-id", None)
+ topics.pop("connection/feeds-device-status", None)
+ topics.pop("connection/feeds-device-type", None)
+
+ entry = await _install(hass, schema_one_snapshot(tree), "entry-pv-absent")
+
+ assert _pv_device(hass, entry) is None
+ shape = _registry_shape(hass, entry)
+ assert not [entity_id for entity_id in shape if "_pv_" in entity_id]
+
+
+# ---------------------------------------------------------------------------
+# The identifier
+# ---------------------------------------------------------------------------
+
+
+async def test_a_serial_arriving_on_the_wire_does_not_move_the_device(
+ hass: HomeAssistant,
+) -> None:
+ """The whole reason the identifier does not mention the inverter's serial.
+
+ `_der_identifier` on the producer side prefers a serial over an instance id,
+ so the day firmware starts publishing `info/serial-number` the inverter's
+ *Homie* device id changes. A Home Assistant identifier derived from it would
+ change with it, and a changed identifier is a new device: the card empties,
+ the entities orphan, and an upgrade rehearsal becomes a device-replacement
+ rehearsal. Keyed on the panel's serial and the kind instead, so this test
+ publishes a serial and watches nothing move.
+ """
+ before = await _install(hass, _pv_snapshot(), "entry-pv-noserial")
+ identifier_before = _pv_device(hass, before)
+ shape_before = _registry_shape(hass, before)
+
+ async with _a_second_home_assistant() as second:
+ after = await _install(
+ second,
+ _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001"),
+ "entry-pv-serial",
+ )
+ identifier_after = _pv_device(second, after)
+ shape_after = _registry_shape(second, after)
+
+ assert identifier_before is not None
+ assert identifier_after is not None
+ assert identifier_before.identifiers == identifier_after.identifiers
+ assert shape_before == shape_after
+
+
+async def test_the_topology_reader_calls_the_new_card_a_pv(hass: HomeAssistant) -> None:
+ """The writing end and the reading end of the identifier grammar agree.
+
+ The MID shipped classifying as `unknown` because a kind was added to the
+ builders and not to the reader, and a card rendered a device with a name and
+ no type. Asserted against the device as registered rather than against the
+ builder's dict, because the reader is handed a `DeviceEntry`.
+ """
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-classify")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert _classify_sub_device(device) == SUB_DEVICE_PV
+ assert classify_sub_device_identifier(f"{entry.unique_id}_{SUB_DEVICE_PV}") == SUB_DEVICE_PV
+
+
+# ---------------------------------------------------------------------------
+# Where the entities land, and what they keep
+# ---------------------------------------------------------------------------
+
+
+def _pv_entities(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, er.RegistryEntry]:
+ """The five PV entities, by `entity_id`, or fail naming the ones missing."""
+ registry = er.async_get(hass)
+ found: dict[str, er.RegistryEntry] = {}
+ missing: list[str] = []
+ for platform, suffix, _entity_id in _LEGACY_PV_ENTITIES:
+ entity_id = registry.async_get_entity_id(
+ platform, DOMAIN, f"span_{entry.unique_id}_{suffix}"
+ )
+ if entity_id is None:
+ missing.append(suffix)
+ continue
+ entity = registry.async_get(entity_id)
+ assert entity is not None
+ found[entity_id] = entity
+ assert not missing, f"the platform created no entity for {missing}"
+ return found
+
+
+async def test_a_fresh_install_still_builds_the_unique_ids_users_already_have(
+ hass: HomeAssistant,
+) -> None:
+ """The literals above are the current code's output, so the migration tests bite.
+
+ A `unique_id` is an identity: changing one does not rename an entity, it
+ orphans the old one and mints a second. This holds the recorded shape to the
+ builders, so a change to either fails here rather than in an upgrade.
+ """
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-uids")
+
+ assert set(_pv_entities(hass, entry)) == {
+ entity_id for _platform, _suffix, entity_id in _LEGACY_PV_ENTITIES
+ }
+
+
+async def test_every_pv_entity_lands_on_the_inverters_card(hass: HomeAssistant) -> None:
+ """All five, and none of them left behind on the panel."""
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-home")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ for entity_id, entity in _pv_entities(hass, entry).items():
+ assert entity.device_id == device.id, f"{entity_id} is not on the inverter's card"
+
+
+def test_the_pinned_ids_are_what_the_panel_card_would_have_generated() -> None:
+ """The pin reproduces Home Assistant's own derivation, rather than guessing it.
+
+ `construct_panel_scoped_entity_id` builds `{panel slug}_{translation key}`,
+ while Home Assistant builds `{device name} {entity name}` and slugifies it.
+ The two agree only because every PV entity's translated name slugifies to its
+ translation key — true today, and silently untrue the day somebody renames
+ one, which would make a *new* installation's entity_id differ from every
+ existing one. Read out of `strings.json`, which is the file that would change.
+ """
+ strings = json.loads(
+ (
+ pathlib.Path(__file__).parent.parent
+ / "custom_components"
+ / "span_panel"
+ / "strings.json"
+ ).read_text(encoding="utf-8")
+ )
+ for platform, suffix, entity_id in _LEGACY_PV_ENTITIES:
+ name = strings["entity"][platform][suffix]["name"]
+ assert slugify(name) == suffix, (
+ f"{platform}.{suffix} is named {name!r}, which slugifies to "
+ f"{slugify(name)!r}; the pinned entity_id would stop matching what "
+ "Home Assistant derives"
+ )
+ assert entity_id == f"{platform}.{slugify(PANEL_NAME)}_{slugify(name)}"
+
+
+def test_the_descriptions_carry_the_translation_keys_the_pin_reads() -> None:
+ """The pin reads `translation_key`, so a description without one would pin to ``.
+
+ An empty key would build `sensor.span_panel_` for every PV entity and Home
+ Assistant would resolve the collision by appending numbers — a silent,
+ permanent scrambling of five entity ids on new installations only.
+ """
+ keys = {PV_POWER_SENSOR.translation_key} | {
+ description.translation_key for description in PV_METADATA_SENSORS
+ }
+ keys.add(PV_PANEL_LINK_SENSOR.translation_key)
+ assert keys == {suffix for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES}
+
+
+# ---------------------------------------------------------------------------
+# The migration
+# ---------------------------------------------------------------------------
+
+
+async def test_the_migration_moves_the_device_and_nothing_else(
+ hass: HomeAssistant,
+) -> None:
+ """Seed the released shape, set up, and read the registry back.
+
+ Home Assistant re-homes an entity when it re-registers with different
+ `device_info` — `async_get_or_create` updates `device_id` on an existing
+ entry — so there is no bespoke migration to write and deliberately none
+ written. What that mechanism does *not* touch is the `entity_id` or the
+ `unique_id`, and those are what a user's dashboards and automations name, so
+ they are asserted one by one rather than in aggregate.
+ """
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate", seed=_seed_the_old_shape)
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ registry = er.async_get(hass)
+ for platform, suffix, entity_id in _LEGACY_PV_ENTITIES:
+ unique_id = f"span_{entry.unique_id}_{suffix}"
+ entity = registry.async_get(entity_id)
+ assert entity is not None, f"{entity_id} no longer exists after setup"
+ assert entity.unique_id == unique_id, f"{entity_id} changed unique_id"
+ assert entity.platform == DOMAIN
+ assert entity.domain == platform
+ assert entity.device_id == device.id, f"{entity_id} did not move to the inverter"
+
+
+async def test_the_migration_leaves_no_duplicate_and_no_orphan(
+ hass: HomeAssistant,
+) -> None:
+ """The two failure modes a re-home has, made observable.
+
+ A duplicate: the entity re-registers under a new `unique_id`, so the old
+ registry entry survives beside a new one and a user sees each reading twice.
+ An orphan: the panel device keeps an entity nothing writes to any more. Both
+ are counted rather than spot-checked, because either would otherwise hide
+ among forty circuit entities.
+ """
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate-clean", seed=_seed_the_old_shape)
+
+ device = _pv_device(hass, entry)
+ panel = dr.async_get(hass).async_get_device_by_identifier(
+ (DOMAIN, entry.unique_id or ""), entry.entry_id
+ )
+ assert device is not None
+ assert panel is not None
+
+ entities = er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
+ unique_ids = [entity.unique_id for entity in entities]
+ assert len(unique_ids) == len(set(unique_ids)), "an entity re-registered under a second id"
+
+ pv_unique_ids = {f"span_{entry.unique_id}_{suffix}" for _p, suffix, _e in _LEGACY_PV_ENTITIES}
+ left_behind = [
+ entity.entity_id
+ for entity in entities
+ if entity.unique_id in pv_unique_ids and entity.device_id != device.id
+ ]
+ assert not left_behind, f"still on the panel card: {left_behind}"
+
+ pv_devices = [
+ candidate
+ for candidate in dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id)
+ if any(
+ classify_sub_device_identifier(name) == SUB_DEVICE_PV
+ for _domain, name in candidate.identifiers
+ )
+ ]
+ assert len(pv_devices) == 1, f"{len(pv_devices)} PV devices registered, expected 1"
+
+
+async def test_a_fresh_install_and_a_migrated_one_are_the_same_shape(
+ hass: HomeAssistant,
+) -> None:
+ """Two installations of the same panel, one upgraded and one new, must agree.
+
+ Divergence here is the long-tail bug: a documentation example, a blueprint or
+ a support answer that is right for exactly half of the installed base. The
+ comparison is over `{entity_id: (unique_id, device identifiers)}` — every fact
+ a user or a dashboard can name — rather than over counts, so a single entity
+ filed under a different card fails with the entity_id in the message.
+ """
+ snapshot = _pv_snapshot()
+ migrated = await _install(hass, snapshot, "entry-pv-shape-old", seed=_seed_the_old_shape)
+ migrated_shape = _registry_shape(hass, migrated)
+
+ async with _a_second_home_assistant() as second:
+ fresh = await _install(second, snapshot, "entry-pv-shape-new")
+ assert migrated_shape == _registry_shape(second, fresh)
+
+
+async def test_a_user_renamed_entity_id_survives_the_move(hass: HomeAssistant) -> None:
+ """The pin is a suggestion, and must lose to a choice the user already made.
+
+ Home Assistant only honours a suggested `entity_id` for an entity it is
+ registering for the first time. Asserted rather than assumed, because the pin
+ is the one new thing in this change that could plausibly overwrite something
+ of the user's.
+ """
+
+ def seed(hass_: HomeAssistant, entry_: MockConfigEntry, panel_device_id: str) -> None:
+ _seed_the_old_shape(hass_, entry_, panel_device_id)
+ er.async_get(hass_).async_update_entity(
+ "sensor.span_panel_pv_vendor", new_entity_id="sensor.my_solar_brand"
+ )
+
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-renamed", seed=seed)
+
+ registry = er.async_get(hass)
+ assert registry.async_get("sensor.span_panel_pv_vendor") is None
+ renamed = registry.async_get("sensor.my_solar_brand")
+ assert renamed is not None
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert renamed.device_id == device.id
+
+
+# ---------------------------------------------------------------------------
+# The inventories
+# ---------------------------------------------------------------------------
+
+
+def test_the_firmware_version_is_no_longer_an_unread_declaration() -> None:
+ """The line this task exists to delete, and the line that stays.
+
+ `pv/info/firmware-version` was baselined saying the inverter had no card to
+ carry a version; it has one now, so the line goes. `pv/info/serial-number`
+ stays, and its reason has to name the identifier decision rather than only
+ the producer-side one, because the Home Assistant identifier is now a second
+ thing that would have moved.
+ """
+ baseline: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8"))
+
+ assert "pv/info/firmware-version" not in baseline
+ reason = baseline.get("pv/info/serial-number")
+ assert reason is not None, "the serial must stay baselined; see decision 3"
+ assert "identifier" in reason
+
+
+def test_the_cards_firmware_read_is_enumerated_as_a_residual() -> None:
+ """`pv_device_info` is not an entity, so its read is an exempt residual.
+
+ `NEITHER` beside the `mid.*` and `panel.*` card reads: flat's `pv` device
+ class declares no firmware version, and a schema_1 metadata row carries a
+ unit and a datatype for a reading, which a version string is not.
+ """
+ assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.NEITHER
+ assert "pv.software_version" not in declared_field_paths()
+
+
+def test_nothing_reads_a_pv_serial_anywhere() -> None:
+ """The negative half of the identifier decision, asserted rather than assumed.
+
+ Adding the field to the snapshot would be harmless; reading it here would
+ not, because every read is a place a future change could route into the
+ identifier. There is no such field and no such path, and this fails the day
+ one arrives without the decision being revisited.
+ """
+ assert "pv.serial_number" not in RESIDUAL_EXEMPT_PATHS
+ assert "pv.serial_number" not in declared_field_paths()
+ snapshot = _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001")
+ assert not hasattr(snapshot.pv, "serial_number")
+
+
+def test_the_kind_vocabulary_is_closed() -> None:
+ """Every kind the classifier answers with, in one place.
+
+ Not a restatement of the classifier: `test_device_links` parametrises over
+ the *builders*, so a fifth sub-device whose identifier nothing classifies
+ fails there. This is the other direction — the reader answering a kind the
+ writers never mint — and it is why the list is spelled out rather than
+ derived from the constants.
+ """
+ assert [
+ classify_sub_device_identifier("panel-serial_bess"),
+ classify_sub_device_identifier("panel-serial_mid"),
+ classify_sub_device_identifier("panel-serial_evse_node"),
+ classify_sub_device_identifier("panel-serial_pv"),
+ classify_sub_device_identifier("panel-serial"),
+ ] == ["bess", "mid", "evse", SUB_DEVICE_PV, None]
diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py
index acc3e599..93348c14 100644
--- a/tests/test_sensor_entities.py
+++ b/tests/test_sensor_entities.py
@@ -154,7 +154,13 @@ def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None:
snapshot,
{"identifiers": {("span_panel", "bess")}},
)
- pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot)
+ pv_sensor = SpanPVMetadataSensor(
+ coordinator,
+ PV_METADATA_SENSORS[0],
+ snapshot,
+ {"identifiers": {("span_panel", "pv")}},
+ "sensor.span_panel_pv_vendor",
+ )
assert panel_data_sensor._generate_friendly_name(snapshot, panel_data_desc) == "Sensor"
assert status_sensor._generate_friendly_name(snapshot, status_desc) == "Status"
@@ -178,7 +184,13 @@ def test_panel_metadata_sensors_return_expected_data_sources() -> None:
snapshot,
{"identifiers": {("span_panel", "bess")}},
)
- pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot)
+ pv_sensor = SpanPVMetadataSensor(
+ coordinator,
+ PV_METADATA_SENSORS[0],
+ snapshot,
+ {"identifiers": {("span_panel", "pv")}},
+ "sensor.span_panel_pv_vendor",
+ )
assert bess_sensor.get_data_source(snapshot) is battery
assert pv_sensor.get_data_source(snapshot) is snapshot
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index 188de7cf..d11647f4 100644
--- a/tests/test_websocket.py
+++ b/tests/test_websocket.py
@@ -184,6 +184,12 @@ def test_evse(self):
device.identifiers = {(DOMAIN, "sp3-242424-001_evse_0")}
assert _classify_sub_device(device) == "evse"
+ def test_pv(self):
+ """Classify the solar inverter sub-device from its identifier."""
+ device = MagicMock()
+ device.identifiers = {(DOMAIN, "sp3-242424-001_pv")}
+ assert _classify_sub_device(device) == "pv"
+
def test_unknown(self):
"""Treat the panel device itself as an unknown sub-device type."""
device = MagicMock()
@@ -382,7 +388,7 @@ async def test_non_span_device(self, hass: HomeAssistant):
@pytest.mark.asyncio
async def test_sub_device_id_rejected(self, hass: HomeAssistant):
- """Error when device_id is a BESS/EVSE sub-device, not the panel."""
+ """Error when device_id is a sub-device, not the panel."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
@@ -419,7 +425,7 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant):
connection.send_error.assert_called_once_with(
1,
"not_panel_device",
- "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.",
+ "Use the SPAN panel device registry ID, not a sub-device.",
)
@pytest.mark.asyncio
From 18937d0a6a882d79236c35f7ce1b4f1cdd8d26be Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 21:26:26 -0700
Subject: [PATCH 057/116] docs(changelog): record the two catch-up tasks that
shipped without an entry
The metadata sweep and the PV sub-device landed with commit messages and no
user-facing changelog, and the capability-reload fix landed with neither. The
changelog is what a user reads to decide whether an upgrade affects them, so a
restored attribute and a reload that did not happen belong in it more than most
of what is already there.
Two of the four are fixes rather than additions, and are written as such: the
SSID emptied silently when a panel upgraded, and a panel that gained a
capability while Home Assistant was running created no entities and asked for
no reload. Both are the kind of thing a user cannot report usefully, because
nothing appears in a log and the symptom is an absence.
---
CHANGELOG.md | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43b78e1b..6629310e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,22 @@ All notable changes to this project will be documented in this file.
### Added
+- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic
+ sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the
+ enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been
+ publishing all along.
+- **Nothing about the move changes an entity.** The five sensors that relocate keep the entity ids and unique ids they already have, so dashboards, automations
+ and history follow them across. A freshly installed system and an upgraded one end up with identical entity ids — they would otherwise have diverged, because
+ Home Assistant derives a new entity's id from the name of the device it sits on.
+
+- **Your panel's own card now shows what the panel says it is** — manufacturer, model and hardware revision, read from the enclosure rather than assumed. A
+ panel on the older data model publishes none of the three and keeps exactly the card it has today; the hardware revision row is left off rather than shown
+ blank where no revision is published.
+- **Every SPAN Drive gets a Part Number** diagnostic sensor, matching the one the battery already has. Off by default.
+- **Circuit Priority's shed policy is readable.** The `dsm_state` sensor gains `shed_algorithm` and the two state-of-charge thresholds that decide when circuits
+ shed and when they come back — the numbers that make the panel's shed behaviour predictable rather than surprising. A policy this integration does not
+ recognise keeps its name and carries the panel's raw document beside it, so you can read what a parser could not.
+
- **A charge-current limit you can set, on panels running the v1.0 data model.** Each commissioned SPAN Drive gets an **EVSE Charge Current Limit** number on
its own device — the ceiling the charger offers your vehicle, which you can lower to charge more slowly and raise back. It is the first control this
integration has that changes something on a charger rather than on the panel.
@@ -65,6 +81,14 @@ All notable changes to this project will be documented in this file.
### Fixed
+- **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on
+ the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a
+ value you had, silently gone, with no error and nothing in the log. It is read again.
+- **A firmware upgrade that adds a capability now actually reloads.** The check that decides whether new hardware warrants a reload knew about four capabilities
+ where the rest of the integration knew about nine. A panel that gained the shed forecast, the power control system, battery telemetry or DER link health while
+ Home Assistant was running published the data, matched every rule for creating the entities, and asked for no reload — so the new entities appeared only the
+ next time you restarted. This affected the Microgrid Interconnect Device before this release too.
+
- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation
was wrong. No entity changed.
From c418ff10426cadf86ee4110989296b7f77413925 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 21:42:40 -0700
Subject: [PATCH 058/116] Revert "feat(device): give the solar inverter a card
of its own, and move its entities to it"
This reverts 85ca619. The change moved four PV entities off the panel device
onto a new sub-device, and the brief that asked for it was wrong about who has
those entities.
`create_power_flow_sensors` gates PV on `has_pv()`, which is schema-agnostic,
and schema_0 populates PV vendor, model and nameplate capacity. So every
existing installation with solar has `pv_vendor`, `pv_product`,
`pv_nameplate_capacity` and `pv_power` on its panel device *today*, on current
firmware. The move was scoped as a v1.0 gap; it would have reached the whole
installed base on an integration update, with no firmware change involved.
Entity ids, unique ids and history survived a re-home -- that much was built
and proved. What does not survive is everything keyed on the device: an entity
with no explicit area inherits its device's area, so PV entities in a panel
assigned to an area would have fallen out of it and stopped matching
area-based automations and filters, and any dashboard card populated from the
panel device would have lost its PV rows.
Adding sensors cannot break an installation. Moving existing entities between
devices can, and this bought nothing but consistency with the BESS and the
charger. `pv/info/firmware-version` returns to the unread baseline, which is
the honest state: the library reads it and no card carries it.
---
CHANGELOG.md | 8 -
custom_components/span_panel/binary_sensor.py | 43 +-
.../span_panel/entity_resolver.py | 34 -
custom_components/span_panel/field_paths.py | 13 -
custom_components/span_panel/helpers.py | 2 -
custom_components/span_panel/sensor.py | 53 +-
custom_components/span_panel/sensor_panel.py | 28 +-
custom_components/span_panel/services.py | 8 +-
custom_components/span_panel/util.py | 60 +-
custom_components/span_panel/websocket.py | 11 +-
.../unread_declarations_baseline.json | 3 +-
tests/test_device_links.py | 34 +-
tests/test_field_path_conformance.py | 6 +-
tests/test_pv_device.py | 739 ------------------
tests/test_sensor_entities.py | 16 +-
tests/test_websocket.py | 10 +-
16 files changed, 36 insertions(+), 1032 deletions(-)
delete mode 100644 tests/test_pv_device.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6629310e..ecce0eab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,14 +6,6 @@ All notable changes to this project will be documented in this file.
### Added
-- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic
- sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the
- enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been
- publishing all along.
-- **Nothing about the move changes an entity.** The five sensors that relocate keep the entity ids and unique ids they already have, so dashboards, automations
- and history follow them across. A freshly installed system and an upgraded one end up with identical entity ids — they would otherwise have diverged, because
- Home Assistant derives a new entity's id from the name of the device it sits on.
-
- **Your panel's own card now shows what the panel says it is** — manufacturer, model and hardware revision, read from the enclosure rather than assumed. A
panel on the older data model publishes none of the three and keeps exactly the card it has today; the hardware revision row is left off rather than shown
blank where no revision is published.
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index f914dee7..0b2e6bda 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -34,13 +34,12 @@
from .helpers import (
build_binary_sensor_unique_id_for_entry,
build_evse_unique_id_for_entry,
- construct_panel_scoped_entity_id,
has_bess,
has_mid,
has_pcs,
resolve_evse_display_suffix,
)
-from .util import bess_device_info, evse_device_info, pv_device_info
+from .util import bess_device_info, evse_device_info
# pylint: disable=invalid-overridden-method
@@ -179,9 +178,8 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
one device class whose link the panel happened to report through the lugs was
the only one a user could see.
-On the inverter's own sub-device, beside `pv_vendor` and `pv_product`, which is
-where it moved when the PV got a device of its own -- the same place
-`bess_connected` sits relative to the battery.
+On the panel device, beside `pv_vendor` and `pv_product`, because the PV is not
+yet a sub-device of its own. It moves with them when it becomes one.
`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`: flat firmware publishes
`connected` on the BESS and on nothing else, so the both-adapters gate cannot be
@@ -234,16 +232,8 @@ def __init__(
data_coordinator: SpanPanelCoordinator,
description: T,
device_info_override: DeviceInfo | None = None,
- entity_id_override: str | None = None,
) -> None:
- """Initialize Span Panel Circuit entity.
-
- `entity_id_override` is how a sensor that used to sit on the panel's own
- card keeps the object id that card gave it after moving to a sub-device --
- see `construct_panel_scoped_entity_id`. Home Assistant treats it as a
- suggestion and ignores it for an entity already in the registry, so it
- only ever decides what a *new* installation gets.
- """
+ """Initialize Span Panel Circuit entity."""
super().__init__(data_coordinator, context=description)
snapshot: SpanPanelSnapshot = data_coordinator.data
@@ -264,9 +254,6 @@ def __init__(
data_coordinator, snapshot, description.key
)
- if entity_id_override is not None:
- self.entity_id = entity_id_override
-
@property
def available(self) -> bool:
"""Return entity availability.
@@ -554,27 +541,7 @@ async def async_setup_entry(
# saying it does not know rather than a fault, and the enum it does publish
# has no UNKNOWN member for it to say that with. See `PV_PANEL_LINK_SENSOR`.
if snapshot.pv.connected is not None:
- configured_name = coordinator.config_entry.data.get(
- CONF_DEVICE_NAME, coordinator.config_entry.title
- )
- entities.append(
- SpanPanelBinarySensor(
- coordinator,
- PV_PANEL_LINK_SENSOR,
- device_info_override=pv_device_info(
- snapshot.serial_number,
- snapshot.pv,
- configured_name or "Span Panel",
- panel_device_id=config_entry.runtime_data.panel_device_id,
- ),
- entity_id_override=construct_panel_scoped_entity_id(
- snapshot,
- "binary_sensor",
- PV_PANEL_LINK_SENSOR.translation_key or "",
- configured_name,
- ),
- )
- )
+ entities.append(SpanPanelBinarySensor(coordinator, PV_PANEL_LINK_SENSOR))
# Add EVSE binary sensors for each commissioned charger
if snapshot.evse:
diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py
index 7ea29929..88458876 100644
--- a/custom_components/span_panel/entity_resolver.py
+++ b/custom_components/span_panel/entity_resolver.py
@@ -459,37 +459,3 @@ def construct_unmapped_friendly_name(
"""Construct friendly name for unmapped circuit sensors."""
# Format: "Unmapped Tab 32 Consumed Energy"
return f"Unmapped Tab {circuit_number} {sensor_description_name}"
-
-
-def construct_panel_scoped_entity_id(
- snapshot: SpanPanelSnapshot,
- platform: str,
- translation_key: str,
- device_name: str | None = None,
-) -> str:
- """Return the entity_id a sub-device entity keeps from the panel's card.
-
- Home Assistant builds an `has_entity_name` entity's object id from the name of
- the device it belongs to, so moving an entity to a sub-device would give a
- *new* installation `sensor.span_panel_solar_pv_vendor` where every existing
- one has `sensor.span_panel_pv_vendor` -- the registry never renames an entity
- that already exists. Two shapes for the same entity, decided by install date,
- is the long-tail bug this pins shut: an entity that was born on the panel card
- keeps the panel-scoped id on both.
-
- The same thing `construct_single_circuit_entity_id` already does for the
- circuit sensors that live on an EVSE sub-device, and for the same reason.
-
- Suggestion only. Home Assistant honours an `entity_id` an entity sets for
- itself when it registers, and ignores it for one that is already registered --
- which is exactly the behaviour wanted here, since an id a user has renamed is
- theirs.
-
- `translation_key` rather than a name, because the name comes from the
- translations and is not loaded when an entity is constructed.
- `test_the_pinned_ids_are_what_the_panel_card_would_have_generated` holds the
- two to each other.
- """
- device_info = snapshot_to_device_info(snapshot, device_name)
- panel_name = device_info.get("name") or "Span Panel"
- return f"{platform}.{slugify(panel_name)}_{translation_key}"
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index e58dce2b..ba874ffd 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -202,19 +202,6 @@ class FieldPathDeclarationMixin:
"mid.model": Producibility.NEITHER,
"mid.serial_number": Producibility.NEITHER,
"mid.grid_forming_device_name": Producibility.NEITHER,
- # `pv_device_info` reads the inverter's firmware version for its device
- # card. `pv.vendor_name` and `pv.model` are not here beside it because
- # they are `field_path` declarations on the three PV metadata sensors
- # already, and the card reads the same two fields those sensors do.
- #
- # `NEITHER` for the same reason as the `mid.*` and `panel.*` card reads
- # above: flat's `pv` device class declares no firmware version at all,
- # and a schema_1 metadata row carries a unit and a datatype for a
- # *reading*, which a version string is not.
- #
- # `pv.serial_number` is deliberately absent -- from this table, from the
- # card and from the snapshot. See `pv_device_info`.
- "pv.software_version": Producibility.NEITHER,
# The `mid_grid_state` sensor's source field — utility-supply health,
# the one non-metadata entity the MID brings. Neither adapter maps the
# MID at all, which is why the description is `NO_SOURCE_FIELD`.
diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py
index 3733020b..8609a208 100644
--- a/custom_components/span_panel/helpers.py
+++ b/custom_components/span_panel/helpers.py
@@ -20,7 +20,6 @@
build_switch_unique_id_for_entry,
construct_circuit_unique_id_for_entry,
construct_multi_circuit_entity_id,
- construct_panel_scoped_entity_id,
construct_panel_unique_id_for_entry,
construct_single_circuit_entity_id,
construct_synthetic_unique_id_for_entry,
@@ -80,7 +79,6 @@
"construct_circuit_unique_id",
"construct_circuit_unique_id_for_entry",
"construct_multi_circuit_entity_id",
- "construct_panel_scoped_entity_id",
"construct_panel_unique_id",
"construct_panel_unique_id_for_entry",
"construct_select_unique_id",
diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py
index cdcdd6b9..cfbc5c77 100644
--- a/custom_components/span_panel/sensor.py
+++ b/custom_components/span_panel/sensor.py
@@ -20,7 +20,6 @@
)
from .coordinator import SpanPanelCoordinator
from .helpers import (
- construct_panel_scoped_entity_id,
has_bess,
has_bess_telemetry,
has_evse,
@@ -79,7 +78,7 @@
SpanPVMetadataSensor,
SpanShedForecastSensor,
)
-from .util import bess_device_info, evse_device_info, mid_device_info, pv_device_info
+from .util import bess_device_info, evse_device_info, mid_device_info
# Export the sensor classes for backward compatibility with tests
__all__ = [
@@ -479,22 +478,6 @@ def create_battery_sensors(
return entities
-def _build_pv_device_info(
- coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
-) -> DeviceInfo:
- """DeviceInfo for the solar inverter sub-device."""
- panel_name = (
- coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title)
- or "Span Panel"
- )
- return pv_device_info(
- snapshot.serial_number,
- snapshot.pv,
- panel_name,
- panel_device_id=coordinator.config_entry.runtime_data.panel_device_id,
- )
-
-
def create_power_flow_sensors(
coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
) -> list[SpanPanelPowerSensor | SpanPVMetadataSensor]:
@@ -503,43 +486,15 @@ def create_power_flow_sensors(
PV Power — only when PV is commissioned.
Site Power — only when the power-flows node is publishing.
PV metadata sensors — only when PV is commissioned.
-
- The PV sensors land on the inverter's own sub-device, matching what the BESS
- has done since v1.0: `battery_power` is the enclosure's reading of the
- battery and it sits on the battery's card, so `pv_power` -- the enclosure's
- reading of the inverter -- belongs on the inverter's. Each keeps the
- panel-scoped entity_id it already has; see `construct_panel_scoped_entity_id`.
"""
entities: list[SpanPanelPowerSensor | SpanPVMetadataSensor] = []
if has_pv(snapshot):
- pv_info = _build_pv_device_info(coordinator, snapshot)
- panel_name = coordinator.config_entry.data.get(
- CONF_DEVICE_NAME, coordinator.config_entry.title
- )
- entities.append(
- SpanPanelPowerSensor(
- coordinator,
- PV_POWER_SENSOR,
- snapshot,
- device_info_override=pv_info,
- entity_id_override=construct_panel_scoped_entity_id(
- snapshot, "sensor", PV_POWER_SENSOR.translation_key or "", panel_name
- ),
- )
- )
+ entities.append(SpanPanelPowerSensor(coordinator, PV_POWER_SENSOR, snapshot))
+ # PV metadata sensors on the main panel device
entities.extend(
- SpanPVMetadataSensor(
- coordinator,
- desc,
- snapshot,
- pv_info,
- construct_panel_scoped_entity_id(
- snapshot, "sensor", desc.translation_key or "", panel_name
- ),
- )
- for desc in PV_METADATA_SENSORS
+ SpanPVMetadataSensor(coordinator, desc, snapshot) for desc in PV_METADATA_SENSORS
)
if has_power_flows(snapshot):
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index dba89837..161fcc47 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -457,23 +457,13 @@ def __init__(
description: SpanPanelDataSensorEntityDescription,
snapshot: SpanPanelSnapshot,
device_info_override: DeviceInfo | None = None,
- entity_id_override: str | None = None,
) -> None:
- """Initialize the enhanced panel power sensor.
-
- `entity_id_override` is how a power sensor that used to sit on the panel's
- own card keeps the object id that card gave it after moving to a
- sub-device -- see `construct_panel_scoped_entity_id`. Home Assistant
- treats it as a suggestion and ignores it for an entity already in the
- registry, so it only ever decides what a *new* installation gets.
- """
+ """Initialize the enhanced panel power sensor."""
self._description_key = description.key
super().__init__(data_coordinator, description, snapshot)
if device_info_override is not None:
self._attr_device_info = device_info_override
- if entity_id_override is not None:
- self.entity_id = entity_id_override
def _generate_unique_id(
self,
@@ -667,30 +657,16 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot:
class SpanPVMetadataSensor(
SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPanelSnapshot]
):
- """PV metadata sensor entity on the PV sub-device.
-
- On the panel's own card until the inverter got one of its own, which put the
- inverter's vendor and model beside the *panel's* vendor and model on the card
- whose job is saying which enclosure this is.
-
- The unique_id stays the panel-scoped one `construct_panel_unique_id_for_entry`
- has always built, because a unique_id is an identity and these are the same
- three entities they were. Only the device they hang off changes, which is a
- registry update Home Assistant performs itself when the entity re-registers.
- """
+ """PV metadata sensor entity on the main panel device."""
def __init__(
self,
data_coordinator: SpanPanelCoordinator,
description: SpanPVMetadataSensorEntityDescription,
snapshot: SpanPanelSnapshot,
- device_info_override: DeviceInfo,
- entity_id_override: str,
) -> None:
"""Initialize the PV metadata sensor."""
super().__init__(data_coordinator, description, snapshot)
- self._attr_device_info = device_info_override
- self.entity_id = entity_id_override
def _generate_unique_id(
self,
diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py
index 24581d91..0500424b 100644
--- a/custom_components/span_panel/services.py
+++ b/custom_components/span_panel/services.py
@@ -513,10 +513,8 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin
``kind`` is ``"circuits"`` or ``"sub_devices"``. For circuits,
``target_id`` is the panel-local circuit uuid (extracted from the
- entity's unique_id). For sub-devices, ``target_id`` is the HA device id
- of the sub-device; the panel id walks up via ``via_device_id``. Nothing
- here enumerates the kinds, so a new one -- the PV inverter most recently
- -- is favouritable the day its device exists.
+ entity's unique_id). For sub-devices, ``target_id`` is the HA
+ device id of the BESS/EVSE; the panel id walks up via ``via_device_id``.
Failure paths use distinct translation keys so users see the
actual reason their pick was rejected.
@@ -553,7 +551,7 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin
# Resolve the panel device id. Sub-devices register with
# via_device_id; main panels never do, so via_device_id presence is a
- # reliable discriminator whatever kinds exist, and we walk up to the
+ # reliable discriminator (BESS / EVSE today) and we walk up to the
# parent SPAN Panel here.
if device_entry.via_device_id is not None:
parent = device_registry.async_get(device_entry.via_device_id)
diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py
index a821d391..85024db4 100644
--- a/custom_components/span_panel/util.py
+++ b/custom_components/span_panel/util.py
@@ -9,7 +9,6 @@
SpanEvseSnapshot,
SpanMidSnapshot,
SpanPanelSnapshot,
- SpanPVSnapshot,
)
from .const import DOMAIN
@@ -36,7 +35,6 @@
SUB_DEVICE_BESS: Final = "bess"
SUB_DEVICE_MID: Final = "mid"
SUB_DEVICE_EVSE: Final = "evse"
-SUB_DEVICE_PV: Final = "pv"
def classify_sub_device_identifier(identifier: str) -> str | None:
@@ -45,23 +43,15 @@ def classify_sub_device_identifier(identifier: str) -> str | None:
None rather than an "unknown" string: the caller knows whether it is looking
at something that must be a sub-device, and a sentinel that reads like a kind
is what let an unclassified device render as a device with no type.
-
- **Most specific first.** EVSE is the one kind whose token is an infix rather
- than a suffix, and a suffix test cannot tell `..._evse_inverter_pv` from a PV
- identifier. Testing the infix first makes the charger's node id opaque to the
- suffix rules below, which is the only ordering that stays right whatever a
- panel names its nodes.
"""
- # Infix, not suffix: the node id follows, and it is what distinguishes one
- # charger from another on the same panel.
- if f"_{SUB_DEVICE_EVSE}_" in identifier:
- return SUB_DEVICE_EVSE
if identifier.endswith(f"_{SUB_DEVICE_BESS}"):
return SUB_DEVICE_BESS
if identifier.endswith(f"_{SUB_DEVICE_MID}"):
return SUB_DEVICE_MID
- if identifier.endswith(f"_{SUB_DEVICE_PV}"):
- return SUB_DEVICE_PV
+ # Infix, not suffix: the node id follows, and it is what distinguishes one
+ # charger from another on the same panel.
+ if f"_{SUB_DEVICE_EVSE}_" in identifier:
+ return SUB_DEVICE_EVSE
return None
@@ -191,45 +181,3 @@ def evse_device_info(
sw_version=evse.software_version,
via_device_id=panel_device_id,
)
-
-
-def pv_device_info(
- panel_identifier: str,
- pv: SpanPVSnapshot,
- panel_name: str,
- *,
- panel_device_id: str,
-) -> DeviceInfo:
- """Create DeviceInfo for the solar inverter, linked to the parent panel.
-
- The last DER to get a card of its own. Its vendor, model and nameplate
- capacity have been readable all along and were shown as three diagnostic
- sensors on the *panel's* card, beside the panel's own manufacturer and model,
- which reads as if the enclosure were an Enphase inverter. The firmware
- version the library also reads reached nothing at all, because a version has
- no home but a device card.
-
- **The identifier deliberately does not mention the inverter's serial.**
- `info/serial-number` is declared by every PV `$description` and published by
- no producer today, so an identifier preferring it would be `_pv` on
- every panel now and `_` on the first panel whose firmware
- starts publishing one -- and a device identifier is what a consumer keys its
- registry on, so that day would read as the inverter being replaced rather
- than as a value arriving. `{panel serial}_pv` answers the only question an
- identifier has to answer, "which panel's inverter", and a panel has exactly
- one `pv` node, so nothing distinguishes two of them. The serial is not on the
- card either, for the same reason it is not in the identifier: nothing in this
- integration should start depending on it before a producer publishes one.
- """
- return DeviceInfo(
- identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_PV}")},
- name=f"{panel_name} Solar",
- manufacturer=pv.vendor_name or "Unknown",
- model=pv.model or "Solar Inverter",
- # Passed through unguarded, as on the BESS and the MID: `DeviceInfo`
- # omits a `None` field and renders an empty string as a present-but-blank
- # row, so `or ""` would invent a version row for an inverter that
- # published none.
- sw_version=pv.software_version,
- via_device_id=panel_device_id,
- )
diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py
index b969bf8b..f2969a60 100644
--- a/custom_components/span_panel/websocket.py
+++ b/custom_components/span_panel/websocket.py
@@ -75,9 +75,8 @@ async def handle_panel_topology(
"""Return the full panel topology with entity mappings.
Admin users must pass the HA device registry ID for the **main SPAN panel**
- device only (not a sub-device). Returns panel metadata, circuits with
- tabs/entity mappings, and sub-devices -- every kind
- `classify_sub_device_identifier` names, which is BESS, MID, EVSE and PV.
+ device only (not BESS/EVSE sub-devices). Returns panel metadata, circuits
+ with tabs/entity mappings, and sub-devices (BESS, EVSE).
"""
device_id = msg["device_id"]
@@ -93,12 +92,12 @@ async def handle_panel_topology(
connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device")
return
- # Every sub-device registers with via_device_id pointing at the panel.
+ # Sub-devices (BESS, EVSE) register with via_device_id pointing at the panel.
if device_entry.via_device_id is not None:
connection.send_error(
msg["id"],
"not_panel_device",
- "Use the SPAN panel device registry ID, not a sub-device.",
+ "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.",
)
return
@@ -226,7 +225,7 @@ def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None:
def _classify_sub_device(device_entry: dr.DeviceEntry) -> str:
- """Classify a sub-device by its identifiers, or 'unknown'.
+ """Classify a sub-device by its identifiers: 'bess', 'mid', 'evse' or 'unknown'.
The grammar lives with the builders that write it, in `util`, rather than
being restated here. Restating it is how the MID went out as 'unknown' for a
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
index 8cfe362e..b9767d52 100644
--- a/tests/fixtures/unread_declarations_baseline.json
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -8,5 +8,6 @@
"lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
"lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it",
"lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
- "pv/info/serial-number": "held, and the PV device identifier deliberately does not use it: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade. The Home Assistant sub-device is keyed _pv for the same reason, so a serial arriving on the wire moves nothing"
+ "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version - it lands with the PV sub-device (Task 7)",
+ "pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)"
}
diff --git a/tests/test_device_links.py b/tests/test_device_links.py
index 6ea08af2..de91e06c 100644
--- a/tests/test_device_links.py
+++ b/tests/test_device_links.py
@@ -21,12 +21,7 @@
from typing import Any
-from homeassistant.const import CONF_HOST
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr
import pytest
-from pytest_homeassistant_custom_component.common import MockConfigEntry
-from span_panel_api import SpanMidSnapshot, SpanPVSnapshot
from custom_components.span_panel import ensure_device_registered
from custom_components.span_panel.const import DOMAIN
@@ -35,8 +30,11 @@
classify_sub_device_identifier,
evse_device_info,
mid_device_info,
- pv_device_info,
)
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+from span_panel_api import SpanMidSnapshot
from .factories import (
SpanBatterySnapshotFactory,
@@ -44,6 +42,8 @@
SpanPanelSnapshotFactory,
)
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
_PANEL_ID = "a-registry-id"
@@ -77,15 +77,6 @@ def _builders() -> list[tuple[str, Any]]:
"sp3-link-001", _mid(), "Panel", panel_device_id=_PANEL_ID
),
),
- (
- "pv",
- lambda: pv_device_info(
- "sp3-link-001",
- SpanPVSnapshot(vendor_name="Enphase", model="IQ8", software_version="v1"),
- "Panel",
- panel_device_id=_PANEL_ID,
- ),
- ),
(
"evse",
lambda: evse_device_info(
@@ -127,19 +118,6 @@ def test_the_panel_is_not_mistaken_for_a_sub_device() -> None:
assert classify_sub_device_identifier("sim-40t-001") is None
-def test_a_charger_node_ending_in_a_kind_is_still_a_charger() -> None:
- """The infix wins over the suffixes, which is why it is tested first.
-
- A charger's identifier carries its Homie node id, and nothing stops a node id
- from ending in `_pv` or `_bess`. Testing the suffix rules first would classify
- such a charger as whatever its node id happened to end with -- a device
- rendering as the wrong type on a dashboard, which is the failure the MID
- already shipped once.
- """
- assert classify_sub_device_identifier("sp3-link-001_evse_inverter_pv") == "evse"
- assert classify_sub_device_identifier("sp3-link-001_evse_garage_bess") == "evse"
-
-
@pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "")
def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None:
"""Both halves asserted: the new key is set and the old one is gone.
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 2e4e0bf2..37b272dc 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -288,11 +288,7 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# `$target` echo it renders as an attribute. Facts about a command rather
# than readings, so no adapter carries a row for either -- the same shape as
# the `circuit.*_target` pair.
- # +1 for `pv.software_version`, the firmware row on the solar inverter's own
- # device card. Flat's `pv` device class declares no firmware version, and a
- # version string is identity rather than a reading -- the same argument as
- # the `mid.*` and `panel.*` card reads above.
- Producibility.NEITHER: 44,
+ Producibility.NEITHER: 43,
# +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's
# source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and
# enumerated nowhere, so `evaluate_field_metadata` counted it as produced-
diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py
deleted file mode 100644
index aa72b9df..00000000
--- a/tests/test_pv_device.py
+++ /dev/null
@@ -1,739 +0,0 @@
-"""The solar inverter gets a device of its own, and takes its entities with it.
-
-PV was the last DER on a v1.0 panel with no card. Its vendor, model and
-nameplate capacity were rendered as three diagnostic sensors on the *panel's*
-device — beside the panel's own manufacturer and model, so a panel card read as
-if the enclosure were an Enphase inverter — and the firmware version the library
-has always read reached nothing at all, because a version has nowhere to go but
-a card. `pv/info/firmware-version` was baselined naming exactly that.
-
-**The identifier is the decision worth writing down.** `{panel serial}_pv`, and
-deliberately not the inverter's serial. Every PV `$description` declares
-`info/serial-number` and no producer publishes one, so an identifier that
-preferred a serial would be `_pv` on every panel today and
-`_` on the first panel whose firmware starts publishing one — and
-a device identifier is what a consumer keys its registry on, so that day would
-read as the inverter being replaced. `test_a_serial_arriving_on_the_wire_does_not_move_the_device`
-produces that day and asserts nothing moves.
-
-**The migration is the risk.** Existing installations have these entities on the
-panel device with panel-scoped `unique_id`s and panel-scoped `entity_id`s. Moving
-an entity to another device must change *only* the device: a changed `entity_id`
-breaks a dashboard, a changed `unique_id` orphans the entity and mints a
-duplicate. Home Assistant re-homes an entity by itself when it re-registers with
-new `device_info`, which is the mechanism used here; what it does *not* do is
-re-derive an `entity_id`, so a fresh installation would otherwise get
-`sensor.span_panel_solar_pv_vendor` where every existing one has
-`sensor.span_panel_pv_vendor`. `construct_panel_scoped_entity_id` pins that shut
-and `test_a_fresh_install_and_a_migrated_one_are_the_same_shape` is the proof.
-
-Every expectation about what the card shows is read out of the vendored capture
-and proved by republishing or unpublishing the property, never by a literal. The
-registry-shape expectations *are* literals, on purpose: they record what a
-released installation carries, and deriving them from the code under test would
-make the migration assertions vacuous.
-"""
-
-from __future__ import annotations
-
-from collections.abc import AsyncIterator, Callable
-from contextlib import asynccontextmanager
-import json
-import logging
-import pathlib
-from typing import Final
-from unittest.mock import AsyncMock, MagicMock
-
-from span_panel_api import SpanPanelSnapshot
-
-from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered
-from custom_components.span_panel.binary_sensor import (
- PV_PANEL_LINK_SENSOR,
- async_setup_entry as binary_sensor_setup_entry,
-)
-from custom_components.span_panel.const import DOMAIN
-from custom_components.span_panel.field_paths import (
- RESIDUAL_EXEMPT_PATHS,
- Producibility,
- declared_field_paths,
-)
-from custom_components.span_panel.sensor import async_setup_entry as sensor_setup_entry
-from custom_components.span_panel.sensor_definitions import PV_METADATA_SENSORS, PV_POWER_SENSOR
-from custom_components.span_panel.util import SUB_DEVICE_PV, classify_sub_device_identifier
-from custom_components.span_panel.websocket import _classify_sub_device
-from homeassistant.const import CONF_HOST
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr, entity_registry as er
-from homeassistant.util import slugify
-
-from .adapter_fixtures import schema_one_snapshot, schema_one_tree
-
-from pytest_homeassistant_custom_component.common import (
- MockConfigEntry,
- MockEntityPlatform,
- async_test_home_assistant,
-)
-
-PV_DEVICE: Final = "pv"
-"""The inverter's Homie device id in the capture."""
-
-VENDOR_TOPIC: Final = "info/vendor-name"
-MODEL_TOPIC: Final = "info/model"
-FIRMWARE_TOPIC: Final = "info/firmware-version"
-SERIAL_TOPIC: Final = "info/serial-number"
-
-PANEL_NAME: Final = "SPAN Panel"
-
-FALLBACK_MANUFACTURER: Final = "Unknown"
-FALLBACK_MODEL: Final = "Solar Inverter"
-
-BASELINE: Final = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json"
-
-_LEGACY_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = (
- ("sensor", "pv_power", "sensor.span_panel_pv_power"),
- ("sensor", "pv_vendor", "sensor.span_panel_pv_vendor"),
- ("sensor", "pv_product", "sensor.span_panel_pv_product"),
- ("sensor", "pv_nameplate_capacity", "sensor.span_panel_pv_nameplate_capacity"),
- ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_pv_panel_link"),
-)
-"""``(platform, unique_id suffix, entity_id)`` as a released installation holds them.
-
-Literals, deliberately. This is the registry a user upgrading already has, which
-is a historical fact rather than something the current code gets to decide —
-deriving it from the builders under test would make every assertion below agree
-with itself. `test_a_fresh_install_still_builds_the_unique_ids_users_already_have`
-is what holds the literals to the code.
-"""
-
-
-# ---------------------------------------------------------------------------
-# Reading the capture
-# ---------------------------------------------------------------------------
-
-
-def _published(topic: str) -> str:
- """What the capture publishes on one PV topic, or fail saying it does not."""
- value = schema_one_tree()[PV_DEVICE].get(topic)
- assert value is not None, f"the capture publishes no {topic} on the inverter"
- return value
-
-
-def _declared(topic: str) -> bool:
- """Whether the inverter's `$description` declares one `node/property`."""
- description = json.loads(schema_one_tree()[PV_DEVICE]["$description"])
- node, _, prop = topic.partition("/")
- return prop in description["nodes"].get(node, {}).get("properties", {})
-
-
-def _pv_snapshot(**rewrites: str | None) -> SpanPanelSnapshot:
- """A snapshot from the capture with the inverter's topics rewritten or removed.
-
- Keyword spelling is `node__property_name`. `None` removes the topic, which is
- what firmware omitting a property looks like — a different event from
- publishing an empty string, and the one the card's fallbacks exist for.
- """
- tree = schema_one_tree()
- for path, value in rewrites.items():
- node, _, prop = path.partition("__")
- topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}"
- if value is None:
- tree[PV_DEVICE].pop(topic, None)
- else:
- tree[PV_DEVICE][topic] = value
- return schema_one_snapshot(tree)
-
-
-# ---------------------------------------------------------------------------
-# Installing for real
-# ---------------------------------------------------------------------------
-
-
-def _entry(hass: HomeAssistant, entry_id: str, serial: str) -> MockConfigEntry:
- """A config entry keyed on the panel's serial, as the config flow makes one."""
- entry = MockConfigEntry(
- domain=DOMAIN,
- data={CONF_HOST: "192.168.1.40", "device_name": PANEL_NAME},
- options={},
- title=PANEL_NAME,
- entry_id=entry_id,
- unique_id=serial,
- )
- entry.add_to_hass(hass)
- return entry
-
-
-def _coordinator(
- hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot
-) -> MagicMock:
- coordinator = MagicMock()
- coordinator.data = snapshot
- coordinator.hass = hass
- coordinator.panel_offline = False
- coordinator.last_update_success = True
- coordinator.unresolved_paths = frozenset()
- coordinator.config_entry = entry
- coordinator.async_request_refresh = AsyncMock()
- return coordinator
-
-
-async def _register(
- hass: HomeAssistant,
- entry: MockConfigEntry,
- platform: str,
- setup: Callable[..., object],
-) -> None:
- """Run one platform's `async_setup_entry` through a real `EntityPlatform`.
-
- Through the platform rather than by inspecting the entities the setup
- function returns, because everything under test here happens *in* the
- registry: which device an entity is filed under, and which `entity_id` it
- keeps. Neither is observable on an entity object.
- """
- added: list[object] = []
- await setup(hass, entry, lambda entities, **_: added.extend(entities))
-
- entity_platform = MockEntityPlatform(
- hass, domain=platform, platform_name=DOMAIN, logger=logging.getLogger(__name__)
- )
- entity_platform.config_entry = entry
- # The translations decide the object id Home Assistant derives, so a harness
- # that skipped them would generate `sensor.span_panel_2` and prove nothing
- # about the ids a user sees.
- await entity_platform.platform_data.async_load_translations()
- await entity_platform.async_add_entities(added)
-
-
-async def _install(
- hass: HomeAssistant,
- snapshot: SpanPanelSnapshot,
- entry_id: str,
- *,
- seed: Callable[[HomeAssistant, MockConfigEntry, str], None] | None = None,
-) -> MockConfigEntry:
- """Set up both platforms the way the integration does, optionally over a seeded registry."""
- entry = _entry(hass, entry_id, snapshot.serial_number)
- panel_device_id = await ensure_device_registered(hass, entry, snapshot, PANEL_NAME)
- if seed is not None:
- seed(hass, entry, panel_device_id)
-
- coordinator = _coordinator(hass, entry, snapshot)
- entry.runtime_data = SpanPanelRuntimeData(
- coordinator=coordinator, panel_device_id=panel_device_id
- )
- await _register(hass, entry, "sensor", sensor_setup_entry)
- await _register(hass, entry, "binary_sensor", binary_sensor_setup_entry)
- return entry
-
-
-@asynccontextmanager
-async def _a_second_home_assistant() -> AsyncIterator[HomeAssistant]:
- """A second, empty Home Assistant, for comparing two installations.
-
- Both halves of the fresh-versus-migrated comparison have to be *the* install
- on their instance. Running them into one registry makes the second collide
- with the first and land on `..._pv_vendor_2`, which is an artefact of the
- harness and would mask or invent a divergence either way.
- """
- async with async_test_home_assistant() as second:
- try:
- yield second
- finally:
- await second.async_stop(force=True)
-
-
-def _seed_the_old_shape(hass: HomeAssistant, entry: MockConfigEntry, panel_device_id: str) -> None:
- """Write the PV entities onto the panel device, as a released install holds them."""
- registry = er.async_get(hass)
- for platform, suffix, entity_id in _LEGACY_PV_ENTITIES:
- created = registry.async_get_or_create(
- platform,
- DOMAIN,
- f"span_{entry.unique_id}_{suffix}",
- config_entry=entry,
- device_id=panel_device_id,
- suggested_object_id=entity_id.split(".", 1)[1],
- )
- assert created.entity_id == entity_id, (
- f"the seed could not reproduce {entity_id}; it landed on {created.entity_id}"
- )
-
-
-def _pv_device(hass: HomeAssistant, entry: MockConfigEntry) -> dr.DeviceEntry | None:
- return dr.async_get(hass).async_get_device_by_identifier(
- (DOMAIN, f"{entry.unique_id}_{SUB_DEVICE_PV}"), entry.entry_id
- )
-
-
-def _registry_shape(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, tuple[str, ...]]:
- """``{entity_id: (unique_id, *device identifiers)}`` for every entity of one entry.
-
- Devices by identifier rather than by registry id, because two installations
- mint different registry ids for the same device and the identifier is the
- stable name for "which card is this on".
- """
- devices = dr.async_get(hass)
- shape: dict[str, tuple[str, ...]] = {}
- for entity in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id):
- device = devices.async_get(entity.device_id) if entity.device_id else None
- identifiers = sorted(name for _domain, name in device.identifiers) if device else []
- shape[entity.entity_id] = (entity.unique_id, *identifiers)
- return shape
-
-
-# ---------------------------------------------------------------------------
-# The premise
-# ---------------------------------------------------------------------------
-
-
-def test_the_capture_publishes_the_identity_the_card_shows() -> None:
- """Guard the premise: every card expectation below is read from these topics.
-
- A test whose expected value comes from an unpublished topic does not fail, it
- stops asserting anything — which is how the fixture drifted eight identity
- properties behind the producer without a single red test.
- """
- assert _published(VENDOR_TOPIC)
- assert _published(MODEL_TOPIC)
- assert _published(FIRMWARE_TOPIC)
-
-
-def test_the_capture_declares_a_serial_and_publishes_none() -> None:
- """The premise of the identifier decision, held to the capture.
-
- If a producer ever values this, the choice of identifier stops being
- hypothetical and `test_a_serial_arriving_on_the_wire_does_not_move_the_device`
- stops being a simulation. Either way the identifier must not move, which is
- what the two together assert.
- """
- assert _declared(SERIAL_TOPIC), "the inverter no longer declares a serial number"
- assert schema_one_tree()[PV_DEVICE].get(SERIAL_TOPIC) is None, (
- "the capture now values PV info/serial-number; decision 3 says it stays unvalued "
- "until the flat side's PV device id is confirmed"
- )
-
-
-# ---------------------------------------------------------------------------
-# The card
-# ---------------------------------------------------------------------------
-
-
-async def test_the_inverter_gets_a_card_showing_what_it_publishes(
- hass: HomeAssistant,
-) -> None:
- """Manufacturer, model and firmware read off the wire, not out of a constant."""
- entry = await _install(hass, _pv_snapshot(), "entry-pv-card")
-
- device = _pv_device(hass, entry)
- assert device is not None
- assert device.manufacturer == _published(VENDOR_TOPIC)
- assert device.model == _published(MODEL_TOPIC)
- assert device.sw_version == _published(FIRMWARE_TOPIC)
-
-
-async def test_the_card_follows_a_republished_identity(hass: HomeAssistant) -> None:
- """The card tracks the wire, so nothing above is passing on a coincidence."""
- rewritten = _pv_snapshot(
- info__vendor_name="Another Vendor",
- info__model="ANOTHER-MODEL-1",
- info__firmware_version="example-pv/v9.9.9",
- )
-
- entry = await _install(hass, rewritten, "entry-pv-card-rewritten")
-
- device = _pv_device(hass, entry)
- assert device is not None
- assert device.manufacturer == "Another Vendor"
- assert device.model == "ANOTHER-MODEL-1"
- assert device.sw_version == "example-pv/v9.9.9"
-
-
-async def test_an_inverter_publishing_no_identity_gets_a_card_with_no_blank_rows(
- hass: HomeAssistant,
-) -> None:
- """The fallbacks, and the difference between an absent row and a blank one.
-
- Vendor and model fall back to strings because a card with no name at all is
- worse than a generic one. The firmware version has no string to fall back to
- and must be *absent* rather than empty: `DeviceInfo` omits a `None` field and
- renders `""` as a present-but-blank row, which reads as an inverter reporting
- a blank version rather than one reporting none.
- """
- bare = _pv_snapshot(
- info__vendor_name=None,
- info__model=None,
- info__firmware_version=None,
- )
-
- entry = await _install(hass, bare, "entry-pv-card-bare")
-
- device = _pv_device(hass, entry)
- assert device is not None
- assert device.manufacturer == FALLBACK_MANUFACTURER
- assert device.model == FALLBACK_MODEL
- assert device.sw_version is None
-
-
-async def test_the_card_hangs_off_the_panel_like_every_other_sub_device(
- hass: HomeAssistant,
-) -> None:
- """By registry id, which is the link Home Assistant stops dropping in 2027.8."""
- snapshot = _pv_snapshot()
- entry = await _install(hass, snapshot, "entry-pv-link")
-
- device = _pv_device(hass, entry)
- panel = dr.async_get(hass).async_get_device_by_identifier(
- (DOMAIN, snapshot.serial_number), entry.entry_id
- )
- assert device is not None
- assert panel is not None
- assert device.via_device_id == panel.id
-
-
-async def test_a_panel_with_no_inverter_gets_no_card(hass: HomeAssistant) -> None:
- """Absence is a reading. No PV node, no device, and no entities to re-home."""
- tree = schema_one_tree()
- del tree[PV_DEVICE]
- for topics in tree.values():
- topics.pop("power-flows/pv", None)
- for circuit, topics in tree.items():
- if topics.get("connection/feeds-device-id") == PV_DEVICE:
- topics.pop("connection/feeds-device-id", None)
- topics.pop("connection/feeds-device-status", None)
- topics.pop("connection/feeds-device-type", None)
-
- entry = await _install(hass, schema_one_snapshot(tree), "entry-pv-absent")
-
- assert _pv_device(hass, entry) is None
- shape = _registry_shape(hass, entry)
- assert not [entity_id for entity_id in shape if "_pv_" in entity_id]
-
-
-# ---------------------------------------------------------------------------
-# The identifier
-# ---------------------------------------------------------------------------
-
-
-async def test_a_serial_arriving_on_the_wire_does_not_move_the_device(
- hass: HomeAssistant,
-) -> None:
- """The whole reason the identifier does not mention the inverter's serial.
-
- `_der_identifier` on the producer side prefers a serial over an instance id,
- so the day firmware starts publishing `info/serial-number` the inverter's
- *Homie* device id changes. A Home Assistant identifier derived from it would
- change with it, and a changed identifier is a new device: the card empties,
- the entities orphan, and an upgrade rehearsal becomes a device-replacement
- rehearsal. Keyed on the panel's serial and the kind instead, so this test
- publishes a serial and watches nothing move.
- """
- before = await _install(hass, _pv_snapshot(), "entry-pv-noserial")
- identifier_before = _pv_device(hass, before)
- shape_before = _registry_shape(hass, before)
-
- async with _a_second_home_assistant() as second:
- after = await _install(
- second,
- _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001"),
- "entry-pv-serial",
- )
- identifier_after = _pv_device(second, after)
- shape_after = _registry_shape(second, after)
-
- assert identifier_before is not None
- assert identifier_after is not None
- assert identifier_before.identifiers == identifier_after.identifiers
- assert shape_before == shape_after
-
-
-async def test_the_topology_reader_calls_the_new_card_a_pv(hass: HomeAssistant) -> None:
- """The writing end and the reading end of the identifier grammar agree.
-
- The MID shipped classifying as `unknown` because a kind was added to the
- builders and not to the reader, and a card rendered a device with a name and
- no type. Asserted against the device as registered rather than against the
- builder's dict, because the reader is handed a `DeviceEntry`.
- """
- entry = await _install(hass, _pv_snapshot(), "entry-pv-classify")
-
- device = _pv_device(hass, entry)
- assert device is not None
- assert _classify_sub_device(device) == SUB_DEVICE_PV
- assert classify_sub_device_identifier(f"{entry.unique_id}_{SUB_DEVICE_PV}") == SUB_DEVICE_PV
-
-
-# ---------------------------------------------------------------------------
-# Where the entities land, and what they keep
-# ---------------------------------------------------------------------------
-
-
-def _pv_entities(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, er.RegistryEntry]:
- """The five PV entities, by `entity_id`, or fail naming the ones missing."""
- registry = er.async_get(hass)
- found: dict[str, er.RegistryEntry] = {}
- missing: list[str] = []
- for platform, suffix, _entity_id in _LEGACY_PV_ENTITIES:
- entity_id = registry.async_get_entity_id(
- platform, DOMAIN, f"span_{entry.unique_id}_{suffix}"
- )
- if entity_id is None:
- missing.append(suffix)
- continue
- entity = registry.async_get(entity_id)
- assert entity is not None
- found[entity_id] = entity
- assert not missing, f"the platform created no entity for {missing}"
- return found
-
-
-async def test_a_fresh_install_still_builds_the_unique_ids_users_already_have(
- hass: HomeAssistant,
-) -> None:
- """The literals above are the current code's output, so the migration tests bite.
-
- A `unique_id` is an identity: changing one does not rename an entity, it
- orphans the old one and mints a second. This holds the recorded shape to the
- builders, so a change to either fails here rather than in an upgrade.
- """
- entry = await _install(hass, _pv_snapshot(), "entry-pv-uids")
-
- assert set(_pv_entities(hass, entry)) == {
- entity_id for _platform, _suffix, entity_id in _LEGACY_PV_ENTITIES
- }
-
-
-async def test_every_pv_entity_lands_on_the_inverters_card(hass: HomeAssistant) -> None:
- """All five, and none of them left behind on the panel."""
- entry = await _install(hass, _pv_snapshot(), "entry-pv-home")
-
- device = _pv_device(hass, entry)
- assert device is not None
- for entity_id, entity in _pv_entities(hass, entry).items():
- assert entity.device_id == device.id, f"{entity_id} is not on the inverter's card"
-
-
-def test_the_pinned_ids_are_what_the_panel_card_would_have_generated() -> None:
- """The pin reproduces Home Assistant's own derivation, rather than guessing it.
-
- `construct_panel_scoped_entity_id` builds `{panel slug}_{translation key}`,
- while Home Assistant builds `{device name} {entity name}` and slugifies it.
- The two agree only because every PV entity's translated name slugifies to its
- translation key — true today, and silently untrue the day somebody renames
- one, which would make a *new* installation's entity_id differ from every
- existing one. Read out of `strings.json`, which is the file that would change.
- """
- strings = json.loads(
- (
- pathlib.Path(__file__).parent.parent
- / "custom_components"
- / "span_panel"
- / "strings.json"
- ).read_text(encoding="utf-8")
- )
- for platform, suffix, entity_id in _LEGACY_PV_ENTITIES:
- name = strings["entity"][platform][suffix]["name"]
- assert slugify(name) == suffix, (
- f"{platform}.{suffix} is named {name!r}, which slugifies to "
- f"{slugify(name)!r}; the pinned entity_id would stop matching what "
- "Home Assistant derives"
- )
- assert entity_id == f"{platform}.{slugify(PANEL_NAME)}_{slugify(name)}"
-
-
-def test_the_descriptions_carry_the_translation_keys_the_pin_reads() -> None:
- """The pin reads `translation_key`, so a description without one would pin to ``.
-
- An empty key would build `sensor.span_panel_` for every PV entity and Home
- Assistant would resolve the collision by appending numbers — a silent,
- permanent scrambling of five entity ids on new installations only.
- """
- keys = {PV_POWER_SENSOR.translation_key} | {
- description.translation_key for description in PV_METADATA_SENSORS
- }
- keys.add(PV_PANEL_LINK_SENSOR.translation_key)
- assert keys == {suffix for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES}
-
-
-# ---------------------------------------------------------------------------
-# The migration
-# ---------------------------------------------------------------------------
-
-
-async def test_the_migration_moves_the_device_and_nothing_else(
- hass: HomeAssistant,
-) -> None:
- """Seed the released shape, set up, and read the registry back.
-
- Home Assistant re-homes an entity when it re-registers with different
- `device_info` — `async_get_or_create` updates `device_id` on an existing
- entry — so there is no bespoke migration to write and deliberately none
- written. What that mechanism does *not* touch is the `entity_id` or the
- `unique_id`, and those are what a user's dashboards and automations name, so
- they are asserted one by one rather than in aggregate.
- """
- entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate", seed=_seed_the_old_shape)
-
- device = _pv_device(hass, entry)
- assert device is not None
- registry = er.async_get(hass)
- for platform, suffix, entity_id in _LEGACY_PV_ENTITIES:
- unique_id = f"span_{entry.unique_id}_{suffix}"
- entity = registry.async_get(entity_id)
- assert entity is not None, f"{entity_id} no longer exists after setup"
- assert entity.unique_id == unique_id, f"{entity_id} changed unique_id"
- assert entity.platform == DOMAIN
- assert entity.domain == platform
- assert entity.device_id == device.id, f"{entity_id} did not move to the inverter"
-
-
-async def test_the_migration_leaves_no_duplicate_and_no_orphan(
- hass: HomeAssistant,
-) -> None:
- """The two failure modes a re-home has, made observable.
-
- A duplicate: the entity re-registers under a new `unique_id`, so the old
- registry entry survives beside a new one and a user sees each reading twice.
- An orphan: the panel device keeps an entity nothing writes to any more. Both
- are counted rather than spot-checked, because either would otherwise hide
- among forty circuit entities.
- """
- entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate-clean", seed=_seed_the_old_shape)
-
- device = _pv_device(hass, entry)
- panel = dr.async_get(hass).async_get_device_by_identifier(
- (DOMAIN, entry.unique_id or ""), entry.entry_id
- )
- assert device is not None
- assert panel is not None
-
- entities = er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
- unique_ids = [entity.unique_id for entity in entities]
- assert len(unique_ids) == len(set(unique_ids)), "an entity re-registered under a second id"
-
- pv_unique_ids = {f"span_{entry.unique_id}_{suffix}" for _p, suffix, _e in _LEGACY_PV_ENTITIES}
- left_behind = [
- entity.entity_id
- for entity in entities
- if entity.unique_id in pv_unique_ids and entity.device_id != device.id
- ]
- assert not left_behind, f"still on the panel card: {left_behind}"
-
- pv_devices = [
- candidate
- for candidate in dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id)
- if any(
- classify_sub_device_identifier(name) == SUB_DEVICE_PV
- for _domain, name in candidate.identifiers
- )
- ]
- assert len(pv_devices) == 1, f"{len(pv_devices)} PV devices registered, expected 1"
-
-
-async def test_a_fresh_install_and_a_migrated_one_are_the_same_shape(
- hass: HomeAssistant,
-) -> None:
- """Two installations of the same panel, one upgraded and one new, must agree.
-
- Divergence here is the long-tail bug: a documentation example, a blueprint or
- a support answer that is right for exactly half of the installed base. The
- comparison is over `{entity_id: (unique_id, device identifiers)}` — every fact
- a user or a dashboard can name — rather than over counts, so a single entity
- filed under a different card fails with the entity_id in the message.
- """
- snapshot = _pv_snapshot()
- migrated = await _install(hass, snapshot, "entry-pv-shape-old", seed=_seed_the_old_shape)
- migrated_shape = _registry_shape(hass, migrated)
-
- async with _a_second_home_assistant() as second:
- fresh = await _install(second, snapshot, "entry-pv-shape-new")
- assert migrated_shape == _registry_shape(second, fresh)
-
-
-async def test_a_user_renamed_entity_id_survives_the_move(hass: HomeAssistant) -> None:
- """The pin is a suggestion, and must lose to a choice the user already made.
-
- Home Assistant only honours a suggested `entity_id` for an entity it is
- registering for the first time. Asserted rather than assumed, because the pin
- is the one new thing in this change that could plausibly overwrite something
- of the user's.
- """
-
- def seed(hass_: HomeAssistant, entry_: MockConfigEntry, panel_device_id: str) -> None:
- _seed_the_old_shape(hass_, entry_, panel_device_id)
- er.async_get(hass_).async_update_entity(
- "sensor.span_panel_pv_vendor", new_entity_id="sensor.my_solar_brand"
- )
-
- entry = await _install(hass, _pv_snapshot(), "entry-pv-renamed", seed=seed)
-
- registry = er.async_get(hass)
- assert registry.async_get("sensor.span_panel_pv_vendor") is None
- renamed = registry.async_get("sensor.my_solar_brand")
- assert renamed is not None
- device = _pv_device(hass, entry)
- assert device is not None
- assert renamed.device_id == device.id
-
-
-# ---------------------------------------------------------------------------
-# The inventories
-# ---------------------------------------------------------------------------
-
-
-def test_the_firmware_version_is_no_longer_an_unread_declaration() -> None:
- """The line this task exists to delete, and the line that stays.
-
- `pv/info/firmware-version` was baselined saying the inverter had no card to
- carry a version; it has one now, so the line goes. `pv/info/serial-number`
- stays, and its reason has to name the identifier decision rather than only
- the producer-side one, because the Home Assistant identifier is now a second
- thing that would have moved.
- """
- baseline: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8"))
-
- assert "pv/info/firmware-version" not in baseline
- reason = baseline.get("pv/info/serial-number")
- assert reason is not None, "the serial must stay baselined; see decision 3"
- assert "identifier" in reason
-
-
-def test_the_cards_firmware_read_is_enumerated_as_a_residual() -> None:
- """`pv_device_info` is not an entity, so its read is an exempt residual.
-
- `NEITHER` beside the `mid.*` and `panel.*` card reads: flat's `pv` device
- class declares no firmware version, and a schema_1 metadata row carries a
- unit and a datatype for a reading, which a version string is not.
- """
- assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.NEITHER
- assert "pv.software_version" not in declared_field_paths()
-
-
-def test_nothing_reads_a_pv_serial_anywhere() -> None:
- """The negative half of the identifier decision, asserted rather than assumed.
-
- Adding the field to the snapshot would be harmless; reading it here would
- not, because every read is a place a future change could route into the
- identifier. There is no such field and no such path, and this fails the day
- one arrives without the decision being revisited.
- """
- assert "pv.serial_number" not in RESIDUAL_EXEMPT_PATHS
- assert "pv.serial_number" not in declared_field_paths()
- snapshot = _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001")
- assert not hasattr(snapshot.pv, "serial_number")
-
-
-def test_the_kind_vocabulary_is_closed() -> None:
- """Every kind the classifier answers with, in one place.
-
- Not a restatement of the classifier: `test_device_links` parametrises over
- the *builders*, so a fifth sub-device whose identifier nothing classifies
- fails there. This is the other direction — the reader answering a kind the
- writers never mint — and it is why the list is spelled out rather than
- derived from the constants.
- """
- assert [
- classify_sub_device_identifier("panel-serial_bess"),
- classify_sub_device_identifier("panel-serial_mid"),
- classify_sub_device_identifier("panel-serial_evse_node"),
- classify_sub_device_identifier("panel-serial_pv"),
- classify_sub_device_identifier("panel-serial"),
- ] == ["bess", "mid", "evse", SUB_DEVICE_PV, None]
diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py
index 93348c14..acc3e599 100644
--- a/tests/test_sensor_entities.py
+++ b/tests/test_sensor_entities.py
@@ -154,13 +154,7 @@ def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None:
snapshot,
{"identifiers": {("span_panel", "bess")}},
)
- pv_sensor = SpanPVMetadataSensor(
- coordinator,
- PV_METADATA_SENSORS[0],
- snapshot,
- {"identifiers": {("span_panel", "pv")}},
- "sensor.span_panel_pv_vendor",
- )
+ pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot)
assert panel_data_sensor._generate_friendly_name(snapshot, panel_data_desc) == "Sensor"
assert status_sensor._generate_friendly_name(snapshot, status_desc) == "Status"
@@ -184,13 +178,7 @@ def test_panel_metadata_sensors_return_expected_data_sources() -> None:
snapshot,
{"identifiers": {("span_panel", "bess")}},
)
- pv_sensor = SpanPVMetadataSensor(
- coordinator,
- PV_METADATA_SENSORS[0],
- snapshot,
- {"identifiers": {("span_panel", "pv")}},
- "sensor.span_panel_pv_vendor",
- )
+ pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot)
assert bess_sensor.get_data_source(snapshot) is battery
assert pv_sensor.get_data_source(snapshot) is snapshot
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index d11647f4..188de7cf 100644
--- a/tests/test_websocket.py
+++ b/tests/test_websocket.py
@@ -184,12 +184,6 @@ def test_evse(self):
device.identifiers = {(DOMAIN, "sp3-242424-001_evse_0")}
assert _classify_sub_device(device) == "evse"
- def test_pv(self):
- """Classify the solar inverter sub-device from its identifier."""
- device = MagicMock()
- device.identifiers = {(DOMAIN, "sp3-242424-001_pv")}
- assert _classify_sub_device(device) == "pv"
-
def test_unknown(self):
"""Treat the panel device itself as an unknown sub-device type."""
device = MagicMock()
@@ -388,7 +382,7 @@ async def test_non_span_device(self, hass: HomeAssistant):
@pytest.mark.asyncio
async def test_sub_device_id_rejected(self, hass: HomeAssistant):
- """Error when device_id is a sub-device, not the panel."""
+ """Error when device_id is a BESS/EVSE sub-device, not the panel."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
@@ -425,7 +419,7 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant):
connection.send_error.assert_called_once_with(
1,
"not_panel_device",
- "Use the SPAN panel device registry ID, not a sub-device.",
+ "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.",
)
@pytest.mark.asyncio
From 2d7b5f9d7face27f2bc08ebfe8163319cd4c398c Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 22:27:17 -0700
Subject: [PATCH 059/116] feat(device): give the solar inverter a card of its
own
Reinstates 85ca619 without the part of it that was wrong.
PV was the last DER on a v1.0 panel with no device. eBus models it as a device
beside the BESS, the MID and the EVSE, and it was the only one of the four with
nothing to render on. Its vendor, model and nameplate capacity were three
diagnostic sensors on the *panel's* card, beside the panel's own manufacturer
and model, so the card whose job is saying which enclosure this is read as if
the enclosure were an Enphase inverter. The firmware version the library has
read all along reached nothing, because a version has nowhere to go but a card;
`pv/info/firmware-version` was baselined saying exactly that, and that line
goes. `pv/info/serial-number` stays, with its reason intact.
`SUB_DEVICE_PV` and `pv_device_info` follow the BESS and the MID, carrying
manufacturer, model and sw_version off the wire. The identifier is
`{panel serial}_pv` and deliberately does not mention the inverter's serial:
every PV description declares `info/serial-number` and no producer publishes
one, so an identifier preferring it would change the day firmware started
publishing, and a device identifier is what a consumer keys its registry on --
that day would read as the inverter being replaced. The four kinds now sit
under one classifier, which tests the EVSE *infix* before the three suffixes
because a suffix rule cannot tell a charger whose node id ends in `_pv` from an
inverter.
Five entities change device: `pv_power`, `pv_vendor`, `pv_product`,
`pv_nameplate_capacity` and `pv_panel_link`. Circuit entities do not move, the
PV circuit's included -- those belong to the circuit model.
**What is different from 85ca619.** That revision pinned all five to their
panel-scoped `entity_id` on every installation, so a new install matched an
upgraded one exactly. The pin is gone. `async_get_or_create` honours a suggested
object id only at first registration -- for a `unique_id` it already knows it
returns the existing entry, updates `device_id` and leaves `entity_id` alone --
so the pin never protected the installed base at all. All it did was give every
future installation an entity id derived from the panel rather than from the
device the entity sits on, permanently, to match a history that installation
does not have. So an existing installation keeps `sensor.span_panel_pv_vendor`
and a new one gets `sensor.span_panel_solar_pv_vendor`, both correct, and
`construct_panel_scoped_entity_id` and the `entity_id_override` parameters that
existed only to serve the pin are removed. `construct_single_circuit_entity_id`
is untouched -- it serves the circuit sensors on an EVSE sub-device and is not
this.
No area is seeded on the new device: `suggested_area` is deprecated with
`breaks_in_ha_version="2026.9"` against a 2026.8 pin, and an explicit registry
write cannot tell an area a user cleared from one never assigned, so it would
re-assign the card on every reload. The reasoning is recorded on
`pv_device_info` rather than left to be rediscovered.
`tests/test_pv_device.py` seeds a registry in the released shape with literal
legacy ids, sets both platforms up through real entity platforms, and reads the
registry back: every entity keeps its `entity_id` and its `unique_id`, lands on
the inverter, and leaves no duplicate or orphan behind. A user-renamed
`entity_id` still wins. `test_a_fresh_install_and_a_migrated_one_differ_only_in_entity_id`
replaces the test that asserted the two were identical: it pins both sets of
literals, asserts the five have the same unique_ids and the same device across
the fork, and asserts every *other* entity is identical between the two -- and
its docstring says why the asymmetry exists, so it is not read later as a bug.
---
CHANGELOG.md | 13 +
custom_components/span_panel/binary_sensor.py | 23 +-
custom_components/span_panel/field_paths.py | 13 +
custom_components/span_panel/sensor.py | 44 +-
custom_components/span_panel/sensor_panel.py | 20 +-
custom_components/span_panel/services.py | 8 +-
custom_components/span_panel/util.py | 71 +-
custom_components/span_panel/websocket.py | 11 +-
.../unread_declarations_baseline.json | 3 +-
tests/test_device_links.py | 34 +-
tests/test_field_path_conformance.py | 6 +-
tests/test_pv_device.py | 821 ++++++++++++++++++
tests/test_sensor_entities.py | 14 +-
tests/test_websocket.py | 10 +-
14 files changed, 1057 insertions(+), 34 deletions(-)
create mode 100644 tests/test_pv_device.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ecce0eab..6cefce3d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,19 @@ All notable changes to this project will be documented in this file.
### Added
+- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic
+ sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the
+ enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been
+ publishing all along.
+- **If you already have these sensors, nothing about them changes.** The five entities that move to the new card — PV Power, PV Vendor, PV Product, PV Nameplate
+ Capacity and PV Panel Link — keep the entity ids and unique ids they have today, so dashboards, automations and history follow them across untouched. Only the
+ card they appear on changes.
+- **New installations get different entity ids for these five, and that is intended.** Home Assistant derives a new entity's id from the name of the device it
+ sits on, so a system installed from now on gets `sensor.span_panel_solar_pv_vendor` where a system installed before this release keeps
+ `sensor.span_panel_pv_vendor`. Both are correct and neither will change again: an existing system must never have an id renamed under it, and a new one gets
+ the id Home Assistant would give it. If you are comparing two SPAN systems and their PV entity ids differ, install date is why. The unique ids are the same on
+ both, and both sets of entities sit on the same new card.
+
- **Your panel's own card now shows what the panel says it is** — manufacturer, model and hardware revision, read from the enclosure rather than assumed. A
panel on the older data model publishes none of the three and keeps exactly the card it has today; the hardware revision row is left off rather than shown
blank where no revision is published.
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 0b2e6bda..769f0cb2 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -39,7 +39,7 @@
has_pcs,
resolve_evse_display_suffix,
)
-from .util import bess_device_info, evse_device_info
+from .util import bess_device_info, evse_device_info, pv_device_info
# pylint: disable=invalid-overridden-method
@@ -178,8 +178,9 @@ def _grid_islandable(snapshot: SpanPanelSnapshot) -> bool | None:
one device class whose link the panel happened to report through the lugs was
the only one a user could see.
-On the panel device, beside `pv_vendor` and `pv_product`, because the PV is not
-yet a sub-device of its own. It moves with them when it becomes one.
+On the inverter's own sub-device, beside `pv_vendor` and `pv_product`, which is
+where it moved when the PV got a device of its own -- the same place
+`bess_connected` sits relative to the battery.
`SCHEMA_CONDITIONAL_FIELD` *and* `field_path`: flat firmware publishes
`connected` on the BESS and on nothing else, so the both-adapters gate cannot be
@@ -541,7 +542,21 @@ async def async_setup_entry(
# saying it does not know rather than a fault, and the enum it does publish
# has no UNKNOWN member for it to say that with. See `PV_PANEL_LINK_SENSOR`.
if snapshot.pv.connected is not None:
- entities.append(SpanPanelBinarySensor(coordinator, PV_PANEL_LINK_SENSOR))
+ configured_name = coordinator.config_entry.data.get(
+ CONF_DEVICE_NAME, coordinator.config_entry.title
+ )
+ entities.append(
+ SpanPanelBinarySensor(
+ coordinator,
+ PV_PANEL_LINK_SENSOR,
+ device_info_override=pv_device_info(
+ snapshot.serial_number,
+ snapshot.pv,
+ configured_name or "Span Panel",
+ panel_device_id=config_entry.runtime_data.panel_device_id,
+ ),
+ )
+ )
# Add EVSE binary sensors for each commissioned charger
if snapshot.evse:
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index ba874ffd..e58dce2b 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -202,6 +202,19 @@ class FieldPathDeclarationMixin:
"mid.model": Producibility.NEITHER,
"mid.serial_number": Producibility.NEITHER,
"mid.grid_forming_device_name": Producibility.NEITHER,
+ # `pv_device_info` reads the inverter's firmware version for its device
+ # card. `pv.vendor_name` and `pv.model` are not here beside it because
+ # they are `field_path` declarations on the three PV metadata sensors
+ # already, and the card reads the same two fields those sensors do.
+ #
+ # `NEITHER` for the same reason as the `mid.*` and `panel.*` card reads
+ # above: flat's `pv` device class declares no firmware version at all,
+ # and a schema_1 metadata row carries a unit and a datatype for a
+ # *reading*, which a version string is not.
+ #
+ # `pv.serial_number` is deliberately absent -- from this table, from the
+ # card and from the snapshot. See `pv_device_info`.
+ "pv.software_version": Producibility.NEITHER,
# The `mid_grid_state` sensor's source field — utility-supply health,
# the one non-metadata entity the MID brings. Neither adapter maps the
# MID at all, which is why the description is `NO_SOURCE_FIELD`.
diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py
index cfbc5c77..7661b1cd 100644
--- a/custom_components/span_panel/sensor.py
+++ b/custom_components/span_panel/sensor.py
@@ -78,7 +78,7 @@
SpanPVMetadataSensor,
SpanShedForecastSensor,
)
-from .util import bess_device_info, evse_device_info, mid_device_info
+from .util import bess_device_info, evse_device_info, mid_device_info, pv_device_info
# Export the sensor classes for backward compatibility with tests
__all__ = [
@@ -478,6 +478,22 @@ def create_battery_sensors(
return entities
+def _build_pv_device_info(
+ coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
+) -> DeviceInfo:
+ """DeviceInfo for the solar inverter sub-device."""
+ panel_name = (
+ coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title)
+ or "Span Panel"
+ )
+ return pv_device_info(
+ snapshot.serial_number,
+ snapshot.pv,
+ panel_name,
+ panel_device_id=coordinator.config_entry.runtime_data.panel_device_id,
+ )
+
+
def create_power_flow_sensors(
coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
) -> list[SpanPanelPowerSensor | SpanPVMetadataSensor]:
@@ -486,15 +502,35 @@ def create_power_flow_sensors(
PV Power — only when PV is commissioned.
Site Power — only when the power-flows node is publishing.
PV metadata sensors — only when PV is commissioned.
+
+ The PV sensors land on the inverter's own sub-device, matching what the BESS
+ has done since v1.0: `battery_power` is the enclosure's reading of the
+ battery and it sits on the battery's card, so `pv_power` -- the enclosure's
+ reading of the inverter -- belongs on the inverter's.
+
+ Nothing pins an entity_id. An installation that already has these five keeps
+ the ids it has, because the registry never renames an entity it already
+ knows; a new one gets whatever Home Assistant derives from the inverter's
+ device name, which is the standard assignment and what every other
+ sub-device entity gets. The two shapes differ by install date and that is
+ deliberate -- see `test_pv_device.py`.
"""
entities: list[SpanPanelPowerSensor | SpanPVMetadataSensor] = []
if has_pv(snapshot):
- entities.append(SpanPanelPowerSensor(coordinator, PV_POWER_SENSOR, snapshot))
+ pv_info = _build_pv_device_info(coordinator, snapshot)
+ entities.append(
+ SpanPanelPowerSensor(
+ coordinator,
+ PV_POWER_SENSOR,
+ snapshot,
+ device_info_override=pv_info,
+ )
+ )
- # PV metadata sensors on the main panel device
entities.extend(
- SpanPVMetadataSensor(coordinator, desc, snapshot) for desc in PV_METADATA_SENSORS
+ SpanPVMetadataSensor(coordinator, desc, snapshot, pv_info)
+ for desc in PV_METADATA_SENSORS
)
if has_power_flows(snapshot):
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index 161fcc47..30d85560 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -657,16 +657,34 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot:
class SpanPVMetadataSensor(
SpanSensorBase[SpanPVMetadataSensorEntityDescription, SpanPanelSnapshot]
):
- """PV metadata sensor entity on the main panel device."""
+ """PV metadata sensor entity on the PV sub-device.
+
+ On the panel's own card until the inverter got one of its own, which put the
+ inverter's vendor and model beside the *panel's* vendor and model on the card
+ whose job is saying which enclosure this is.
+
+ The unique_id stays the panel-scoped one `construct_panel_unique_id_for_entry`
+ has always built, because a unique_id is an identity and these are the same
+ three entities they were. Only the device they hang off changes, which is a
+ registry update Home Assistant performs itself when the entity re-registers.
+
+ The `entity_id` is not touched either way. An installation that already has
+ these three keeps the panel-scoped ids it has, because the registry never
+ renames an entity it already knows; a new one gets the id Home Assistant
+ derives from the inverter's device name. That asymmetry is intended -- see
+ `test_pv_device.py`.
+ """
def __init__(
self,
data_coordinator: SpanPanelCoordinator,
description: SpanPVMetadataSensorEntityDescription,
snapshot: SpanPanelSnapshot,
+ device_info_override: DeviceInfo,
) -> None:
"""Initialize the PV metadata sensor."""
super().__init__(data_coordinator, description, snapshot)
+ self._attr_device_info = device_info_override
def _generate_unique_id(
self,
diff --git a/custom_components/span_panel/services.py b/custom_components/span_panel/services.py
index 0500424b..24581d91 100644
--- a/custom_components/span_panel/services.py
+++ b/custom_components/span_panel/services.py
@@ -513,8 +513,10 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin
``kind`` is ``"circuits"`` or ``"sub_devices"``. For circuits,
``target_id`` is the panel-local circuit uuid (extracted from the
- entity's unique_id). For sub-devices, ``target_id`` is the HA
- device id of the BESS/EVSE; the panel id walks up via ``via_device_id``.
+ entity's unique_id). For sub-devices, ``target_id`` is the HA device id
+ of the sub-device; the panel id walks up via ``via_device_id``. Nothing
+ here enumerates the kinds, so a new one -- the PV inverter most recently
+ -- is favouritable the day its device exists.
Failure paths use distinct translation keys so users see the
actual reason their pick was rejected.
@@ -551,7 +553,7 @@ def _resolve_entity_to_favorite_target(entity_id: str) -> tuple[str, FavoriteKin
# Resolve the panel device id. Sub-devices register with
# via_device_id; main panels never do, so via_device_id presence is a
- # reliable discriminator (BESS / EVSE today) and we walk up to the
+ # reliable discriminator whatever kinds exist, and we walk up to the
# parent SPAN Panel here.
if device_entry.via_device_id is not None:
parent = device_registry.async_get(device_entry.via_device_id)
diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py
index 85024db4..93f6f0cb 100644
--- a/custom_components/span_panel/util.py
+++ b/custom_components/span_panel/util.py
@@ -9,6 +9,7 @@
SpanEvseSnapshot,
SpanMidSnapshot,
SpanPanelSnapshot,
+ SpanPVSnapshot,
)
from .const import DOMAIN
@@ -35,6 +36,7 @@
SUB_DEVICE_BESS: Final = "bess"
SUB_DEVICE_MID: Final = "mid"
SUB_DEVICE_EVSE: Final = "evse"
+SUB_DEVICE_PV: Final = "pv"
def classify_sub_device_identifier(identifier: str) -> str | None:
@@ -43,15 +45,23 @@ def classify_sub_device_identifier(identifier: str) -> str | None:
None rather than an "unknown" string: the caller knows whether it is looking
at something that must be a sub-device, and a sentinel that reads like a kind
is what let an unclassified device render as a device with no type.
+
+ **Most specific first.** EVSE is the one kind whose token is an infix rather
+ than a suffix, and a suffix test cannot tell `..._evse_inverter_pv` from a PV
+ identifier. Testing the infix first makes the charger's node id opaque to the
+ suffix rules below, which is the only ordering that stays right whatever a
+ panel names its nodes.
"""
- if identifier.endswith(f"_{SUB_DEVICE_BESS}"):
- return SUB_DEVICE_BESS
- if identifier.endswith(f"_{SUB_DEVICE_MID}"):
- return SUB_DEVICE_MID
# Infix, not suffix: the node id follows, and it is what distinguishes one
# charger from another on the same panel.
if f"_{SUB_DEVICE_EVSE}_" in identifier:
return SUB_DEVICE_EVSE
+ if identifier.endswith(f"_{SUB_DEVICE_BESS}"):
+ return SUB_DEVICE_BESS
+ if identifier.endswith(f"_{SUB_DEVICE_MID}"):
+ return SUB_DEVICE_MID
+ if identifier.endswith(f"_{SUB_DEVICE_PV}"):
+ return SUB_DEVICE_PV
return None
@@ -181,3 +191,56 @@ def evse_device_info(
sw_version=evse.software_version,
via_device_id=panel_device_id,
)
+
+
+def pv_device_info(
+ panel_identifier: str,
+ pv: SpanPVSnapshot,
+ panel_name: str,
+ *,
+ panel_device_id: str,
+) -> DeviceInfo:
+ """Create DeviceInfo for the solar inverter, linked to the parent panel.
+
+ The last DER to get a card of its own. Its vendor, model and nameplate
+ capacity have been readable all along and were shown as three diagnostic
+ sensors on the *panel's* card, beside the panel's own manufacturer and model,
+ which reads as if the enclosure were an Enphase inverter. The firmware
+ version the library also reads reached nothing at all, because a version has
+ no home but a device card.
+
+ **The identifier deliberately does not mention the inverter's serial.**
+ `info/serial-number` is declared by every PV `$description` and published by
+ no producer today, so an identifier preferring it would be `_pv` on
+ every panel now and `_` on the first panel whose firmware
+ starts publishing one -- and a device identifier is what a consumer keys its
+ registry on, so that day would read as the inverter being replaced rather
+ than as a value arriving. `{panel serial}_pv` answers the only question an
+ identifier has to answer, "which panel's inverter", and a panel has exactly
+ one `pv` node, so nothing distinguishes two of them. The serial is not on the
+ card either, for the same reason it is not in the identifier: nothing in this
+ integration should start depending on it before a producer publishes one.
+
+ **No area is seeded**, so an upgraded installation has to assign this card to
+ an area the way it assigned the battery's and the chargers'. Both routes were
+ considered and neither is clean. `DeviceInfo`'s `suggested_area` is deprecated
+ with `breaks_in_ha_version="2026.9"`, one release past the version pinned
+ here, so adopting it would be adopting a removal. An explicit
+ `device_registry.async_update_device(area_id=...)` after setup cannot tell an
+ area a user deliberately cleared from one never assigned -- the registry
+ records `None` for both -- so it would silently re-assign the card on every
+ reload. Doing nothing also keeps every sub-device the same: the BESS, the MID
+ and each charger seed no area either.
+ """
+ return DeviceInfo(
+ identifiers={(DOMAIN, f"{panel_identifier}_{SUB_DEVICE_PV}")},
+ name=f"{panel_name} Solar",
+ manufacturer=pv.vendor_name or "Unknown",
+ model=pv.model or "Solar Inverter",
+ # Passed through unguarded, as on the BESS and the MID: `DeviceInfo`
+ # omits a `None` field and renders an empty string as a present-but-blank
+ # row, so `or ""` would invent a version row for an inverter that
+ # published none.
+ sw_version=pv.software_version,
+ via_device_id=panel_device_id,
+ )
diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py
index f2969a60..b969bf8b 100644
--- a/custom_components/span_panel/websocket.py
+++ b/custom_components/span_panel/websocket.py
@@ -75,8 +75,9 @@ async def handle_panel_topology(
"""Return the full panel topology with entity mappings.
Admin users must pass the HA device registry ID for the **main SPAN panel**
- device only (not BESS/EVSE sub-devices). Returns panel metadata, circuits
- with tabs/entity mappings, and sub-devices (BESS, EVSE).
+ device only (not a sub-device). Returns panel metadata, circuits with
+ tabs/entity mappings, and sub-devices -- every kind
+ `classify_sub_device_identifier` names, which is BESS, MID, EVSE and PV.
"""
device_id = msg["device_id"]
@@ -92,12 +93,12 @@ async def handle_panel_topology(
connection.send_error(msg["id"], "not_span_panel", "Device is not a SPAN Panel device")
return
- # Sub-devices (BESS, EVSE) register with via_device_id pointing at the panel.
+ # Every sub-device registers with via_device_id pointing at the panel.
if device_entry.via_device_id is not None:
connection.send_error(
msg["id"],
"not_panel_device",
- "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.",
+ "Use the SPAN panel device registry ID, not a sub-device.",
)
return
@@ -225,7 +226,7 @@ def _find_config_entry_id(device_entry: dr.DeviceEntry) -> str | None:
def _classify_sub_device(device_entry: dr.DeviceEntry) -> str:
- """Classify a sub-device by its identifiers: 'bess', 'mid', 'evse' or 'unknown'.
+ """Classify a sub-device by its identifiers, or 'unknown'.
The grammar lives with the builders that write it, in `util`, rather than
being restated here. Restating it is how the MID went out as 'unknown' for a
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
index b9767d52..8cfe362e 100644
--- a/tests/fixtures/unread_declarations_baseline.json
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -8,6 +8,5 @@
"lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
"lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it",
"lugs/connection/feeds-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
- "pv/info/firmware-version": "reaches pv.software_version and stops there; PV has no sub-device registry entry, so there is no DeviceInfo to carry a version - it lands with the PV sub-device (Task 7)",
- "pv/info/serial-number": "not read at all: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade (Task 7)"
+ "pv/info/serial-number": "held, and the PV device identifier deliberately does not use it: _der_identifier prefers a serial over instance_id, so valuing it would move the PV device id from -pv-1 to - - a device replacement, not an upgrade. The Home Assistant sub-device is keyed _pv for the same reason, so a serial arriving on the wire moves nothing"
}
diff --git a/tests/test_device_links.py b/tests/test_device_links.py
index de91e06c..6ea08af2 100644
--- a/tests/test_device_links.py
+++ b/tests/test_device_links.py
@@ -21,7 +21,12 @@
from typing import Any
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from span_panel_api import SpanMidSnapshot, SpanPVSnapshot
from custom_components.span_panel import ensure_device_registered
from custom_components.span_panel.const import DOMAIN
@@ -30,11 +35,8 @@
classify_sub_device_identifier,
evse_device_info,
mid_device_info,
+ pv_device_info,
)
-from homeassistant.const import CONF_HOST
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr
-from span_panel_api import SpanMidSnapshot
from .factories import (
SpanBatterySnapshotFactory,
@@ -42,8 +44,6 @@
SpanPanelSnapshotFactory,
)
-from pytest_homeassistant_custom_component.common import MockConfigEntry
-
_PANEL_ID = "a-registry-id"
@@ -77,6 +77,15 @@ def _builders() -> list[tuple[str, Any]]:
"sp3-link-001", _mid(), "Panel", panel_device_id=_PANEL_ID
),
),
+ (
+ "pv",
+ lambda: pv_device_info(
+ "sp3-link-001",
+ SpanPVSnapshot(vendor_name="Enphase", model="IQ8", software_version="v1"),
+ "Panel",
+ panel_device_id=_PANEL_ID,
+ ),
+ ),
(
"evse",
lambda: evse_device_info(
@@ -118,6 +127,19 @@ def test_the_panel_is_not_mistaken_for_a_sub_device() -> None:
assert classify_sub_device_identifier("sim-40t-001") is None
+def test_a_charger_node_ending_in_a_kind_is_still_a_charger() -> None:
+ """The infix wins over the suffixes, which is why it is tested first.
+
+ A charger's identifier carries its Homie node id, and nothing stops a node id
+ from ending in `_pv` or `_bess`. Testing the suffix rules first would classify
+ such a charger as whatever its node id happened to end with -- a device
+ rendering as the wrong type on a dashboard, which is the failure the MID
+ already shipped once.
+ """
+ assert classify_sub_device_identifier("sp3-link-001_evse_inverter_pv") == "evse"
+ assert classify_sub_device_identifier("sp3-link-001_evse_garage_bess") == "evse"
+
+
@pytest.mark.parametrize(("label", "build"), _builders(), ids=lambda v: v if isinstance(v, str) else "")
def test_sub_devices_link_by_registry_id(label: str, build: Any) -> None:
"""Both halves asserted: the new key is set and the old one is gone.
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 37b272dc..2e4e0bf2 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -288,7 +288,11 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# `$target` echo it renders as an attribute. Facts about a command rather
# than readings, so no adapter carries a row for either -- the same shape as
# the `circuit.*_target` pair.
- Producibility.NEITHER: 43,
+ # +1 for `pv.software_version`, the firmware row on the solar inverter's own
+ # device card. Flat's `pv` device class declares no firmware version, and a
+ # version string is identity rather than a reading -- the same argument as
+ # the `mid.*` and `panel.*` card reads above.
+ Producibility.NEITHER: 44,
# +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's
# source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and
# enumerated nowhere, so `evaluate_field_metadata` counted it as produced-
diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py
new file mode 100644
index 00000000..db353cba
--- /dev/null
+++ b/tests/test_pv_device.py
@@ -0,0 +1,821 @@
+"""The solar inverter gets a device of its own, and takes its entities with it.
+
+PV was the last DER on a v1.0 panel with no card. Its vendor, model and
+nameplate capacity were rendered as three diagnostic sensors on the *panel's*
+device — beside the panel's own manufacturer and model, so a panel card read as
+if the enclosure were an Enphase inverter — and the firmware version the library
+has always read reached nothing at all, because a version has nowhere to go but
+a card. `pv/info/firmware-version` was baselined naming exactly that.
+
+**The identifier is the decision worth writing down.** `{panel serial}_pv`, and
+deliberately not the inverter's serial. Every PV `$description` declares
+`info/serial-number` and no producer publishes one, so an identifier that
+preferred a serial would be `_pv` on every panel today and
+`_` on the first panel whose firmware starts publishing one — and
+a device identifier is what a consumer keys its registry on, so that day would
+read as the inverter being replaced. `test_a_serial_arriving_on_the_wire_does_not_move_the_device`
+produces that day and asserts nothing moves.
+
+**The migration is the risk.** Existing installations have these entities on the
+panel device with panel-scoped `unique_id`s and panel-scoped `entity_id`s. Moving
+an entity to another device must change *only* the device: a changed `entity_id`
+breaks a dashboard, a changed `unique_id` orphans the entity and mints a
+duplicate. Home Assistant re-homes an entity by itself when it re-registers with
+new `device_info` — `async_get_or_create` returns the entry it already holds for
+a `unique_id`, updates its `device_id` and leaves its `entity_id` untouched — so
+there is no bespoke migration here and deliberately none written.
+
+**The fork in entity ids is deliberate; do not close it.** Nothing pins an
+`entity_id`. Home Assistant derives a new entity's object id from the name of the
+device it belongs to, so a fresh installation gets
+`sensor.span_panel_solar_pv_vendor` where every existing one keeps
+`sensor.span_panel_pv_vendor`. Both are correct: an existing installation must
+never have an id change under it, and a new one gets the standard Home Assistant
+assignment rather than a legacy shape invented to match history it does not have.
+An earlier revision pinned the panel-scoped id on both to keep the two identical;
+that was reversed, because the pin bought uniformity by giving every future
+installation an entity id derived from the wrong device.
+`test_a_fresh_install_and_a_migrated_one_differ_only_in_entity_id` is the record
+of the decision.
+
+Every expectation about what the card shows is read out of the vendored capture
+and proved by republishing or unpublishing the property, never by a literal. The
+registry-shape expectations *are* literals, on purpose: they record what a
+released installation carries, and deriving them from the code under test would
+make the migration assertions vacuous.
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable
+from contextlib import asynccontextmanager
+import json
+import logging
+import pathlib
+from typing import Final
+from unittest.mock import AsyncMock, MagicMock
+
+from span_panel_api import SpanPanelSnapshot
+
+from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered
+from custom_components.span_panel.binary_sensor import (
+ PV_PANEL_LINK_SENSOR,
+ async_setup_entry as binary_sensor_setup_entry,
+)
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ Producibility,
+ declared_field_paths,
+)
+from custom_components.span_panel.sensor import async_setup_entry as sensor_setup_entry
+from custom_components.span_panel.sensor_definitions import PV_METADATA_SENSORS, PV_POWER_SENSOR
+from custom_components.span_panel.util import SUB_DEVICE_PV, classify_sub_device_identifier
+from custom_components.span_panel.websocket import _classify_sub_device
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.util import slugify
+
+from .adapter_fixtures import schema_one_snapshot, schema_one_tree
+
+from pytest_homeassistant_custom_component.common import (
+ MockConfigEntry,
+ MockEntityPlatform,
+ async_test_home_assistant,
+)
+
+PV_DEVICE: Final = "pv"
+"""The inverter's Homie device id in the capture."""
+
+VENDOR_TOPIC: Final = "info/vendor-name"
+MODEL_TOPIC: Final = "info/model"
+FIRMWARE_TOPIC: Final = "info/firmware-version"
+SERIAL_TOPIC: Final = "info/serial-number"
+
+PANEL_NAME: Final = "SPAN Panel"
+
+FALLBACK_MANUFACTURER: Final = "Unknown"
+FALLBACK_MODEL: Final = "Solar Inverter"
+
+BASELINE: Final = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json"
+
+_LEGACY_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = (
+ ("sensor", "pv_power", "sensor.span_panel_pv_power"),
+ ("sensor", "pv_vendor", "sensor.span_panel_pv_vendor"),
+ ("sensor", "pv_product", "sensor.span_panel_pv_product"),
+ ("sensor", "pv_nameplate_capacity", "sensor.span_panel_pv_nameplate_capacity"),
+ ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_pv_panel_link"),
+)
+"""``(platform, unique_id suffix, entity_id)`` as a released installation holds them.
+
+Literals, deliberately. This is the registry a user upgrading already has, which
+is a historical fact rather than something the current code gets to decide —
+deriving it from the builders under test would make every assertion below agree
+with itself. `test_a_fresh_install_still_builds_the_unique_ids_users_already_have`
+is what holds the literals to the code.
+"""
+
+_FRESH_PV_ENTITIES: Final[tuple[tuple[str, str, str], ...]] = (
+ ("sensor", "pv_power", "sensor.span_panel_solar_pv_power"),
+ ("sensor", "pv_vendor", "sensor.span_panel_solar_pv_vendor"),
+ ("sensor", "pv_product", "sensor.span_panel_solar_pv_product"),
+ ("sensor", "pv_nameplate_capacity", "sensor.span_panel_solar_pv_nameplate_capacity"),
+ ("binary_sensor", "pv_panel_link", "binary_sensor.span_panel_solar_pv_panel_link"),
+)
+"""The same five as a *new* installation gets them, on the inverter's own card.
+
+`{device name} {entity name}`, slugified — Home Assistant's own derivation, with
+the device being `SPAN Panel Solar` rather than `SPAN Panel`. Literals for the
+same reason the legacy tuple is: these are what a user, a dashboard and a
+support answer will name, and deriving them from the code under test would make
+the assertions agree with themselves.
+`test_the_fresh_ids_are_what_the_inverters_card_derives` holds them to
+`strings.json`.
+"""
+
+
+# ---------------------------------------------------------------------------
+# Reading the capture
+# ---------------------------------------------------------------------------
+
+
+def _published(topic: str) -> str:
+ """What the capture publishes on one PV topic, or fail saying it does not."""
+ value = schema_one_tree()[PV_DEVICE].get(topic)
+ assert value is not None, f"the capture publishes no {topic} on the inverter"
+ return value
+
+
+def _declared(topic: str) -> bool:
+ """Whether the inverter's `$description` declares one `node/property`."""
+ description = json.loads(schema_one_tree()[PV_DEVICE]["$description"])
+ node, _, prop = topic.partition("/")
+ return prop in description["nodes"].get(node, {}).get("properties", {})
+
+
+def _pv_snapshot(**rewrites: str | None) -> SpanPanelSnapshot:
+ """A snapshot from the capture with the inverter's topics rewritten or removed.
+
+ Keyword spelling is `node__property_name`. `None` removes the topic, which is
+ what firmware omitting a property looks like — a different event from
+ publishing an empty string, and the one the card's fallbacks exist for.
+ """
+ tree = schema_one_tree()
+ for path, value in rewrites.items():
+ node, _, prop = path.partition("__")
+ topic = f"{node.replace('_', '-')}/{prop.replace('_', '-')}"
+ if value is None:
+ tree[PV_DEVICE].pop(topic, None)
+ else:
+ tree[PV_DEVICE][topic] = value
+ return schema_one_snapshot(tree)
+
+
+# ---------------------------------------------------------------------------
+# Installing for real
+# ---------------------------------------------------------------------------
+
+
+def _entry(hass: HomeAssistant, entry_id: str, serial: str) -> MockConfigEntry:
+ """A config entry keyed on the panel's serial, as the config flow makes one."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "192.168.1.40", "device_name": PANEL_NAME},
+ options={},
+ title=PANEL_NAME,
+ entry_id=entry_id,
+ unique_id=serial,
+ )
+ entry.add_to_hass(hass)
+ return entry
+
+
+def _coordinator(
+ hass: HomeAssistant, entry: MockConfigEntry, snapshot: SpanPanelSnapshot
+) -> MagicMock:
+ coordinator = MagicMock()
+ coordinator.data = snapshot
+ coordinator.hass = hass
+ coordinator.panel_offline = False
+ coordinator.last_update_success = True
+ coordinator.unresolved_paths = frozenset()
+ coordinator.config_entry = entry
+ coordinator.async_request_refresh = AsyncMock()
+ return coordinator
+
+
+async def _register(
+ hass: HomeAssistant,
+ entry: MockConfigEntry,
+ platform: str,
+ setup: Callable[..., object],
+) -> None:
+ """Run one platform's `async_setup_entry` through a real `EntityPlatform`.
+
+ Through the platform rather than by inspecting the entities the setup
+ function returns, because everything under test here happens *in* the
+ registry: which device an entity is filed under, and which `entity_id` it
+ keeps. Neither is observable on an entity object.
+ """
+ added: list[object] = []
+ await setup(hass, entry, lambda entities, **_: added.extend(entities))
+
+ entity_platform = MockEntityPlatform(
+ hass, domain=platform, platform_name=DOMAIN, logger=logging.getLogger(__name__)
+ )
+ entity_platform.config_entry = entry
+ # The translations decide the object id Home Assistant derives, so a harness
+ # that skipped them would generate `sensor.span_panel_2` and prove nothing
+ # about the ids a user sees.
+ await entity_platform.platform_data.async_load_translations()
+ await entity_platform.async_add_entities(added)
+
+
+async def _install(
+ hass: HomeAssistant,
+ snapshot: SpanPanelSnapshot,
+ entry_id: str,
+ *,
+ seed: Callable[[HomeAssistant, MockConfigEntry, str], None] | None = None,
+) -> MockConfigEntry:
+ """Set up both platforms the way the integration does, optionally over a seeded registry."""
+ entry = _entry(hass, entry_id, snapshot.serial_number)
+ panel_device_id = await ensure_device_registered(hass, entry, snapshot, PANEL_NAME)
+ if seed is not None:
+ seed(hass, entry, panel_device_id)
+
+ coordinator = _coordinator(hass, entry, snapshot)
+ entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id=panel_device_id
+ )
+ await _register(hass, entry, "sensor", sensor_setup_entry)
+ await _register(hass, entry, "binary_sensor", binary_sensor_setup_entry)
+ return entry
+
+
+@asynccontextmanager
+async def _a_second_home_assistant() -> AsyncIterator[HomeAssistant]:
+ """A second, empty Home Assistant, for comparing two installations.
+
+ Both halves of the fresh-versus-migrated comparison have to be *the* install
+ on their instance. Running them into one registry makes the second collide
+ with the first and land on `..._pv_vendor_2`, which is an artefact of the
+ harness and would mask or invent a divergence either way.
+ """
+ async with async_test_home_assistant() as second:
+ try:
+ yield second
+ finally:
+ await second.async_stop(force=True)
+
+
+def _seed_the_old_shape(hass: HomeAssistant, entry: MockConfigEntry, panel_device_id: str) -> None:
+ """Write the PV entities onto the panel device, as a released install holds them."""
+ registry = er.async_get(hass)
+ for platform, suffix, entity_id in _LEGACY_PV_ENTITIES:
+ created = registry.async_get_or_create(
+ platform,
+ DOMAIN,
+ f"span_{entry.unique_id}_{suffix}",
+ config_entry=entry,
+ device_id=panel_device_id,
+ suggested_object_id=entity_id.split(".", 1)[1],
+ )
+ assert created.entity_id == entity_id, (
+ f"the seed could not reproduce {entity_id}; it landed on {created.entity_id}"
+ )
+
+
+def _pv_device(hass: HomeAssistant, entry: MockConfigEntry) -> dr.DeviceEntry | None:
+ return dr.async_get(hass).async_get_device_by_identifier(
+ (DOMAIN, f"{entry.unique_id}_{SUB_DEVICE_PV}"), entry.entry_id
+ )
+
+
+def _registry_shape(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, tuple[str, ...]]:
+ """``{entity_id: (unique_id, *device identifiers)}`` for every entity of one entry.
+
+ Devices by identifier rather than by registry id, because two installations
+ mint different registry ids for the same device and the identifier is the
+ stable name for "which card is this on".
+ """
+ devices = dr.async_get(hass)
+ shape: dict[str, tuple[str, ...]] = {}
+ for entity in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id):
+ device = devices.async_get(entity.device_id) if entity.device_id else None
+ identifiers = sorted(name for _domain, name in device.identifiers) if device else []
+ shape[entity.entity_id] = (entity.unique_id, *identifiers)
+ return shape
+
+
+# ---------------------------------------------------------------------------
+# The premise
+# ---------------------------------------------------------------------------
+
+
+def test_the_capture_publishes_the_identity_the_card_shows() -> None:
+ """Guard the premise: every card expectation below is read from these topics.
+
+ A test whose expected value comes from an unpublished topic does not fail, it
+ stops asserting anything — which is how the fixture drifted eight identity
+ properties behind the producer without a single red test.
+ """
+ assert _published(VENDOR_TOPIC)
+ assert _published(MODEL_TOPIC)
+ assert _published(FIRMWARE_TOPIC)
+
+
+def test_the_capture_declares_a_serial_and_publishes_none() -> None:
+ """The premise of the identifier decision, held to the capture.
+
+ If a producer ever values this, the choice of identifier stops being
+ hypothetical and `test_a_serial_arriving_on_the_wire_does_not_move_the_device`
+ stops being a simulation. Either way the identifier must not move, which is
+ what the two together assert.
+ """
+ assert _declared(SERIAL_TOPIC), "the inverter no longer declares a serial number"
+ assert schema_one_tree()[PV_DEVICE].get(SERIAL_TOPIC) is None, (
+ "the capture now values PV info/serial-number; decision 3 says it stays unvalued "
+ "until the flat side's PV device id is confirmed"
+ )
+
+
+# ---------------------------------------------------------------------------
+# The card
+# ---------------------------------------------------------------------------
+
+
+async def test_the_inverter_gets_a_card_showing_what_it_publishes(
+ hass: HomeAssistant,
+) -> None:
+ """Manufacturer, model and firmware read off the wire, not out of a constant."""
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-card")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert device.manufacturer == _published(VENDOR_TOPIC)
+ assert device.model == _published(MODEL_TOPIC)
+ assert device.sw_version == _published(FIRMWARE_TOPIC)
+
+
+async def test_the_card_follows_a_republished_identity(hass: HomeAssistant) -> None:
+ """The card tracks the wire, so nothing above is passing on a coincidence."""
+ rewritten = _pv_snapshot(
+ info__vendor_name="Another Vendor",
+ info__model="ANOTHER-MODEL-1",
+ info__firmware_version="example-pv/v9.9.9",
+ )
+
+ entry = await _install(hass, rewritten, "entry-pv-card-rewritten")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert device.manufacturer == "Another Vendor"
+ assert device.model == "ANOTHER-MODEL-1"
+ assert device.sw_version == "example-pv/v9.9.9"
+
+
+async def test_an_inverter_publishing_no_identity_gets_a_card_with_no_blank_rows(
+ hass: HomeAssistant,
+) -> None:
+ """The fallbacks, and the difference between an absent row and a blank one.
+
+ Vendor and model fall back to strings because a card with no name at all is
+ worse than a generic one. The firmware version has no string to fall back to
+ and must be *absent* rather than empty: `DeviceInfo` omits a `None` field and
+ renders `""` as a present-but-blank row, which reads as an inverter reporting
+ a blank version rather than one reporting none.
+ """
+ bare = _pv_snapshot(
+ info__vendor_name=None,
+ info__model=None,
+ info__firmware_version=None,
+ )
+
+ entry = await _install(hass, bare, "entry-pv-card-bare")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert device.manufacturer == FALLBACK_MANUFACTURER
+ assert device.model == FALLBACK_MODEL
+ assert device.sw_version is None
+
+
+async def test_the_card_hangs_off_the_panel_like_every_other_sub_device(
+ hass: HomeAssistant,
+) -> None:
+ """By registry id, which is the link Home Assistant stops dropping in 2027.8."""
+ snapshot = _pv_snapshot()
+ entry = await _install(hass, snapshot, "entry-pv-link")
+
+ device = _pv_device(hass, entry)
+ panel = dr.async_get(hass).async_get_device_by_identifier(
+ (DOMAIN, snapshot.serial_number), entry.entry_id
+ )
+ assert device is not None
+ assert panel is not None
+ assert device.via_device_id == panel.id
+
+
+async def test_a_panel_with_no_inverter_gets_no_card(hass: HomeAssistant) -> None:
+ """Absence is a reading. No PV node, no device, and no entities to re-home."""
+ tree = schema_one_tree()
+ del tree[PV_DEVICE]
+ for topics in tree.values():
+ topics.pop("power-flows/pv", None)
+ for circuit, topics in tree.items():
+ if topics.get("connection/feeds-device-id") == PV_DEVICE:
+ topics.pop("connection/feeds-device-id", None)
+ topics.pop("connection/feeds-device-status", None)
+ topics.pop("connection/feeds-device-type", None)
+
+ entry = await _install(hass, schema_one_snapshot(tree), "entry-pv-absent")
+
+ assert _pv_device(hass, entry) is None
+ shape = _registry_shape(hass, entry)
+ assert not [entity_id for entity_id in shape if "_pv_" in entity_id]
+
+
+# ---------------------------------------------------------------------------
+# The identifier
+# ---------------------------------------------------------------------------
+
+
+async def test_a_serial_arriving_on_the_wire_does_not_move_the_device(
+ hass: HomeAssistant,
+) -> None:
+ """The whole reason the identifier does not mention the inverter's serial.
+
+ `_der_identifier` on the producer side prefers a serial over an instance id,
+ so the day firmware starts publishing `info/serial-number` the inverter's
+ *Homie* device id changes. A Home Assistant identifier derived from it would
+ change with it, and a changed identifier is a new device: the card empties,
+ the entities orphan, and an upgrade rehearsal becomes a device-replacement
+ rehearsal. Keyed on the panel's serial and the kind instead, so this test
+ publishes a serial and watches nothing move.
+ """
+ before = await _install(hass, _pv_snapshot(), "entry-pv-noserial")
+ identifier_before = _pv_device(hass, before)
+ shape_before = _registry_shape(hass, before)
+
+ async with _a_second_home_assistant() as second:
+ after = await _install(
+ second,
+ _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001"),
+ "entry-pv-serial",
+ )
+ identifier_after = _pv_device(second, after)
+ shape_after = _registry_shape(second, after)
+
+ assert identifier_before is not None
+ assert identifier_after is not None
+ assert identifier_before.identifiers == identifier_after.identifiers
+ assert shape_before == shape_after
+
+
+async def test_the_topology_reader_calls_the_new_card_a_pv(hass: HomeAssistant) -> None:
+ """The writing end and the reading end of the identifier grammar agree.
+
+ The MID shipped classifying as `unknown` because a kind was added to the
+ builders and not to the reader, and a card rendered a device with a name and
+ no type. Asserted against the device as registered rather than against the
+ builder's dict, because the reader is handed a `DeviceEntry`.
+ """
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-classify")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert _classify_sub_device(device) == SUB_DEVICE_PV
+ assert classify_sub_device_identifier(f"{entry.unique_id}_{SUB_DEVICE_PV}") == SUB_DEVICE_PV
+
+
+# ---------------------------------------------------------------------------
+# Where the entities land, and what they keep
+# ---------------------------------------------------------------------------
+
+
+def _pv_entities(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, er.RegistryEntry]:
+ """The five PV entities, by `entity_id`, or fail naming the ones missing."""
+ registry = er.async_get(hass)
+ found: dict[str, er.RegistryEntry] = {}
+ missing: list[str] = []
+ for platform, suffix, _entity_id in _LEGACY_PV_ENTITIES:
+ entity_id = registry.async_get_entity_id(
+ platform, DOMAIN, f"span_{entry.unique_id}_{suffix}"
+ )
+ if entity_id is None:
+ missing.append(suffix)
+ continue
+ entity = registry.async_get(entity_id)
+ assert entity is not None
+ found[entity_id] = entity
+ assert not missing, f"the platform created no entity for {missing}"
+ return found
+
+
+async def test_a_fresh_install_still_builds_the_unique_ids_users_already_have(
+ hass: HomeAssistant,
+) -> None:
+ """The `unique_id` literals above are the current code's output, so the migration bites.
+
+ A `unique_id` is an identity: changing one does not rename an entity, it
+ orphans the old one and mints a second. The migration tests seed a registry
+ from `_LEGACY_PV_ENTITIES` and then expect setup to find those same ids, so
+ if the builders ever stopped producing them the seed would simply never be
+ matched and every migration assertion would pass over a registry it had
+ quietly rebuilt. This holds the recorded ids to the builders.
+
+ Only the `unique_id`s. The `entity_id`s a fresh installation gets are the
+ ones on `_FRESH_PV_ENTITIES`, which is the intended fork.
+ """
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-uids")
+
+ found = _pv_entities(hass, entry)
+ assert {entity.unique_id for entity in found.values()} == {
+ f"span_{entry.unique_id}_{suffix}" for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES
+ }
+ assert set(found) == {entity_id for _platform, _suffix, entity_id in _FRESH_PV_ENTITIES}
+
+
+async def test_every_pv_entity_lands_on_the_inverters_card(hass: HomeAssistant) -> None:
+ """All five, and none of them left behind on the panel."""
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-home")
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ for entity_id, entity in _pv_entities(hass, entry).items():
+ assert entity.device_id == device.id, f"{entity_id} is not on the inverter's card"
+
+
+def test_the_fresh_ids_are_what_the_inverters_card_derives() -> None:
+ """`_FRESH_PV_ENTITIES` is Home Assistant's derivation, not a guess at it.
+
+ Home Assistant slugifies `{device name} {entity name}` for an
+ `has_entity_name` entity. The device is `pv_device_info`'s
+ `f"{panel_name} Solar"` and the name comes from `strings.json`, so both
+ halves are read from the sources that would change rather than restated.
+ Renaming one of these in `strings.json` changes the entity_id a *new*
+ installation gets and nothing else, which is a user-visible change worth
+ failing a test over.
+
+ Also asserts each fresh id differs from the legacy one, so the fork this file
+ documents is a fact the test suite carries rather than a claim in a comment.
+ """
+ strings = json.loads(
+ (
+ pathlib.Path(__file__).parent.parent
+ / "custom_components"
+ / "span_panel"
+ / "strings.json"
+ ).read_text(encoding="utf-8")
+ )
+ legacy = {suffix: entity_id for _platform, suffix, entity_id in _LEGACY_PV_ENTITIES}
+ for platform, suffix, entity_id in _FRESH_PV_ENTITIES:
+ name = strings["entity"][platform][suffix]["name"]
+ assert entity_id == f"{platform}.{slugify(f'{PANEL_NAME} Solar')}_{slugify(name)}", (
+ f"{platform}.{suffix} is named {name!r}; a new installation's entity_id "
+ "is derived from that name and the inverter's device name"
+ )
+ assert entity_id != legacy[suffix]
+
+
+def test_the_descriptions_carry_the_translation_keys_the_names_are_read_from() -> None:
+ """Without a `translation_key` an entity has no name, and no derived object id.
+
+ Home Assistant falls back to the device name alone for an `has_entity_name`
+ entity with no name, so five PV entities would all want
+ `sensor.span_panel_solar` and the registry would resolve the collision by
+ appending numbers — a silent, permanent scrambling of five entity ids on new
+ installations only. The keys are also the `unique_id` suffixes, which is why
+ they are compared against the recorded shape.
+ """
+ keys = {PV_POWER_SENSOR.translation_key} | {
+ description.translation_key for description in PV_METADATA_SENSORS
+ }
+ keys.add(PV_PANEL_LINK_SENSOR.translation_key)
+ assert keys == {suffix for _platform, suffix, _entity_id in _LEGACY_PV_ENTITIES}
+
+
+# ---------------------------------------------------------------------------
+# The migration
+# ---------------------------------------------------------------------------
+
+
+async def test_the_migration_moves_the_device_and_nothing_else(
+ hass: HomeAssistant,
+) -> None:
+ """Seed the released shape, set up, and read the registry back.
+
+ Home Assistant re-homes an entity when it re-registers with different
+ `device_info` — `async_get_or_create` updates `device_id` on an existing
+ entry — so there is no bespoke migration to write and deliberately none
+ written. What that mechanism does *not* touch is the `entity_id` or the
+ `unique_id`, and those are what a user's dashboards and automations name, so
+ they are asserted one by one rather than in aggregate.
+ """
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate", seed=_seed_the_old_shape)
+
+ device = _pv_device(hass, entry)
+ assert device is not None
+ registry = er.async_get(hass)
+ for platform, suffix, entity_id in _LEGACY_PV_ENTITIES:
+ unique_id = f"span_{entry.unique_id}_{suffix}"
+ entity = registry.async_get(entity_id)
+ assert entity is not None, f"{entity_id} no longer exists after setup"
+ assert entity.unique_id == unique_id, f"{entity_id} changed unique_id"
+ assert entity.platform == DOMAIN
+ assert entity.domain == platform
+ assert entity.device_id == device.id, f"{entity_id} did not move to the inverter"
+
+
+async def test_the_migration_leaves_no_duplicate_and_no_orphan(
+ hass: HomeAssistant,
+) -> None:
+ """The two failure modes a re-home has, made observable.
+
+ A duplicate: the entity re-registers under a new `unique_id`, so the old
+ registry entry survives beside a new one and a user sees each reading twice.
+ An orphan: the panel device keeps an entity nothing writes to any more. Both
+ are counted rather than spot-checked, because either would otherwise hide
+ among forty circuit entities.
+ """
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-migrate-clean", seed=_seed_the_old_shape)
+
+ device = _pv_device(hass, entry)
+ panel = dr.async_get(hass).async_get_device_by_identifier(
+ (DOMAIN, entry.unique_id or ""), entry.entry_id
+ )
+ assert device is not None
+ assert panel is not None
+
+ entities = er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
+ unique_ids = [entity.unique_id for entity in entities]
+ assert len(unique_ids) == len(set(unique_ids)), "an entity re-registered under a second id"
+
+ pv_unique_ids = {f"span_{entry.unique_id}_{suffix}" for _p, suffix, _e in _LEGACY_PV_ENTITIES}
+ left_behind = [
+ entity.entity_id
+ for entity in entities
+ if entity.unique_id in pv_unique_ids and entity.device_id != device.id
+ ]
+ assert not left_behind, f"still on the panel card: {left_behind}"
+
+ pv_devices = [
+ candidate
+ for candidate in dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id)
+ if any(
+ classify_sub_device_identifier(name) == SUB_DEVICE_PV
+ for _domain, name in candidate.identifiers
+ )
+ ]
+ assert len(pv_devices) == 1, f"{len(pv_devices)} PV devices registered, expected 1"
+
+
+async def test_a_fresh_install_and_a_migrated_one_differ_only_in_entity_id(
+ hass: HomeAssistant,
+) -> None:
+ """The two installations fork on `entity_id`, and on nothing else. On purpose.
+
+ **This asymmetry is a decision, not a bug.** An earlier revision pinned the
+ panel-scoped `entity_id` on both installations so they matched exactly. That
+ was reversed. The governing constraint is that an *existing* installation's
+ `entity_id` and `unique_id` must never change — dashboards, automations and
+ history all name them — and that constraint is satisfied by Home Assistant's
+ own behaviour, without a pin: `async_get_or_create` honours a suggested
+ object id only at first registration. The pin therefore did nothing for the
+ installed base; all it did was give every *future* installation an entity id
+ derived from the panel rather than from the device the entity actually sits
+ on, permanently, to match a history that installation does not have.
+
+ So: same `unique_id`s, same device, different `entity_id`s, decided by install
+ date. If you are reading this because the divergence looks wrong, it is
+ intended — the fix is not to pin the legacy shape back on.
+
+ Compared over `{entity_id: (unique_id, device identifiers)}` — every fact a
+ user or a dashboard can name — so an entity filed under the wrong card fails
+ with its entity_id in the message.
+ """
+ snapshot = _pv_snapshot()
+ migrated = await _install(hass, snapshot, "entry-pv-shape-old", seed=_seed_the_old_shape)
+ migrated_shape = _registry_shape(hass, migrated)
+
+ async with _a_second_home_assistant() as second:
+ fresh = await _install(second, snapshot, "entry-pv-shape-new")
+ fresh_shape = _registry_shape(second, fresh)
+
+ legacy_ids = {entity_id for _platform, _suffix, entity_id in _LEGACY_PV_ENTITIES}
+ fresh_ids = {entity_id for _platform, _suffix, entity_id in _FRESH_PV_ENTITIES}
+
+ # The literals, both ways round: the upgraded installation kept every id it
+ # had, and the new one took the id its device derives.
+ assert legacy_ids <= set(migrated_shape)
+ assert not (fresh_ids & set(migrated_shape)), "an upgraded installation grew a second shape"
+ assert fresh_ids <= set(fresh_shape)
+ assert not (legacy_ids & set(fresh_shape)), "a new installation was given the legacy shape"
+
+ # Everything that is not one of the five is identical, so the fork is exactly
+ # as wide as it is meant to be and no other entity moved or was renamed.
+ assert {
+ entity_id: value
+ for entity_id, value in migrated_shape.items()
+ if entity_id not in legacy_ids
+ } == {
+ entity_id: value for entity_id, value in fresh_shape.items() if entity_id not in fresh_ids
+ }
+
+ # And across the fork the five are the same five: same identity, same card.
+ assert {migrated_shape[entity_id] for entity_id in legacy_ids} == {
+ fresh_shape[entity_id] for entity_id in fresh_ids
+ }
+
+
+async def test_a_user_renamed_entity_id_survives_the_move(hass: HomeAssistant) -> None:
+ """A `entity_id` the user chose is theirs, and the re-home must not touch it.
+
+ The same registry behaviour the whole migration rests on, asserted at its
+ sharpest point: an id that matches neither the legacy shape nor the shape the
+ inverter's card derives still survives, because Home Assistant re-derives an
+ object id for a `unique_id` it has never seen and for no other.
+ """
+
+ def seed(hass_: HomeAssistant, entry_: MockConfigEntry, panel_device_id: str) -> None:
+ _seed_the_old_shape(hass_, entry_, panel_device_id)
+ er.async_get(hass_).async_update_entity(
+ "sensor.span_panel_pv_vendor", new_entity_id="sensor.my_solar_brand"
+ )
+
+ entry = await _install(hass, _pv_snapshot(), "entry-pv-renamed", seed=seed)
+
+ registry = er.async_get(hass)
+ assert registry.async_get("sensor.span_panel_pv_vendor") is None
+ renamed = registry.async_get("sensor.my_solar_brand")
+ assert renamed is not None
+ device = _pv_device(hass, entry)
+ assert device is not None
+ assert renamed.device_id == device.id
+
+
+# ---------------------------------------------------------------------------
+# The inventories
+# ---------------------------------------------------------------------------
+
+
+def test_the_firmware_version_is_no_longer_an_unread_declaration() -> None:
+ """The line this task exists to delete, and the line that stays.
+
+ `pv/info/firmware-version` was baselined saying the inverter had no card to
+ carry a version; it has one now, so the line goes. `pv/info/serial-number`
+ stays, and its reason has to name the identifier decision rather than only
+ the producer-side one, because the Home Assistant identifier is now a second
+ thing that would have moved.
+ """
+ baseline: dict[str, str] = json.loads(BASELINE.read_text(encoding="utf-8"))
+
+ assert "pv/info/firmware-version" not in baseline
+ reason = baseline.get("pv/info/serial-number")
+ assert reason is not None, "the serial must stay baselined; see decision 3"
+ assert "identifier" in reason
+
+
+def test_the_cards_firmware_read_is_enumerated_as_a_residual() -> None:
+ """`pv_device_info` is not an entity, so its read is an exempt residual.
+
+ `NEITHER` beside the `mid.*` and `panel.*` card reads: flat's `pv` device
+ class declares no firmware version, and a schema_1 metadata row carries a
+ unit and a datatype for a reading, which a version string is not.
+ """
+ assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.NEITHER
+ assert "pv.software_version" not in declared_field_paths()
+
+
+def test_nothing_reads_a_pv_serial_anywhere() -> None:
+ """The negative half of the identifier decision, asserted rather than assumed.
+
+ Adding the field to the snapshot would be harmless; reading it here would
+ not, because every read is a place a future change could route into the
+ identifier. There is no such field and no such path, and this fails the day
+ one arrives without the decision being revisited.
+ """
+ assert "pv.serial_number" not in RESIDUAL_EXEMPT_PATHS
+ assert "pv.serial_number" not in declared_field_paths()
+ snapshot = _pv_snapshot(info__serial_number="INVERTER-SERIAL-0001")
+ assert not hasattr(snapshot.pv, "serial_number")
+
+
+def test_the_kind_vocabulary_is_closed() -> None:
+ """Every kind the classifier answers with, in one place.
+
+ Not a restatement of the classifier: `test_device_links` parametrises over
+ the *builders*, so a fifth sub-device whose identifier nothing classifies
+ fails there. This is the other direction — the reader answering a kind the
+ writers never mint — and it is why the list is spelled out rather than
+ derived from the constants.
+ """
+ assert [
+ classify_sub_device_identifier("panel-serial_bess"),
+ classify_sub_device_identifier("panel-serial_mid"),
+ classify_sub_device_identifier("panel-serial_evse_node"),
+ classify_sub_device_identifier("panel-serial_pv"),
+ classify_sub_device_identifier("panel-serial"),
+ ] == ["bess", "mid", "evse", SUB_DEVICE_PV, None]
diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py
index acc3e599..ff2f5a9f 100644
--- a/tests/test_sensor_entities.py
+++ b/tests/test_sensor_entities.py
@@ -154,7 +154,12 @@ def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None:
snapshot,
{"identifiers": {("span_panel", "bess")}},
)
- pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot)
+ pv_sensor = SpanPVMetadataSensor(
+ coordinator,
+ PV_METADATA_SENSORS[0],
+ snapshot,
+ {"identifiers": {("span_panel", "pv")}},
+ )
assert panel_data_sensor._generate_friendly_name(snapshot, panel_data_desc) == "Sensor"
assert status_sensor._generate_friendly_name(snapshot, status_desc) == "Status"
@@ -178,7 +183,12 @@ def test_panel_metadata_sensors_return_expected_data_sources() -> None:
snapshot,
{"identifiers": {("span_panel", "bess")}},
)
- pv_sensor = SpanPVMetadataSensor(coordinator, PV_METADATA_SENSORS[0], snapshot)
+ pv_sensor = SpanPVMetadataSensor(
+ coordinator,
+ PV_METADATA_SENSORS[0],
+ snapshot,
+ {"identifiers": {("span_panel", "pv")}},
+ )
assert bess_sensor.get_data_source(snapshot) is battery
assert pv_sensor.get_data_source(snapshot) is snapshot
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index 188de7cf..d11647f4 100644
--- a/tests/test_websocket.py
+++ b/tests/test_websocket.py
@@ -184,6 +184,12 @@ def test_evse(self):
device.identifiers = {(DOMAIN, "sp3-242424-001_evse_0")}
assert _classify_sub_device(device) == "evse"
+ def test_pv(self):
+ """Classify the solar inverter sub-device from its identifier."""
+ device = MagicMock()
+ device.identifiers = {(DOMAIN, "sp3-242424-001_pv")}
+ assert _classify_sub_device(device) == "pv"
+
def test_unknown(self):
"""Treat the panel device itself as an unknown sub-device type."""
device = MagicMock()
@@ -382,7 +388,7 @@ async def test_non_span_device(self, hass: HomeAssistant):
@pytest.mark.asyncio
async def test_sub_device_id_rejected(self, hass: HomeAssistant):
- """Error when device_id is a BESS/EVSE sub-device, not the panel."""
+ """Error when device_id is a sub-device, not the panel."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
@@ -419,7 +425,7 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant):
connection.send_error.assert_called_once_with(
1,
"not_panel_device",
- "Use the SPAN panel device registry ID, not a BESS or EVSE sub-device.",
+ "Use the SPAN panel device registry ID, not a sub-device.",
)
@pytest.mark.asyncio
From 2508b44488ef413468a5eb2dfd4eca20d8ab41ef Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:12:18 -0700
Subject: [PATCH 060/116] fix(strings): say what the new-entity count actually
counts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The notice reads "1 new entity/entities have appeared for your SPAN Panel",
which scans as a total. It is not: the notice only ever reports entities
created with `entity_registry_enabled_default=False`, because those are the
ones that reach the user through nothing at all. Everything added enabled --
the shed-forecast sensors, the PCS entities, the battery meter -- announces
itself by appearing, and is deliberately outside the count. A user who reads
"1" after an upgrade that added a dozen entities concludes the upgrade did
almost nothing.
The count now follows a label that names its scope, and a second sentence says
what it excludes.
`entity/entities` goes at the same time, along with `entidad(es)`,
`entité(s)` and `entidade(s)` in the three Latin locales. The parenthetical is
there because the string has to read for one or many, and Home Assistant's
issue translations have no plural forms. Putting the number after a label --
"Entities affected: 3" -- removes the agreement problem instead of papering
over it, and removes it in every language rather than only in English. Japanese
never had the problem, since it counts with 個 and does not inflect; it gains
only the scope sentence.
---
custom_components/span_panel/strings.json | 6 +++---
custom_components/span_panel/translations/en.json | 6 +++---
custom_components/span_panel/translations/es.json | 6 +++---
custom_components/span_panel/translations/fr.json | 6 +++---
custom_components/span_panel/translations/ja.json | 2 +-
custom_components/span_panel/translations/pt.json | 6 +++---
6 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 9ecc5d28..47968d20 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -767,7 +767,7 @@
"issues": {
"new_entities_disabled": {
"title": "New SPAN Panel entities are available",
- "description": "{count} new entity/entities have appeared for your SPAN Panel (for example: {examples}). They were created **disabled**, so nothing appeared in your dashboards or history uninvited.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
+ "description": "New entities added and left **disabled**: {count} (for example: {examples}). Nothing appeared in your dashboards or history uninvited.\n\nThis count covers only entities that were switched off. Anything added enabled is already visible in your entity list and history.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
},
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
@@ -775,11 +775,11 @@
},
"schema_field_unresolved": {
"title": "SPAN Panel data field unavailable",
- "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware."
+ "description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware."
},
"schema_unit_mismatch": {
"title": "SPAN Panel unit mismatch",
- "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. {count} entity/entities are affected (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
+ "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
}
}
}
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 9ecc5d28..47968d20 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -767,7 +767,7 @@
"issues": {
"new_entities_disabled": {
"title": "New SPAN Panel entities are available",
- "description": "{count} new entity/entities have appeared for your SPAN Panel (for example: {examples}). They were created **disabled**, so nothing appeared in your dashboards or history uninvited.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
+ "description": "New entities added and left **disabled**: {count} (for example: {examples}). Nothing appeared in your dashboards or history uninvited.\n\nThis count covers only entities that were switched off. Anything added enabled is already visible in your entity list and history.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
},
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
@@ -775,11 +775,11 @@
},
"schema_field_unresolved": {
"title": "SPAN Panel data field unavailable",
- "description": "Your panel's firmware no longer provides `{field_path}`. {count} entity/entities are affected (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware."
+ "description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware."
},
"schema_unit_mismatch": {
"title": "SPAN Panel unit mismatch",
- "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. {count} entity/entities are affected (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
+ "description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
}
}
}
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 34bb52a7..b572d0a1 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -767,7 +767,7 @@
"issues": {
"new_entities_disabled": {
"title": "Hay nuevas entidades del Panel SPAN disponibles",
- "description": "Han aparecido {count} entidad(es) nueva(s) en su Panel SPAN (por ejemplo: {examples}). Se han creado **deshabilitadas**, por lo que no ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido.\n\nPueden aparecer entidades nuevas por varios motivos: una actualización de la integración que expone más de lo que informa su panel, un circuito añadido al panel, o hardware como una batería o energía solar que se pone en marcha. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado."
+ "description": "Entidades nuevas creadas **deshabilitadas**: {count} (por ejemplo: {examples}). No ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido.\n\nEste recuento incluye solo las entidades que se crearon desactivadas. Todo lo que se añade habilitado ya es visible en su lista de entidades y en su historial.\n\nPueden aparecer entidades nuevas por varios motivos: una actualización de la integración que expone más de lo que informa su panel, un circuito añadido al panel, o hardware como una batería o energía solar que se pone en marcha. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado."
},
"panel_upgraded_to_ebus_v1": {
"title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred",
@@ -775,11 +775,11 @@
},
"schema_field_unresolved": {
"title": "Campo de datos del Panel SPAN no disponible",
- "description": "El firmware de su panel ya no proporciona `{field_path}`. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Pueden quedar no disponibles, o informar datos incompletos, hasta que una actualización de la integración añada compatibilidad con el nuevo firmware."
+ "description": "El firmware de su panel ya no proporciona `{field_path}`. Entidades afectadas: {count} (por ejemplo: {examples}). Pueden quedar no disponibles, o informar datos incompletos, hasta que una actualización de la integración añada compatibilidad con el nuevo firmware."
},
"schema_unit_mismatch": {
"title": "Discrepancia de unidades del Panel SPAN",
- "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Hay {count} entidad(es) afectada(s) (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente."
+ "description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Entidades afectadas: {count} (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente."
}
}
}
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index dea10a79..3fce2ae9 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -767,7 +767,7 @@
"issues": {
"new_entities_disabled": {
"title": "De nouvelles entités du Panneau SPAN sont disponibles",
- "description": "{count} nouvelle(s) entité(s) sont apparues pour votre Panneau SPAN (par exemple : {examples}). Elles ont été créées **désactivées**, afin que rien n'apparaisse dans vos tableaux de bord ni dans votre historique sans votre accord.\n\nDe nouvelles entités peuvent apparaître pour plusieurs raisons : une mise à jour de l'intégration qui expose davantage de ce que votre panneau signale, un circuit ajouté au panneau, ou du matériel tel qu'une batterie ou du solaire qui entre en service. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil."
+ "description": "Nouvelles entités créées **désactivées** : {count} (par exemple : {examples}). Rien n'est apparu dans vos tableaux de bord ni dans votre historique sans votre accord.\n\nCe décompte ne couvre que les entités créées désactivées. Tout ce qui est ajouté activé est déjà visible dans votre liste d'entités et votre historique.\n\nDe nouvelles entités peuvent apparaître pour plusieurs raisons : une mise à jour de l'intégration qui expose davantage de ce que votre panneau signale, un circuit ajouté au panneau, ou du matériel tel qu'une batterie ou du solaire qui entre en service. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil."
},
"panel_upgraded_to_ebus_v1": {
"title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau",
@@ -775,11 +775,11 @@
},
"schema_field_unresolved": {
"title": "Champ de données du Panneau SPAN indisponible",
- "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. {count} entité(s) sont concernées (par exemple : {examples}). Elles peuvent devenir indisponibles, ou renvoyer des données incomplètes, jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel."
+ "description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. Entités concernées : {count} (par exemple : {examples}). Elles peuvent devenir indisponibles, ou renvoyer des données incomplètes, jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel."
},
"schema_unit_mismatch": {
"title": "Incohérence d'unité du Panneau SPAN",
- "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. {count} entité(s) sont concernées (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement."
+ "description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Entités concernées : {count} (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement."
}
}
}
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 5309c41d..54ef81d8 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -767,7 +767,7 @@
"issues": {
"new_entities_disabled": {
"title": "SPAN パネルの新しいエンティティが利用可能です",
- "description": "SPAN パネルに {count} 個の新しいエンティティが現れました (例: {examples})。これらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。\n\n新しいエンティティが現れる理由はいくつかあります。パネルが報告する内容をより多く公開する統合の更新、パネルに追加された回路、あるいはバッテリーや太陽光などのハードウェアの稼働開始などです。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。"
+ "description": "SPAN パネルに {count} 個の新しいエンティティが現れました (例: {examples})。これらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。\n\nこの件数には、無効な状態で作成されたエンティティのみが含まれます。有効な状態で追加されたものは、すでにエンティティ一覧や履歴に表示されています。\n\n新しいエンティティが現れる理由はいくつかあります。パネルが報告する内容をより多く公開する統合の更新、パネルに追加された回路、あるいはバッテリーや太陽光などのハードウェアの稼働開始などです。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。"
},
"panel_upgraded_to_ebus_v1": {
"title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました",
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index e67c4436..fc8208f0 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -767,7 +767,7 @@
"issues": {
"new_entities_disabled": {
"title": "Estão disponíveis novas entidades do Painel SPAN",
- "description": "Apareceram {count} nova(s) entidade(s) no seu Painel SPAN (por exemplo: {examples}). Foram criadas **desativadas**, pelo que nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido.\n\nPodem aparecer novas entidades por vários motivos: uma atualização da integração que expõe mais do que o seu painel comunica, um circuito acrescentado ao painel, ou hardware como uma bateria ou energia solar que entra em funcionamento. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto."
+ "description": "Entidades novas criadas **desativadas**: {count} (por exemplo: {examples}). Nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido.\n\nEsta contagem inclui apenas as entidades criadas desativadas. Tudo o que é acrescentado ativado já está visível na sua lista de entidades e no seu histórico.\n\nPodem aparecer novas entidades por vários motivos: uma atualização da integração que expõe mais do que o seu painel comunica, um circuito acrescentado ao painel, ou hardware como uma bateria ou energia solar que entra em funcionamento. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto."
},
"panel_upgraded_to_ebus_v1": {
"title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede",
@@ -775,11 +775,11 @@
},
"schema_field_unresolved": {
"title": "Campo de dados do Painel SPAN indisponível",
- "description": "O firmware do seu painel já não fornece `{field_path}`. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). Podem ficar indisponíveis, ou comunicar dados incompletos, até que uma atualização da integração adicione suporte ao novo firmware."
+ "description": "O firmware do seu painel já não fornece `{field_path}`. Entidades afetadas: {count} (por exemplo: {examples}). Podem ficar indisponíveis, ou comunicar dados incompletos, até que uma atualização da integração adicione suporte ao novo firmware."
},
"schema_unit_mismatch": {
"title": "Discrepância de unidades do Painel SPAN",
- "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Há {count} entidade(s) afetada(s) (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente."
+ "description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Entidades afetadas: {count} (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente."
}
}
}
From 67f00ebd627b82ee94e2268215188db1ef24eda3 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:38:21 -0700
Subject: [PATCH 061/116] feat(binary_sensor): put the Wi-Fi network name on
the Wi-Fi link
`wifi_ssid` has been an attribute of the Software Version sensor since the
integration existed, for the historical reason that `panel_size` was already
squatting in that attribute block. A network name on a firmware-version sensor
is incoherent. The coherent host is the Wi-Fi Link binary sensor: both values
come off the same node on the wire -- `status/wifi` and `status/wifi-ssid` on
v1.0, `core/wifi` and `core/wifi-ssid` on flat -- and the entity that reports
whether Wi-Fi is up is the one that should report which network it is up on.
It stays on the Software Version sensor as well. That copy is what a user's
`state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template reads,
and a template reading a missing attribute returns None rather than erroring, so
moving it would break those silently. One duplicated attribute string is the
price; the old location comes out at a future major version.
The Wi-Fi Link gets an entity class of its own rather than a branch on
`SpanPanelBinarySensor`. `_residual_field_paths` is a `ClassVar` and that base
class serves every panel binary sensor -- door, both links, panel status, PCS
activity, PV link -- so declaring `panel.wifi_ssid` there would claim all six
read the SSID. That is not cosmetic: the declaration is what a Repair consults
to name the entities a dead field took down with it, so an unresolved SSID would
name the door sensor. Which class serves which description is a named map keyed
by description key, so the setup comprehension goes on saying only "one entity
per description".
Two classes now declare one residual path, which the apparatus was built for and
is now verified: `residual_field_paths()` unions a frozenset and the coordinator
maps a path to a set of entity ids, so the Repair names both readers.
`residual_field_paths()` also imports `binary_sensor` now -- the subclass walk
sees only imported modules, and the path being declared twice is exactly what
would have hidden the omission.
No exemption counts move: `panel.wifi_ssid` was already a declared residual
rather than an entry in `RESIDUAL_EXEMPT_PATHS`.
---
CHANGELOG.md | 4 +
README.md | 14 ++-
custom_components/span_panel/binary_sensor.py | 80 +++++++++++++-
custom_components/span_panel/field_paths.py | 1 +
custom_components/span_panel/sensor_panel.py | 19 +++-
tests/test_metadata_sweep.py | 104 +++++++++++++++++-
tests/test_schema_repairs.py | 55 +++++++++
7 files changed, 268 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6cefce3d..84822ed8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -84,6 +84,10 @@ All notable changes to this project will be documented in this file.
everything unconfigured is reporting a state, and that is the state most panels are in; entities that vanished until somebody configured a limit would be
entities nobody could build a dashboard on.
+- **The Wi-Fi network name is now on the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells
+ you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID. The same attribute stays on the
+ Software Version sensor, where it has always been, so existing templates reading it there keep working unchanged.
+
### Fixed
- **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on
diff --git a/README.md b/README.md
index 7bf49a5a..20fac9b0 100644
--- a/README.md
+++ b/README.md
@@ -226,10 +226,16 @@ Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Powe
### Software Version Sensor Attributes
-| Attribute | Type | Notes |
-| ------------ | ------ | ----------------------------------- |
-| `panel_size` | int | Total breaker spaces (e.g., 32, 40) |
-| `wifi_ssid` | string | Current Wi-Fi network |
+| Attribute | Type | Notes |
+| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------- |
+| `panel_size` | int | Total breaker spaces (e.g., 32, 40) |
+| `wifi_ssid` | string | Current Wi-Fi network. Also on the Wi-Fi Link binary sensor, which is where it belongs; this copy stays for template compatibility |
+
+### Wi-Fi Link Binary Sensor Attributes
+
+| Attribute | Type | Notes |
+| ----------- | ------ | ---------------------------------------------------------------- |
+| `wifi_ssid` | string | Network this link is to. Absent when the panel publishes no SSID |
### EVSE (EV Charger) Entities
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 769f0cb2..b3aa5a4b 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -2,9 +2,11 @@
from __future__ import annotations
-from collections.abc import Callable
+from collections.abc import Callable, Mapping
from dataclasses import dataclass
import logging
+from types import MappingProxyType
+from typing import Any, ClassVar
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -356,6 +358,77 @@ def _construct_binary_sensor_unique_id(
)
+class SpanPanelWifiLinkBinarySensor(SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]):
+ """The Wi-Fi link, which also reports the network the link is to.
+
+ Its own class, not a branch on `SpanPanelBinarySensor`, because
+ `_residual_field_paths` is a `ClassVar` and that base class serves every
+ panel binary sensor — the door, the two links, the panel status, the PCS
+ activity, the PV link. Declaring the SSID there would claim all of them read
+ it, and the declaration is not decoration: it is what a Repair consults to
+ name the entities a dead field took down with it, so an unresolved
+ `panel.wifi_ssid` would name the door sensor. `SpanPanelStatus` is its own
+ class in `sensor_panel` for exactly this reason.
+ """
+
+ _residual_field_paths: ClassVar[tuple[str, ...]] = ("panel.wifi_ssid",)
+ """The SSID, read for an attribute rather than by the `value_fn`.
+
+ A plain residual and not an exemption: both adapters map the property
+ (`core/wifi-ssid` on flat, `status/wifi-ssid` on v1.0), so the producible
+ gate covers it.
+
+ `SpanPanelStatus` declares the same path, and that is deliberate — see
+ `extra_state_attributes` below. `residual_field_paths()` unions a set, and
+ the coordinator's `field_path -> entity_id` map holds a set of entity ids
+ per path, so two readers of one path is the case both were built for: the
+ Repair names both entities, which is what a user needs to see.
+ """
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any] | None:
+ """The network this link is up on, when the panel names it.
+
+ The coherent host for the SSID: the entity that reports whether Wi-Fi is
+ up is the one that should report which network it is up on. Both values
+ come from the same node on the wire — `status/wifi` and
+ `status/wifi-ssid` on v1.0, `core/wifi` and `core/wifi-ssid` on flat.
+
+ The same attribute is still published by the Software Version sensor,
+ which is where it has always lived. That duplication is deliberate and
+ is documented at `SpanPanelStatus.extra_state_attributes`; do not remove
+ either half without reading it.
+
+ Omitted rather than reported as `None` when the panel publishes no SSID:
+ an attribute present and empty reads as a reading that failed, which is
+ a different claim from the panel never having made one.
+ """
+ snapshot = self.coordinator.data
+ if snapshot is None or snapshot.wifi_ssid is None:
+ return None
+ return {"wifi_ssid": snapshot.wifi_ssid}
+
+
+_PANEL_BINARY_SENSOR_CLASSES: Mapping[
+ str, type[SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]]
+] = MappingProxyType({SYSTEM_WIFI_LINK: SpanPanelWifiLinkBinarySensor})
+"""Panel binary sensors needing a class of their own, by description key.
+
+Everything absent from this map is a plain `SpanPanelBinarySensor`. A named map
+rather than a conditional inside the setup comprehension: the comprehension says
+"build one entity per description" and should keep saying only that, and the
+next description that needs its own class is then a one-line addition here
+rather than a second branch to read past.
+"""
+
+
+def _panel_binary_sensor_class(
+ description: SpanPanelBinarySensorEntityDescription,
+) -> type[SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription]]:
+ """Return the entity class that serves one panel binary sensor description."""
+ return _PANEL_BINARY_SENSOR_CLASSES.get(description.key, SpanPanelBinarySensor)
+
+
# ---------------------------------------------------------------------------
# EVSE (EV Charger) binary sensors
# ---------------------------------------------------------------------------
@@ -499,7 +572,10 @@ async def async_setup_entry(
entities: list[
SpanPanelBinarySensor[SpanPanelBinarySensorEntityDescription] | SpanEvseBinarySensor
- ] = [SpanPanelBinarySensor(coordinator, description) for description in BINARY_SENSORS]
+ ] = [
+ _panel_binary_sensor_class(description)(coordinator, description)
+ for description in BINARY_SENSORS
+ ]
# Add grid islandable binary sensor when v2 data is available
snapshot: SpanPanelSnapshot = coordinator.data
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index e58dce2b..fbc30901 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -477,6 +477,7 @@ def residual_field_paths() -> frozenset[str]:
# Deferred for the same cycle-avoidance reason as `declared_field_paths()`
# below: every platform module imports this one for the declaration mixin.
from . import ( # noqa: F401 pylint: disable=import-outside-toplevel,unused-import
+ binary_sensor,
select,
sensor_circuit,
sensor_panel,
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index 30d85560..38583d50 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -349,6 +349,11 @@ class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, Spa
demanded the move the moment schema_1 grew its row. Until then the path sat
in `RESIDUAL_EXEMPT_PATHS` annotated `SCHEMA_0_ONLY` -- true, and the reason
a v1.0 panel silently stopped filling an attribute a flat panel filled.
+
+ `SpanPanelWifiLinkBinarySensor` declares the same path, which is the
+ duplication `extra_state_attributes` explains. Nothing breaks: the collector
+ unions a frozenset, and the coordinator maps a path to a *set* of entity
+ ids, so the Repair names both readers.
"""
def __init__(
@@ -386,7 +391,19 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot:
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
- """Return additional state attributes for the software version sensor."""
+ """Return additional state attributes for the software version sensor.
+
+ **`wifi_ssid` is published here and on the Wi-Fi Link binary sensor, on
+ purpose. Do not "tidy" it away from either place.** The coherent host is
+ the binary sensor — a network name on a firmware-version sensor is
+ incoherent, and it only sits here because `panel_size` was already
+ occupying this attribute block. But it has been here for as long as the
+ integration has existed, so a user's
+ `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template
+ keeps working. One duplicated attribute string is the price of not
+ breaking those; this copy comes out at a future major version, not
+ before. `test_the_ssid_stays_on_the_software_version_sensor_too` pins it.
+ """
if not self.coordinator.data:
return None
diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py
index 2fb4ecf7..629419c7 100644
--- a/tests/test_metadata_sweep.py
+++ b/tests/test_metadata_sweep.py
@@ -1,6 +1,6 @@
"""Identity that reaches a device card, an attribute or a diagnostic sensor.
-Four surfaces, no new entity classes, and one of them is a regression rather
+Four surfaces, one new entity class, and one of them is a regression rather
than a feature. Grouped because they share a proof obligation: each is a value
the panel has published all along that nothing rendered, so a test asserting a
constant the code also holds would pass whether or not the wire is ever read.
@@ -17,6 +17,12 @@
annotation said `SCHEMA_0_ONLY` -- which was true, and sanctioned a user losing
an attribute on upgrade. With the library reading it, both adapters produce the
path, so it is a declaration now and the producible gate covers it.
+
+It is published on the **Wi-Fi Link binary sensor** as well, which is the
+coherent host: the entity that reports whether Wi-Fi is up is the one that
+should say which network it is up on, and both values come off the same node on
+the wire. The Software Version copy stays, deliberately, so existing templates
+keep working -- pinned below rather than left to a reader's judgement.
"""
from __future__ import annotations
@@ -29,7 +35,10 @@
from span_panel_api import SpanPanelSnapshot
from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered
-from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.binary_sensor import (
+ async_setup_entry as binary_sensor_async_setup_entry,
+)
+from custom_components.span_panel.const import DOMAIN, SYSTEM_DOOR_STATE, SYSTEM_WIFI_LINK
from custom_components.span_panel.field_paths import (
RESIDUAL_EXEMPT_PATHS,
declared_field_paths,
@@ -148,6 +157,20 @@ def _attributes(snapshot: SpanPanelSnapshot, key: str) -> dict[str, Any]:
return sensor.extra_state_attributes or {}
+async def _binary_sensors(hass: HomeAssistant, snapshot: SpanPanelSnapshot) -> dict[str, Any]:
+ """Every binary sensor the platform creates, keyed by description key.
+
+ Through `async_setup_entry` rather than by constructing an entity directly:
+ which entity class serves which description is exactly what is under test
+ here, and a direct construction would only assert the class the test itself
+ picked.
+ """
+ coordinator = _coordinator(snapshot)
+ added = MagicMock()
+ await binary_sensor_async_setup_entry(hass, coordinator.config_entry, added)
+ return {entity.entity_description.key: entity for entity in added.call_args.args[0]}
+
+
async def _registered_panel(
hass: HomeAssistant, snapshot: SpanPanelSnapshot, entry_id: str
) -> dr.DeviceEntry:
@@ -316,6 +339,83 @@ def test_the_ssid_is_a_declaration_now_rather_than_an_exemption() -> None:
assert "panel.wifi_ssid" in SpanPanelStatus._residual_field_paths
+async def test_the_wifi_link_sensor_carries_the_network_it_is_linked_to(
+ hass: HomeAssistant,
+) -> None:
+ """The coherent host: the link sensor says which network the link is to.
+
+ Read out of the capture rather than compared against a literal, so what is
+ under test is the whole route -- published topic, mapper, snapshot field,
+ attribute -- and not a constant the code also holds.
+ """
+ sensors = await _binary_sensors(hass, _snapshot())
+
+ attributes = sensors[SYSTEM_WIFI_LINK].extra_state_attributes
+
+ assert attributes == {"wifi_ssid": _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)}
+
+
+async def test_the_wifi_link_attribute_follows_a_republished_ssid(
+ hass: HomeAssistant,
+) -> None:
+ """A panel that joins another network says so, which a hardcoded value never could."""
+ sensors = await _binary_sensors(hass, _snapshot(status__wifi_ssid="another-network"))
+
+ assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes == {"wifi_ssid": "another-network"}
+
+
+async def test_an_unpublished_ssid_leaves_the_wifi_link_attribute_off_entirely(
+ hass: HomeAssistant,
+) -> None:
+ """Absent, not `None`. A present-but-empty attribute reads as a failed reading."""
+ sensors = await _binary_sensors(hass, _snapshot(status__wifi_ssid=None))
+
+ assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes is None
+
+
+async def test_the_ssid_stays_on_the_software_version_sensor_too(
+ hass: HomeAssistant,
+) -> None:
+ """The duplication is the compatibility guarantee, not an oversight.
+
+ `wifi_ssid` has been an attribute of the Software Version sensor for as long
+ as the integration has existed, so a user's
+ `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template
+ depends on it. Moving it to its coherent host would break those templates
+ silently -- a template that reads a missing attribute returns `None` rather
+ than erroring -- so both entities publish it and the old copy comes out at a
+ future major version. This test is what stops it being tidied away sooner.
+ """
+ snapshot = _snapshot()
+ published = _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)
+ sensors = await _binary_sensors(hass, snapshot)
+
+ assert _attributes(snapshot, SOFTWARE_VERSION_KEY)["wifi_ssid"] == published
+ assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes == {"wifi_ssid": published}
+
+
+async def test_only_the_wifi_link_sensor_declares_the_ssid_it_reads(
+ hass: HomeAssistant,
+) -> None:
+ """The reason the Wi-Fi link gets an entity class of its own.
+
+ `_residual_field_paths` is a `ClassVar` and one class serves every panel
+ binary sensor, so declaring the SSID on that base class would claim the door
+ sensor reads it. That is not cosmetic: the declaration is what a Repair
+ consults to name the entities a dead field took down with it, so an
+ unresolved `panel.wifi_ssid` would name the door.
+ """
+ sensors = await _binary_sensors(hass, _snapshot())
+ wifi_link = sensors[SYSTEM_WIFI_LINK]
+ door = sensors[SYSTEM_DOOR_STATE]
+
+ assert "panel.wifi_ssid" in type(wifi_link)._residual_field_paths
+ assert "panel.wifi_ssid" in wifi_link._declared_field_paths()
+
+ assert "panel.wifi_ssid" not in type(door)._residual_field_paths
+ assert "panel.wifi_ssid" not in door._declared_field_paths()
+
+
# ---------------------------------------------------------------------------
# `shed/policy` -- attributes on `dsm_state`
# ---------------------------------------------------------------------------
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index 354a5744..524cfd18 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -885,3 +885,58 @@ async def test_a_dead_priority_names_the_selects(hass) -> None:
assert affected["circuit.priority"] == sorted(s.entity_id for s in selects)
finally:
await _stop_scheduling(coordinator)
+
+
+async def test_two_entities_reading_one_residual_path_are_both_named(hass) -> None:
+ """`panel.wifi_ssid` has two readers, and the Repair has to name both.
+
+ The Wi-Fi Link binary sensor is the coherent host for the SSID; the Software
+ Version sensor keeps publishing it so existing templates go on working. Two
+ entities therefore declare one residual path, which the apparatus is built
+ for -- `residual_field_paths()` unions a frozenset and the coordinator maps a
+ path to a *set* of entity ids -- but "built for" is not "verified", and a map
+ that dropped one of the two would report half the damage without failing.
+ """
+ import dataclasses
+ from unittest.mock import MagicMock
+
+ from custom_components.span_panel.binary_sensor import (
+ async_setup_entry as binary_setup,
+ )
+ from custom_components.span_panel.const import SYSTEM_WIFI_LINK
+ from custom_components.span_panel.sensor import async_setup_entry as sensor_setup
+
+ coordinator, config_entry, _ = await _entities_by_declared_path(hass)
+ try:
+ coordinator.data = dataclasses.replace(coordinator.data, wifi_ssid="synthetic-network")
+
+ added_sensors = MagicMock()
+ await sensor_setup(hass, config_entry, added_sensors)
+ software_version = [
+ entity
+ for entity in added_sensors.call_args.args[0]
+ if getattr(entity.entity_description, "key", None) == "software_version"
+ ]
+ assert len(software_version) == 1
+
+ added_binary = MagicMock()
+ await binary_setup(hass, config_entry, added_binary)
+ wifi_link = [
+ entity
+ for entity in added_binary.call_args.args[0]
+ if getattr(entity.entity_description, "key", None) == SYSTEM_WIFI_LINK
+ ]
+ assert len(wifi_link) == 1
+
+ await _add_to_platform(hass, config_entry, software_version, "sensor")
+ await _add_to_platform(hass, config_entry, wifi_link, "binary_sensor")
+
+ assert coordinator.entity_ids_by_field_path["panel.wifi_ssid"] == sorted(
+ entity.entity_id for entity in (*software_version, *wifi_link)
+ )
+ # Both really do publish it, so the pair the Repair names is the pair a
+ # user would see go blank.
+ assert software_version[0].extra_state_attributes["wifi_ssid"] == "synthetic-network"
+ assert wifi_link[0].extra_state_attributes == {"wifi_ssid": "synthetic-network"}
+ finally:
+ await _stop_scheduling(coordinator)
From f2a49980a842734d5360d55eb3e168d5db86cf90 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Wed, 19 Aug 2026 23:46:55 -0700
Subject: [PATCH 062/116] refactor(sensor): move the Wi-Fi network name rather
than duplicate it
The previous commit left `wifi_ssid` on the Software Version sensor as well,
on the argument that a `state_attr('sensor.span_panel_software_version',
'wifi_ssid')` template must keep working. That argument does not survive
checking: at v2.0.8 `STATUS_SENSORS` held four descriptions -- `dsm_state`,
`dsm_grid_state`, `current_run_config`, `software_version` -- so the attribute
appeared on all four. The other three have since moved to
`SpanPanelDataSensorEntityDescription` / `SpanPanelPanelStatus`, which narrowed
it to one sensor without anyone recording that it had happened. This finishes
that narrowing and writes it down instead of restoring half of it.
So the SSID is on the Wi-Fi Link binary sensor and nowhere else. `panel_size` is
untouched. The residual declaration moved with the read: it is what a Repair
consults to name the entity a dead field took down with it, and a copy left on a
sensor that no longer reads the field would name the wrong entity.
That leaves `panel.wifi_ssid` declared only in `binary_sensor`, which made the
subclass walk's import list load-bearing for the first time -- and nothing held
that list to anything. `test_source_residuals_match_the_subclass_walk` cannot:
under pytest every platform module is imported many times over for other
reasons, so an omission still walks and the test passes. Dropping `binary_sensor`
from the list was a green mutation. It is now pinned in both directions against
the modules the source scan finds declarations in, which also caught `sensor_panel`
becoming a stale entry the moment its declaration left.
Tests that asserted the attribute on Software Version now assert its absence,
against a snapshot that does publish an SSID so a restored read fails rather
than passing on a panel that carries none.
---
CHANGELOG.md | 11 ++-
README.md | 9 ++-
custom_components/span_panel/binary_sensor.py | 21 +++---
custom_components/span_panel/field_paths.py | 12 +--
custom_components/span_panel/sensor_panel.py | 51 ++++++-------
tests/test_field_path_conformance.py | 75 +++++++++++++++++--
tests/test_metadata_sweep.py | 74 ++++++------------
tests/test_panel_sensors.py | 21 ++++--
tests/test_schema_repairs.py | 50 ++++++-------
9 files changed, 188 insertions(+), 136 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84822ed8..52731a7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -84,15 +84,18 @@ All notable changes to this project will be documented in this file.
everything unconfigured is reporting a state, and that is the state most panels are in; entities that vanished until somebody configured a limit would be
entities nobody could build a dashboard on.
-- **The Wi-Fi network name is now on the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells
- you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID. The same attribute stays on the
- Software Version sensor, where it has always been, so existing templates reading it there keep working unchanged.
+- **The Wi-Fi network name moved to the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells
+ you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID.
+- **It is no longer an attribute of the Software Version sensor.** A network name on a firmware-version sensor never made sense — it sat there because
+ `panel_size` was already in that attribute block. If you have a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at
+ the Wi-Fi Link binary sensor instead. `panel_size` is unaffected and stays where it is.
### Fixed
- **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on
the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a
- value you had, silently gone, with no error and nothing in the log. It is read again.
+ value you had, silently gone, with no error and nothing in the log. It is read again, and it is now published on the Wi-Fi Link binary sensor rather than on
+ the Software Version sensor — see above.
- **A firmware upgrade that adds a capability now actually reloads.** The check that decides whether new hardware warrants a reload knew about four capabilities
where the rest of the integration knew about nine. A panel that gained the shed forecast, the power control system, battery telemetry or DER link health while
Home Assistant was running published the data, matched every rule for creating the entities, and asked for no reload — so the new entities appeared only the
diff --git a/README.md b/README.md
index 20fac9b0..0029b23a 100644
--- a/README.md
+++ b/README.md
@@ -226,10 +226,11 @@ Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Powe
### Software Version Sensor Attributes
-| Attribute | Type | Notes |
-| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------- |
-| `panel_size` | int | Total breaker spaces (e.g., 32, 40) |
-| `wifi_ssid` | string | Current Wi-Fi network. Also on the Wi-Fi Link binary sensor, which is where it belongs; this copy stays for template compatibility |
+| Attribute | Type | Notes |
+| ------------ | ---- | ----------------------------------- |
+| `panel_size` | int | Total breaker spaces (e.g., 32, 40) |
+
+`wifi_ssid` used to appear here. It moved to the Wi-Fi Link binary sensor below; a template reading it from this sensor should be pointed there.
### Wi-Fi Link Binary Sensor Attributes
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index b3aa5a4b..3de76fe5 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -378,11 +378,14 @@ class in `sensor_panel` for exactly this reason.
(`core/wifi-ssid` on flat, `status/wifi-ssid` on v1.0), so the producible
gate covers it.
- `SpanPanelStatus` declares the same path, and that is deliberate — see
- `extra_state_attributes` below. `residual_field_paths()` unions a set, and
- the coordinator's `field_path -> entity_id` map holds a set of entity ids
- per path, so two readers of one path is the case both were built for: the
- Repair names both entities, which is what a user needs to see.
+ The only declaration of this path in the integration. It was on
+ `SpanPanelStatus` while that sensor rendered the SSID, and it moved with the
+ read rather than being left behind: the declaration is what a Repair
+ consults to name the entity a dead field took down with it, so a stale copy
+ would name a sensor that no longer reads the field. Because this is now the
+ only declaration, `residual_field_paths()` has to import `binary_sensor` for
+ the subclass walk to see it -- the walk sees only imported modules, and this
+ path would otherwise leave the producible gate silently.
"""
@property
@@ -394,10 +397,10 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
come from the same node on the wire — `status/wifi` and
`status/wifi-ssid` on v1.0, `core/wifi` and `core/wifi-ssid` on flat.
- The same attribute is still published by the Software Version sensor,
- which is where it has always lived. That duplication is deliberate and
- is documented at `SpanPanelStatus.extra_state_attributes`; do not remove
- either half without reading it.
+ The Software Version sensor used to publish it, for the historical
+ reason that `panel_size` was already occupying its attribute block. That
+ copy is gone -- see `SpanPanelStatus.extra_state_attributes` for why the
+ compatibility argument for keeping one did not hold up.
Omitted rather than reported as `None` when the panel publishes no SSID:
an attribute present and empty reads as a reading that failed, which is
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index fbc30901..86c94495 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -469,10 +469,13 @@ def residual_field_paths() -> frozenset[str]:
that the Repair actually consumes is the entity's.
The walk sees only classes Python has imported, so the platform modules
- that declare residuals are imported here explicitly. A residual declared in
- a module this function does not reach would go missing silently, so
- `test_source_residuals_match_the_subclass_walk` scans the package source
- for `_residual_field_paths` assignments and fails on any the walk missed.
+ that declare residuals are imported here explicitly -- exactly those, no
+ more: a module listed here that declares nothing is a stale import, and one
+ that declares something and is missing would go missing silently. Both
+ directions are pinned by
+ `test_the_residual_walk_imports_exactly_the_modules_that_declare_one`, which
+ reads this list out of the source because under pytest the platform modules
+ are already imported for other reasons and an omission here would still walk.
"""
# Deferred for the same cycle-avoidance reason as `declared_field_paths()`
# below: every platform module imports this one for the declaration mixin.
@@ -480,7 +483,6 @@ def residual_field_paths() -> frozenset[str]:
binary_sensor,
select,
sensor_circuit,
- sensor_panel,
switch,
)
from .entity import SpanPanelEntity # pylint: disable=import-outside-toplevel
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index 38583d50..efc64516 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
-from typing import Any, ClassVar
+from typing import Any
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import UNDEFINED
@@ -340,21 +340,12 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
class SpanPanelStatus(SpanSensorBase[SpanPanelStatusSensorEntityDescription, SpanPanelSnapshot]):
"""Span Panel hardware status sensor entity."""
- _residual_field_paths: ClassVar[tuple[str, ...]] = ("panel.wifi_ssid",)
- """The SSID, read for an attribute rather than by a `value_fn`.
-
- A plain residual and not an exemption: both adapters map the property
- (`core/wifi-ssid` on flat, `status/wifi-ssid` on v1.0), so the producible
- gate covers it, and `test_no_exempt_path_is_producible_by_both` is what
- demanded the move the moment schema_1 grew its row. Until then the path sat
- in `RESIDUAL_EXEMPT_PATHS` annotated `SCHEMA_0_ONLY` -- true, and the reason
- a v1.0 panel silently stopped filling an attribute a flat panel filled.
-
- `SpanPanelWifiLinkBinarySensor` declares the same path, which is the
- duplication `extra_state_attributes` explains. Nothing breaks: the collector
- unions a frozenset, and the coordinator maps a path to a *set* of entity
- ids, so the Repair names both readers.
- """
+ # `_residual_field_paths` stays empty on purpose. `panel.wifi_ssid` was
+ # declared here while this sensor rendered the SSID; the read moved to
+ # `SpanPanelWifiLinkBinarySensor` and the declaration went with it, because
+ # the declaration exists to let a Repair name the entity that made the read.
+ # `panel.panel_size` is not declared for the older reason: no adapter
+ # produces it, so it is an entry in `RESIDUAL_EXEMPT_PATHS` instead.
def __init__(
self,
@@ -393,16 +384,22 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanPanelSnapshot:
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return additional state attributes for the software version sensor.
- **`wifi_ssid` is published here and on the Wi-Fi Link binary sensor, on
- purpose. Do not "tidy" it away from either place.** The coherent host is
- the binary sensor — a network name on a firmware-version sensor is
- incoherent, and it only sits here because `panel_size` was already
- occupying this attribute block. But it has been here for as long as the
- integration has existed, so a user's
- `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template
- keeps working. One duplicated attribute string is the price of not
- breaking those; this copy comes out at a future major version, not
- before. `test_the_ssid_stays_on_the_software_version_sensor_too` pins it.
+ **No `wifi_ssid` here. It moved to the Wi-Fi Link binary sensor and is
+ not coming back; do not restore it "for compatibility".** A network name
+ on a firmware-version sensor was incoherent — it only ever sat here
+ because `panel_size` was already occupying this attribute block — and the
+ entity that reports whether Wi-Fi is up is the one that should report
+ which network it is up on.
+
+ The compatibility argument for keeping a copy does not hold up. At v2.0.8
+ `STATUS_SENSORS` held four descriptions, so the attribute appeared on
+ four sensors; the other three have since moved to
+ `SpanPanelPanelStatus`, which narrowed it to this one sensor without
+ anybody recording that it had happened. This is that narrowing finished
+ and written down rather than half-done and undocumented.
+ `test_the_ssid_moved_off_the_software_version_sensor` pins the absence.
+
+ `panel_size` is untouched and stays here.
"""
if not self.coordinator.data:
return None
@@ -411,8 +408,6 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
attributes: dict[str, Any] = {}
attributes["panel_size"] = snapshot.panel_size
- if snapshot.wifi_ssid is not None:
- attributes["wifi_ssid"] = snapshot.wifi_ssid
if self.entity_description.key == "grid_forming_entity":
forming = _grid_forming_device_name(snapshot)
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 2e4e0bf2..4cdf6489 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -12,7 +12,7 @@
import ast
from collections import Counter
-from collections.abc import Callable
+from collections.abc import Callable, Iterator
import pathlib
import pytest
@@ -105,15 +105,18 @@ def test_gate_covers_every_declaration_in_the_source() -> None:
)
-def _source_residual_paths() -> dict[str, str]:
- """Every `_residual_field_paths` tuple literal in the integration source, by module.
+def _iter_source_residuals() -> Iterator[tuple[str, str]]:
+ """Yield ``(field_path, module_filename)`` for every residual literal in the source.
The runtime counterpart, `residual_field_paths()`, unions the class
attribute over a `SpanPanelEntity.__subclasses__()` walk, and a walk sees
only what has been imported. This reads the same declarations out of the
source text, where importedness is not a factor.
+
+ A path declared by two modules yields twice, deliberately: which *paths*
+ exist and which *modules* declare one are different questions, and collapsing
+ to the first module that mentions a path answers the second wrongly.
"""
- found: dict[str, str] = {}
for source in sorted(_PACKAGE_ROOT.rglob("*.py")):
tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source))
for node in ast.walk(tree):
@@ -133,10 +136,22 @@ def _source_residual_paths() -> dict[str, str]:
continue
for element in node.value.elts:
if isinstance(element, ast.Constant) and isinstance(element.value, str):
- found.setdefault(element.value, source.name)
+ yield element.value, source.name
+
+
+def _source_residual_paths() -> dict[str, str]:
+ """Every residual path in the integration source, against a module declaring it."""
+ found: dict[str, str] = {}
+ for path, module in _iter_source_residuals():
+ found.setdefault(path, module)
return found
+def _source_residual_modules() -> set[str]:
+ """Every integration module that declares at least one residual path."""
+ return {module.removesuffix(".py") for _, module in _iter_source_residuals()}
+
+
def test_source_residuals_match_the_subclass_walk() -> None:
"""A residual the walk cannot see must fail, not vanish.
@@ -173,6 +188,56 @@ def test_source_residuals_match_the_subclass_walk() -> None:
)
+def _walk_imported_modules() -> set[str]:
+ """Return the sibling modules `residual_field_paths()` imports for its walk.
+
+ Read out of the source rather than by calling the function, because what is
+ under test is the import list itself: a module missing from it still gets
+ walked in-process whenever some *other* importer has already pulled it in,
+ which is true of every module in the test suite and is exactly why the walk
+ test below cannot see the omission.
+ """
+ tree = ast.parse((_PACKAGE_ROOT / "field_paths.py").read_text(encoding="utf-8"))
+ function = next(
+ node
+ for node in ast.walk(tree)
+ if isinstance(node, ast.FunctionDef) and node.name == "residual_field_paths"
+ )
+ return {
+ alias.name
+ for node in ast.walk(function)
+ if isinstance(node, ast.ImportFrom) and node.level == 1 and node.module is None
+ for alias in node.names
+ }
+
+
+def test_the_residual_walk_imports_exactly_the_modules_that_declare_one() -> None:
+ """The walk's import list, pinned against the declarations it exists to reach.
+
+ `residual_field_paths()` walks `SpanPanelEntity.__subclasses__()`, which sees
+ only classes Python has already imported, so it imports the declaring
+ platform modules itself. Nothing held that list to the declarations: a module
+ dropped from it goes on being walked under pytest, where the platform modules
+ are imported many times over for other reasons, and fails only in production
+ where `field_paths` may be reached first. A path that leaves the walk leaves
+ the producible gate and the Repair's affected-entity count with it, silently.
+
+ Both directions. An unlisted module is the failure above; a listed module
+ that declares nothing is a stale import, which is how the list stops meaning
+ what its docstring says and starts being copied forward unread.
+ """
+ declaring = _source_residual_modules()
+ imported = _walk_imported_modules()
+
+ assert declaring, "the source scan found no residual declarations at all"
+ assert imported == declaring, (
+ f"`residual_field_paths()` imports {sorted(imported)} for its subclass walk but "
+ f"residuals are declared in {sorted(declaring)}. Unlisted modules drop out of the "
+ "producible gate in any process that reaches `field_paths` first; listed modules "
+ "that declare nothing are stale."
+ )
+
+
@pytest.mark.parametrize(("adapter", "metadata_fn"), _ADAPTERS)
def test_gate_is_one_directional(
adapter: str, metadata_fn: MetadataFn, capsys: pytest.CaptureFixture[str]
diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py
index 629419c7..4e540481 100644
--- a/tests/test_metadata_sweep.py
+++ b/tests/test_metadata_sweep.py
@@ -18,11 +18,13 @@
an attribute on upgrade. With the library reading it, both adapters produce the
path, so it is a declaration now and the producible gate covers it.
-It is published on the **Wi-Fi Link binary sensor** as well, which is the
-coherent host: the entity that reports whether Wi-Fi is up is the one that
-should say which network it is up on, and both values come off the same node on
-the wire. The Software Version copy stays, deliberately, so existing templates
-keep working -- pinned below rather than left to a reader's judgement.
+It is published on the **Wi-Fi Link binary sensor**, which is the coherent host:
+the entity that reports whether Wi-Fi is up is the one that should say which
+network it is up on, and both values come off the same node on the wire. The
+Software Version sensor no longer carries it. That narrowing had already begun
+undocumented -- at v2.0.8 four `STATUS_SENSORS` descriptions rendered the
+attribute and three have since moved elsewhere, leaving one -- and this finishes
+it and writes it down.
"""
from __future__ import annotations
@@ -36,6 +38,7 @@
from custom_components.span_panel import SpanPanelRuntimeData, ensure_device_registered
from custom_components.span_panel.binary_sensor import (
+ SpanPanelWifiLinkBinarySensor,
async_setup_entry as binary_sensor_async_setup_entry,
)
from custom_components.span_panel.const import DOMAIN, SYSTEM_DOOR_STATE, SYSTEM_WIFI_LINK
@@ -297,32 +300,22 @@ def test_the_panel_identity_paths_are_enumerated_as_device_card_reads() -> None:
# ---------------------------------------------------------------------------
-def test_the_ssid_attribute_carries_what_the_panel_publishes() -> None:
- """The attribute a flat panel filled, filled again on v1.0.
+def test_the_ssid_moved_off_the_software_version_sensor() -> None:
+ """The old site, asserted absent — deliberately, and not coming back.
- Asserted on a sensor built by the platform over a snapshot the real schema_1
- adapter produced from the capture, so what is under test is the whole route:
- published topic, mapper, snapshot field, attribute.
+ A network name on a firmware-version sensor was incoherent; it only ever sat
+ there because `panel_size` was already occupying the attribute block. The
+ value is not lost, it moved: `test_the_wifi_link_sensor_carries_the_network_it_is_linked_to`
+ reads it back out of this same capture on the Wi-Fi Link binary sensor.
+
+ Asserted against a snapshot that *does* publish an SSID, so restoring the
+ read fails here rather than passing on a panel that happens to carry none.
"""
attributes = _attributes(_snapshot(), SOFTWARE_VERSION_KEY)
- assert attributes["wifi_ssid"] == _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)
-
-
-def test_the_ssid_attribute_follows_a_republished_ssid() -> None:
- """A panel that joins another network says so, which a hardcoded `None` never could."""
- attributes = _attributes(_snapshot(status__wifi_ssid="another-network"), SOFTWARE_VERSION_KEY)
-
- assert attributes["wifi_ssid"] == "another-network"
-
-
-def test_an_unpublished_ssid_leaves_the_attribute_off_entirely() -> None:
- """Absent, not empty. An attribute present and blank reads as a failed reading."""
- attributes = _attributes(_snapshot(status__wifi_ssid=None), SOFTWARE_VERSION_KEY)
-
+ assert _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)
assert "wifi_ssid" not in attributes
- # The sensor still reports its other attributes, so this is the SSID going
- # missing rather than the attribute block collapsing.
+ # The attribute block did not collapse; only the SSID left it.
assert "panel_size" in attributes
@@ -331,12 +324,14 @@ def test_the_ssid_is_a_declaration_now_rather_than_an_exemption() -> None:
Both adapters map `wifi_ssid`, so the path satisfies the producible gate and
`test_no_exempt_path_is_producible_by_both` refuses to let it stay exempt.
- It is declared on the entity that reads it, which is what lets a Repair name
- the entities a dead field takes with it.
+ It is declared on the entity that reads it -- one entity, now that the read
+ has moved -- which is what lets a Repair name the entity a dead field takes
+ with it.
"""
assert "panel.wifi_ssid" not in RESIDUAL_EXEMPT_PATHS
assert "panel.wifi_ssid" in declared_field_paths()
- assert "panel.wifi_ssid" in SpanPanelStatus._residual_field_paths
+ assert "panel.wifi_ssid" in SpanPanelWifiLinkBinarySensor._residual_field_paths
+ assert "panel.wifi_ssid" not in SpanPanelStatus._residual_field_paths
async def test_the_wifi_link_sensor_carries_the_network_it_is_linked_to(
@@ -373,27 +368,6 @@ async def test_an_unpublished_ssid_leaves_the_wifi_link_attribute_off_entirely(
assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes is None
-async def test_the_ssid_stays_on_the_software_version_sensor_too(
- hass: HomeAssistant,
-) -> None:
- """The duplication is the compatibility guarantee, not an oversight.
-
- `wifi_ssid` has been an attribute of the Software Version sensor for as long
- as the integration has existed, so a user's
- `state_attr('sensor.span_panel_software_version', 'wifi_ssid')` template
- depends on it. Moving it to its coherent host would break those templates
- silently -- a template that reads a missing attribute returns `None` rather
- than erroring -- so both entities publish it and the old copy comes out at a
- future major version. This test is what stops it being tidied away sooner.
- """
- snapshot = _snapshot()
- published = _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)
- sensors = await _binary_sensors(hass, snapshot)
-
- assert _attributes(snapshot, SOFTWARE_VERSION_KEY)["wifi_ssid"] == published
- assert sensors[SYSTEM_WIFI_LINK].extra_state_attributes == {"wifi_ssid": published}
-
-
async def test_only_the_wifi_link_sensor_declares_the_ssid_it_reads(
hass: HomeAssistant,
) -> None:
diff --git a/tests/test_panel_sensors.py b/tests/test_panel_sensors.py
index 746ed1c9..60e933c6 100644
--- a/tests/test_panel_sensors.py
+++ b/tests/test_panel_sensors.py
@@ -202,24 +202,33 @@ def test_software_version_extra_state_attributes_panel_size(
assert attrs is not None
assert attrs["panel_size"] == 32
- def test_software_version_extra_state_attributes_wifi_ssid(
+ def test_software_version_no_longer_carries_the_wifi_ssid(
self, mock_coordinator: MagicMock
) -> None:
- """Test that wifi_ssid appears in extra_state_attributes when present."""
+ """The SSID moved to the Wi-Fi Link binary sensor, deliberately.
+
+ A network name on a firmware-version sensor was incoherent; it only sat
+ here because `panel_size` was already occupying the attribute block. The
+ value is not lost -- `SpanPanelWifiLinkBinarySensor` publishes it, and
+ `tests/test_metadata_sweep.py` reads it back out of the capture there.
+ Asserted even with an SSID published, so a restored read fails here
+ rather than passing on a snapshot that happens to carry none.
+ """
description = next(d for d in STATUS_SENSORS if d.key == "software_version")
- snapshot = SpanPanelSnapshotFactory.create(panel_size=24, wifi_ssid="MyNetwork")
+ snapshot = SpanPanelSnapshotFactory.create(panel_size=24, wifi_ssid="synthetic-network")
mock_coordinator.data = snapshot
sensor = SpanPanelStatus(mock_coordinator, description, snapshot)
attrs = sensor.extra_state_attributes
assert attrs is not None
+ assert "wifi_ssid" not in attrs
+ # The attribute block did not collapse; only the SSID left it.
assert attrs["panel_size"] == 24
- assert attrs["wifi_ssid"] == "MyNetwork"
- def test_software_version_extra_state_attributes_no_wifi(
+ def test_software_version_keeps_panel_size_without_a_wifi_ssid(
self, mock_coordinator: MagicMock
) -> None:
- """Test that wifi_ssid is omitted when None."""
+ """A panel publishing no SSID is indistinguishable here, which is the point."""
description = next(d for d in STATUS_SENSORS if d.key == "software_version")
snapshot = SpanPanelSnapshotFactory.create(panel_size=16, wifi_ssid=None)
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index 524cfd18..b1a21798 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -887,15 +887,14 @@ async def test_a_dead_priority_names_the_selects(hass) -> None:
await _stop_scheduling(coordinator)
-async def test_two_entities_reading_one_residual_path_are_both_named(hass) -> None:
- """`panel.wifi_ssid` has two readers, and the Repair has to name both.
-
- The Wi-Fi Link binary sensor is the coherent host for the SSID; the Software
- Version sensor keeps publishing it so existing templates go on working. Two
- entities therefore declare one residual path, which the apparatus is built
- for -- `residual_field_paths()` unions a frozenset and the coordinator maps a
- path to a *set* of entity ids -- but "built for" is not "verified", and a map
- that dropped one of the two would report half the damage without failing.
+async def test_the_wifi_link_sensor_registers_the_ssid_it_reads(hass) -> None:
+ """The Wi-Fi Link is the only entity a dead `panel.wifi_ssid` should name.
+
+ The read moved off the Software Version sensor, and the declaration moved
+ with it. That leaves the SSID as this integration's one residual declared on
+ a *binary sensor* -- every other residual test here covers a switch, a select
+ or a circuit sensor -- so nothing but this holds the binary-sensor route into
+ the Repair's affected-entity map.
"""
import dataclasses
from unittest.mock import MagicMock
@@ -910,15 +909,6 @@ async def test_two_entities_reading_one_residual_path_are_both_named(hass) -> No
try:
coordinator.data = dataclasses.replace(coordinator.data, wifi_ssid="synthetic-network")
- added_sensors = MagicMock()
- await sensor_setup(hass, config_entry, added_sensors)
- software_version = [
- entity
- for entity in added_sensors.call_args.args[0]
- if getattr(entity.entity_description, "key", None) == "software_version"
- ]
- assert len(software_version) == 1
-
added_binary = MagicMock()
await binary_setup(hass, config_entry, added_binary)
wifi_link = [
@@ -928,15 +918,25 @@ async def test_two_entities_reading_one_residual_path_are_both_named(hass) -> No
]
assert len(wifi_link) == 1
- await _add_to_platform(hass, config_entry, software_version, "sensor")
+ added_sensors = MagicMock()
+ await sensor_setup(hass, config_entry, added_sensors)
+ software_version = [
+ entity
+ for entity in added_sensors.call_args.args[0]
+ if getattr(entity.entity_description, "key", None) == "software_version"
+ ]
+ assert len(software_version) == 1
+
await _add_to_platform(hass, config_entry, wifi_link, "binary_sensor")
+ await _add_to_platform(hass, config_entry, software_version, "sensor")
- assert coordinator.entity_ids_by_field_path["panel.wifi_ssid"] == sorted(
- entity.entity_id for entity in (*software_version, *wifi_link)
- )
- # Both really do publish it, so the pair the Repair names is the pair a
- # user would see go blank.
- assert software_version[0].extra_state_attributes["wifi_ssid"] == "synthetic-network"
+ named = coordinator.entity_ids_by_field_path["panel.wifi_ssid"]
+
+ assert named == [wifi_link[0].entity_id]
+ # The sensor that used to read it must not still be claiming it: a stale
+ # declaration would name an entity a dead SSID no longer affects.
+ assert software_version[0].entity_id not in named
assert wifi_link[0].extra_state_attributes == {"wifi_ssid": "synthetic-network"}
+ assert "wifi_ssid" not in (software_version[0].extra_state_attributes or {})
finally:
await _stop_scheduling(coordinator)
From 4e717b72dbcb806cb02c11b17605eab7ff7aea6b Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:06:59 -0700
Subject: [PATCH 063/116] docs(developer): explain the declared-but-unread gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The gate is new and its mechanism is not obvious from reading it: it decides
"read" by experiment rather than by inspecting a map, republishing each declared
property with a legal different value and checking whether anything a consumer
reads moved. That is why it sees consumption `_PROPERTY_FIELD_MAP` cannot
express, and it is worth writing down before someone reads the map and concludes
the gate is redundant.
Also states the two limits, because both are easy to mistake for coverage. The
gate reads the vendored fixture, so it cannot see a property a real panel starts
publishing in the field. And it asks whether anything moved, not whether
everything did — a property read on the first PV child and discarded on the
second still counts as read.
---
developer.md | 38 ++++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/developer.md b/developer.md
index 46b96dcc..8ccc0d9d 100644
--- a/developer.md
+++ b/developer.md
@@ -162,6 +162,44 @@ python -m pytest tests/test_current_monitor.py -q
python -m pytest tests/ --cov=custom_components/span_panel --cov-report=term-missing
```
+## Knowing what the panel publishes that nothing reads
+
+The panel declares more than this integration surfaces, and the gap is tracked mechanically rather than by memory.
+
+### The gate
+
+`tests/test_declared_but_unread.py` asserts that **every property declared in a device's `$description`** is one of three things: mapped to a snapshot field by
+an adapter, consumed by a known internal route (topology, dispatch, device_info, role resolution), or listed in
+`tests/fixtures/unread_declarations_baseline.json` with a one-line reason.
+
+It decides "read" **by experiment**, not by inspecting a map. For each declared property it republishes a legal different value derived from the property's own
+`datatype`/`format`, rebuilds the snapshot through the real adapter, and checks whether any field a consumer reads actually moved. That is why it sees
+consumption the `_PROPERTY_FIELD_MAP` cannot express, and why it caught a case where a metadata row existed while nothing read the value.
+
+It fails in **both** directions:
+
+- A newly declared property that reaches nothing fails the build until somebody triages it.
+- A property that becomes read fails until its baseline line is deleted.
+
+### Working with the baseline
+
+When you surface a property, delete its baseline line in the same change. The test will tell you if you deleted one you did not surface, or surfaced one whose
+line you left.
+
+When you decide a property should _stay_ unread, add a line with an honest reason. The reasons are load-bearing — they are what stops the file becoming a list
+of things nobody remembers deciding. The current entries are all permanent: deliberate skips (`status/postal-code` copies location into recorder history; Home
+Assistant owns `status/time-zone`), values held for identity reasons (`pv/info/serial-number`), redundant echoes (`connection/*-device-type` dereferences to a
+declared `$type`), and properties no producer publishes (`connection/count`).
+
+### What it does not cover
+
+The gate reads the **vendored fixture**, so it answers "what does our capture declare that we do not read". It cannot see a property a real panel starts
+publishing in the field. That is the purpose of the runtime discovery work on `feat/discovery-and-catalog-validation`: the same question asked of a live panel
+and surfaced in diagnostics, so a maintainer triaging an issue can see what that fleet declares that this integration ignores.
+
+Note also that a property can be _read on one device and not another_ and the gate will not see it — it asks whether anything moved, not whether everything did.
+`snapshot.pv` keeps the first `energy.ebus.device.pv` child and discards the rest, so a second inverter is invisible while the property still counts as read.
+
## Linting and Type Checking
```bash
From 8e33fce2a0ee4ec8ff5e8ec8ae34c24932b1bb6d Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 09:10:16 -0700
Subject: [PATCH 064/116] feat(diagnostics): carry what the panel declares and
this integration ignores
`test_declared_but_unread` answers "what does our capture declare that we do not
read" by experiment, and it is fixture-bound: a real panel that starts
publishing a property fails nothing until somebody recaptures. schema_1 now asks
the same question of the live tree and returns the answer as namespaced rows in
its metadata map. This is the consumer half.
**The partition comes first, because a discovery feature that breaks the
catch-up's gate is a net loss.** `schema_validation.partition` splits an
adapter's map on the `discovered.` namespace before any other question is asked
of it, and every inventory downstream -- the producible gate, `unread`, the
exemption annotations, the unit vocabulary -- sees the curated half only. All of
them read "in an adapter's map" as "this integration could read this", which a
discovered path is not: one landing in `unread` would bury ten deliberate
entries under whatever a firmware release added, and would make the count depend
on the panel in front of the user. `test_the_unread_inventory_is_deaf_to_
discovery` proves it by mutation -- a synthetic discovered row moves `unread`,
`unresolved` and the unit mismatches not at all. The test fixtures follow the
same rule, so no other module can be perturbed by accident.
**Diagnostics ships paths, datatypes, units and retention -- never values.**
`TO_REDACT` is key-based over the config entry: it knows nothing about wire
property names and could not protect a value added here, so the block is safe by
construction rather than by redaction. The row shape is a `TypedDict` so a fifth
key is a type error rather than a review comment, and the privacy claim is
asserted against the capture's own published values.
Nothing user-facing: no entity, no Repair, no notification. That line is the
one between this and automatic adoption, whose costs -- notice aggregation, an
exclusion denylist, the accumulator register -- are unsettled, and
`test_the_discovery_block_creates_nothing_a_user_sees` holds it.
**Pointed at the library worktree this branch belongs with.** `[tool.uv.sources]`
named `-p2`, which has neither half of this change, so the branch was being
tested against a library four commits behind it. Repointing at `-p3` surfaced a
stale annotation the conformance gate caught immediately and correctly:
`pv.software_version` is `SCHEMA_0_ONLY`, not `NEITHER`. It was annotated on the
claim that flat's `pv` device class declares no firmware version; flat declares
`software-version` on it, and the library grew the mapping row once a producer
valued the v1.0 half. The exemption stands -- schema_1 still carries no row,
because a version string is identity rather than a reading -- but the reason it
stands has changed.
Against the vendored tree the report is nine properties, which is the ten-entry
unread baseline less `circuit/breaker/poles`, whose value the library does read.
---
custom_components/span_panel/diagnostics.py | 71 +++-
custom_components/span_panel/field_paths.py | 14 +-
.../span_panel/schema_validation.py | 116 +++++-
developer.md | 49 ++-
pyproject.toml | 13 +-
tests/adapter_fixtures.py | 64 +++-
tests/test_diagnostics.py | 5 +
tests/test_field_path_conformance.py | 16 +-
tests/test_pv_device.py | 11 +-
tests/test_schema_discovery.py | 355 ++++++++++++++++++
uv.lock | 20 +-
11 files changed, 691 insertions(+), 43 deletions(-)
create mode 100644 tests/test_schema_discovery.py
diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py
index 0c46cc6e..9020bb94 100644
--- a/custom_components/span_panel/diagnostics.py
+++ b/custom_components/span_panel/diagnostics.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, TypedDict
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_ACCESS_TOKEN
@@ -14,6 +14,7 @@
CONF_EBUS_BROKER_USERNAME,
CONF_HOP_PASSPHRASE,
)
+from .schema_validation import SchemaFindings
TO_REDACT = {
CONF_ACCESS_TOKEN,
@@ -23,6 +24,73 @@
"password",
"username",
}
+"""Config-entry keys whose values are replaced before the payload leaves.
+
+Key-based, and only over `entry.as_dict()`. It knows nothing about wire
+property names and cannot be taught them cheaply, so anything added to this
+payload from the panel has to be safe by construction rather than by redaction.
+That is the constraint `_discovery` is built to — see its docstring.
+"""
+
+
+class DiscoveredRow(TypedDict):
+ """One declared-but-unread property, as it appears in the payload."""
+
+ path: str
+ datatype: str
+ unit: str | None
+ retained: bool | None
+
+
+class DiscoveryBlock(TypedDict):
+ """The `schema_discovery` section. Typed so the shape is checked, not described.
+
+ Four keys per row and no more: a fifth would be the seam a wire value slips
+ through, and this is what makes adding one a type error rather than a review
+ comment.
+ """
+
+ available: bool
+ count: int
+ properties: list[DiscoveredRow]
+
+
+def _discovery(findings: SchemaFindings | None) -> DiscoveryBlock:
+ """Report what the panel declares that this integration reads nothing from.
+
+ Maintainer-facing, and the reason it is here rather than anywhere a user
+ looks: a diagnostics attachment on an issue is where the question "what does
+ that fleet publish that we ignore" actually gets asked, and aggregating a
+ few attachments is how the rate gets measured. Nothing is created from it —
+ no entity, no Repair, no notification.
+
+ **Declarations, never values.** Each row is the property's path, its declared
+ datatype and unit, and whether the panel has published a value for it. A
+ diagnostics payload leaves the house into issues and forum posts, and
+ `TO_REDACT` above is key-based over the config entry — it could not protect a
+ wire value put here, so no wire value is put here. `test_diagnostics` asserts
+ that against the vendored capture rather than leaving it to review.
+
+ `available` is False while the adapter has not reported metadata yet, which
+ is a real state on a reconnect. It is not the same as an empty report: an
+ empty `properties` list means the panel declares nothing this integration
+ ignores, which is a finding.
+ """
+ if findings is None:
+ return {"available": False, "count": 0, "properties": []}
+ return {
+ "available": True,
+ "count": len(findings.discovered),
+ "properties": [
+ {
+ "path": entry.path,
+ "datatype": entry.datatype,
+ "unit": entry.unit,
+ "retained": entry.retained,
+ }
+ for entry in findings.discovered
+ ],
+ }
async def async_get_config_entry_diagnostics(
@@ -93,4 +161,5 @@ async def async_get_config_entry_diagnostics(
"panel_offline": coordinator.panel_offline,
"last_update_success": coordinator.last_update_success,
},
+ "schema_discovery": _discovery(coordinator.schema_findings),
}
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 86c94495..791f019f 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -207,14 +207,18 @@ class FieldPathDeclarationMixin:
# they are `field_path` declarations on the three PV metadata sensors
# already, and the card reads the same two fields those sensors do.
#
- # `NEITHER` for the same reason as the `mid.*` and `panel.*` card reads
- # above: flat's `pv` device class declares no firmware version at all,
- # and a schema_1 metadata row carries a unit and a datatype for a
- # *reading*, which a version string is not.
+ # `SCHEMA_0_ONLY`, and it says so because the annotation is checked
+ # rather than asserted: this read `NEITHER` on the claim that "flat's
+ # `pv` device class declares no firmware version at all", which was
+ # wrong. Flat declares `software-version` on `energy.ebus.device.pv`,
+ # and the library grew the mapping row for it once a producer valued the
+ # v1.0 half. schema_1 still carries no row, for the reason the `mid.*`
+ # and `panel.*` card reads above carry none: a row states a *reading's*
+ # unit and datatype, and a version string is not a reading.
#
# `pv.serial_number` is deliberately absent -- from this table, from the
# card and from the snapshot. See `pv_device_info`.
- "pv.software_version": Producibility.NEITHER,
+ "pv.software_version": Producibility.SCHEMA_0_ONLY,
# The `mid_grid_state` sensor's source field — utility-supply health,
# the one non-metadata entity the MID brings. Neither adapter maps the
# MID at all, which is why the description is `NO_SOURCE_FIELD`.
diff --git a/custom_components/span_panel/schema_validation.py b/custom_components/span_panel/schema_validation.py
index 0ae2955a..a4c77573 100644
--- a/custom_components/span_panel/schema_validation.py
+++ b/custom_components/span_panel/schema_validation.py
@@ -9,6 +9,19 @@
Because the adapter classifies absence, this module needs no capability table
and never infers hardware presence from telemetry.
+
+**Two inventories, not one.** An adapter's metadata map carries curated rows —
+snapshot field paths this integration knows about — and, under the library's
+discovery namespace, rows for properties the panel declares and the adapter
+reads nothing from. They answer opposite questions and must never mix:
+`unread` below is "we produce this and render nothing from it", which is an
+inventory of *our* backlog, while a discovered row is "the panel has something
+we never modelled". Letting the second into the first would bury ten known
+entries under whatever a firmware release happened to add, and would make the
+conformance gate's producible set depend on the panel in front of the user.
+
+So the metadata is partitioned by namespace before any other question is asked
+of it — see `partition`.
"""
from __future__ import annotations
@@ -17,7 +30,7 @@
import logging
from homeassistant.components.sensor import SensorEntityDescription
-from span_panel_api.models import FieldMetadata
+from span_panel_api.models import DiscoveredMetadata, FieldMetadata, is_discovery_path
from .field_paths import RESIDUAL_EXEMPT_PATHS, conditional_field_paths, declared_field_paths
@@ -55,6 +68,46 @@ class UnitMismatch:
schema_unit: str
+@dataclass(frozen=True, slots=True)
+class DiscoveredProperty:
+ """A property the panel declares that the running adapter reads nothing from.
+
+ The runtime half of `tests/test_declared_but_unread`, which asks the same
+ question of a vendored capture and therefore cannot see a panel that starts
+ publishing something in the field.
+
+ Maintainer-facing only. Nothing creates an entity, a Repair or a
+ notification from one of these — it is carried in diagnostics so that
+ triaging an issue shows what that panel declares and this integration
+ ignores, and so the rate can be measured across a few attachments.
+
+ **Declarations only, deliberately.** Diagnostics leave the house into GitHub
+ issues and forum posts, and `diagnostics.TO_REDACT` is key-based: it knows
+ the config entry's keys and nothing at all about wire property names, so it
+ could not protect a value put here. `retained` is the only thing this says
+ about a value, and it says whether one exists rather than what it is.
+ """
+
+ path: str
+ """The library's namespaced path, ``discovered.{device type}/{node}/{property}``.
+
+ Carried verbatim rather than trimmed to the wire path: it is the key the
+ adapter emitted, so a maintainer cross-referencing a capability catalog or
+ the unread baseline is looking at the same string the library is.
+ """
+
+ datatype: str
+ unit: str | None
+ retained: bool | None
+ """Whether the panel has published a value, or None if the adapter did not say.
+
+ None is the forward-compatible case: the namespace is the contract and the
+ enriched row type is not, so an adapter that namespaces a row without
+ carrying `DiscoveredMetadata` still reports its path, datatype and unit
+ rather than being dropped.
+ """
+
+
@dataclass(frozen=True, slots=True)
class SchemaFindings:
"""Outcome of one validation pass."""
@@ -62,6 +115,41 @@ class SchemaFindings:
unresolved: frozenset[str]
unit_mismatches: tuple[UnitMismatch, ...]
unread: frozenset[str]
+ discovered: tuple[DiscoveredProperty, ...] = ()
+ """Properties the panel declares and the adapter reads nothing from.
+
+ Defaulted because it is additive and because every other member is a
+ finding about *our* declarations, which this is not: an adapter that emits
+ no discovered rows — the flat one does not — leaves this empty, and that is
+ a fact about the adapter rather than a clean bill of health.
+ """
+
+
+def partition(
+ field_metadata: dict[str, FieldMetadata],
+) -> tuple[dict[str, FieldMetadata], tuple[DiscoveredProperty, ...]]:
+ """Split one adapter metadata map into the curated rows and the discovered ones.
+
+ The single place the namespace is tested, so a caller cannot half-apply it.
+ Everything downstream — the producible gate, the unread inventory, the unit
+ check, the Repairs reconciler — takes the curated half and can therefore not
+ be perturbed by what a panel happens to declare.
+ """
+ curated: dict[str, FieldMetadata] = {}
+ discovered: list[DiscoveredProperty] = []
+ for path, entry in field_metadata.items():
+ if not is_discovery_path(path):
+ curated[path] = entry
+ continue
+ discovered.append(
+ DiscoveredProperty(
+ path=path,
+ datatype=entry.datatype,
+ unit=entry.unit,
+ retained=entry.retained if isinstance(entry, DiscoveredMetadata) else None,
+ )
+ )
+ return curated, tuple(sorted(discovered, key=lambda item: item.path))
def evaluate_field_metadata(
@@ -76,6 +164,10 @@ def evaluate_field_metadata(
resolved. Callers interpret the sentinel themselves; see
`SpanPanelCoordinator._run_schema_validation`.
"""
+ # First, before anything reads the map: the discovered rows are a report
+ # about the panel, not an inventory of what we produce, and every question
+ # below is the second kind.
+ curated, discovered = partition(field_metadata)
declared = declared_field_paths()
# Schema-conditional entities read a real field off a real metadata row;
# what they cannot do is satisfy a gate that demands *both* adapters
@@ -91,7 +183,7 @@ def evaluate_field_metadata(
mismatches: list[UnitMismatch] = []
for field_path in resolvable:
- entry = field_metadata.get(field_path)
+ entry = curated.get(field_path)
if entry is None:
# Hardware not present. Not a defect, and deliberately silent.
continue
@@ -117,10 +209,26 @@ def evaluate_field_metadata(
# does, so they are absent from `declared` without being unread. Only the
# paths matter here; each entry's `Producibility` annotation is what the
# conformance tests verify.
- unread = frozenset(set(field_metadata) - set(declared) - RESIDUAL_EXEMPT_PATHS.keys())
+ #
+ # `curated` and never `field_metadata`, which still holds both halves. A
+ # discovered path reaching this set would read as a produced field nothing
+ # renders -- a defect's shape -- and would bury ten deliberate entries under
+ # whatever the panel's firmware happens to declare.
+ unread = frozenset(set(curated) - set(declared) - RESIDUAL_EXEMPT_PATHS.keys())
for field_path in sorted(unread):
# An addition is legal within a major version. This is an inventory for
# us, never a user-facing finding.
_LOGGER.debug("Schema: %s is produced but no platform reads it", field_path)
- return SchemaFindings(frozenset(unresolved), tuple(mismatches), unread)
+ for declaration in discovered:
+ # The panel's side of the same question, and equally not user-facing:
+ # the user-facing half of this would be adoption, which is not built.
+ _LOGGER.debug(
+ "Schema: %s is declared by the panel and read by nothing here (%s%s, retained=%s)",
+ declaration.path,
+ declaration.datatype,
+ f" in {declaration.unit}" if declaration.unit else "",
+ declaration.retained,
+ )
+
+ return SchemaFindings(frozenset(unresolved), tuple(mismatches), unread, discovered)
diff --git a/developer.md b/developer.md
index 8ccc0d9d..3afec603 100644
--- a/developer.md
+++ b/developer.md
@@ -194,12 +194,57 @@ declared `$type`), and properties no producer publishes (`connection/count`).
### What it does not cover
The gate reads the **vendored fixture**, so it answers "what does our capture declare that we do not read". It cannot see a property a real panel starts
-publishing in the field. That is the purpose of the runtime discovery work on `feat/discovery-and-catalog-validation`: the same question asked of a live panel
-and surfaced in diagnostics, so a maintainer triaging an issue can see what that fleet declares that this integration ignores.
+publishing in the field. The runtime half below is what answers that.
Note also that a property can be _read on one device and not another_ and the gate will not see it — it asks whether anything moved, not whether everything did.
`snapshot.pv` keeps the first `energy.ebus.device.pv` child and discards the rest, so a second inverter is invisible while the property still counts as read.
+### The runtime half: what the panel in front of the user declares
+
+The schema_1 adapter asks the same question of the live tree and puts the answer in **diagnostics**, under `schema_discovery`:
+
+```json
+"schema_discovery": {
+ "available": true,
+ "count": 9,
+ "properties": [
+ { "path": "discovered.circuit/connection/count", "datatype": "integer", "unit": null, "retained": false },
+ { "path": "discovered.distribution-enclosure/status/postal-code", "datatype": "string", "unit": null, "retained": true }
+ ]
+}
+```
+
+`available: false` means the adapter has not reported metadata yet — a real state on a reconnect, and not the same as an empty report. `retained` says whether
+the panel has published a value for the property, which is the declared-but-never-valued signal; it never says what the value is.
+
+**Paths, datatypes, units and retention only — never values.** Diagnostics leave the house into issues and forum posts, and `TO_REDACT` in `diagnostics.py` is
+key-based over the config entry: it knows nothing about wire property names and could not protect a value added here. `test_schema_discovery` asserts that
+against the capture's own published values rather than leaving it to review.
+
+This is **maintainer-facing only**. Nothing creates an entity, a Repair or a notification from it. Automatic adoption is a separate, unbuilt step whose costs —
+notice aggregation, an exclusion denylist, the accumulator register — are not settled.
+
+### Why discovered rows cannot reach the curated inventories
+
+The adapter returns both kinds of row in one map, keyed by the library's `discovered.` namespace. `schema_validation.partition` splits them **before any other
+question is asked**, and everything downstream — the producible gate, the `unread` inventory, the exemption annotations, the unit vocabulary — sees the curated
+half only.
+
+That partition is load-bearing rather than tidy. Every one of those inventories reads "in an adapter's map" as "this integration could read this", which a
+discovered path is not: a discovered row in `unread` would bury ten deliberate entries under whatever a firmware release added, and would make the count depend
+on the panel in front of the user. `test_the_unread_inventory_is_deaf_to_discovery` proves it by mutation — a synthetic discovered row changes `unread`,
+`unresolved` and the unit mismatches not at all.
+
+The test fixtures follow the same rule: `adapter_fixtures.schema_one_metadata()` hands out the curated half, and a test that wants the other half asks for
+`schema_one_discovery()` by name.
+
+### Keeping the library's answer honest
+
+The adapter decides "read" from four enumerations of what it addresses — the metadata map, the lugs direction tables, the charge-limit resolution, and
+`_CONSUMED_WITHOUT_A_ROW` for the properties it reads into the snapshot without a unit surface. A stale entry there fails _silently_, by keeping a property out
+of the report. `tests/test_schema_one_discovery.py` in the library runs the same republish-and-diff experiment this gate uses and holds every entry to it in
+both directions, so the report means "nothing reads this" rather than "nobody wrote it down".
+
## Linting and Type Checking
```bash
diff --git a/pyproject.toml b/pyproject.toml
index 49c7c78e..e79e0a13 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,10 +46,13 @@ package = false
[tool.uv.sources]
# This branch develops against the in-progress schema-1 adapter, which lives in a
-# worktree rather than the primary checkout the symlink points at.
-span-panel-api = { path = "../../span/span-panel-api-p2", editable = true }
-span-panel-api-schema-0 = { path = "../../span/span-panel-api-p2/packages/schema-0", editable = true }
-span-panel-api-schema-1 = { path = "../../span/span-panel-api-p2/packages/schema-1", editable = true }
+# worktree rather than the primary checkout the symlink points at. `-p3` carries
+# the library half of `feat/discovery-and-catalog-validation`; the two halves are
+# one change and pointing at the wrong worktree means the branch is not being
+# tested against the library it needs.
+span-panel-api = { path = "../../span/span-panel-api-p3", editable = true }
+span-panel-api-schema-0 = { path = "../../span/span-panel-api-p3/packages/schema-0", editable = true }
+span-panel-api-schema-1 = { path = "../../span/span-panel-api-p3/packages/schema-1", editable = true }
[tool.jscpd]
path = ["custom_components/span_panel", "./*.{html,md}"]
@@ -129,7 +132,7 @@ exclude = [
]
extraPaths = [
"./custom_components",
- "../../span/span-panel-api-p2/src",
+ "../../span/span-panel-api-p3/src",
"../ha-synthetic-sensors/src"
]
pythonPlatform = "Darwin"
diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py
index c71743ca..869c786b 100644
--- a/tests/adapter_fixtures.py
+++ b/tests/adapter_fixtures.py
@@ -19,6 +19,8 @@
from ebus_sdk.homie import DiscoveredDevice
from span_panel_api.models import FieldMetadata, SpanPanelSnapshot
+from custom_components.span_panel.schema_validation import DiscoveredProperty
+
_FIXTURES = pathlib.Path(__file__).parent / "fixtures"
SCHEMA_ONE_PANEL = "example-40t-001"
@@ -88,29 +90,79 @@ def schema_one_snapshot(tree: dict[str, dict[str, str]] | None = None) -> SpanPa
def schema_zero_metadata() -> dict[str, FieldMetadata]:
- """Field metadata as schema_0 builds it from the flat REST schema."""
+ """Curated field metadata as schema_0 builds it from the flat REST schema.
+
+ Partitioned like its schema_1 counterpart even though the flat adapter emits
+ no discovered rows: the fixtures state the rule, not the current contents of
+ one adapter.
+ """
from span_panel_api_schema_0.field_metadata import build_field_metadata
raw = json.loads((_FIXTURES / "schema_zero_types.json").read_text())
- return build_field_metadata(raw["types"])
+ return _curated(build_field_metadata(raw["types"]))
+
+
+def _curated(metadata: dict[str, FieldMetadata]) -> dict[str, FieldMetadata]:
+ """The half of an adapter's map that names snapshot fields we curate.
+
+ Every fixture below hands out the curated half, through the same
+ `schema_validation.partition` the coordinator uses, so no test can be
+ perturbed by what a panel declares and nobody reads. That is not tidiness:
+ the producible gate, the exemption annotations, the derived-reason checks
+ and the unit vocabulary all treat "in an adapter's map" as "this integration
+ could read it", and a discovered path satisfies neither half of that.
+
+ `schema_one_discovery` is how a test asks for the other half, and
+ `test_schema_discovery` is where the partition itself is checked against the
+ unpartitioned map.
+ """
+ from custom_components.span_panel.schema_validation import partition
+
+ return partition(metadata)[0]
def schema_one_metadata() -> dict[str, FieldMetadata]:
- """Field metadata as schema_1 builds it from a full parent/child tree."""
+ """Curated field metadata as schema_1 builds it from a full parent/child tree."""
+ from span_panel_api_schema_1.field_metadata import build_field_metadata
+
+ return _curated(build_field_metadata(_devices("schema_one_tree.json")))
+
+
+def schema_one_metadata_raw(name: str = "schema_one_tree.json") -> dict[str, FieldMetadata]:
+ """The adapter's map exactly as it returns it, both halves together.
+
+ The one fixture that does *not* partition, because the partition is the
+ thing under test in `test_schema_discovery`. Everywhere else, ask for a
+ partitioned half by name.
+ """
from span_panel_api_schema_1.field_metadata import build_field_metadata
- return build_field_metadata(_devices("schema_one_tree.json"))
+ return build_field_metadata(_devices(name))
+
+
+def schema_one_discovery() -> tuple[DiscoveredProperty, ...]:
+ """What schema_1 declares in the vendored tree that it reads nothing from.
+
+ The other half of the same map. Held apart from `schema_one_metadata` so a
+ test has to ask for it by name — a discovered path arriving unannounced in a
+ curated inventory is the failure mode the namespace exists to prevent.
+ """
+ from span_panel_api_schema_1.field_metadata import build_field_metadata
+
+ from custom_components.span_panel.schema_validation import partition
+
+ return partition(build_field_metadata(_devices("schema_one_tree.json")))[1]
def schema_one_metadata_batteryless() -> dict[str, FieldMetadata]:
"""Build the same tree with the BESS removed — no battery hardware present."""
from span_panel_api_schema_1.field_metadata import build_field_metadata
- return build_field_metadata(_devices("schema_one_tree_batteryless.json"))
+ return _curated(build_field_metadata(_devices("schema_one_tree_batteryless.json")))
def schema_one_metadata_no_pv() -> dict[str, FieldMetadata]:
"""Build the same tree with the PV device removed, power-flows still present."""
from span_panel_api_schema_1.field_metadata import build_field_metadata
- return build_field_metadata(_devices("schema_one_tree_no_pv.json"))
+ return _curated(build_field_metadata(_devices("schema_one_tree_no_pv.json")))
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
index f257c2e8..d1748fa7 100644
--- a/tests/test_diagnostics.py
+++ b/tests/test_diagnostics.py
@@ -64,6 +64,10 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data(
coordinator.data = snapshot
coordinator.panel_offline = False
coordinator.last_update_success = True
+ # Explicit: a MagicMock answers `len()` and iteration happily, so leaving
+ # this unset would let the discovery block render as an empty report rather
+ # than as the "no metadata yet" state it actually is.
+ coordinator.schema_findings = None
entry = MockConfigEntry(
domain=DOMAIN,
@@ -155,6 +159,7 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable
coordinator.data = snapshot
coordinator.panel_offline = True
coordinator.last_update_success = False
+ coordinator.schema_findings = None
entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel")
entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id")
diff --git a/tests/test_field_path_conformance.py b/tests/test_field_path_conformance.py
index 4cdf6489..f018dcaa 100644
--- a/tests/test_field_path_conformance.py
+++ b/tests/test_field_path_conformance.py
@@ -353,11 +353,7 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# `$target` echo it renders as an attribute. Facts about a command rather
# than readings, so no adapter carries a row for either -- the same shape as
# the `circuit.*_target` pair.
- # +1 for `pv.software_version`, the firmware row on the solar inverter's own
- # device card. Flat's `pv` device class declares no firmware version, and a
- # version string is identity rather than a reading -- the same argument as
- # the `mid.*` and `panel.*` card reads above.
- Producibility.NEITHER: 44,
+ Producibility.NEITHER: 43,
# +1 for `panel.dominant_power_source`, the `grid_forming_entity` sensor's
# source field. It was read by a `SCHEMA_CONDITIONAL_FIELD` description and
# enumerated nowhere, so `evaluate_field_metadata` counted it as produced-
@@ -368,7 +364,15 @@ def test_no_exempt_path_is_producible_by_both() -> None:
# declaration -- `SpanPanelStatus._residual_field_paths`. Its time here as a
# true `SCHEMA_0_ONLY` annotation is what sanctioned a flat -> v1.0
# regression: the attribute a flat panel filled, a v1.0 panel did not.
- Producibility.SCHEMA_0_ONLY: 10,
+ # +1 for `pv.software_version`, the firmware row on the solar inverter's own
+ # device card, which moved here from `NEITHER`. It was annotated on the
+ # claim that flat's `pv` device class declares no firmware version; flat
+ # declares `software-version` on it, and the library grew the mapping row
+ # once a producer valued the v1.0 half. schema_1 still carries no row -- a
+ # version string is identity rather than a reading, the same argument as the
+ # `mid.*` and `panel.*` card reads above -- so this stays an exemption
+ # rather than becoming a declaration.
+ Producibility.SCHEMA_0_ONLY: 11,
# +2 with the shed forecast: the two live estimates, which schema_1 maps and
# flat firmware does not publish at all.
# +2 for `battery.power_w` and `battery.communication_state`, the BESS's own
diff --git a/tests/test_pv_device.py b/tests/test_pv_device.py
index db353cba..49e19fba 100644
--- a/tests/test_pv_device.py
+++ b/tests/test_pv_device.py
@@ -781,11 +781,14 @@ def test_the_firmware_version_is_no_longer_an_unread_declaration() -> None:
def test_the_cards_firmware_read_is_enumerated_as_a_residual() -> None:
"""`pv_device_info` is not an entity, so its read is an exempt residual.
- `NEITHER` beside the `mid.*` and `panel.*` card reads: flat's `pv` device
- class declares no firmware version, and a schema_1 metadata row carries a
- unit and a datatype for a reading, which a version string is not.
+ `SCHEMA_0_ONLY`: flat declares `software-version` on its `pv` device class
+ and the library maps it, while schema_1 carries no row -- a row states a
+ reading's unit and datatype, and a version string is identity, the same
+ argument as the `mid.*` and `panel.*` card reads. One adapter short of the
+ both-adapters gate either way, so it stays an exemption rather than becoming
+ a declaration.
"""
- assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.NEITHER
+ assert RESIDUAL_EXEMPT_PATHS["pv.software_version"] is Producibility.SCHEMA_0_ONLY
assert "pv.software_version" not in declared_field_paths()
diff --git a/tests/test_schema_discovery.py b/tests/test_schema_discovery.py
new file mode 100644
index 00000000..0124f09d
--- /dev/null
+++ b/tests/test_schema_discovery.py
@@ -0,0 +1,355 @@
+"""What the panel declares that nothing here reads, surfaced to a maintainer.
+
+`test_declared_but_unread` asks this question of the vendored capture and
+answers it by experiment. It is the right check and it is fixture-bound: a real
+panel that starts publishing a property fails nothing until somebody recaptures.
+The adapter answers the same question at runtime, for the panel in front of the
+user, and this module is the consumer half — the partition that keeps those rows
+out of every curated inventory, and the diagnostics block that carries them to
+whoever is triaging the issue.
+
+Two properties are load-bearing and both are asserted here rather than reviewed:
+
+**The partition.** Discovered rows arrive in the same map as curated ones. Every
+inventory downstream — the producible gate, the unread set, the exemption
+annotations, the unit vocabulary — reads "in an adapter's map" as "this
+integration could read this", which a discovered path is not. One namespace test
+applied once is what keeps that true, and `test_the_unread_inventory_is_deaf_to
+_discovery` is the mutation proof that it is applied.
+
+**No value leaves.** A diagnostics payload goes into GitHub issues and forum
+posts. `TO_REDACT` is key-based over the config entry and knows nothing about
+wire property names, so nothing downstream can protect a value put in this
+block. It carries declarations only, and that is checked against the capture's
+own published values.
+"""
+
+from __future__ import annotations
+
+import json
+import pathlib
+from unittest.mock import MagicMock
+
+from homeassistant.core import HomeAssistant
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from span_panel_api.models import (
+ DiscoveredMetadata,
+ FieldMetadata,
+ SpanPanelSnapshot,
+ is_discovery_path,
+)
+
+from custom_components.span_panel import SpanPanelRuntimeData
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.diagnostics import async_get_config_entry_diagnostics
+from custom_components.span_panel.field_paths import (
+ RESIDUAL_EXEMPT_PATHS,
+ declared_field_paths,
+)
+from custom_components.span_panel.schema_validation import (
+ SchemaFindings,
+ evaluate_field_metadata,
+ partition,
+)
+
+from .adapter_fixtures import (
+ SCHEMA_ONE_PANEL,
+ schema_one_discovery,
+ schema_one_metadata,
+ schema_one_metadata_raw,
+ schema_one_tree,
+)
+
+BASELINE = pathlib.Path(__file__).parent / "fixtures" / "unread_declarations_baseline.json"
+
+_SYNTHETIC = DiscoveredMetadata(unit="°C", datatype="float", retained=True)
+"""A row for a property no firmware in the fixtures declares.
+
+Used to prove the partition by mutation rather than by inspection: a curated
+inventory that is genuinely deaf to discovery is unchanged by this, and one that
+merely happens to contain nothing surprising is not.
+"""
+_SYNTHETIC_PATH = "discovered.distribution-enclosure/status/enclosure-temperature"
+
+
+# --- the partition ---------------------------------------------------------
+
+
+def test_the_adapter_emits_discovered_rows_at_all() -> None:
+ """The floor. Every assertion below passes trivially against an empty report."""
+ raw = schema_one_metadata_raw()
+ namespaced = {path for path in raw if is_discovery_path(path)}
+ assert namespaced, (
+ "schema_1 emitted no discovered rows for the vendored tree, so nothing "
+ "below is being tested. If that is real, the adapter stopped emitting them."
+ )
+ assert all(isinstance(raw[path], DiscoveredMetadata) for path in namespaced)
+
+
+def test_partition_splits_the_map_and_loses_nothing() -> None:
+ raw = schema_one_metadata_raw()
+ curated, discovered = partition(raw)
+
+ assert len(curated) + len(discovered) == len(raw)
+ assert not [path for path in curated if is_discovery_path(path)]
+ assert {entry.path for entry in discovered} == {path for path in raw if is_discovery_path(path)}
+ assert [entry.path for entry in discovered] == sorted(entry.path for entry in discovered)
+
+
+def test_a_discovered_row_carries_the_declaration_and_the_retention() -> None:
+ by_path = {entry.path: entry for entry in schema_one_discovery()}
+ raw = schema_one_metadata_raw()
+ for path, entry in by_path.items():
+ row = raw[path]
+ assert entry.datatype == row.datatype
+ assert entry.unit == row.unit
+ assert by_path["discovered.distribution-enclosure/status/time-zone"].retained is True
+ assert by_path["discovered.circuit/connection/count"].retained is False
+
+
+def test_a_namespaced_row_without_the_enriched_type_still_reports() -> None:
+ """The namespace is the contract; the row type is the enrichment.
+
+ An adapter distribution built against a later library could namespace a row
+ and carry a plain `FieldMetadata`. Dropping it would be the worst of both —
+ absent from the curated inventory *and* absent from the report.
+ """
+ _curated, discovered = partition({_SYNTHETIC_PATH: FieldMetadata(unit="°C", datatype="float")})
+ assert len(discovered) == 1
+ assert discovered[0].retained is None
+ assert discovered[0].unit == "°C"
+
+
+# --- discovered paths reach no curated inventory ---------------------------
+
+
+def test_no_curated_inventory_names_a_discovered_path() -> None:
+ """The three enumerations the conformance gate and the unread gate consult."""
+ for path in declared_field_paths():
+ assert not is_discovery_path(path)
+ for path in RESIDUAL_EXEMPT_PATHS:
+ assert not is_discovery_path(path)
+ for key in json.loads(BASELINE.read_text(encoding="utf-8")):
+ assert not is_discovery_path(key), (
+ f"{key} is a discovered path in the unread baseline. The baseline is the "
+ "consumer's own backlog, decided per line; discovery is a report about "
+ "the panel and nothing may be written into the baseline from it."
+ )
+
+
+def test_the_curated_fixture_hands_out_no_discovered_row() -> None:
+ """Every other test module reads this fixture, so the guarantee lives here."""
+ assert not [path for path in schema_one_metadata() if is_discovery_path(path)]
+
+
+def test_the_unread_inventory_is_deaf_to_discovery() -> None:
+ """The mutation proof, and the one that matters for cost #4.
+
+ `unread` is "we produce this and render nothing from it" — ten known entries
+ with reasons. A discovered row landing there would bury them under whatever a
+ firmware release added, and would make the count depend on the panel in front
+ of the user rather than on this integration's backlog.
+ """
+ raw = schema_one_metadata_raw()
+ before = evaluate_field_metadata(raw)
+ after = evaluate_field_metadata({**raw, _SYNTHETIC_PATH: _SYNTHETIC})
+
+ assert after.unread == before.unread
+ assert after.unresolved == before.unresolved
+ assert after.unit_mismatches == before.unit_mismatches
+ assert _SYNTHETIC_PATH in {entry.path for entry in after.discovered}
+ assert len(after.discovered) == len(before.discovered) + 1
+
+
+def test_a_discovered_row_raises_no_unit_mismatch() -> None:
+ """A wire unit is not a claim about any sensor we declare.
+
+ `°C` matches no sensor's declared unit, so an unpartitioned map would report
+ it as a mismatch and, downstream, as a Repair the user cannot act on.
+ """
+ findings = evaluate_field_metadata({**schema_one_metadata_raw(), _SYNTHETIC_PATH: _SYNTHETIC})
+ assert not [m for m in findings.unit_mismatches if is_discovery_path(m.field_path)]
+
+
+# --- the diagnostics block -------------------------------------------------
+
+
+def _entry(findings: SchemaFindings | None) -> MockConfigEntry:
+ coordinator = MagicMock()
+ coordinator.data = _snapshot()
+ coordinator.panel_offline = False
+ coordinator.last_update_success = True
+ coordinator.schema_findings = findings
+ entry = MockConfigEntry(domain=DOMAIN, title="SPAN Panel", unique_id="example-40t-001")
+ entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+ return entry
+
+
+def _snapshot() -> SpanPanelSnapshot:
+ from .adapter_fixtures import schema_one_snapshot
+
+ return schema_one_snapshot()
+
+
+async def test_diagnostics_carries_the_discovery_report(hass: HomeAssistant) -> None:
+ """The block a maintainer reads off an issue attachment."""
+ findings = evaluate_field_metadata(schema_one_metadata_raw())
+ result = await async_get_config_entry_diagnostics(hass, _entry(findings))
+
+ block = result["schema_discovery"]
+ assert block["available"] is True
+ assert block["count"] == len(findings.discovered)
+ assert block["count"] > 0
+ assert block["properties"][0] == {
+ "path": "discovered.circuit/connection/count",
+ "datatype": "integer",
+ "unit": None,
+ "retained": False,
+ }
+ assert {key for row in block["properties"] for key in row} == {
+ "path",
+ "datatype",
+ "unit",
+ "retained",
+ }
+
+
+async def test_diagnostics_says_unavailable_rather_than_empty(hass: HomeAssistant) -> None:
+ """No metadata yet is a real state on a reconnect, and it is not "nothing to report"."""
+ block = (await async_get_config_entry_diagnostics(hass, _entry(None)))["schema_discovery"]
+ assert block == {"available": False, "count": 0, "properties": []}
+
+
+async def test_no_published_value_reaches_the_discovery_block(hass: HomeAssistant) -> None:
+ """The privacy constraint, checked against the capture's own values.
+
+ Diagnostics leave the house. `TO_REDACT` is key-based over the config entry
+ and knows nothing about wire property names, so a value added here would be
+ published verbatim — including the postal code and the time zone, which the
+ reference panel does publish and this integration deliberately does not
+ surface.
+ """
+ tree = schema_one_tree()
+ published = {
+ value
+ for topics in tree.values()
+ for topic, value in topics.items()
+ if not topic.startswith("$") and value
+ }
+ # The declaration vocabulary a row is allowed to be built from: device type
+ # tails, node ids and property ids all appear inside a `$description`. A
+ # value that is also one of those cannot be told apart from its declaration,
+ # and is excluded rather than scanned for.
+ vocabulary: set[str] = set()
+ for topics in tree.values():
+ for token in json.dumps(json.loads(topics["$description"])).replace('"', " ").split():
+ vocabulary.update(token.split("."))
+ vocabulary.update(token.split(","))
+ vocabulary.add(token)
+
+ findings = evaluate_field_metadata(schema_one_metadata_raw())
+ block = (await async_get_config_entry_diagnostics(hass, _entry(findings)))["schema_discovery"]
+
+ # `retained` is scanned by type rather than by content: it is a bool, so it
+ # has no room for a value, and its JSON rendering collides with the literal
+ # "false" a boolean property publishes. The three string fields are where a
+ # value could actually appear, and they are what the scan reads.
+ strings = [
+ text
+ for row in block["properties"]
+ for text in (row["path"], row["datatype"], row["unit"])
+ if isinstance(text, str)
+ ]
+ leaked = sorted(
+ value for value in published - vocabulary if any(value in text for text in strings)
+ )
+ assert not leaked, f"published values reached the diagnostics discovery block: {leaked}"
+ for row in block["properties"]:
+ assert isinstance(row["retained"], bool)
+ assert row["path"].startswith("discovered.")
+
+
+async def test_the_discovery_block_creates_nothing_a_user_sees(hass: HomeAssistant) -> None:
+ """Maintainer-facing only: no path here is one an entity reads.
+
+ The line between step 1 and step 3. A discovered path becoming an entity's
+ source would mean adoption shipped by accident, and adoption is deliberately
+ not built — its costs (notice aggregation, a denylist, the accumulator
+ register) are unsettled.
+ """
+ readable = declared_field_paths() | frozenset(RESIDUAL_EXEMPT_PATHS)
+ for entry in schema_one_discovery():
+ assert entry.path not in readable
+
+
+# --- it bites in both directions -------------------------------------------
+
+
+def _tree_declaring(
+ node: str, property_id: str, definition: dict[str, str], value: str
+) -> dict[str, dict[str, str]]:
+ tree = schema_one_tree()
+ description: dict[str, dict[str, dict[str, dict[str, object]]]] = json.loads(
+ tree[SCHEMA_ONE_PANEL]["$description"]
+ )
+ description["nodes"][node]["properties"][property_id] = dict(definition)
+ tree[SCHEMA_ONE_PANEL]["$description"] = json.dumps(description)
+ tree[SCHEMA_ONE_PANEL][f"{node}/{property_id}"] = value
+ return tree
+
+
+def _discovery_for(tree: dict[str, dict[str, str]]) -> dict[str, dict[str, object]]:
+ from ebus_sdk.homie import DiscoveredDevice
+ from span_panel_api_schema_1.field_metadata import build_field_metadata
+
+ devices: list[DiscoveredDevice] = []
+ for device_id, topics in tree.items():
+ device = DiscoveredDevice(device_id, "ebus")
+ device.update_description(topics["$description"])
+ device.update_state(topics.get("$state", "ready"))
+ for topic, value in topics.items():
+ if topic.startswith("$"):
+ continue
+ node, _, prop = topic.partition("/")
+ if prop:
+ device.update_property(node, prop, value)
+ devices.append(device)
+ findings = evaluate_field_metadata(build_field_metadata(devices))
+ return {
+ entry.path: {"datatype": entry.datatype, "unit": entry.unit, "retained": entry.retained}
+ for entry in findings.discovered
+ }
+
+
+def test_a_property_the_panel_adds_shows_up_with_its_declaration() -> None:
+ """A firmware release that starts declaring something reaches the report.
+
+ This is the whole of what step 1 buys over the fixture-bound gate: no
+ recapture, no release, and the maintainer sees it on the next attachment.
+ """
+ tree = _tree_declaring(
+ "status",
+ "enclosure-temperature",
+ {"name": "Enclosure temperature", "datatype": "float", "unit": "°C"},
+ "41.5",
+ )
+ reported = _discovery_for(tree)
+ assert reported[_SYNTHETIC_PATH] == {"datatype": "float", "unit": "°C", "retained": True}
+ assert "41.5" not in json.dumps(reported)
+
+
+def test_a_property_that_becomes_read_leaves_the_report() -> None:
+ """The acceptance criterion for acting on a discovered row.
+
+ Adding a `_PROPERTY_FIELD_MAP` row in the library is what a maintainer does
+ next, and the row leaving this report is how they know it landed. Proved by
+ the property that already has one: `status/cloud-connection` is declared,
+ mapped, and absent from the report, while `status/postal-code` beside it on
+ the same node is declared, unmapped, and present.
+ """
+ reported = _discovery_for(schema_one_tree())
+ assert "discovered.distribution-enclosure/status/postal-code" in reported
+ assert "discovered.distribution-enclosure/status/cloud-connection" not in reported
+ assert "panel.vendor_cloud" in schema_one_metadata()
diff --git a/uv.lock b/uv.lock
index b94ffd87..f765a5cb 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2523,9 +2523,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "homeassistant", specifier = "==2026.8.0" },
- { name = "span-panel-api", editable = "../../span/span-panel-api-p2" },
- { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" },
- { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" },
+ { name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
+ { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" },
+ { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" },
]
[package.metadata.requires-dev]
@@ -2551,7 +2551,7 @@ dev = [
[[package]]
name = "span-panel-api"
version = "3.0.0b5"
-source = { editable = "../../span/span-panel-api-p2" }
+source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
{ name = "paho-mqtt" },
@@ -2578,8 +2578,8 @@ dev = [
{ name = "pytest-cov" },
{ name = "radon" },
{ name = "ruff", specifier = ">=0.15.5" },
- { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p2/packages/schema-0" },
- { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p2/packages/schema-1" },
+ { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" },
+ { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" },
{ name = "twine", specifier = ">=7.0" },
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
{ name = "vulture", specifier = ">=2.14" },
@@ -2588,18 +2588,18 @@ dev = [
[[package]]
name = "span-panel-api-schema-0"
version = "1.0.0b4"
-source = { editable = "../../span/span-panel-api-p2/packages/schema-0" }
+source = { editable = "../../span/span-panel-api-p3/packages/schema-0" }
dependencies = [
{ name = "span-panel-api" },
]
[package.metadata]
-requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p2" }]
+requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" }]
[[package]]
name = "span-panel-api-schema-1"
version = "0.1.0b5"
-source = { editable = "../../span/span-panel-api-p2/packages/schema-1" }
+source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
{ name = "span-panel-api" },
@@ -2608,7 +2608,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "ebus-sdk", specifier = ">=0.19.0,<1.0" },
- { name = "span-panel-api", editable = "../../span/span-panel-api-p2" },
+ { name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
]
[[package]]
From 8c63d2b95ae142a95f789e5b7c501af94f86e040 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 10:42:55 -0700
Subject: [PATCH 065/116] feat(adoption): surface devices this integration
models nothing for
The panel is a hub for whatever plugs into it and the eBus schema is
vendor-extensible, so a device type nobody modelled is an expected arrival.
It used to produce no device, no entity and no sign it was there.
Adoption is scoped to a device. A new property on a modelled device stays a
curation task, because that is where the judgement lives about entity versus
attribute versus device-card field, and adopting it would spend an entity_id
permanently on a machine-derived shape before anyone made that call.
No adopted entity carries a state_class. It is not derivable from the wire,
a wrong one corrupts long-term statistics irreversibly, and enrolling
properties nobody asked for is a permanent write to every recorder database.
A user who wants statistics wraps the entity themselves.
info resolves to the device card and connection to the device link, keyed on
the node rather than on property names because the catalogs carry no marker
for a device reference.
An adopted device freezes its identity anchor at first sighting, with the
registry as the memory: a late serial, or a wire id that moves when a serial
appears, would otherwise read as a device replacement.
The notice counts an adopted device rather than listing its entities, and
diagnostics carry declarations and never values.
Controls are classified and not yet built: a generic property write would put
a new member on SchemaAdapter, required of every adapter package. Diagnostics
count what is waiting on it.
---
CHANGELOG.md | 28 ++
custom_components/span_panel/adoption.py | 400 ++++++++++++++++++
custom_components/span_panel/binary_sensor.py | 17 +-
custom_components/span_panel/diagnostics.py | 80 ++++
.../span_panel/schema_repairs.py | 52 ++-
custom_components/span_panel/sensor.py | 15 +-
custom_components/span_panel/util.py | 18 +
tests/fixtures/README.md | 43 +-
tests/test_adoption.py | 350 +++++++++++++++
tests/test_diagnostics.py | 1 +
websocket-api.md | 16 +-
11 files changed, 981 insertions(+), 39 deletions(-)
create mode 100644 custom_components/span_panel/adoption.py
create mode 100644 tests/test_adoption.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52731a7e..e6eddae3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,34 @@ All notable changes to this project will be documented in this file.
### Added
+- **A device the panel publishes that this integration has never modelled now appears, instead of appearing nowhere.** SPAN positions the panel as the hub for
+ whatever plugs into it and the eBus schema is explicitly vendor-extensible, so a device type nobody modelled is an expected arrival — and until now it
+ produced no device, no entity and no sign it was there. Such a device now gets a card of its own hanging off the panel, carrying whatever `info` it publishes,
+ with its readings as entities beneath it. Everything adopted arrives **disabled and diagnostic**: nothing reaches a dashboard uninvited, and the new-entity
+ notice names the device so you can find it.
+- **Devices this integration does model are left alone, deliberately.** A new property on a circuit, the battery, a charger or the panel is not adopted — it is
+ curated in a release, because that is where the judgement lives about whether it should be an entity, an attribute, or a line on a device card. Automatic
+ adoption would spend an `entity_id` permanently on a machine-derived shape before anyone made that call. The sixteen Power Control System properties that
+ curation collapsed into one entity and thirteen attributes are the worked example of what a rule cannot produce.
+- **Nothing adopted enters long-term statistics, and that is a decision rather than an omission.** No adopted entity carries a `state_class`. It is not declared
+ on the wire and cannot be derived from one — this integration ships `feedthroughEnergyProducedWh` as `TOTAL` beside `mainMeterEnergyProducedWh` as
+ `TOTAL_INCREASING`, same unit and same device class — and a wrong one writes corrupt statistics that fixing the panel afterwards does not repair. Enrolling a
+ property nobody asked for into long-term statistics is also a permanent write to your recorder database. If you want statistics from an adopted reading, wrap
+ it in a template sensor, a Riemann-sum integration or a utility meter: that is your call, made on an entity you chose to enable.
+- **`info` becomes the device card and `connection` becomes the device link — neither becomes entities.** A panel publishing its own build metadata and its own
+ wiring topology should not arrive as a handful of sensors holding version strings and opaque device ids. The split is keyed on the Homie node rather than on
+ property names, because the capability catalogs carry no marker for "this value is a device reference" and a hard-coded name list goes stale silently.
+- **An adopted device keeps the identity it was first seen under.** A serial number arriving after the device was adopted is recorded on its card and moves
+ nothing, and a device adopted under its serial survives its wire id changing. Either move would read to Home Assistant as a device _replacement_ and would
+ take the device's entities and their history with it.
+- **The new-entity notice counts an adopted device rather than listing its entities.** A vendor device declaring a dozen properties would otherwise spend the
+ whole notice on itself and teach you that the category is noise — which would cost you the curated additions too.
+- **Diagnostics report which device types and properties were adopted, and never their values.** The same rule the declared-but-unread report follows: a
+ diagnostics attachment leaves the house, and the redaction that protects your config entry is key-based and knows nothing about wire property names.
+- **Controls are classified but not yet built.** A property the panel accepts writes to is recognised as a switch, a select or a number, and surfaces as a
+ reading until the write path exists — every write this integration performs today goes through a curated, adapter-named topic, and a generic one is a change
+ to the adapter contract with its own version bump. Diagnostics count how many properties are waiting on it, so the decision is made on a measurement.
+
- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic
sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the
enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been
diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py
new file mode 100644
index 00000000..332e7203
--- /dev/null
+++ b/custom_components/span_panel/adoption.py
@@ -0,0 +1,400 @@
+"""Entities for devices the panel publishes and this integration models nothing for.
+
+The panel is a hub for whatever plugs into it, and the eBus schema is explicitly
+vendor-extensible. A device type this integration has never modelled therefore
+arrives as an expected event rather than a hypothetical one -- and until now it
+arrived as nothing at all: no device, no entity, no sign it was there.
+
+**The unit of adoption is a device, never a property.** A new property on a device
+this integration already models is a curation task with a short turnaround, and
+minting an entity for it automatically would spend an `entity_id` permanently on
+a shape a human would likely have chosen differently. That cost only bites where
+curation is coming. On a device type nobody has modelled, no better identity is
+coming, so a disabled diagnostic entity is strictly better than the silence.
+
+**Nothing adopted enters long-term statistics.** No adopted entity carries a
+`state_class`, ever. Three reasons, and the third is the one that shapes the
+module: `state_class` is not declared on the wire and is not derivable from one
+(`feedthroughEnergyProducedWh` is `TOTAL` beside `mainMeterEnergyProducedWh` as
+`TOTAL_INCREASING` -- same unit, same device class); a wrong one writes corrupt
+statistics that fixing the producer does not repair; and enrolling a property
+nobody asked for into long-term statistics is a permanent write to every
+install's recorder database. A user who wants statistics from an adopted reading
+can wrap it in a template sensor, a Riemann sum or a utility meter, which is
+their call to make on an entity they chose to enable.
+
+**These entities declare no field paths.** `snapshot.adopted_devices` is outside
+the curated field-path vocabulary by construction: it carries no metadata row, so
+the producible gate has nothing to check it against and `residual_field_paths()`
+must not collect it. That is why this module is absent from that walk's import
+list, and why the classes below declare no `_residual_field_paths`.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Final
+
+from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription
+from homeassistant.const import EntityCategory, Platform
+from homeassistant.helpers.device_registry import DeviceInfo
+from span_panel_api import AdoptedDevice, AdoptedProperty, SpanPanelSnapshot
+
+from .const import DOMAIN
+from .entity import SpanPanelEntity
+from .util import ADOPTED_IDENTIFIER_TOKEN
+
+if TYPE_CHECKING:
+ from homeassistant.helpers.device_registry import DeviceRegistry
+
+ from .coordinator import SpanPanelCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+BOOLEAN_DATATYPE: Final = "boolean"
+ENUM_DATATYPE: Final = "enum"
+NUMERIC_DATATYPES: Final = frozenset({"float", "integer"})
+
+DEVICE_CLASS_BY_UNIT: dict[str, SensorDeviceClass] = {
+ "W": SensorDeviceClass.POWER,
+ "kW": SensorDeviceClass.POWER,
+ "Wh": SensorDeviceClass.ENERGY,
+ "kWh": SensorDeviceClass.ENERGY,
+ "V": SensorDeviceClass.VOLTAGE,
+ "A": SensorDeviceClass.CURRENT,
+ "Hz": SensorDeviceClass.FREQUENCY,
+ "VA": SensorDeviceClass.APPARENT_POWER,
+ "var": SensorDeviceClass.REACTIVE_POWER,
+ "°C": SensorDeviceClass.TEMPERATURE,
+ "°F": SensorDeviceClass.TEMPERATURE,
+ "s": SensorDeviceClass.DURATION,
+}
+"""Units this integration is willing to claim a device class for, enumerated.
+
+Enumerated rather than inferred, and the omissions are the point. `%` is absent
+because its uses in this vocabulary are not one class -- a state of charge, a
+confidence, a duty cycle -- and guessing `BATTERY` for all of them mislabels the
+rest. A unit outside this map yields **no** device class rather than a guess: an
+unlabelled reading is honest, a mislabelled one is not.
+
+Note what is *not* here: a `state_class`. A device class is a display decision
+and a wrong one is fixed by a line in the next release. A state class writes
+long-term statistics, and a wrong one is not repaired by fixing it afterwards.
+"""
+
+
+def classify(declaration: AdoptedProperty) -> Platform:
+ """Return the platform a declared property surfaces on.
+
+ Driven by the declaration, in the order the declaration constrains it:
+
+ | Declaration | Platform |
+ | --- | --- |
+ | `boolean`, settable | `SWITCH` |
+ | `boolean` | `BINARY_SENSOR` |
+ | `enum`, settable, with a `format` | `SELECT` |
+ | numeric, settable, with a `format` | `NUMBER` |
+ | anything else | `SENSOR` |
+
+ **A settable property with no usable value domain falls back to a reading,
+ and that is not caution.** A select with no option list and a number with no
+ bounds are not safer controls; they are broken ones. `format` is where Homie
+ carries the domain, so its absence is the absence of the thing a control
+ needs.
+
+ Disabled-by-default is what gates a control, not read-only. Enabling an
+ entity is a deliberate act and commanding it is a second one, the panel
+ authorises the write regardless of what is created here, and this
+ integration already ships switches that open and close breakers.
+ """
+ settable_with_domain = declaration.settable and bool(declaration.format)
+ if declaration.datatype == BOOLEAN_DATATYPE:
+ return Platform.SWITCH if declaration.settable else Platform.BINARY_SENSOR
+ if declaration.datatype == ENUM_DATATYPE and settable_with_domain:
+ return Platform.SELECT
+ if declaration.datatype in NUMERIC_DATATYPES and settable_with_domain:
+ return Platform.NUMBER
+ return Platform.SENSOR
+
+
+CONTROL_PLATFORMS: Final = frozenset({Platform.SWITCH, Platform.SELECT, Platform.NUMBER})
+"""Platforms `classify` names that this integration cannot yet construct.
+
+Not a reversal of the rule -- `classify` is the rule and it is complete. A
+control needs a write, and every write this integration performs goes through a
+curated, adapter-named topic: `set_circuit_relay`, `set_circuit_priority`,
+`set_evse_charge_limit`. There is no generic property write, and adding one would
+put a new member on `SchemaAdapter`, whose required set is derived from the
+protocol itself -- so it would be required of every adapter package and would
+invalidate built adapter wheels.
+
+That is a contract change and belongs in its own one, with its own version bump.
+Until then a property that classifies as a control surfaces as a reading, and
+`adopted_control_count` is what makes the gap countable rather than invisible.
+"""
+
+
+def adopted_anchor(device: AdoptedDevice) -> str:
+ """Return the identity this device would be adopted under, before any freezing.
+
+ The serial when the device publishes one, because the specification is
+ explicit that consumers correlate representations of a physical device by
+ `info/serial-number` and never by device id -- ids are opaque, and a proxied
+ id is `{proxier-id}-{proxied-id}`, so the same hardware carries different
+ ids under different enclosures by design.
+
+ The wire id otherwise, as this panel's local handle.
+
+ This is only the *candidate*. What an install actually uses is frozen at
+ first sighting; see `resolve_identifier`.
+ """
+ return device.serial_number or device.device_id
+
+
+def adopted_identifier(panel_serial: str, anchor: str) -> str:
+ """Return a registry identifier for one adopted device."""
+ return f"{panel_serial}_{ADOPTED_IDENTIFIER_TOKEN}_{anchor}"
+
+
+def resolve_identifier(registry: DeviceRegistry, panel_serial: str, device: AdoptedDevice) -> str:
+ """Return the identifier this install already uses for this device, or a new one.
+
+ **An adopted device freezes its identity anchor at first sighting.** Both
+ candidate spellings are looked up before either is minted, because both
+ drift in practice and each covers the other's case:
+
+ - a serial arriving *after* adoption would move the device from its wire id
+ onto the serial, and
+ - a producer that derives its wire id from a serial moves the id itself when
+ the serial appears -- which is why this repository holds PV's
+ `info/serial-number` unvalued, since publishing it moves the PV device
+ from `-pv-1` to `-`.
+
+ Either move is a device *replacement* to the registry, taking the device's
+ entities and their history with it. Whatever was seen first is what is kept;
+ a better anchor arriving later is recorded on the card and changes nothing.
+
+ The registry is the memory, so this needs no new persistence: a device that
+ exists was adopted before, and one that does not is being adopted now.
+ """
+ for candidate in (device.device_id, device.serial_number):
+ if candidate is None:
+ continue
+ identifier = adopted_identifier(panel_serial, candidate)
+ if registry.async_get_device(identifiers={(DOMAIN, identifier)}) is not None:
+ return identifier
+ return adopted_identifier(panel_serial, adopted_anchor(device))
+
+
+def adopted_device_info(
+ identifier: str,
+ device: AdoptedDevice,
+ *,
+ panel_device_id: str,
+) -> DeviceInfo:
+ """Device card for an adopted device, from its `info` node.
+
+ The same reading `bess_device_info` has done since v1.0, applied to a device
+ nobody modelled. `info` describes the thing rather than reporting a reading,
+ so it lands here and never as entities -- a panel publishing its own build
+ metadata should not arrive as a handful of string sensors.
+
+ `name` falls back to the wire vocabulary because there is nothing better: an
+ adopted device has no translation key until somebody curates it, which is
+ the main thing curating it is for.
+ """
+ return DeviceInfo(
+ identifiers={(DOMAIN, identifier)},
+ name=device.name or _humanised(device.device_type.rsplit(".", 1)[-1]),
+ manufacturer=device.vendor_name or "Unknown",
+ model=device.model or _humanised(device.device_type.rsplit(".", 1)[-1]),
+ serial_number=device.serial_number,
+ sw_version=device.software_version,
+ hw_version=device.hardware_version,
+ via_device_id=panel_device_id,
+ )
+
+
+def _humanised(wire_token: str) -> str:
+ """`backup-generator` -> `Backup Generator`, for a name with no translation.
+
+ Deliberately plain. An adopted entity renders from wire vocabulary until it
+ is promoted, and dressing that up would disguise which entities are curated
+ and which are waiting to be.
+ """
+ return wire_token.replace("-", " ").replace("_", " ").title()
+
+
+class AdoptedEntity(SpanPanelEntity):
+ """Base for an entity built from a declaration rather than from a description.
+
+ Disabled and diagnostic without exception. Adoption's job is to make a device
+ reachable, not to put it on somebody's dashboard: the user decides what is
+ worth enabling, having seen the device exists.
+ """
+
+ _attr_entity_registry_enabled_default = False
+ _attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
+
+ def __init__(
+ self,
+ coordinator: SpanPanelCoordinator,
+ identifier: str,
+ device: AdoptedDevice,
+ declaration: AdoptedProperty,
+ *,
+ panel_device_id: str,
+ ) -> None:
+ """Bind this entity to one property of one adopted device."""
+ super().__init__(coordinator)
+ self._device_wire_id = device.device_id
+ self._declaration_path = declaration.path
+ self._attr_unique_id = (
+ f"span_{identifier}_{declaration.node_id}_{declaration.property_id}".replace("-", "_")
+ )
+ self._attr_name = _humanised(declaration.property_id)
+ self._attr_device_info = adopted_device_info(
+ identifier, device, panel_device_id=panel_device_id
+ )
+
+ def _published(self) -> str | None:
+ """Return this property's current value, or None when the panel publishes none.
+
+ Read back out of the snapshot each time rather than captured at
+ construction: the device is matched by its wire id, so a device that
+ leaves the tree and returns keeps reporting through the same entity.
+ """
+ snapshot: SpanPanelSnapshot = self.coordinator.data
+ for device in snapshot.adopted_devices:
+ if device.device_id != self._device_wire_id:
+ continue
+ for declaration in device.properties:
+ if declaration.path == self._declaration_path:
+ return declaration.value
+ return None
+
+
+class AdoptedSensor(AdoptedEntity, SensorEntity):
+ """A reading from an adopted device, with no `state_class`."""
+
+ def __init__(
+ self,
+ coordinator: SpanPanelCoordinator,
+ identifier: str,
+ device: AdoptedDevice,
+ declaration: AdoptedProperty,
+ *,
+ panel_device_id: str,
+ ) -> None:
+ """Take the unit and device class from what the panel declared."""
+ super().__init__(
+ coordinator, identifier, device, declaration, panel_device_id=panel_device_id
+ )
+ self.entity_description = SensorEntityDescription(
+ key=declaration.path,
+ device_class=DEVICE_CLASS_BY_UNIT.get(declaration.unit or ""),
+ native_unit_of_measurement=declaration.unit,
+ )
+ self._attr_name = _humanised(declaration.property_id)
+
+ @property
+ def native_value(self) -> str | float | None:
+ """Return the published value, parsed to a number only where one is declared.
+
+ A declared numeric that arrives unparseable is reported as `None` rather
+ than as its raw text: the entity has a unit and a device class, and
+ putting a string behind those would be a worse lie than reporting
+ nothing.
+ """
+ raw = self._published()
+ if raw is None:
+ return None
+ if self.entity_description.native_unit_of_measurement is None:
+ return raw
+ try:
+ return float(raw)
+ except ValueError:
+ _LOGGER.debug(
+ "Adopted %s published %r, which is not a number", self._declaration_path, raw
+ )
+ return None
+
+
+class AdoptedBinarySensor(AdoptedEntity, BinarySensorEntity):
+ """A declared `boolean` from an adopted device that the panel does not accept writes to."""
+
+ @property
+ def is_on(self) -> bool | None:
+ """Homie spells a boolean `true`/`false`; anything else is not an answer."""
+ raw = self._published()
+ if raw is None:
+ return None
+ lowered = raw.strip().lower()
+ if lowered in ("true", "false"):
+ return lowered == "true"
+ return None
+
+
+def adopted_control_count(snapshot: SpanPanelSnapshot) -> int:
+ """How many declared properties would be controls if this could build them.
+
+ Zero on every panel that publishes no settable property on an unmodelled
+ device, which is every panel seen so far. Non-zero is the signal that the
+ generic write path `CONTROL_PLATFORMS` describes is worth its contract bump,
+ measured rather than assumed.
+ """
+ return sum(
+ 1
+ for device in snapshot.adopted_devices
+ for declaration in device.properties
+ if classify(declaration) in CONTROL_PLATFORMS
+ )
+
+
+def create_adopted_sensors(
+ coordinator: SpanPanelCoordinator,
+ snapshot: SpanPanelSnapshot,
+ registry: DeviceRegistry,
+ *,
+ panel_device_id: str,
+) -> list[AdoptedSensor]:
+ """Every adopted property that is not a declared boolean.
+
+ Properties that classify as a control land here too, as readings, until the
+ write path exists -- see `CONTROL_PLATFORMS`.
+ """
+ return [
+ AdoptedSensor(coordinator, identifier, device, declaration, panel_device_id=panel_device_id)
+ for device, identifier in _adopted(snapshot, registry)
+ for declaration in device.properties
+ if classify(declaration) != Platform.BINARY_SENSOR
+ ]
+
+
+def create_adopted_binary_sensors(
+ coordinator: SpanPanelCoordinator,
+ snapshot: SpanPanelSnapshot,
+ registry: DeviceRegistry,
+ *,
+ panel_device_id: str,
+) -> list[AdoptedBinarySensor]:
+ """Every adopted property declared `boolean` and not settable."""
+ return [
+ AdoptedBinarySensor(
+ coordinator, identifier, device, declaration, panel_device_id=panel_device_id
+ )
+ for device, identifier in _adopted(snapshot, registry)
+ for declaration in device.properties
+ if classify(declaration) == Platform.BINARY_SENSOR
+ ]
+
+
+def _adopted(
+ snapshot: SpanPanelSnapshot, registry: DeviceRegistry
+) -> list[tuple[AdoptedDevice, str]]:
+ """Each adopted device paired with the identifier this install uses for it."""
+ return [
+ (device, resolve_identifier(registry, snapshot.serial_number, device))
+ for device in snapshot.adopted_devices
+ ]
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 3de76fe5..f25ce6db 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -14,12 +14,14 @@
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from span_panel_api import SpanEvseSnapshot, SpanPanelSnapshot
from . import SpanPanelConfigEntry
+from .adoption import create_adopted_binary_sensors
from .const import (
CONF_DEVICE_NAME,
PANEL_STATUS,
@@ -649,4 +651,17 @@ async def async_setup_entry(
if evse.connected is not None:
entities.append(SpanEvseBinarySensor(coordinator, EVSE_PANEL_LINK_SENSOR, evse_id))
- async_add_entities(entities)
+ # Declared booleans on devices this integration models nothing for. Disabled
+ # and diagnostic, so a panel that gains a vendor device gains no dashboard
+ # clutter -- only something the user can find and enable.
+ async_add_entities(
+ [
+ *entities,
+ *create_adopted_binary_sensors(
+ coordinator,
+ snapshot,
+ dr.async_get(hass),
+ panel_device_id=config_entry.runtime_data.panel_device_id,
+ ),
+ ]
+ )
diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py
index 9020bb94..811e94e2 100644
--- a/custom_components/span_panel/diagnostics.py
+++ b/custom_components/span_panel/diagnostics.py
@@ -7,8 +7,10 @@
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
+from span_panel_api import SpanPanelSnapshot
from . import SpanPanelConfigEntry
+from .adoption import adopted_control_count, classify
from .const import (
CONF_EBUS_BROKER_PASSWORD,
CONF_EBUS_BROKER_USERNAME,
@@ -93,6 +95,83 @@ def _discovery(findings: SchemaFindings | None) -> DiscoveryBlock:
}
+class AdoptedDeviceRow(TypedDict):
+ """One adopted device, as it appears in the payload.
+
+ No `name`, no `serial_number`. Both are on `AdoptedDevice` and both reach the
+ device card, but neither answers the question this block exists to ask -- and
+ a vendor-set device name is free text a household chose. The type, the shape
+ and the counts are what a maintainer needs.
+ """
+
+ device_type: str
+ model: str | None
+ property_count: int
+ properties: list[AdoptedPropertyRow]
+
+
+class AdoptedPropertyRow(TypedDict):
+ """One adopted property's declaration, with no value.
+
+ The same rule `DiscoveredRow` follows and for the same reason: `TO_REDACT` is
+ key-based over the config entry and cannot protect a wire value put here.
+ `platform` is included because it is derived from the other three and is the
+ decision a maintainer would otherwise recompute by hand.
+ """
+
+ path: str
+ datatype: str
+ unit: str | None
+ settable: bool
+ platform: str
+
+
+class AdoptionBlock(TypedDict):
+ """The `adopted_devices` section.
+
+ `pending_controls` is the measurement that decides whether a generic write
+ path earns its contract bump -- how many declared properties would be
+ controls if one existed. Zero on every panel seen so far, and the number
+ stays invisible unless it is reported.
+ """
+
+ count: int
+ pending_controls: int
+ devices: list[AdoptedDeviceRow]
+
+
+def _adoption(snapshot: SpanPanelSnapshot) -> AdoptionBlock:
+ """Report the devices this integration models nothing for.
+
+ Declarations, never values -- see `AdoptedPropertyRow`. Empty on every panel
+ that publishes only device types this integration reads, which is every panel
+ seen so far, and a non-empty block is the first evidence that the schema's
+ vendor extensibility is being used in the field.
+ """
+ return {
+ "count": len(snapshot.adopted_devices),
+ "pending_controls": adopted_control_count(snapshot),
+ "devices": [
+ {
+ "device_type": device.device_type,
+ "model": device.model,
+ "property_count": len(device.properties),
+ "properties": [
+ {
+ "path": declaration.path,
+ "datatype": declaration.datatype,
+ "unit": declaration.unit,
+ "settable": declaration.settable,
+ "platform": classify(declaration).value,
+ }
+ for declaration in device.properties
+ ],
+ }
+ for device in snapshot.adopted_devices
+ ],
+ }
+
+
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: SpanPanelConfigEntry,
@@ -162,4 +241,5 @@ async def async_get_config_entry_diagnostics(
"last_update_success": coordinator.last_update_success,
},
"schema_discovery": _discovery(coordinator.schema_findings),
+ "adopted_devices": _adoption(snapshot),
}
diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py
index b45b7106..2b2d1c1f 100644
--- a/custom_components/span_panel/schema_repairs.py
+++ b/custom_components/span_panel/schema_repairs.py
@@ -31,10 +31,11 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import entity_registry as er, issue_registry as ir
+from homeassistant.helpers import device_registry as dr, entity_registry as er, issue_registry as ir
from .const import DOMAIN, EVENT_SCHEMA_ISSUE
from .schema_validation import SchemaFindings
+from .util import ADOPTED_IDENTIFIER_TOKEN
_LOGGER = logging.getLogger(__name__)
@@ -225,6 +226,53 @@ def _label(registry_entry: er.RegistryEntry) -> str:
return registry_entry.name or registry_entry.original_name or registry_entry.entity_id
+def _labels(hass: HomeAssistant, new_disabled: list[er.RegistryEntry]) -> list[str]:
+ """Return what to call the new entities, with adopted ones counted rather than listed.
+
+ A curated addition is a handful of entities a maintainer chose, so naming each
+ one is the notice doing its job. An adopted device is the opposite shape:
+ every property a vendor device declares becomes an entity at once, so listing
+ them would spend the whole notice on one device and teach the user that this
+ category is noise -- which would cost them the curated additions too.
+
+ So an adopted device contributes exactly one line carrying its own count, and
+ the notice reads "Backup Generator (6 entities)" beside whatever else the
+ release added.
+ """
+ devices = dr.async_get(hass)
+ adopted: dict[str, int] = {}
+ labels: list[str] = []
+ for registry_entry in new_disabled:
+ device_name = _adopted_device_name(devices, registry_entry)
+ if device_name is None:
+ labels.append(_label(registry_entry))
+ else:
+ adopted[device_name] = adopted.get(device_name, 0) + 1
+ labels.extend(f"{name} ({count} entities)" for name, count in adopted.items())
+ return sorted(labels)
+
+
+def _adopted_device_name(
+ devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry
+) -> str | None:
+ """Return the adopted device this entity belongs to, or None when it belongs to none.
+
+ Read off the device identifier rather than off the entity, because the entity
+ carries nothing that distinguishes an adopted reading from a curated one --
+ by design, since an adopted entity is meant to be indistinguishable once it
+ is enabled.
+ """
+ if registry_entry.device_id is None:
+ return None
+ device = devices.async_get(registry_entry.device_id)
+ if device is None:
+ return None
+ token = f"_{ADOPTED_IDENTIFIER_TOKEN}_"
+ if not any(token in identifier for _domain, identifier in device.identifiers):
+ return None
+ return device.name_by_user or device.name or registry_entry.entity_id
+
+
@callback
def async_notice_new_disabled_entities(
hass: HomeAssistant,
@@ -294,7 +342,7 @@ def async_notice_new_disabled_entities(
_LOGGER.debug("New-entity notice %s already raised; leaving it alone", issue_id)
return
- labels = sorted(_label(registry_entry) for registry_entry in new_disabled)
+ labels = _labels(hass, new_disabled)
_LOGGER.debug("Raising new-entity notice %s for %s", issue_id, labels)
ir.async_create_issue(
hass,
diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py
index 7661b1cd..2d1d111e 100644
--- a/custom_components/span_panel/sensor.py
+++ b/custom_components/span_panel/sensor.py
@@ -6,11 +6,13 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from span_panel_api import SpanPanelSnapshot
from . import SpanPanelConfigEntry
+from .adoption import create_adopted_sensors
from .const import (
CONF_DEVICE_NAME,
ENABLE_CIRCUIT_NET_ENERGY_SENSORS,
@@ -117,8 +119,19 @@ async def async_setup_entry(
# Create all native sensors (panel, circuit, and battery sensors)
entities = create_native_sensors(coordinator, snapshot, config_entry)
+ # Readings from devices this integration models nothing for. Appended
+ # rather than merged into `create_native_sensors`: those are curated
+ # descriptions and these are declarations, and the two inventories answer
+ # different questions -- see `adoption`.
+ adopted = create_adopted_sensors(
+ coordinator,
+ snapshot,
+ dr.async_get(hass),
+ panel_device_id=config_entry.runtime_data.panel_device_id,
+ )
+
# Add all native sensor entities
- async_add_entities(entities)
+ async_add_entities([*entities, *adopted])
# Force immediate coordinator refresh to ensure all sensors update right away
await coordinator.async_request_refresh()
diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py
index 93f6f0cb..1e0230ad 100644
--- a/custom_components/span_panel/util.py
+++ b/custom_components/span_panel/util.py
@@ -38,6 +38,17 @@
SUB_DEVICE_EVSE: Final = "evse"
SUB_DEVICE_PV: Final = "pv"
+ADOPTED_IDENTIFIER_TOKEN: Final = "adopted"
+"""The infix marking a sub-device identifier as adopted rather than curated.
+
+An adopted device is `{panel serial}_adopted_{anchor}`, where the anchor is
+whatever the device was first seen under. Kept here beside the curated kinds
+because the two namespaces have to be readable apart, and the reading end below
+is what would otherwise mistake one for the other: a vendor device whose id
+happens to end in `pv` would classify as the solar sub-device under a suffix
+rule that had never heard of adoption.
+"""
+
def classify_sub_device_identifier(identifier: str) -> str | None:
"""Return the kind of sub-device an identifier names, or None if it names none.
@@ -52,6 +63,13 @@ def classify_sub_device_identifier(identifier: str) -> str | None:
suffix rules below, which is the only ordering that stays right whatever a
panel names its nodes.
"""
+ # Adopted devices are not a curated kind and must not be read as one. Tested
+ # before every suffix rule below, because the anchor that follows the token
+ # is vendor vocabulary: a device id ending in `pv` would otherwise classify
+ # as the panel's solar sub-device.
+ if f"_{ADOPTED_IDENTIFIER_TOKEN}_" in identifier:
+ return None
+
# Infix, not suffix: the node id follows, and it is what distinguishes one
# charger from another on the same panel.
if f"_{SUB_DEVICE_EVSE}_" in identifier:
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
index dea51ed7..9c458c60 100644
--- a/tests/fixtures/README.md
+++ b/tests/fixtures/README.md
@@ -1,33 +1,26 @@
# Adapter fixtures
-Real schema-adapter inputs, used by the field-path conformance tests via
-`tests/adapter_fixtures.py`. They are **committed rather than generated** so the
-test suite has no cross-repo dependency and CI needs no checkout of the library.
-The cost of that choice is that they go stale silently — hence this file.
+Real schema-adapter inputs, used by the field-path conformance tests via `tests/adapter_fixtures.py`. They are **committed rather than generated** so the test
+suite has no cross-repo dependency and CI needs no checkout of the library. The cost of that choice is that they go stale silently — hence this file.
## Provenance
Both are byte-identical copies from the `span-panel-api` repository:
-| File here | Source in `span-panel-api` |
-|---|---|
-| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` |
-| `schema_one_tree.json` | `packages/schema-1/src/.../reference_payloads/parent_child_tree.json` |
+| File here | Source in `span-panel-api` |
+| ------------------------ | --------------------------------------------------------------------- |
+| `schema_zero_types.json` | `tests/fixtures/v2/homie_schema.json` |
+| `schema_one_tree.json` | `packages/schema-1/src/.../reference_payloads/parent_child_tree.json` |
-Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to
-its source: the library pins what that capture leaves unvalued against
-panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a
-copy that has drifted puts these tests on a wire no producer sends.
+Refresh by copying them again, and keep `schema_one_tree.json` byte-identical to its source: the library pins what that capture leaves unvalued against
+panelbench's own baseline (`tests/test_reference_tree_values.py` there), so a copy that has drifted puts these tests on a wire no producer sends.
-If a copy changes shape rather than content, the
-loader in `tests/adapter_fixtures.py` is what needs updating — note that
-`schema_one_tree.json` is a **dict keyed by device id**, whose `$description`
-value is a **JSON string**, not a parsed object.
+If a copy changes shape rather than content, the loader in `tests/adapter_fixtures.py` is what needs updating — note that `schema_one_tree.json` is a **dict
+keyed by device id**, whose `$description` value is a **JSON string**, not a parsed object.
## Derived variants
-Both are produced from `schema_one_tree.json` by dropping every device whose
-parsed `$description["type"]` contains a marker. Regenerate with:
+Both are produced from `schema_one_tree.json` by dropping every device whose parsed `$description["type"]` contains a marker. Regenerate with:
```bash
uv run python - << 'PY'
@@ -49,15 +42,11 @@ drop(".pv", "schema_one_tree_no_pv.json")
PY
```
-Each must remove **exactly one** device (13 -> 12) and must retain the panel and
-both lugs devices. A variant that removed more would make the conformance tests
-pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and
-correctly survives the `.bess` filter.
+Each must remove **exactly one** device (13 -> 12) and must retain the panel and both lugs devices. A variant that removed more would make the conformance tests
+pass for the wrong reason. Note `bess-mid` is typed `energy.ebus.device.mid` and correctly survives the `.bess` filter.
## Why these exist
-`schema_one_tree_batteryless.json` proves a panel with no BESS produces **no**
-`battery.*` entries — hardware absence, not degradation.
-`schema_one_tree_no_pv.json` proves the same for a panel that has power-flows
-telemetry but no PV device, which is the case telemetry-based capability
-detection gets wrong.
+`schema_one_tree_batteryless.json` proves a panel with no BESS produces **no** `battery.*` entries — hardware absence, not degradation.
+`schema_one_tree_no_pv.json` proves the same for a panel that has power-flows telemetry but no PV device, which is the case telemetry-based capability detection
+gets wrong.
diff --git a/tests/test_adoption.py b/tests/test_adoption.py
new file mode 100644
index 00000000..6a3924f8
--- /dev/null
+++ b/tests/test_adoption.py
@@ -0,0 +1,350 @@
+"""Devices this integration models nothing for surface without disturbing curated ones.
+
+Three properties carry the whole design and each has a test here that fails if it
+stops holding: nothing adopted enters long-term statistics, an adopted device
+keeps the identity it was first seen under, and the notice counts adopted devices
+rather than listing their entities.
+"""
+
+from __future__ import annotations
+
+import ast
+from dataclasses import replace
+from pathlib import Path
+from typing import TYPE_CHECKING
+from unittest.mock import MagicMock
+
+from homeassistant.components.sensor import SensorDeviceClass
+from homeassistant.const import EntityCategory, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+import pytest
+from span_panel_api import AdoptedDevice, AdoptedProperty
+
+from custom_components.span_panel.adoption import (
+ CONTROL_PLATFORMS,
+ DEVICE_CLASS_BY_UNIT,
+ adopted_control_count,
+ adopted_identifier,
+ classify,
+ create_adopted_binary_sensors,
+ create_adopted_sensors,
+ resolve_identifier,
+)
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.util import (
+ ADOPTED_IDENTIFIER_TOKEN,
+ classify_sub_device_identifier,
+)
+
+from .factories import SpanPanelSnapshotFactory
+
+if TYPE_CHECKING:
+ from span_panel_api import SpanPanelSnapshot
+
+PANEL_SERIAL = "sp3-242424-001"
+
+
+def _property(
+ node_id: str = "meter",
+ property_id: str = "active-power",
+ datatype: str = "float",
+ unit: str | None = "W",
+ fmt: str | None = None,
+ settable: bool = False,
+ value: str | None = "2400",
+) -> AdoptedProperty:
+ return AdoptedProperty(
+ node_id=node_id,
+ property_id=property_id,
+ datatype=datatype,
+ unit=unit,
+ format=fmt,
+ settable=settable,
+ value=value,
+ )
+
+
+def _device(
+ device_id: str = "generator-1",
+ *,
+ serial_number: str | None = None,
+ properties: tuple[AdoptedProperty, ...] = (),
+) -> AdoptedDevice:
+ return AdoptedDevice(
+ device_id=device_id,
+ device_type="energy.ebus.device.generator",
+ name="Backup Generator",
+ model="GEN-9000",
+ serial_number=serial_number,
+ properties=properties,
+ )
+
+
+def _snapshot(*devices: AdoptedDevice) -> SpanPanelSnapshot:
+ """Return a complete snapshot carrying the given adopted devices.
+
+ Built through `replace` rather than by teaching the factory a keyword, so the
+ factory keeps describing a curated panel and adoption stays visibly additive.
+ """
+ return replace(SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL), adopted_devices=devices)
+
+
+# -- The platform table ------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("datatype", "settable", "fmt", "expected"),
+ [
+ ("boolean", True, None, Platform.SWITCH),
+ ("boolean", False, None, Platform.BINARY_SENSOR),
+ ("enum", True, "AUTO,MANUAL,OFF", Platform.SELECT),
+ ("float", True, "0:100:1", Platform.NUMBER),
+ ("integer", True, "0:100:1", Platform.NUMBER),
+ ("float", False, None, Platform.SENSOR),
+ ("enum", False, "AUTO,MANUAL", Platform.SENSOR),
+ ("string", True, None, Platform.SENSOR),
+ ],
+)
+def test_the_declaration_decides_the_platform(
+ datatype: str, settable: bool, fmt: str | None, expected: Platform
+) -> None:
+ """The rule in one table, including its two fallbacks."""
+ assert classify(_property(datatype=datatype, fmt=fmt, settable=settable, unit=None)) is expected
+
+
+@pytest.mark.parametrize("datatype", ["enum", "float", "integer"])
+def test_a_settable_property_with_no_value_domain_falls_back_to_a_reading(datatype: str) -> None:
+ """Not caution -- the absence of the thing a control is made of.
+
+ `format` is where Homie carries the domain. A select with no option list and
+ a number with no bounds are not safer controls, they are broken ones, so the
+ property surfaces as the reading it can still be.
+ """
+ assert classify(_property(datatype=datatype, settable=True, fmt=None, unit=None)) is Platform.SENSOR
+
+
+def test_a_settable_boolean_needs_no_format_because_its_domain_is_the_datatype() -> None:
+ """The one control whose value domain the datatype already states in full."""
+ assert classify(_property(datatype="boolean", settable=True, fmt=None, unit=None)) is Platform.SWITCH
+
+
+# -- Nothing adopted enters long-term statistics -----------------------------
+
+
+def test_no_adopted_sensor_carries_a_state_class(hass: HomeAssistant) -> None:
+ """The single most important assertion in this module.
+
+ `state_class` is not declared on the wire and is not derivable from one: this
+ integration ships `feedthroughEnergyProducedWh` as TOTAL beside
+ `mainMeterEnergyProducedWh` as TOTAL_INCREASING, same unit and same device
+ class. A wrong one writes corrupt long-term statistics that fixing the
+ producer afterwards does not repair, so adoption classifies none of them --
+ and a user who wants statistics from an adopted reading wraps it themselves.
+ """
+ declarations = tuple(
+ _property(property_id=name, unit=unit, datatype="float")
+ for name, unit in (("active-power", "W"), ("imported-energy", "Wh"), ("exported-energy", "kWh"))
+ )
+ entities = create_adopted_sensors(
+ MagicMock(data=_snapshot(_device(properties=declarations))),
+ _snapshot(_device(properties=declarations)),
+ dr.async_get(hass),
+ panel_device_id="panel-device-id",
+ )
+
+ assert len(entities) == 3
+ assert all(entity.state_class is None for entity in entities)
+
+
+def test_no_state_class_is_set_anywhere_in_the_module() -> None:
+ """The rule stated once more, against the syntax rather than an instance.
+
+ An instance test only covers the paths a test constructs, so a future branch
+ that set a state class on some platform nobody instantiated here would pass
+ every test above. Read as syntax rather than as text because the module's own
+ prose has to be free to explain why the rule exists.
+ """
+ from custom_components.span_panel import adoption
+
+ tree = ast.parse(Path(adoption.__file__).read_text(encoding="utf-8"))
+ keywords = [node.arg for node in ast.walk(tree) if isinstance(node, ast.keyword)]
+ targets = [node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)]
+ assert "state_class" not in keywords
+ assert "_attr_state_class" not in targets
+ assert not [node.id for node in ast.walk(tree) if isinstance(node, ast.Name) and "StateClass" in node.id]
+
+
+# -- Device class is enumerated, never inferred ------------------------------
+
+
+def test_a_declared_unit_this_integration_knows_gets_a_device_class(hass: HomeAssistant) -> None:
+ declarations = (_property(unit="W"),)
+ (entity,) = create_adopted_sensors(
+ MagicMock(data=_snapshot(_device(properties=declarations))),
+ _snapshot(_device(properties=declarations)),
+ dr.async_get(hass),
+ panel_device_id="panel-device-id",
+ )
+ assert entity.device_class is SensorDeviceClass.POWER
+
+
+@pytest.mark.parametrize("unit", ["%", "ppm", "kg"])
+def test_a_unit_outside_the_map_gets_no_device_class(hass: HomeAssistant, unit: str) -> None:
+ """An unlabelled reading is honest; a mislabelled one is not.
+
+ `%` is the case that makes the rule earn its keep. Its uses in this
+ vocabulary are not one class -- a state of charge, a confidence, a duty cycle
+ -- so a rule that guessed BATTERY for all of them would mislabel most.
+ """
+ declarations = (_property(unit=unit),)
+ (entity,) = create_adopted_sensors(
+ MagicMock(data=_snapshot(_device(properties=declarations))),
+ _snapshot(_device(properties=declarations)),
+ dr.async_get(hass),
+ panel_device_id="panel-device-id",
+ )
+ assert entity.device_class is None
+ assert unit not in DEVICE_CLASS_BY_UNIT
+
+
+# -- Everything adopted is disabled and diagnostic ---------------------------
+
+
+def test_every_adopted_entity_is_disabled_and_diagnostic(hass: HomeAssistant) -> None:
+ """Adoption makes a device reachable; it does not put one on a dashboard."""
+ declarations = (_property(), _property(node_id="relay", property_id="closed", datatype="boolean", unit=None))
+ snapshot = _snapshot(_device(properties=declarations))
+ coordinator = MagicMock(data=snapshot)
+ registry = dr.async_get(hass)
+
+ entities = [
+ *create_adopted_sensors(coordinator, snapshot, registry, panel_device_id="panel-device-id"),
+ *create_adopted_binary_sensors(coordinator, snapshot, registry, panel_device_id="panel-device-id"),
+ ]
+
+ assert len(entities) == 2
+ assert all(entity.entity_registry_enabled_default is False for entity in entities)
+ assert all(entity.entity_category is EntityCategory.DIAGNOSTIC for entity in entities)
+
+
+def test_a_declared_boolean_becomes_a_binary_sensor_and_not_a_sensor(hass: HomeAssistant) -> None:
+ """The two creators partition the properties rather than both claiming one."""
+ declarations = (_property(node_id="relay", property_id="closed", datatype="boolean", unit=None, value="true"),)
+ snapshot = _snapshot(_device(properties=declarations))
+ coordinator = MagicMock(data=snapshot)
+ registry = dr.async_get(hass)
+
+ assert create_adopted_sensors(coordinator, snapshot, registry, panel_device_id="p") == []
+ (entity,) = create_adopted_binary_sensors(coordinator, snapshot, registry, panel_device_id="p")
+ assert entity.is_on is True
+
+
+# -- Identity freezes at first sighting --------------------------------------
+
+
+def test_a_serial_arriving_after_adoption_does_not_move_the_device(hass: HomeAssistant) -> None:
+ """The failure this rule exists to prevent, in its most likely form.
+
+ A device adopted under its wire id that later publishes `info/serial-number`
+ would, without freezing, be re-derived onto the serial -- which the registry
+ reads as a device replacement and which takes the entities and their history
+ with it.
+ """
+ registry = dr.async_get(hass)
+ first_seen = adopted_identifier(PANEL_SERIAL, "generator-1")
+ entry = MagicMock()
+ registry.async_get_or_create = MagicMock()
+ registry.async_get_device = lambda identifiers: entry if (DOMAIN, first_seen) in identifiers else None
+
+ later = _device("generator-1", serial_number="EX-0000-0001")
+ assert resolve_identifier(registry, PANEL_SERIAL, later) == first_seen
+
+
+def test_a_wire_id_that_moves_keeps_a_device_adopted_under_its_serial(hass: HomeAssistant) -> None:
+ """The other direction, which this repository has already been bitten by.
+
+ Producers derive a DER's id preferring a serial over a default slug, so the
+ wire id itself moves when a serial appears -- which is why PV's
+ `info/serial-number` is held unvalued. A device adopted under its serial has
+ to survive that.
+ """
+ registry = dr.async_get(hass)
+ first_seen = adopted_identifier(PANEL_SERIAL, "EX-0000-0001")
+ entry = MagicMock()
+ registry.async_get_device = lambda identifiers: entry if (DOMAIN, first_seen) in identifiers else None
+
+ moved = _device("panel-EX-0000-0001", serial_number="EX-0000-0001")
+ assert resolve_identifier(registry, PANEL_SERIAL, moved) == first_seen
+
+
+def test_a_device_never_seen_before_is_adopted_under_its_serial_when_it_has_one(hass: HomeAssistant) -> None:
+ """The specification's own correlator, used where nothing is frozen yet.
+
+ Device ids are opaque and a proxied id is `{proxier-id}-{proxied-id}`, so the
+ same hardware carries different ids under different enclosures by design. The
+ serial is what the specification says to correlate on.
+ """
+ registry = dr.async_get(hass)
+ registry.async_get_device = lambda identifiers: None
+
+ fresh = _device("generator-1", serial_number="EX-0000-0001")
+ assert resolve_identifier(registry, PANEL_SERIAL, fresh) == adopted_identifier(PANEL_SERIAL, "EX-0000-0001")
+
+
+def test_a_device_with_no_serial_is_adopted_under_its_wire_id(hass: HomeAssistant) -> None:
+ registry = dr.async_get(hass)
+ registry.async_get_device = lambda identifiers: None
+
+ assert resolve_identifier(registry, PANEL_SERIAL, _device("generator-1")) == adopted_identifier(
+ PANEL_SERIAL, "generator-1"
+ )
+
+
+# -- The two identifier namespaces stay readable apart -----------------------
+
+
+@pytest.mark.parametrize("anchor", ["generator-1", "some-pv", "an_evse_thing", "ends-in-mid", "bess"])
+def test_an_adopted_identifier_never_classifies_as_a_curated_sub_device(anchor: str) -> None:
+ """The anchor is vendor vocabulary and can spell anything.
+
+ `classify_sub_device_identifier` reads the curated grammar with suffix rules,
+ so a vendor device id ending in `pv` would classify as the panel's solar
+ sub-device -- which is how a device nobody modelled would end up rendered as
+ one that was.
+ """
+ identifier = adopted_identifier(PANEL_SERIAL, anchor)
+ assert ADOPTED_IDENTIFIER_TOKEN in identifier
+ assert classify_sub_device_identifier(identifier) is None
+
+
+def test_a_curated_identifier_still_classifies(hass: HomeAssistant) -> None:
+ """The other direction: adding the adoption test must not blind the reader."""
+ assert classify_sub_device_identifier(f"{PANEL_SERIAL}_bess") == "bess"
+ assert classify_sub_device_identifier(f"{PANEL_SERIAL}_pv") == "pv"
+
+
+# -- The pending write path is counted rather than hidden --------------------
+
+
+def test_a_settable_property_is_counted_as_a_pending_control() -> None:
+ """`classify` is the complete rule; the write path is what does not exist yet.
+
+ Every write this integration performs goes through a curated, adapter-named
+ topic, and a generic one would put a new member on `SchemaAdapter` -- required
+ of every adapter package, invalidating built wheels. That is its own change.
+ Counting the properties waiting on it is what decides whether it is worth one.
+ """
+ declarations = (
+ _property(datatype="boolean", settable=True, unit=None),
+ _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None),
+ _property(),
+ )
+ snapshot = _snapshot(_device(properties=declarations))
+
+ assert adopted_control_count(snapshot) == 2
+ assert classify(declarations[0]) in CONTROL_PLATFORMS
+
+
+def test_a_panel_with_no_adopted_device_counts_no_pending_controls() -> None:
+ assert adopted_control_count(_snapshot()) == 0
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
index d1748fa7..6d010a63 100644
--- a/tests/test_diagnostics.py
+++ b/tests/test_diagnostics.py
@@ -154,6 +154,7 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable
},
evse={},
battery=None,
+ adopted_devices=(),
)
coordinator = MagicMock()
coordinator.data = snapshot
diff --git a/websocket-api.md b/websocket-api.md
index 4e74b8f4..2eeb6f8f 100644
--- a/websocket-api.md
+++ b/websocket-api.md
@@ -109,14 +109,14 @@ relationships explicitly, keyed by circuit UUID, so the card reads a single stru
#### Top Level
-| Field | Type | Description |
-| ------------- | ----------- | ----------------------------------------------- |
-| `serial` | string | Panel serial number |
-| `firmware` | string | Panel firmware version |
-| `panel_size` | int or null | Total breaker spaces (e.g., 32, 40) |
-| `device_id` | string | HA device registry ID (echoed from request) |
-| `device_name` | string | HA device display name |
-| `circuits` | object | Circuit UUID keyed map (see below) |
+| Field | Type | Description |
+| ------------- | ----------- | --------------------------------------------------- |
+| `serial` | string | Panel serial number |
+| `firmware` | string | Panel firmware version |
+| `panel_size` | int or null | Total breaker spaces (e.g., 32, 40) |
+| `device_id` | string | HA device registry ID (echoed from request) |
+| `device_name` | string | HA device display name |
+| `circuits` | object | Circuit UUID keyed map (see below) |
| `sub_devices` | object | HA device ID keyed map of BESS/MID/EVSE (see below) |
#### Circuit Object
From 3eacbd3c538e2eaec2cd456347043ac79fa4d043 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 10:46:18 -0700
Subject: [PATCH 066/116] docs(developer): explain device-scoped adoption and
the rules that bound it
---
developer.md | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 107 insertions(+), 2 deletions(-)
diff --git a/developer.md b/developer.md
index 3afec603..d473c1cf 100644
--- a/developer.md
+++ b/developer.md
@@ -221,8 +221,8 @@ the panel has published a value for the property, which is the declared-but-neve
key-based over the config entry: it knows nothing about wire property names and could not protect a value added here. `test_schema_discovery` asserts that
against the capture's own published values rather than leaving it to review.
-This is **maintainer-facing only**. Nothing creates an entity, a Repair or a notification from it. Automatic adoption is a separate, unbuilt step whose costs —
-notice aggregation, an exclusion denylist, the accumulator register — are not settled.
+This is **maintainer-facing only**. Nothing creates an entity, a Repair or a notification from it — including for a device that _is_ adopted, whose properties
+are reported here as declarations exactly like any other. Adoption itself is the next section.
### Why discovered rows cannot reach the curated inventories
@@ -245,6 +245,111 @@ The adapter decides "read" from four enumerations of what it addresses — the m
of the report. `tests/test_schema_one_discovery.py` in the library runs the same republish-and-diff experiment this gate uses and holds every entry to it in
both directions, so the report means "nothing reads this" rather than "nobody wrote it down".
+## Adopting a device this integration models nothing for
+
+The section above is about properties on devices we already read. This one is about a device type nobody modelled at all — a vendor's generator, heat pump or
+second inverter, which the eBus schema explicitly permits. Such a device used to produce nothing: no device, no entity, no sign it was there.
+
+### The rule
+
+**The unit of adoption is a device, never a property.**
+
+| What arrives | What happens |
+| ------------------------------------------------ | -------------------------------------------------------------------------------- |
+| A device type `MODELLED_TYPES` does not name | **Adopt.** One sub-device, its properties surfaced beneath it. |
+| A new node or property on a device we _do_ model | **Do not adopt.** It lands in `schema_discovery`; curate it in the next release. |
+| A new property on a device already adopted | Adopt, with its siblings. |
+
+The asymmetry is where the cost calculus actually points. An adopted entity's id is machine-derived and permanent once it registers, which is only a loss where
+curation is coming. On a type nobody modelled, no better id is coming and the alternative is silence.
+
+Extra instances of a modelled type are **not** adopted. A second BESS is a multiplicity limitation of the snapshot model, not an unmodelled device, and adopting
+it would stand a machine-named card beside the curated Battery describing the same hardware. The gap stays visible as a gap.
+
+### Inside an adopted device, the node decides the destination
+
+Keyed on the Homie node — what the eBus vocabulary defines — rather than on property names:
+
+- **`info/*` → device-card fields.** `model`, `serial-number`, `firmware-version`, `hardware-version`, `vendor-name`. The whole node, not just the five the card
+ reads: dropping only the recognised ones would surface `info/nominal-power` as a string sensor the moment a vendor declared one.
+- **`connection/*` → the device link.** Topology, which is `via_device`.
+- **Everything else → entities**, `EntityCategory.DIAGNOSTIC` and disabled by default.
+
+Why by node: the capability catalogs carry **no marker** for "this value is a device reference", so the only alternative is a hard-coded property-name list —
+and such a list goes stale silently. `ebus-sdk`'s own `topology.py` covers `feeds-device-id` and `fed-by-device-id` and omits `grid-forming-entity`, which lives
+on the `grid` capability. A node cannot go stale that way.
+
+### Nothing adopted enters long-term statistics
+
+No adopted entity carries a `state_class`. `test_no_state_class_is_set_anywhere_in_the_module` reads `adoption.py` as syntax and fails if one ever appears.
+
+Three reasons, and they are independent:
+
+1. It is not declared on the wire and is not derivable from one. This integration ships `feedthroughEnergyProducedWh` as `TOTAL` beside
+ `mainMeterEnergyProducedWh` as `TOTAL_INCREASING` — same unit, same device class, opposite classification.
+2. A wrong one writes corrupt long-term statistics, and fixing the producer afterwards does not repair them.
+3. Enrolling a property nobody asked for into long-term statistics is a permanent write to every install's recorder database.
+
+A user who wants statistics from an adopted reading wraps it in a template sensor, a Riemann-sum integration or a utility meter. That is their call, made on an
+entity they chose to enable.
+
+`device_class` is enumerated in `DEVICE_CLASS_BY_UNIT` rather than inferred. A unit outside the map gets **no** device class — `%` is deliberately absent,
+because its uses here are a state of charge, a confidence and a duty cycle, and no single class is right for all of them.
+
+### Identity freezes at first sighting
+
+`resolve_identifier` looks up **both** candidate spellings — `{panel serial}_adopted_{wire id}` and `{panel serial}_adopted_{serial}` — before minting either,
+and keeps whichever already exists. Both drift in practice:
+
+- a serial arriving _after_ adoption would move the device off its wire id, and
+- a producer that derives its wire id from a serial moves the id itself when the serial appears, which is why this repository holds PV's `info/serial-number`
+ unvalued.
+
+Either move reads to Home Assistant as a device **replacement**, taking the entities and their history. The device registry is the memory, so this needs no new
+persistence.
+
+`classify_sub_device_identifier` returns `None` for any identifier carrying the `adopted` token, tested before its suffix rules — the anchor is vendor
+vocabulary, and a device id ending in `pv` would otherwise classify as the panel's solar sub-device.
+
+### Controls are classified but not built
+
+`classify` implements the full rule, including the three control platforms:
+
+| Declaration | Platform |
+| ---------------------------------- | --------------- |
+| `boolean`, settable | `SWITCH` |
+| `boolean` | `BINARY_SENSOR` |
+| `enum`, settable, with a `format` | `SELECT` |
+| numeric, settable, with a `format` | `NUMBER` |
+| anything else | `SENSOR` |
+
+A settable property with no `format` falls back to a reading because `format` is the value domain: a select with no option list and a number with no bounds are
+broken controls, not safe ones.
+
+The three control platforms are in `CONTROL_PLATFORMS` and are **not constructed yet**. Every write this integration performs goes through a curated,
+adapter-named topic, and a generic property write would put a new member on `SchemaAdapter` — whose required set is derived from the protocol itself, so it
+would be required of every adapter package and would invalidate built adapter wheels. That is a contract change with its own version bump.
+
+`adopted_control_count` reports how many declared properties are waiting on it, in diagnostics under `adopted_devices.pending_controls`, so the decision is made
+on a measurement rather than a guess.
+
+### The notice counts devices, not entities
+
+Adopted entities are disabled, so they reach the user only through `async_notice_new_disabled_entities`. That notice lists curated additions individually and
+collapses each adopted device to one line with a count — `Backup Generator (6 entities)`. A vendor device declaring a dozen properties would otherwise spend the
+whole notice on itself and teach the user that the category is noise, which would cost them the curated additions too.
+
+### Diagnostics
+
+`adopted_devices` in the diagnostics payload carries the device type, model, property paths, datatypes, units, `settable` flags and the platform each would
+take. **No values, no device name, no serial.** Same rule as `schema_discovery` and for the same reason: `TO_REDACT` is key-based over the config entry and
+cannot protect a wire value put there.
+
+### Adopted entities declare no field paths
+
+`snapshot.adopted_devices` is outside the curated field-path vocabulary by construction — it carries no metadata row, so the producible gate has nothing to
+check it against. `adoption.py` is therefore absent from `residual_field_paths()`'s import list, and its entity classes declare no `_residual_field_paths`.
+
## Linting and Type Checking
```bash
From a3f8a4d8ea616cf3ade4115dfb50c5c6f0dea731 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 11:31:29 -0700
Subject: [PATCH 067/116] feat(adoption): build the controls, authorised by the
snapshot not by arguments
A settable property on an adopted device now becomes a switch, a select or a
number rather than surfacing as a reading. Disabled and diagnostic like every
other adopted entity, so a control exists only where a user enables one.
The write goes through set_adopted_property, which resolves the property
against the current snapshot's adopted devices and publishes to the set topic
that property carries. No topic is accepted from the caller.
The alternative considered and rejected was a set_property_topic member on
SchemaAdapter. It would have put every curated control one argument away, and
two of them do real work on the way out: the islanding assertion translates
its value, and the charge ceiling refuses one above the commissioned limit. It
would also have been required of every adapter package, so an install with an
older adapter wheel would fail at discovery rather than losing one feature.
All five creators share one partition function so classify stays the only
place a platform is decided, asserted as a partition rather than per platform.
Nothing is translated or clamped on the way out. The declaration is all this
integration knows about an adopted property, and a NUMBER on an integer
property publishes 45 rather than 45.0.
---
CHANGELOG.md | 14 +-
custom_components/span_panel/adoption.py | 319 +++++++++++++++++---
custom_components/span_panel/diagnostics.py | 11 +-
custom_components/span_panel/manifest.json | 6 +-
custom_components/span_panel/number.py | 36 ++-
custom_components/span_panel/select.py | 17 +-
custom_components/span_panel/switch.py | 17 +-
developer.md | 39 ++-
pyproject.toml | 6 +-
tests/test_adoption.py | 138 ++++++++-
uv.lock | 6 +-
11 files changed, 522 insertions(+), 87 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6eddae3..cc3d1b9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,9 +30,17 @@ All notable changes to this project will be documented in this file.
whole notice on itself and teach you that the category is noise — which would cost you the curated additions too.
- **Diagnostics report which device types and properties were adopted, and never their values.** The same rule the declared-but-unread report follows: a
diagnostics attachment leaves the house, and the redaction that protects your config entry is key-based and knows nothing about wire property names.
-- **Controls are classified but not yet built.** A property the panel accepts writes to is recognised as a switch, a select or a number, and surfaces as a
- reading until the write path exists — every write this integration performs today goes through a curated, adapter-named topic, and a generic one is a change
- to the adapter contract with its own version bump. Diagnostics count how many properties are waiting on it, so the decision is made on a measurement.
+- **A property an adopted device accepts writes to becomes a control, not just a reading.** A declared `boolean` becomes a switch, an `enum` with its option
+ list becomes a select, and a number with its `min:max:step` becomes a number entity — all disabled and diagnostic like every other adopted entity, so a
+ control appears only if you go and enable it. A settable property that declares no value domain stays a reading: a select with no options and a number with no
+ bounds are broken controls, not safe ones.
+- **The write cannot reach a device this integration does model, by construction.** It is authorised by looking the property up in the current snapshot rather
+ than by its arguments, and a modelled device produces no adopted record to find. That matters because the curated controls do real work on the way out — the
+ islanding assertion translates its value, and the EV charge limit refuses one above what your charger was commissioned for — and a generic write would route
+ around both.
+- **Your panel stays the authority on the value.** Nothing is translated or clamped on the way out: this integration knows an adopted property's declaration and
+ nothing else, so inventing a bound would be inventing a fact about your hardware. The control constrains you to what the device declared, and the panel
+ accepts or refuses.
- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic
sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the
diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py
index 332e7203..b0a1a0c8 100644
--- a/custom_components/span_panel/adoption.py
+++ b/custom_components/span_panel/adoption.py
@@ -36,7 +36,10 @@
from typing import TYPE_CHECKING, Final
from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.components.number import NumberEntity
+from homeassistant.components.select import SelectEntity
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription
+from homeassistant.components.switch import SwitchEntity
from homeassistant.const import EntityCategory, Platform
from homeassistant.helpers.device_registry import DeviceInfo
from span_panel_api import AdoptedDevice, AdoptedProperty, SpanPanelSnapshot
@@ -119,19 +122,19 @@ def classify(declaration: AdoptedProperty) -> Platform:
CONTROL_PLATFORMS: Final = frozenset({Platform.SWITCH, Platform.SELECT, Platform.NUMBER})
-"""Platforms `classify` names that this integration cannot yet construct.
-
-Not a reversal of the rule -- `classify` is the rule and it is complete. A
-control needs a write, and every write this integration performs goes through a
-curated, adapter-named topic: `set_circuit_relay`, `set_circuit_priority`,
-`set_evse_charge_limit`. There is no generic property write, and adding one would
-put a new member on `SchemaAdapter`, whose required set is derived from the
-protocol itself -- so it would be required of every adapter package and would
-invalidate built adapter wheels.
-
-That is a contract change and belongs in its own one, with its own version bump.
-Until then a property that classifies as a control surfaces as a reading, and
-`adopted_control_count` is what makes the gap countable rather than invisible.
+"""The platforms `classify` names that write back to the panel.
+
+Built, since 2026-08-20. The write goes through `set_adopted_property`, whose
+authorisation is a snapshot lookup rather than its arguments: it resolves the
+property against the current `adopted_devices` and publishes to the topic that
+property carries. A device the adapter models produces no adopted record, so it
+cannot be addressed that way however the arguments are spelled -- which is what
+keeps this from becoming a generic write around `set_circuit_relay`,
+`set_circuit_priority` and `set_evse_charge_limit`.
+
+That mattered: two of those do real work on the way out. The islanding assertion
+translates its value, and the charge ceiling refuses one above what the charger
+was commissioned for.
"""
@@ -327,22 +330,187 @@ class AdoptedBinarySensor(AdoptedEntity, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Homie spells a boolean `true`/`false`; anything else is not an answer."""
- raw = self._published()
- if raw is None:
+ return _boolean(self._published())
+
+
+class AdoptedControl(AdoptedEntity):
+ """Base for an adopted entity that writes back to the panel.
+
+ The write is refused by the library unless the property is still there and
+ still settable, so nothing here re-checks it: a control for a device that has
+ left the tree raises rather than publishing into a topic nothing subscribes
+ to, and that is the correct outcome to surface.
+ """
+
+ def __init__(
+ self,
+ coordinator: SpanPanelCoordinator,
+ identifier: str,
+ device: AdoptedDevice,
+ declaration: AdoptedProperty,
+ *,
+ panel_device_id: str,
+ ) -> None:
+ """Remember the wire address this control publishes to."""
+ super().__init__(
+ coordinator, identifier, device, declaration, panel_device_id=panel_device_id
+ )
+ self._node_id = declaration.node_id
+ self._property_id = declaration.property_id
+
+ async def _publish(self, value: str) -> None:
+ """Write one value and refresh, or raise what the library raised.
+
+ No `hasattr` guard, unlike the curated controls. Those ask because a
+ transport may not implement an optional protocol at all; this entity only
+ exists because a v1.0 tree reported an adopted device, and that is the
+ same transport that carries the write.
+ """
+ await self.coordinator.client.set_adopted_property(
+ self._device_wire_id, self._node_id, self._property_id, value
+ )
+ await self.coordinator.async_request_refresh()
+
+
+class AdoptedSwitch(AdoptedControl, SwitchEntity):
+ """A declared `boolean` the panel accepts writes to."""
+
+ @property
+ def is_on(self) -> bool | None:
+ """Homie spells a boolean `true`/`false`; anything else is not an answer."""
+ return _boolean(self._published())
+
+ async def async_turn_on(self, **kwargs: object) -> None:
+ """Publish the vocabulary Homie defines for a boolean, not HA's."""
+ await self._publish("true")
+
+ async def async_turn_off(self, **kwargs: object) -> None:
+ """Publish the vocabulary Homie defines for a boolean, not HA's."""
+ await self._publish("false")
+
+
+class AdoptedSelect(AdoptedControl, SelectEntity):
+ """A declared `enum` the panel accepts writes to, with its declared options."""
+
+ def __init__(
+ self,
+ coordinator: SpanPanelCoordinator,
+ identifier: str,
+ device: AdoptedDevice,
+ declaration: AdoptedProperty,
+ *,
+ panel_device_id: str,
+ ) -> None:
+ """Take the option list from the declaration, which is the whole domain."""
+ super().__init__(
+ coordinator, identifier, device, declaration, panel_device_id=panel_device_id
+ )
+ self._attr_options = parse_enum_format(declaration.format)
+
+ @property
+ def current_option(self) -> str | None:
+ """The published value, but only when it is one of the declared options.
+
+ A value outside the declared set is reported as unknown rather than as a
+ selection. Home Assistant rejects a `current_option` outside `options`,
+ and quietly widening the list to admit whatever arrived would hide a
+ panel disagreeing with its own declaration.
+ """
+ published = self._published()
+ return published if published in self._attr_options else None
+
+ async def async_select_option(self, option: str) -> None:
+ """Publish the option verbatim -- it came from the panel's own list."""
+ await self._publish(option)
+
+
+class AdoptedNumber(AdoptedControl, NumberEntity):
+ """A declared numeric the panel accepts writes to, with its declared bounds."""
+
+ def __init__(
+ self,
+ coordinator: SpanPanelCoordinator,
+ identifier: str,
+ device: AdoptedDevice,
+ declaration: AdoptedProperty,
+ *,
+ panel_device_id: str,
+ ) -> None:
+ """Take the bounds from the declaration, which is what makes this a number."""
+ bounds = parse_number_format(declaration.format)
+ super().__init__(
+ coordinator, identifier, device, declaration, panel_device_id=panel_device_id
+ )
+ self._attr_native_min_value, self._attr_native_max_value, self._attr_native_step = bounds
+ self._attr_native_unit_of_measurement = declaration.unit
+ self._integral = declaration.datatype == "integer"
+
+ @property
+ def native_value(self) -> float | None:
+ """The published value as a number, or None when it is not one."""
+ published = self._published()
+ if published is None:
+ return None
+ try:
+ return float(published)
+ except ValueError:
+ _LOGGER.debug(
+ "Adopted %s published %r, which is not a number", self._declaration_path, published
+ )
return None
- lowered = raw.strip().lower()
- if lowered in ("true", "false"):
- return lowered == "true"
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Publish the value in the datatype the property declares.
+
+ An `integer` property gets an integer literal. Publishing `5.0` where the
+ declaration says `integer` is a payload outside the declared datatype,
+ and this library has no business sending one.
+ """
+ await self._publish(str(int(value)) if self._integral else str(value))
+
+
+def _boolean(published: str | None) -> bool | None:
+ """Return Homie's `true`/`false`, with anything else meaning no answer."""
+ if published is None:
return None
+ lowered = published.strip().lower()
+ if lowered in ("true", "false"):
+ return lowered == "true"
+ return None
+
+
+def parse_enum_format(declared: str | None) -> list[str]:
+ """Return the options a Homie `enum` `$format` lists.
+
+ Comma-separated, per Homie 5. An empty result means the declaration carried
+ no usable domain, which `classify` has already used to route the property to
+ a sensor -- so this never returns empty for a property that reached a select.
+ """
+ if not declared:
+ return []
+ return [option.strip() for option in declared.split(",") if option.strip()]
+
+
+def parse_number_format(declared: str | None) -> tuple[float, float, float]:
+ """Return the `min:max:step` a Homie numeric `$format` states.
+
+ Step defaults to 1 when the declaration gives only a range, which Homie
+ permits. `classify` has already required a format, so the two bounds are
+ present by the time this is called.
+ """
+ parts = (declared or "").split(":")
+ minimum = float(parts[0]) if len(parts) > 0 and parts[0] else 0.0
+ maximum = float(parts[1]) if len(parts) > 1 and parts[1] else 100.0
+ step = float(parts[2]) if len(parts) > 2 and parts[2] else 1.0
+ return minimum, maximum, step
def adopted_control_count(snapshot: SpanPanelSnapshot) -> int:
- """How many declared properties would be controls if this could build them.
+ """How many adopted properties this panel exposes as controls rather than readings.
- Zero on every panel that publishes no settable property on an unmodelled
- device, which is every panel seen so far. Non-zero is the signal that the
- generic write path `CONTROL_PLATFORMS` describes is worth its contract bump,
- measured rather than assumed.
+ Reported in diagnostics beside the device list. A control on a device nobody
+ modelled is the highest-consequence thing adoption creates, so the count is
+ worth having in the one artefact that reaches a maintainer.
"""
return sum(
1
@@ -361,15 +529,17 @@ def create_adopted_sensors(
) -> list[AdoptedSensor]:
"""Every adopted property that is not a declared boolean.
- Properties that classify as a control land here too, as readings, until the
- write path exists -- see `CONTROL_PLATFORMS`.
+ Everything `classify` routes to `SENSOR`: every property that is not a
+ declared boolean and not a settable one with a usable value domain.
"""
- return [
- AdoptedSensor(coordinator, identifier, device, declaration, panel_device_id=panel_device_id)
- for device, identifier in _adopted(snapshot, registry)
- for declaration in device.properties
- if classify(declaration) != Platform.BINARY_SENSOR
- ]
+ return _create(
+ AdoptedSensor,
+ coordinator,
+ snapshot,
+ registry,
+ Platform.SENSOR,
+ panel_device_id=panel_device_id,
+ )
def create_adopted_binary_sensors(
@@ -379,14 +549,91 @@ def create_adopted_binary_sensors(
*,
panel_device_id: str,
) -> list[AdoptedBinarySensor]:
- """Every adopted property declared `boolean` and not settable."""
+ """Every adopted property declared `boolean` that the panel accepts no write to."""
+ return _create(
+ AdoptedBinarySensor,
+ coordinator,
+ snapshot,
+ registry,
+ Platform.BINARY_SENSOR,
+ panel_device_id=panel_device_id,
+ )
+
+
+def create_adopted_switches(
+ coordinator: SpanPanelCoordinator,
+ snapshot: SpanPanelSnapshot,
+ registry: DeviceRegistry,
+ *,
+ panel_device_id: str,
+) -> list[AdoptedSwitch]:
+ """Every adopted property declared `boolean` and settable."""
+ return _create(
+ AdoptedSwitch,
+ coordinator,
+ snapshot,
+ registry,
+ Platform.SWITCH,
+ panel_device_id=panel_device_id,
+ )
+
+
+def create_adopted_selects(
+ coordinator: SpanPanelCoordinator,
+ snapshot: SpanPanelSnapshot,
+ registry: DeviceRegistry,
+ *,
+ panel_device_id: str,
+) -> list[AdoptedSelect]:
+ """Every adopted `enum` that is settable and declares its option list."""
+ return _create(
+ AdoptedSelect,
+ coordinator,
+ snapshot,
+ registry,
+ Platform.SELECT,
+ panel_device_id=panel_device_id,
+ )
+
+
+def create_adopted_numbers(
+ coordinator: SpanPanelCoordinator,
+ snapshot: SpanPanelSnapshot,
+ registry: DeviceRegistry,
+ *,
+ panel_device_id: str,
+) -> list[AdoptedNumber]:
+ """Every adopted numeric that is settable and declares its bounds."""
+ return _create(
+ AdoptedNumber,
+ coordinator,
+ snapshot,
+ registry,
+ Platform.NUMBER,
+ panel_device_id=panel_device_id,
+ )
+
+
+def _create[AdoptedT: AdoptedEntity](
+ entity_class: type[AdoptedT],
+ coordinator: SpanPanelCoordinator,
+ snapshot: SpanPanelSnapshot,
+ registry: DeviceRegistry,
+ platform: Platform,
+ *,
+ panel_device_id: str,
+) -> list[AdoptedT]:
+ """Build one platform's share of the adopted properties.
+
+ One partition function rather than five bodies, so `classify` stays the only
+ place a property's platform is decided. Five bodies would each restate the
+ predicate, and a property could then reach two platforms or none.
+ """
return [
- AdoptedBinarySensor(
- coordinator, identifier, device, declaration, panel_device_id=panel_device_id
- )
+ entity_class(coordinator, identifier, device, declaration, panel_device_id=panel_device_id)
for device, identifier in _adopted(snapshot, registry)
for declaration in device.properties
- if classify(declaration) == Platform.BINARY_SENSOR
+ if classify(declaration) is platform
]
diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py
index 811e94e2..cc95bc25 100644
--- a/custom_components/span_panel/diagnostics.py
+++ b/custom_components/span_panel/diagnostics.py
@@ -129,14 +129,13 @@ class AdoptedPropertyRow(TypedDict):
class AdoptionBlock(TypedDict):
"""The `adopted_devices` section.
- `pending_controls` is the measurement that decides whether a generic write
- path earns its contract bump -- how many declared properties would be
- controls if one existed. Zero on every panel seen so far, and the number
- stays invisible unless it is reported.
+ `controls` counts the adopted properties that write back to the panel rather
+ than only reporting. It is the highest-consequence thing adoption creates, so
+ it is worth a number in the one artefact that reaches a maintainer.
"""
count: int
- pending_controls: int
+ controls: int
devices: list[AdoptedDeviceRow]
@@ -150,7 +149,7 @@ def _adoption(snapshot: SpanPanelSnapshot) -> AdoptionBlock:
"""
return {
"count": len(snapshot.adopted_devices),
- "pending_controls": adopted_control_count(snapshot),
+ "controls": adopted_control_count(snapshot),
"devices": [
{
"device_type": device.device_type,
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 400717d8..9a7ca8d3 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,9 +22,9 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b5",
- "span-panel-api-schema-0==1.0.0b4",
- "span-panel-api-schema-1==0.1.0b5"
+ "span-panel-api==3.0.0b6",
+ "span-panel-api-schema-0==1.0.0b5",
+ "span-panel-api-schema-1==0.1.0b6"
],
"version": "2.1.0",
"zeroconf": [
diff --git a/custom_components/span_panel/number.py b/custom_components/span_panel/number.py
index ab62620d..1bd7f9bb 100644
--- a/custom_components/span_panel/number.py
+++ b/custom_components/span_panel/number.py
@@ -41,12 +41,14 @@
from homeassistant.const import UnitOfElectricCurrent
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from span_panel_api import EvseControlProtocol, SpanEvseSnapshot, SpanPanelSnapshot
from span_panel_api.exceptions import SpanPanelServerError
from . import SpanPanelConfigEntry
+from .adoption import AdoptedNumber, create_adopted_numbers
from .const import CONF_DEVICE_NAME, DOMAIN, USE_CIRCUIT_NUMBERS
from .coordinator import SpanPanelCoordinator
from .entity import SpanPanelEntity
@@ -281,15 +283,27 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data.coordinator
snapshot: SpanPanelSnapshot = coordinator.data
- async_add_entities(
- [
- SpanEvseNumber(coordinator, description, evse_id)
- for evse_id, evse in snapshot.evse.items()
- for description in EVSE_NUMBERS
- # The declaration is the gate, never the value: a charger that
- # declares the property settable and has not published one yet still
- # has the control, and a charger that publishes a value it does not
- # declare settable does not.
- if description.settable_fn(evse)
- ]
+ entities: list[SpanEvseNumber | AdoptedNumber] = [
+ SpanEvseNumber(coordinator, description, evse_id)
+ for evse_id, evse in snapshot.evse.items()
+ for description in EVSE_NUMBERS
+ # The declaration is the gate, never the value: a charger that
+ # declares the property settable and has not published one yet still
+ # has the control, and a charger that publishes a value it does not
+ # declare settable does not.
+ if description.settable_fn(evse)
+ ]
+
+ # Settable numerics on devices this integration models nothing for, whose
+ # bounds come from the declaration -- which is what made them numbers rather
+ # than readings in the first place.
+ entities.extend(
+ create_adopted_numbers(
+ coordinator,
+ snapshot,
+ dr.async_get(hass),
+ panel_device_id=config_entry.runtime_data.panel_device_id,
+ )
)
+
+ async_add_entities(entities)
diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py
index 22993eac..42f76e2d 100644
--- a/custom_components/span_panel/select.py
+++ b/custom_components/span_panel/select.py
@@ -7,13 +7,14 @@
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceNotFound
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot
from span_panel_api.exceptions import SpanPanelServerError
from . import SpanPanelConfigEntry
+from .adoption import AdoptedSelect, create_adopted_selects
from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitPriority
from .coordinator import SpanPanelCoordinator
from .entity import SpanPanelEntity
@@ -389,7 +390,7 @@ async def async_setup_entry(
# Get device name from config entry data
device_name = config_entry.data.get("device_name", config_entry.title)
- entities: list[SpanPanelCircuitsSelect] = []
+ entities: list[SpanPanelCircuitsSelect | AdoptedSelect] = []
for circuit_id, circuit_data in snapshot.circuits.items():
if not circuit_data.is_user_controllable:
@@ -411,4 +412,16 @@ async def async_setup_entry(
)
)
+ # Settable properties on devices this integration models nothing for.
+ # Disabled and diagnostic like every adopted entity: the panel authorises the
+ # write, and the user decides whether the control is one they want.
+ entities.extend(
+ create_adopted_selects(
+ coordinator,
+ coordinator.data,
+ dr.async_get(hass),
+ panel_device_id=config_entry.runtime_data.panel_device_id,
+ )
+ )
+
async_add_entities(entities)
diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py
index 7a368e1b..a8ea3e73 100644
--- a/custom_components/span_panel/switch.py
+++ b/custom_components/span_panel/switch.py
@@ -6,11 +6,12 @@
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from span_panel_api import SpanCircuitSnapshot, SpanPanelSnapshot
from . import SpanPanelConfigEntry
+from .adoption import AdoptedSwitch, create_adopted_switches
from .const import DOMAIN, USE_CIRCUIT_NUMBERS, CircuitRelayState
from .coordinator import SpanPanelCoordinator
from .entity import SpanPanelEntity
@@ -335,7 +336,7 @@ async def async_setup_entry(
# Get device name from config entry data
_device_name = config_entry.data.get("device_name", config_entry.title)
- entities: list[SpanPanelCircuitsSwitch] = []
+ entities: list[SpanPanelCircuitsSwitch | AdoptedSwitch] = []
for circuit_id, circuit_data in snapshot.circuits.items():
if not circuit_data.is_user_controllable:
@@ -351,4 +352,16 @@ async def async_setup_entry(
SpanPanelCircuitsSwitch(coordinator, circuit_id, circuit_data.name, _device_name)
)
+ # Settable properties on devices this integration models nothing for.
+ # Disabled and diagnostic like every adopted entity: the panel authorises the
+ # write, and the user decides whether the control is one they want.
+ entities.extend(
+ create_adopted_switches(
+ coordinator,
+ coordinator.data,
+ dr.async_get(hass),
+ panel_device_id=config_entry.runtime_data.panel_device_id,
+ )
+ )
+
async_add_entities(entities)
diff --git a/developer.md b/developer.md
index d473c1cf..9eef3d9d 100644
--- a/developer.md
+++ b/developer.md
@@ -311,9 +311,9 @@ persistence.
`classify_sub_device_identifier` returns `None` for any identifier carrying the `adopted` token, tested before its suffix rules — the anchor is vendor
vocabulary, and a device id ending in `pv` would otherwise classify as the panel's solar sub-device.
-### Controls are classified but not built
+### Controls
-`classify` implements the full rule, including the three control platforms:
+`classify` routes a declaration to a platform:
| Declaration | Platform |
| ---------------------------------- | --------------- |
@@ -324,14 +324,37 @@ vocabulary, and a device id ending in `pv` would otherwise classify as the panel
| anything else | `SENSOR` |
A settable property with no `format` falls back to a reading because `format` is the value domain: a select with no option list and a number with no bounds are
-broken controls, not safe ones.
+broken controls, not safe ones. A settable `boolean` needs none — its datatype states the domain in full.
-The three control platforms are in `CONTROL_PLATFORMS` and are **not constructed yet**. Every write this integration performs goes through a curated,
-adapter-named topic, and a generic property write would put a new member on `SchemaAdapter` — whose required set is derived from the protocol itself, so it
-would be required of every adapter package and would invalidate built adapter wheels. That is a contract change with its own version bump.
+All five creators share `_create`, so `classify` is the only place a property's platform is decided. `test_every_property_reaches_exactly_one_platform` asserts
+that as a partition, which is what five separate predicates could not guarantee.
-`adopted_control_count` reports how many declared properties are waiting on it, in diagnostics under `adopted_devices.pending_controls`, so the decision is made
-on a measurement rather than a guess.
+Controls are disabled and diagnostic like every other adopted entity. There is deliberately no second, weaker gate — no read-only mode for settable properties.
+Enabling an entity is a deliberate act, commanding it is a second one, the panel authorises the write regardless of what we create, and this integration already
+ships switches that open and close breakers.
+
+### The write, and why it is not a generic one
+
+`SpanMqttClient.set_adopted_property(device_id, node_id, property_id, value)` publishes the write. **The lookup is the authorisation**: it resolves the property
+against the current snapshot's `adopted_devices` and publishes to the `set_topic` that property carries. No topic is accepted from the caller.
+
+That matters because the obvious alternative — a `set_property_topic(device, node, property)` member on `SchemaAdapter` — would put every curated control one
+argument away, and two of them do real work on the way out:
+
+- `set_dominant_power_source` translates `GRID` into the `ON_GRID` the v1.0 islanding assertion accepts.
+- `set_evse_charge_limit` **refuses** a value above the commissioned ceiling, because publishing past it is the one write with a physical consequence.
+
+A generic write reachable at modelled devices routes around both. Because `set_topic` is populated only for settable properties on devices `is_modelled`
+rejected, a modelled device produces no `AdoptedDevice` and cannot be addressed this way however the arguments are spelled.
+
+It also kept the change additive. A new `SchemaAdapter` member is required of every adapter package, so an install carrying an older adapter wheel would fail at
+**discovery** — the whole integration, not one feature.
+
+No payload translation and no bounds check on the way out, deliberately: the library knows nothing about an adopted property beyond its declaration, and
+inventing a bound would be inventing a fact about somebody's hardware. The entity constrains the value to the declared domain and the panel stays the authority
+on whether to accept it. A `NUMBER` on an `integer` property publishes `45`, never `45.0`.
+
+Diagnostics report `adopted_devices.controls` — how many adopted properties write back rather than only reporting.
### The notice counts devices, not entities
diff --git a/pyproject.toml b/pyproject.toml
index e79e0a13..9c496798 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,13 +8,13 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b5",
+ "span-panel-api==3.0.0b6",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
# and that is the list Home Assistant actually reads.
- "span-panel-api-schema-0==1.0.0b4",
- "span-panel-api-schema-1==0.1.0b5",
+ "span-panel-api-schema-0==1.0.0b5",
+ "span-panel-api-schema-1==0.1.0b6",
]
[dependency-groups]
diff --git a/tests/test_adoption.py b/tests/test_adoption.py
index 6a3924f8..5ef7e20f 100644
--- a/tests/test_adoption.py
+++ b/tests/test_adoption.py
@@ -12,7 +12,7 @@
from dataclasses import replace
from pathlib import Path
from typing import TYPE_CHECKING
-from unittest.mock import MagicMock
+from unittest.mock import AsyncMock, MagicMock
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import EntityCategory, Platform
@@ -28,7 +28,10 @@
adopted_identifier,
classify,
create_adopted_binary_sensors,
+ create_adopted_numbers,
+ create_adopted_selects,
create_adopted_sensors,
+ create_adopted_switches,
resolve_identifier,
)
from custom_components.span_panel.const import DOMAIN
@@ -327,14 +330,8 @@ def test_a_curated_identifier_still_classifies(hass: HomeAssistant) -> None:
# -- The pending write path is counted rather than hidden --------------------
-def test_a_settable_property_is_counted_as_a_pending_control() -> None:
- """`classify` is the complete rule; the write path is what does not exist yet.
-
- Every write this integration performs goes through a curated, adapter-named
- topic, and a generic one would put a new member on `SchemaAdapter` -- required
- of every adapter package, invalidating built wheels. That is its own change.
- Counting the properties waiting on it is what decides whether it is worth one.
- """
+def test_controls_are_counted_for_diagnostics() -> None:
+ """A control on a device nobody modelled is the highest-consequence thing here."""
declarations = (
_property(datatype="boolean", settable=True, unit=None),
_property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None),
@@ -346,5 +343,126 @@ def test_a_settable_property_is_counted_as_a_pending_control() -> None:
assert classify(declarations[0]) in CONTROL_PLATFORMS
-def test_a_panel_with_no_adopted_device_counts_no_pending_controls() -> None:
+def test_a_panel_with_no_adopted_device_counts_no_controls() -> None:
assert adopted_control_count(_snapshot()) == 0
+
+
+# -- Controls are built, and their domain comes from the declaration ---------
+
+
+def _built(hass: HomeAssistant, *declarations: AdoptedProperty) -> dict[Platform, list[object]]:
+ """Every platform's share of one adopted device, keyed by platform."""
+ snapshot = _snapshot(_device(properties=declarations))
+ coordinator = MagicMock(data=snapshot)
+ registry = dr.async_get(hass)
+ kwargs = {"panel_device_id": "panel-device-id"}
+ return {
+ Platform.SENSOR: list(create_adopted_sensors(coordinator, snapshot, registry, **kwargs)),
+ Platform.BINARY_SENSOR: list(create_adopted_binary_sensors(coordinator, snapshot, registry, **kwargs)),
+ Platform.SWITCH: list(create_adopted_switches(coordinator, snapshot, registry, **kwargs)),
+ Platform.SELECT: list(create_adopted_selects(coordinator, snapshot, registry, **kwargs)),
+ Platform.NUMBER: list(create_adopted_numbers(coordinator, snapshot, registry, **kwargs)),
+ }
+
+
+def test_every_property_reaches_exactly_one_platform(hass: HomeAssistant) -> None:
+ """The partition `classify` defines, asserted as a partition.
+
+ Five creators sharing one predicate is what makes this hold. Five bodies each
+ restating it would let a property reach two platforms or none, and neither
+ failure shows up in a test that only counts one platform at a time.
+ """
+ declarations = (
+ _property(),
+ _property(node_id="relay", property_id="closed", datatype="boolean", unit=None),
+ _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True),
+ _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None),
+ _property(node_id="generator", property_id="setpoint", datatype="integer", fmt="0:100:5", settable=True),
+ )
+ built = _built(hass, *declarations)
+
+ assert [len(entities) for entities in built.values()] == [1, 1, 1, 1, 1]
+ assert sum(len(entities) for entities in built.values()) == len(declarations)
+
+
+def test_a_select_takes_its_options_from_the_declaration(hass: HomeAssistant) -> None:
+ declaration = _property(
+ node_id="generator", property_id="mode", datatype="enum", fmt="AUTO, MANUAL ,OFF", settable=True, unit=None
+ )
+ (entity,) = _built(hass, declaration)[Platform.SELECT]
+ assert entity.options == ["AUTO", "MANUAL", "OFF"]
+
+
+def test_a_select_reports_no_option_when_the_panel_publishes_one_it_never_declared(hass: HomeAssistant) -> None:
+ """Widening the list to admit whatever arrived would hide the disagreement.
+
+ Home Assistant rejects a `current_option` outside `options`, so the choice is
+ between reporting unknown and quietly rewriting the panel's own declaration.
+ """
+ declaration = _property(
+ node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None, value="ECO"
+ )
+ (entity,) = _built(hass, declaration)[Platform.SELECT]
+ assert entity.current_option is None
+ assert entity.options == ["AUTO", "OFF"]
+
+
+def test_a_number_takes_its_bounds_from_the_declaration(hass: HomeAssistant) -> None:
+ """The bounds are what made this a number rather than a reading."""
+ declaration = _property(
+ node_id="generator", property_id="setpoint", datatype="integer", fmt="10:80:5", settable=True, unit="%"
+ )
+ (entity,) = _built(hass, declaration)[Platform.NUMBER]
+ assert (entity.native_min_value, entity.native_max_value, entity.native_step) == (10.0, 80.0, 5.0)
+
+
+async def test_a_switch_publishes_the_vocabulary_homie_defines(hass: HomeAssistant) -> None:
+ """`true` and `false`, not Home Assistant's `on` and `off`."""
+ declaration = _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True)
+ snapshot = _snapshot(_device(properties=(declaration,)))
+ coordinator = MagicMock(data=snapshot)
+ coordinator.client.set_adopted_property = AsyncMock()
+ coordinator.async_request_refresh = AsyncMock()
+
+ (entity,) = create_adopted_switches(
+ coordinator, snapshot, dr.async_get(hass), panel_device_id="panel-device-id"
+ )
+ await entity.async_turn_on()
+
+ coordinator.client.set_adopted_property.assert_awaited_once_with("generator-1", "relay", "enabled", "true")
+
+
+async def test_a_number_publishes_an_integer_where_the_declaration_says_integer(hass: HomeAssistant) -> None:
+ """`5`, never `5.0`: a float literal is outside the declared datatype."""
+ declaration = _property(
+ node_id="generator", property_id="setpoint", datatype="integer", fmt="0:100:5", settable=True, unit=None
+ )
+ snapshot = _snapshot(_device(properties=(declaration,)))
+ coordinator = MagicMock(data=snapshot)
+ coordinator.client.set_adopted_property = AsyncMock()
+ coordinator.async_request_refresh = AsyncMock()
+
+ (entity,) = create_adopted_numbers(coordinator, snapshot, dr.async_get(hass), panel_device_id="panel-device-id")
+ await entity.async_set_native_value(45.0)
+
+ coordinator.client.set_adopted_property.assert_awaited_once_with("generator-1", "generator", "setpoint", "45")
+
+
+def test_a_control_is_disabled_and_diagnostic_like_every_other_adopted_entity(hass: HomeAssistant) -> None:
+ """Disabled-by-default is the gate, and it is the same gate for a reading.
+
+ A second, weaker gate for controls -- surfacing them read-only -- would be
+ inconsistent and would not add safety: enabling is a deliberate act,
+ commanding is a second one, and the panel authorises the write regardless.
+ """
+ declarations = (
+ _property(node_id="relay", property_id="enabled", datatype="boolean", unit=None, settable=True),
+ _property(node_id="generator", property_id="mode", datatype="enum", fmt="AUTO,OFF", settable=True, unit=None),
+ _property(node_id="generator", property_id="setpoint", datatype="integer", fmt="0:9:1", settable=True),
+ )
+ built = _built(hass, *declarations)
+ controls = built[Platform.SWITCH] + built[Platform.SELECT] + built[Platform.NUMBER]
+
+ assert len(controls) == 3
+ assert all(entity.entity_registry_enabled_default is False for entity in controls)
+ assert all(entity.entity_category is EntityCategory.DIAGNOSTIC for entity in controls)
diff --git a/uv.lock b/uv.lock
index f765a5cb..04f4fe2d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b5"
+version = "3.0.0b6"
source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
@@ -2587,7 +2587,7 @@ dev = [
[[package]]
name = "span-panel-api-schema-0"
-version = "1.0.0b4"
+version = "1.0.0b5"
source = { editable = "../../span/span-panel-api-p3/packages/schema-0" }
dependencies = [
{ name = "span-panel-api" },
@@ -2598,7 +2598,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap
[[package]]
name = "span-panel-api-schema-1"
-version = "0.1.0b5"
+version = "0.1.0b6"
source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
From 90aa8c86faabdefe1a22b3b6fbe8a868fe877186 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 12:20:18 -0700
Subject: [PATCH 068/116] feat(additions): announce added entities in a
notification that names them
Two problems with the previous new-entity notice.
It covered only entities added disabled, on the reasoning that an enabled one
is already visible in the entity list and its history. Nobody watches their
entity count, so that made every enabled addition invisible.
And it derived the diff from setup timing -- the registry before the platforms
against the registry after -- which answers correctly exactly once. On the
next startup the entity is already registered beforehand and the diff is empty
by construction, so anything not announced in that one window never was. What
is announced is now recorded in a Store, so the question is whether the user
has been told rather than whether it happened seconds ago.
It is a notification rather than a Repair because an addition is not a repair:
nothing is broken and nothing needs fixing. The retired issue is deleted at
setup, since it was raised is_persistent and would otherwise stand forever
with nothing left to re-derive it.
Translations are read from this component's own translations directory. The
Home Assistant helper filters to the categories it defines and a persistent
notification is not one of them -- a custom category loads as nothing, which
was verified rather than assumed.
Separately: adopted devices are now registered explicitly, before the
platforms. A vendor device publishing only an info node resolves entirely to
its device card, creates no entity, and so had nothing to create it -- it
produced no device, no entity and no notification at all.
---
CHANGELOG.md | 14 +
custom_components/span_panel/__init__.py | 39 +-
custom_components/span_panel/additions.py | 254 ++++++++++++
custom_components/span_panel/adoption.py | 38 ++
.../span_panel/schema_repairs.py | 202 ++--------
custom_components/span_panel/strings.json | 15 +-
.../span_panel/translations/en.json | 15 +-
.../span_panel/translations/es.json | 15 +-
.../span_panel/translations/fr.json | 15 +-
.../span_panel/translations/ja.json | 15 +-
.../span_panel/translations/pt.json | 15 +-
developer.md | 39 +-
tests/test_adoption.py | 91 +++++
tests/test_new_entity_announcement.py | 268 +++++++++++++
tests/test_new_entity_notice.py | 364 ------------------
tests/test_setup_entry.py | 46 +--
16 files changed, 834 insertions(+), 611 deletions(-)
create mode 100644 custom_components/span_panel/additions.py
create mode 100644 tests/test_new_entity_announcement.py
delete mode 100644 tests/test_new_entity_notice.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc3d1b9a..c08598b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,20 @@ All notable changes to this project will be documented in this file.
### Added
+- **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were
+ mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity
+ list, which nobody is: an addition that breaks nothing was indistinguishable from no addition at all. The notification names every entity that was added,
+ splits them by whether they are ready to use or still switched off, and tells you where to turn the switched-off ones on.
+- **It is a notification rather than a Repair, because an addition is not a repair.** Nothing is broken and nothing needs fixing. Any new-entity item still
+ sitting in your Repairs list from a previous version is removed on upgrade.
+- **And it can no longer be missed by a restart landing in the wrong place.** The old notice compared the entity registry before and after startup, which
+ answers correctly exactly once — on the next startup the entity is already registered and the comparison is empty. What has been announced is now recorded, so
+ the question is whether you have been told rather than whether it happened in the last few seconds. It stays quiet on a first install, and quiet once more on
+ the first startup after this change, so upgrading does not announce a release's worth of history.
+- **An adopted device now appears even when it publishes no readings at all.** A device that advertises what it is — its vendor, model and firmware — before
+ publishing any measurement resolves entirely to its device card, so it created no entities, and because devices were created as a side effect of entity
+ creation it produced nothing whatsoever: no device, no entity, no notification. It is now registered in its own right.
+
- **A device the panel publishes that this integration has never modelled now appears, instead of appearing nowhere.** SPAN positions the panel as the hub for
whatever plugs into it and the eBus schema is explicitly vendor-extensible, so a device type nobody modelled is an expected arrival — and until now it
produced no device, no entity and no sign it was there. Such a device now gets a card of its own hanging off the panel, carrying whatever `info` it publishes,
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index 741e2d71..aa5c298d 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -28,6 +28,8 @@
# Import config flow to ensure it's registered
from . import config_flow # noqa: F401
+from .additions import async_announce_new_entities, async_forget_announcements
+from .adoption import async_register_adopted_devices
from .const import (
CONF_API_VERSION,
CONF_EBUS_BROKER_HOST,
@@ -52,11 +54,7 @@
from .graph_horizon import GraphHorizonManager
from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401
from .options import SNAPSHOT_UPDATE_INTERVAL
-from .schema_repairs import (
- async_clear_schema_issues,
- async_notice_new_disabled_entities,
- async_registered_unique_ids,
-)
+from .schema_repairs import async_clear_retired_new_entity_notices, async_clear_schema_issues
from .services import ( # noqa: F401
_async_register_favorites_services,
_async_register_graph_horizon_services,
@@ -261,10 +259,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
),
)
- # Taken before the forward, because forwarding is what registers the
- # entities: everything absent here and present afterwards is an entity
- # this version of the integration added.
- known_unique_ids = async_registered_unique_ids(hass, entry)
+ # Before the forward, because a sub-device's `via_device_id` has to name a
+ # device that already exists -- and because an adopted device whose whole
+ # declaration is an `info` node has no entity to be created by.
+ async_register_adopted_devices(
+ hass, entry.entry_id, snapshot, panel_device_id=entry.runtime_data.panel_device_id
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -276,9 +276,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
# Also after the platforms, for the other half of the same reason: a
# newly added entity is only in the registry once its platform has added
- # it. An addition that arrives disabled reaches the user through nothing
- # else at all.
- async_notice_new_disabled_entities(hass, entry, known_unique_ids)
+ # it. Nobody watches their entity count, so an addition that breaks
+ # nothing reaches the user through this and nothing else -- whether or
+ # not it arrived switched on.
+ async_clear_retired_new_entity_notices(hass, entry)
+ await async_announce_new_entities(hass, entry)
except Exception:
if coordinator is not None:
await coordinator.async_shutdown()
@@ -310,14 +312,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -
async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> None:
- """Clean up the Repairs this entry raised.
+ """Clean up what this entry left outside its own runtime data.
Core deletes neither issues nor their dismissals when a config entry is
- removed, so a panel that is taken out of the system would otherwise leave
- its schema notices behind forever. Scoped to this entry: another panel's
- issues share the domain and must survive.
+ removed, so a panel taken out of the system would otherwise leave its schema
+ notices behind forever. Scoped to this entry: another panel's issues share the
+ domain and must survive.
+
+ The announcement record goes with them, and for a sharper reason: it outliving
+ the entry would mean re-adding the same panel announces none of the entities
+ it recreates, because every one of them is already recorded as announced.
"""
async_clear_schema_issues(hass, entry)
+ await async_forget_announcements(hass, entry)
async def async_remove_config_entry_device(
diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py
new file mode 100644
index 00000000..83a87681
--- /dev/null
+++ b/custom_components/span_panel/additions.py
@@ -0,0 +1,254 @@
+"""Telling the user what this integration just added.
+
+Split out of `schema_repairs` deliberately. An addition is not a repair: nothing
+is broken, nothing needs fixing, and filing it in the Repairs list puts it in a
+category whose whole meaning is "something wants your attention because it went
+wrong". It is a notification, and it says what was added rather than only how
+many.
+
+Two things this fixes about the previous behaviour.
+
+**Enabled additions were announced to nobody.** The old notice covered only
+entities added `disabled_by=INTEGRATION`, on the reasoning that an enabled entity
+is already visible in the entity list and its history. That reasoning does not
+survive contact with how anyone actually uses Home Assistant: nobody watches
+their entity count, so an addition that breaks nothing is indistinguishable from
+no addition at all.
+
+**The diff was derived from setup timing, so it was unrepeatable.** It compared
+the registry before the platforms against the registry after, which answers
+correctly exactly once: on the next startup the entity is already registered
+beforehand and the diff is empty by construction. Anything not announced in that
+one window was never announced. What is announced is now recorded, so the
+question becomes "has this been announced" rather than "was this registered in
+the last few seconds", and nothing is lost to a restart landing in the wrong
+place.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Final
+
+from homeassistant.components.persistent_notification import async_create
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.storage import Store
+
+from .const import DOMAIN
+from .util import ADOPTED_IDENTIFIER_TOKEN
+
+if TYPE_CHECKING:
+ from homeassistant.config_entries import ConfigEntry
+ from homeassistant.core import HomeAssistant
+
+_LOGGER = logging.getLogger(__name__)
+
+_STORE_VERSION: Final = 1
+_ANNOUNCED: Final = "announced_unique_ids"
+
+_FALLBACK: Final[dict[str, str]] = {
+ "title": "SPAN Panel added new entities",
+ "intro_one": "This update added 1 new entity to your SPAN Panel.",
+ "intro_many": "This update added {count} new entities to your SPAN Panel.",
+ "enabled_heading": "Added and ready to use",
+ "disabled_heading": "Added but switched off",
+ "how_to_enable": (
+ "Switched-off entities record nothing until you turn them on. Open the SPAN Panel "
+ "device page, show its disabled entities, and enable the ones you want."
+ ),
+ "nothing_broken": "Nothing is broken and no action is required.",
+}
+"""English text, used when a translation file cannot be read.
+
+A fallback rather than the source of truth: `strings.json` and `translations/`
+carry the same keys in five languages, and `_text` prefers those. Kept in code so
+a notification is never *lost* to an unreadable file -- the addition still gets
+announced, in English.
+"""
+
+
+def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]:
+ """Return the record of what has already been announced for this entry.
+
+ Per entry rather than per domain: two panels add entities independently, and
+ a shared record would let one panel's announcement suppress the other's.
+ """
+ return Store(hass, _STORE_VERSION, f"{DOMAIN}.announced.{entry.entry_id}")
+
+
+def _read_translations(language: str) -> dict[str, str]:
+ """Our own notification strings for one language, or an empty mapping.
+
+ Read from this component's `translations/` directory rather than through
+ `homeassistant.helpers.translation`, because that helper filters to the
+ categories Home Assistant defines and a persistent notification is not one of
+ them -- a custom category loads as nothing at all. These are this
+ integration's own package files, so reading them is not reaching into
+ somebody else's layout.
+
+ Blocking file I/O. Callers run it in an executor.
+ """
+ directory = Path(__file__).parent / "translations"
+ for candidate in (f"{language}.json", f"{language.split('-')[0]}.json", "en.json"):
+ path = directory / candidate
+ if not path.is_file():
+ continue
+ try:
+ loaded = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ _LOGGER.debug("Could not read notification strings from %s", path, exc_info=True)
+ continue
+ section = loaded.get("notifications", {}).get("new_entities", {})
+ if section:
+ return {str(key): str(value) for key, value in section.items()}
+ return {}
+
+
+async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Tell the user which entities this setup added, and which need enabling.
+
+ Silent on a first install. With nothing recorded and nothing registered, every
+ entity is new, so the notification would name the entire integration and teach
+ the user that this category is noise -- which would cost them the real
+ additions later.
+
+ Silent, once, on the first run after this mechanism ships. An install that
+ predates the record has entities that were never announced but are not new
+ either, so the first pass adopts them as already-known rather than announcing
+ a release's worth of history.
+ """
+ store = _store(hass, entry)
+ stored = await store.async_load()
+ registered = _registered(hass, entry)
+
+ if stored is None:
+ await store.async_save({_ANNOUNCED: sorted(registered)})
+ _LOGGER.debug(
+ "Seeded the announcement record for %s with %d entities; nothing announced",
+ entry.entry_id,
+ len(registered),
+ )
+ return
+
+ announced = frozenset(stored.get(_ANNOUNCED, ()))
+ added = [
+ registry_entry
+ for registry_entry in _entries(hass, entry)
+ if registry_entry.unique_id not in announced
+ ]
+ if not added:
+ return
+
+ text = await hass.async_add_executor_job(_read_translations, hass.config.language)
+ async_create(
+ hass,
+ message=_message(hass, added, text),
+ title=_text(text, "title"),
+ notification_id=f"{DOMAIN}_new_entities_{entry.entry_id}",
+ )
+ await store.async_save({_ANNOUNCED: sorted(announced | registered)})
+ _LOGGER.debug("Announced %d new entities for %s", len(added), entry.entry_id)
+
+
+def _entries(hass: HomeAssistant, entry: ConfigEntry) -> list[er.RegistryEntry]:
+ return er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
+
+
+def _registered(hass: HomeAssistant, entry: ConfigEntry) -> frozenset[str]:
+ return frozenset(registry_entry.unique_id for registry_entry in _entries(hass, entry))
+
+
+def _text(text: dict[str, str], key: str, **placeholders: object) -> str:
+ """One string, translated where a file supplied it and English where not."""
+ template = text.get(key) or _FALLBACK[key]
+ return template.format(**placeholders) if placeholders else template
+
+
+def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str, str]) -> str:
+ """Build the notification body: what was added, split by whether it is switched on.
+
+ The split is the actionable part. An enabled entity is already recording and
+ needs nothing; a disabled one records nothing at all until the user turns it
+ on, and saying so is the difference between a notice they can act on and one
+ they can only acknowledge.
+ """
+ devices = dr.async_get(hass)
+ enabled: list[str] = []
+ disabled: list[str] = []
+ adopted: dict[str, int] = {}
+
+ for registry_entry in added:
+ device_name = _adopted_device_name(devices, registry_entry)
+ if device_name is not None:
+ adopted[device_name] = adopted.get(device_name, 0) + 1
+ continue
+ target = (
+ disabled
+ if registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
+ else enabled
+ )
+ target.append(_label(registry_entry))
+
+ # Two keys rather than one with a plural placeholder: a plural rule the caller
+ # picks a *word* for is an English rule, and the five languages here do not
+ # share it.
+ intro = (
+ _text(text, "intro_one") if len(added) == 1 else _text(text, "intro_many", count=len(added))
+ )
+ lines = [intro, ""]
+ if enabled:
+ lines += [f"**{_text(text, 'enabled_heading')}**", ""]
+ lines += [f"- {label}" for label in sorted(enabled)]
+ lines.append("")
+ if adopted:
+ lines += [f"- {name} ({count} entities)" for name, count in sorted(adopted.items())]
+ lines.append("")
+ if disabled:
+ lines += [f"**{_text(text, 'disabled_heading')}**", ""]
+ lines += [f"- {label}" for label in sorted(disabled)]
+ lines += ["", _text(text, "how_to_enable"), ""]
+ lines.append(_text(text, "nothing_broken"))
+ return "\n".join(lines)
+
+
+def _adopted_device_name(
+ devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry
+) -> str | None:
+ """Return the adopted device this entity belongs to, or None when it belongs to none.
+
+ An adopted device's entities are collapsed to one line with a count. A vendor
+ device declaring a dozen properties would otherwise spend the whole
+ notification on itself and teach the user to skip it -- which would cost them
+ the curated additions in the same message.
+ """
+ if registry_entry.device_id is None:
+ return None
+ device = devices.async_get(registry_entry.device_id)
+ if device is None:
+ return None
+ token = f"_{ADOPTED_IDENTIFIER_TOKEN}_"
+ if not any(token in identifier for _domain, identifier in device.identifiers):
+ return None
+ return device.name_by_user or device.name or registry_entry.entity_id
+
+
+def _label(registry_entry: er.RegistryEntry) -> str:
+ """Return what to call an entity the user has never seen.
+
+ A disabled entity has no state, so there is no friendly name on the state
+ machine -- only what the registry recorded when the platform added it. The
+ entity_id is the last resort rather than the first choice, because it is the
+ name the user will *not* see in the device's entity list.
+ """
+ return registry_entry.name or registry_entry.original_name or registry_entry.entity_id
+
+
+async def async_forget_announcements(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Drop the announcement record when the entry is removed.
+
+ Without this the record outlives the entry it describes, and re-adding the
+ same panel would suppress the announcement of every entity it recreates.
+ """
+ await _store(hass, entry).async_remove()
diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py
index b0a1a0c8..2c4bee52 100644
--- a/custom_components/span_panel/adoption.py
+++ b/custom_components/span_panel/adoption.py
@@ -41,6 +41,7 @@
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import EntityCategory, Platform
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from span_panel_api import AdoptedDevice, AdoptedProperty, SpanPanelSnapshot
@@ -49,6 +50,7 @@
from .util import ADOPTED_IDENTIFIER_TOKEN
if TYPE_CHECKING:
+ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceRegistry
from .coordinator import SpanPanelCoordinator
@@ -229,6 +231,42 @@ def _humanised(wire_token: str) -> str:
return wire_token.replace("-", " ").replace("_", " ").title()
+def async_register_adopted_devices(
+ hass: HomeAssistant,
+ entry_id: str,
+ snapshot: SpanPanelSnapshot,
+ *,
+ panel_device_id: str,
+) -> None:
+ """Create a device-registry entry for every adopted device, before any entity.
+
+ Registered explicitly rather than left to fall out of entity creation, and the
+ reason is a device that has no entities to fall out of. A vendor device that
+ publishes only an `info` node -- advertising what it is before it publishes
+ any reading -- resolves entirely to the device card by the node rule, so it
+ creates no entity, so nothing ever calls `async_get_or_create` for it. It used
+ to produce *nothing at all*: no device, no entity, and no notice. Which is the
+ silence adoption exists to end, reached by a different route.
+
+ It also fixes the identity freeze in one place. `resolve_identifier` reads the
+ registry to decide which spelling this install already uses, so running it
+ here -- once, before the platforms -- means every entity created afterwards
+ resolves against a device that already exists and cannot disagree.
+
+ Called before the platforms are forwarded, for the same reason the panel's own
+ registration is: a sub-device's `via_device_id` has to name a device that
+ exists.
+ """
+ registry = dr.async_get(hass)
+ for device in snapshot.adopted_devices:
+ identifier = resolve_identifier(registry, snapshot.serial_number, device)
+ registry.async_get_or_create(
+ config_entry_id=entry_id,
+ **adopted_device_info(identifier, device, panel_device_id=panel_device_id),
+ )
+ _LOGGER.debug("Registered adopted device %s as %s", device.device_type, identifier)
+
+
class AdoptedEntity(SpanPanelEntity):
"""Base for an entity built from a declaration rather than from a description.
diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py
index 2b2d1c1f..e7184da5 100644
--- a/custom_components/span_panel/schema_repairs.py
+++ b/custom_components/span_panel/schema_repairs.py
@@ -25,17 +25,15 @@
from __future__ import annotations
-from collections.abc import Collection, Mapping
-import hashlib
+from collections.abc import Mapping
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import device_registry as dr, entity_registry as er, issue_registry as ir
+from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN, EVENT_SCHEMA_ISSUE
from .schema_validation import SchemaFindings
-from .util import ADOPTED_IDENTIFIER_TOKEN
_LOGGER = logging.getLogger(__name__)
@@ -45,6 +43,13 @@
# new-entity notice is deliberately not among them. See `_scoped_issue_ids`.
_DEFECT_PREFIXES = ("unresolved_", "unit_mismatch_")
_NEW_ENTITIES_PREFIX = "new_entities_"
+"""Id prefix of the retired new-entity Repair.
+
+Nothing raises one any more -- an addition is not a repair, and it is announced
+as a notification by `additions` instead. The prefix survives so the ones already
+standing on upgraded installs get cleared: they were raised `is_persistent`, so
+without this they would outlive the mechanism that made them.
+"""
def _unresolved_id(entry_id: str, field_path: str) -> str:
@@ -55,25 +60,6 @@ def _unit_id(entry_id: str, field_path: str) -> str:
return f"unit_mismatch_{entry_id}_{field_path}"
-def _new_entities_id(entry_id: str, unique_ids: Collection[str]) -> str:
- """One id per (entry, exact set of new entities).
-
- Keyed on the set and not on the entry alone for the reason the two defect
- notices are keyed per field path: `async_get_or_create`'s update branch
- preserves `dismissed_version` while replacing the placeholders, so an entry-
- wide id would let a user who dismissed "Part Number appeared" never be told
- about the next addition — it would silently rewrite the notice they already
- put away.
-
- Truncated to 12 hex characters. The digest only has to separate one set from
- another within a single config entry, and the id ends up in a storage file a
- human occasionally reads.
- """
- joined = "\n".join(sorted(unique_ids))
- digest = hashlib.sha256(joined.encode()).hexdigest()[:12]
- return f"{_NEW_ENTITIES_PREFIX}{entry_id}_{digest}"
-
-
def _affected(entity_ids_by_path: Mapping[str, list[str]], field_path: str) -> list[str]:
"""Entities in hass that read this field, sorted.
@@ -201,161 +187,6 @@ def async_sync_schema_issues(
@callback
-def async_registered_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> frozenset[str]:
- """Return the unique_ids already registered for this entry.
-
- Taken by `async_setup_entry` immediately before the platforms are forwarded,
- which is the last moment "already registered" still means "registered by an
- earlier run". Unique_ids rather than entity_ids: an entity_id is the user's
- to rename, a unique_id is the identity the registry itself keys on.
- """
- return frozenset(
- registry_entry.unique_id
- for registry_entry in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
- )
-
-
-def _label(registry_entry: er.RegistryEntry) -> str:
- """Return what to call an entity the user has never seen.
-
- A disabled entity has no state, so there is no friendly name to read off the
- state machine — only what the registry recorded when the platform added it.
- The entity_id is the last resort rather than the first choice because it is
- the name the user will *not* see in the device's disabled-entity list.
- """
- return registry_entry.name or registry_entry.original_name or registry_entry.entity_id
-
-
-def _labels(hass: HomeAssistant, new_disabled: list[er.RegistryEntry]) -> list[str]:
- """Return what to call the new entities, with adopted ones counted rather than listed.
-
- A curated addition is a handful of entities a maintainer chose, so naming each
- one is the notice doing its job. An adopted device is the opposite shape:
- every property a vendor device declares becomes an entity at once, so listing
- them would spend the whole notice on one device and teach the user that this
- category is noise -- which would cost them the curated additions too.
-
- So an adopted device contributes exactly one line carrying its own count, and
- the notice reads "Backup Generator (6 entities)" beside whatever else the
- release added.
- """
- devices = dr.async_get(hass)
- adopted: dict[str, int] = {}
- labels: list[str] = []
- for registry_entry in new_disabled:
- device_name = _adopted_device_name(devices, registry_entry)
- if device_name is None:
- labels.append(_label(registry_entry))
- else:
- adopted[device_name] = adopted.get(device_name, 0) + 1
- labels.extend(f"{name} ({count} entities)" for name, count in adopted.items())
- return sorted(labels)
-
-
-def _adopted_device_name(
- devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry
-) -> str | None:
- """Return the adopted device this entity belongs to, or None when it belongs to none.
-
- Read off the device identifier rather than off the entity, because the entity
- carries nothing that distinguishes an adopted reading from a curated one --
- by design, since an adopted entity is meant to be indistinguishable once it
- is enabled.
- """
- if registry_entry.device_id is None:
- return None
- device = devices.async_get(registry_entry.device_id)
- if device is None:
- return None
- token = f"_{ADOPTED_IDENTIFIER_TOKEN}_"
- if not any(token in identifier for _domain, identifier in device.identifiers):
- return None
- return device.name_by_user or device.name or registry_entry.entity_id
-
-
-@callback
-def async_notice_new_disabled_entities(
- hass: HomeAssistant,
- entry: ConfigEntry,
- known_unique_ids: Collection[str],
-) -> None:
- """Tell the user about entities this setup added and disabled.
-
- A release that adds a diagnostic with `entity_registry_enabled_default=False`
- grows nobody's entity list, which is the point — and is also why the addition
- reaches the user through nothing at all. This is the notice for that: the
- entities exist, they are switched off, and here is what they are called.
-
- Silent on a first install. With nothing registered beforehand every entity is
- new, so the notice would name the entire integration and teach the user to
- ignore it. An empty `known_unique_ids` is the probe for that:
- `er.async_entries_for_config_entry` answers with nothing for an entry that
- has never registered anything.
-
- An EVENT, not a condition, and that shapes three decisions:
-
- * `is_persistent=True`, unlike the two defect notices. Those are re-derived
- from live state at every startup, so they can afford to reload as
- tombstones. This one cannot be re-derived at all: on the next startup the
- entity is in `known_unique_ids` and the diff is empty by construction. A
- non-persistent issue would therefore vanish unread at the first restart
- after the upgrade, which for a user who was away is the same silent add
- the notice exists to prevent.
- * It is never reconciled away. `async_sync_schema_issues` deletes the defect
- ids it does not re-derive, and applying that here would delete this notice
- on the very next startup. Its reconcile scope is `_DEFECT_PREFIXES`, which
- deliberately does not include this one.
- * Raising is skipped outright when the id already exists. What that buys is
- narrow and worth stating exactly: a repeat of the same set cannot rewrite
- the text of a notice the user has already read. Without it the repeat would
- take `async_get_or_create`'s update branch, which replaces the placeholders
- — so a set that came back with a renamed entity would silently restate
- itself. It buys nothing against duplication, which the shared id already
- rules out.
-
- Severity is the mildest Home Assistant offers. `IssueSeverity` has no
- informational member — it is CRITICAL, ERROR, WARNING — so WARNING is the
- floor, the same floor `panel_upgraded_to_ebus_v1` settled on for the same
- reason: nothing is broken and no action is required.
- """
- if not known_unique_ids:
- _LOGGER.debug(
- "Suppressed new-entity notice for %s: nothing was registered before this "
- "setup, so this is a first install and every entity is new",
- entry.entry_id,
- )
- return
-
- new_disabled = [
- registry_entry
- for registry_entry in er.async_entries_for_config_entry(er.async_get(hass), entry.entry_id)
- if registry_entry.unique_id not in known_unique_ids
- and registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
- ]
- if not new_disabled:
- return
-
- issue_id = _new_entities_id(
- entry.entry_id, [registry_entry.unique_id for registry_entry in new_disabled]
- )
- if ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None:
- _LOGGER.debug("New-entity notice %s already raised; leaving it alone", issue_id)
- return
-
- labels = _labels(hass, new_disabled)
- _LOGGER.debug("Raising new-entity notice %s for %s", issue_id, labels)
- ir.async_create_issue(
- hass,
- DOMAIN,
- issue_id,
- is_fixable=False,
- is_persistent=True,
- severity=ir.IssueSeverity.WARNING,
- translation_key="new_entities_disabled",
- translation_placeholders=_placeholders(labels),
- )
-
-
def _scoped_issue_ids(
registry: ir.IssueRegistry, entry_id: str, prefixes: tuple[str, ...]
) -> set[str]:
@@ -380,6 +211,21 @@ def _scoped_issue_ids(
}
+@callback
+def async_clear_retired_new_entity_notices(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Delete new-entity Repairs raised before additions became notifications.
+
+ They were raised `is_persistent=True` precisely so a restart could not sweep
+ them away, which now means an upgraded install keeps one standing in its
+ Repairs list forever with nothing left to re-derive it. Cleared at setup
+ rather than at removal, because the user is looking at it now.
+ """
+ registry = ir.async_get(hass)
+ for issue_id in _scoped_issue_ids(registry, entry.entry_id, (_NEW_ENTITIES_PREFIX,)):
+ _LOGGER.debug("Clearing retired new-entity notice %s", issue_id)
+ ir.async_delete_issue(hass, DOMAIN, issue_id)
+
+
@callback
def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove one entry's issues. Core does not do this on entry removal.
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 47968d20..10936f33 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "new_entities_disabled": {
- "title": "New SPAN Panel entities are available",
- "description": "New entities added and left **disabled**: {count} (for example: {examples}). Nothing appeared in your dashboards or history uninvited.\n\nThis count covers only entities that were switched off. Anything added enabled is already visible in your entity list and history.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
- },
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
"description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look."
@@ -781,5 +777,16 @@
"title": "SPAN Panel unit mismatch",
"description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
}
+ },
+ "notifications": {
+ "new_entities": {
+ "title": "SPAN Panel added new entities",
+ "intro_one": "This update added 1 new entity to your SPAN Panel.",
+ "intro_many": "This update added {count} new entities to your SPAN Panel.",
+ "enabled_heading": "Added and ready to use",
+ "disabled_heading": "Added but switched off",
+ "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.",
+ "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online."
+ }
}
}
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 47968d20..10936f33 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "new_entities_disabled": {
- "title": "New SPAN Panel entities are available",
- "description": "New entities added and left **disabled**: {count} (for example: {examples}). Nothing appeared in your dashboards or history uninvited.\n\nThis count covers only entities that were switched off. Anything added enabled is already visible in your entity list and history.\n\nEntities can appear for several reasons: an integration update that exposes more of what your panel reports, a circuit added to the panel, or hardware such as a battery or solar coming online. Nothing is broken and no action is required — this notice exists only so the addition is not silent.\n\nTo use one, open the SPAN Panel device page, show its disabled entities, and enable the ones you want. Dismiss this once you have had a look."
- },
"panel_upgraded_to_ebus_v1": {
"title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
"description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look."
@@ -781,5 +777,16 @@
"title": "SPAN Panel unit mismatch",
"description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
}
+ },
+ "notifications": {
+ "new_entities": {
+ "title": "SPAN Panel added new entities",
+ "intro_one": "This update added 1 new entity to your SPAN Panel.",
+ "intro_many": "This update added {count} new entities to your SPAN Panel.",
+ "enabled_heading": "Added and ready to use",
+ "disabled_heading": "Added but switched off",
+ "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.",
+ "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online."
+ }
}
}
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index b572d0a1..492ba61a 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "new_entities_disabled": {
- "title": "Hay nuevas entidades del Panel SPAN disponibles",
- "description": "Entidades nuevas creadas **deshabilitadas**: {count} (por ejemplo: {examples}). No ha aparecido nada en sus paneles de control ni en su historial sin haberlo pedido.\n\nEste recuento incluye solo las entidades que se crearon desactivadas. Todo lo que se añade habilitado ya es visible en su lista de entidades y en su historial.\n\nPueden aparecer entidades nuevas por varios motivos: una actualización de la integración que expone más de lo que informa su panel, un circuito añadido al panel, o hardware como una batería o energía solar que se pone en marcha. No hay nada averiado ni es necesaria ninguna acción: este aviso existe solo para que la novedad no pase desapercibida.\n\nPara usar alguna, abra la página del dispositivo Panel SPAN, muestre sus entidades deshabilitadas y habilite las que desee. Descarte este aviso cuando lo haya revisado."
- },
"panel_upgraded_to_ebus_v1": {
"title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred",
"description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0. Nada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha mejorado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. La v1.0 no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como la v1.0 determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba.\n\nPuede descartar este aviso cuando lo haya revisado."
@@ -781,5 +777,16 @@
"title": "Discrepancia de unidades del Panel SPAN",
"description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Entidades afectadas: {count} (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente."
}
+ },
+ "notifications": {
+ "new_entities": {
+ "title": "El Panel SPAN ha añadido nuevas entidades",
+ "intro_one": "Esta actualización añadió 1 entidad nueva a tu Panel SPAN.",
+ "intro_many": "Esta actualización añadió {count} entidades nuevas a tu Panel SPAN.",
+ "enabled_heading": "Añadidas y listas para usar",
+ "disabled_heading": "Añadidas pero desactivadas",
+ "how_to_enable": "Las entidades desactivadas no registran nada hasta que las actives. Abre la página del dispositivo Panel SPAN, muestra sus entidades desactivadas y activa las que quieras.",
+ "nothing_broken": "No hay ningún problema y no se requiere ninguna acción. Las entidades aparecen por varios motivos: una actualización que expone más de lo que informa tu panel, un circuito añadido al panel, o hardware como una batería, paneles solares o un cargador que se pone en línea."
+ }
}
}
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 3fce2ae9..438e943c 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "new_entities_disabled": {
- "title": "De nouvelles entités du Panneau SPAN sont disponibles",
- "description": "Nouvelles entités créées **désactivées** : {count} (par exemple : {examples}). Rien n'est apparu dans vos tableaux de bord ni dans votre historique sans votre accord.\n\nCe décompte ne couvre que les entités créées désactivées. Tout ce qui est ajouté activé est déjà visible dans votre liste d'entités et votre historique.\n\nDe nouvelles entités peuvent apparaître pour plusieurs raisons : une mise à jour de l'intégration qui expose davantage de ce que votre panneau signale, un circuit ajouté au panneau, ou du matériel tel qu'une batterie ou du solaire qui entre en service. Rien n'est cassé et aucune action n'est requise : cet avis existe uniquement pour que cet ajout ne soit pas silencieux.\n\nPour en utiliser une, ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez. Ignorez cet avis une fois que vous y avez jeté un œil."
- },
"panel_upgraded_to_ebus_v1": {
"title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau",
"description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0. Rien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui s'est amélioré.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. La v1.0 ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont v1.0 détermine la capacité de secours.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas.\n\nVous pouvez ignorer cet avis après consultation."
@@ -781,5 +777,16 @@
"title": "Incohérence d'unité du Panneau SPAN",
"description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Entités concernées : {count} (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement."
}
+ },
+ "notifications": {
+ "new_entities": {
+ "title": "Le Panneau SPAN a ajouté de nouvelles entités",
+ "intro_one": "Cette mise à jour a ajouté 1 nouvelle entité à votre Panneau SPAN.",
+ "intro_many": "Cette mise à jour a ajouté {count} nouvelles entités à votre Panneau SPAN.",
+ "enabled_heading": "Ajoutées et prêtes à l'emploi",
+ "disabled_heading": "Ajoutées mais désactivées",
+ "how_to_enable": "Les entités désactivées n'enregistrent rien tant que vous ne les activez pas. Ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez.",
+ "nothing_broken": "Rien n'est cassé et aucune action n'est requise. Des entités apparaissent pour plusieurs raisons : une mise à jour qui expose davantage de ce que votre panneau rapporte, un circuit ajouté au panneau, ou du matériel tel qu'une batterie, du solaire ou un chargeur qui se met en ligne."
+ }
}
}
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 54ef81d8..bf45da3d 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "new_entities_disabled": {
- "title": "SPAN パネルの新しいエンティティが利用可能です",
- "description": "SPAN パネルに {count} 個の新しいエンティティが現れました (例: {examples})。これらは**無効な状態**で作成されているため、ダッシュボードや履歴に無断で何かが現れることはありません。\n\nこの件数には、無効な状態で作成されたエンティティのみが含まれます。有効な状態で追加されたものは、すでにエンティティ一覧や履歴に表示されています。\n\n新しいエンティティが現れる理由はいくつかあります。パネルが報告する内容をより多く公開する統合の更新、パネルに追加された回路、あるいはバッテリーや太陽光などのハードウェアの稼働開始などです。故障ではなく、対応も不要です。この通知は、追加が知らされないままにならないようにするためだけのものです。\n\n使用するには、SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。確認が済んだらこの通知を閉じてください。"
- },
"panel_upgraded_to_ebus_v1": {
"title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました",
"description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。ご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は改善点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。\n\n内容を確認したら、この通知は閉じて構いません。"
@@ -781,5 +777,16 @@
"title": "SPAN パネルの単位の不一致",
"description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。{count} 個のエンティティが影響を受けます (例: {examples})。これらの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。"
}
+ },
+ "notifications": {
+ "new_entities": {
+ "title": "SPAN パネルに新しいエンティティが追加されました",
+ "intro_one": "このアップデートで SPAN パネルに新しいエンティティが 1 個追加されました。",
+ "intro_many": "このアップデートで SPAN パネルに新しいエンティティが {count} 個追加されました。",
+ "enabled_heading": "追加され、すぐに使用できます",
+ "disabled_heading": "追加されましたが無効です",
+ "how_to_enable": "無効なエンティティは、有効にするまで何も記録しません。SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。",
+ "nothing_broken": "問題はなく、対応は不要です。エンティティが追加される理由はいくつかあります: パネルが報告する内容をより多く公開するアップデート、パネルに追加された回路、またはバッテリー、太陽光、充電器などのハードウェアがオンラインになった場合です。"
+ }
}
}
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index fc8208f0..fcd4649b 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "new_entities_disabled": {
- "title": "Estão disponíveis novas entidades do Painel SPAN",
- "description": "Entidades novas criadas **desativadas**: {count} (por exemplo: {examples}). Nada apareceu nos seus painéis nem no seu histórico sem ter sido pedido.\n\nEsta contagem inclui apenas as entidades criadas desativadas. Tudo o que é acrescentado ativado já está visível na sua lista de entidades e no seu histórico.\n\nPodem aparecer novas entidades por vários motivos: uma atualização da integração que expõe mais do que o seu painel comunica, um circuito acrescentado ao painel, ou hardware como uma bateria ou energia solar que entra em funcionamento. Não há nada avariado e não é necessária qualquer ação: este aviso existe apenas para que a novidade não passe despercebida.\n\nPara usar alguma, abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser. Dispense este aviso depois de o ter visto."
- },
"panel_upgraded_to_ebus_v1": {
"title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede",
"description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0. Nada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que melhorou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. A v1.0 não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a v1.0 determina a capacidade de reserva.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava.\n\nPode dispensar este aviso depois de o consultar."
@@ -781,5 +777,16 @@
"title": "Discrepância de unidades do Painel SPAN",
"description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Entidades afetadas: {count} (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente."
}
+ },
+ "notifications": {
+ "new_entities": {
+ "title": "O Painel SPAN adicionou novas entidades",
+ "intro_one": "Esta atualização adicionou 1 nova entidade ao seu Painel SPAN.",
+ "intro_many": "Esta atualização adicionou {count} novas entidades ao seu Painel SPAN.",
+ "enabled_heading": "Adicionadas e prontas a usar",
+ "disabled_heading": "Adicionadas mas desativadas",
+ "how_to_enable": "As entidades desativadas não registam nada até as ativar. Abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser.",
+ "nothing_broken": "Nada está avariado e não é necessária qualquer ação. As entidades aparecem por vários motivos: uma atualização que expõe mais do que o seu painel reporta, um circuito adicionado ao painel, ou hardware como uma bateria, solar ou um carregador a ficar online."
+ }
}
}
diff --git a/developer.md b/developer.md
index 9eef3d9d..dc91db48 100644
--- a/developer.md
+++ b/developer.md
@@ -296,6 +296,15 @@ entity they chose to enable.
`device_class` is enumerated in `DEVICE_CLASS_BY_UNIT` rather than inferred. A unit outside the map gets **no** device class — `%` is deliberately absent,
because its uses here are a state of charge, a confidence and a duty cycle, and no single class is right for all of them.
+### The device exists even with no entities
+
+`async_register_adopted_devices` registers each adopted device explicitly, before the platforms are forwarded, rather than letting it fall out of entity
+creation. The reason is a device that has no entities to fall out of: a vendor device publishing only an `info` node resolves entirely to the device card by the
+node rule, creates no entity, and so had nothing to call `async_get_or_create` for it. It produced _nothing at all_ — no device, no entity, no notification.
+
+Running it before the platforms also makes the identity freeze single-valued: `resolve_identifier` runs once, at registration, so every entity created
+afterwards resolves against a device that already exists and cannot disagree.
+
### Identity freezes at first sighting
`resolve_identifier` looks up **both** candidate spellings — `{panel serial}_adopted_{wire id}` and `{panel serial}_adopted_{serial}` — before minting either,
@@ -356,11 +365,33 @@ on whether to accept it. A `NUMBER` on an `integer` property publishes `45`, nev
Diagnostics report `adopted_devices.controls` — how many adopted properties write back rather than only reporting.
-### The notice counts devices, not entities
+### Telling the user what was added
+
+Additions are announced by `additions.async_announce_new_entities` as a **persistent notification**, not a Repair. An addition is not a repair: nothing is
+broken and nothing needs fixing, and filing it under Repairs puts it in a category whose whole meaning is "something went wrong". The retired
+`new_entities_disabled` issue is deleted at setup by `async_clear_retired_new_entity_notices`, because it was raised `is_persistent` and would otherwise stand
+forever on an upgraded install with nothing left to re-derive it.
+
+Three things it does that the Repair did not:
+
+- **Enabled additions are announced too.** The old notice covered only `disabled_by=INTEGRATION`, reasoning that an enabled entity is already visible in the
+ entity list and its history. Nobody watches their entity count, so that reasoning made every enabled addition invisible.
+- **It names every entity**, rather than a count plus three examples. "What exactly was added" means all of it.
+- **The record is durable.** The old diff compared the registry before the platforms against the registry after, which answers correctly exactly once — on the
+ next startup the entity is already registered beforehand and the diff is empty by construction. `additions` records what it announced in a `Store`, so the
+ question is "has this been announced" rather than "was this registered in the last few seconds".
+
+It stays silent on a first install, and silent once more on the first run after this mechanism ships: an install predating the record has entities that were
+never announced but are not new either, so the first pass adopts them as known.
+
+Adopted devices are collapsed to one line with a count — `Backup Generator (6 entities)` — for the reason the whole design is device-scoped: a vendor device
+declaring a dozen properties would otherwise spend the entire notification on itself and teach the user to skip it, costing them the curated additions in the
+same message.
-Adopted entities are disabled, so they reach the user only through `async_notice_new_disabled_entities`. That notice lists curated additions individually and
-collapses each adopted device to one line with a count — `Backup Generator (6 entities)`. A vendor device declaring a dozen properties would otherwise spend the
-whole notice on itself and teach the user that the category is noise, which would cost them the curated additions too.
+**Translations are read from this component's own `translations/` directory**, not through `homeassistant.helpers.translation`. That helper filters to the
+categories Home Assistant defines, and a persistent notification is not one of them — a custom category loads as nothing at all, which was verified rather than
+assumed. The strings live under a `notifications` key in `strings.json` and all five locales, with English constants in `additions._FALLBACK` so an unreadable
+file costs the translation and not the notification.
### Diagnostics
diff --git a/tests/test_adoption.py b/tests/test_adoption.py
index 5ef7e20f..7784103e 100644
--- a/tests/test_adoption.py
+++ b/tests/test_adoption.py
@@ -19,6 +19,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
from span_panel_api import AdoptedDevice, AdoptedProperty
from custom_components.span_panel.adoption import (
@@ -26,6 +27,7 @@
DEVICE_CLASS_BY_UNIT,
adopted_control_count,
adopted_identifier,
+ async_register_adopted_devices,
classify,
create_adopted_binary_sensors,
create_adopted_numbers,
@@ -48,6 +50,26 @@
PANEL_SERIAL = "sp3-242424-001"
+@pytest.fixture
+def registered_panel(hass: HomeAssistant) -> tuple[str, str]:
+ """Return a config entry and a registered panel device, as setup would leave them.
+
+ Both are required rather than convenient: the device registry refuses to link
+ a device to an unknown config entry, and refuses a `via_device_id` naming a
+ device that does not exist. An adopted device is a sub-device of the panel, so
+ the panel has to be there first -- which is exactly why registration runs
+ after `ensure_device_registered` and before the platforms.
+ """
+ mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id=PANEL_SERIAL)
+ mock.add_to_hass(hass)
+ panel = dr.async_get(hass).async_get_or_create(
+ config_entry_id=mock.entry_id,
+ identifiers={(DOMAIN, PANEL_SERIAL)},
+ name="Span Panel",
+ )
+ return str(mock.entry_id), panel.id
+
+
def _property(
node_id: str = "meter",
property_id: str = "active-power",
@@ -466,3 +488,72 @@ def test_a_control_is_disabled_and_diagnostic_like_every_other_adopted_entity(ha
assert len(controls) == 3
assert all(entity.entity_registry_enabled_default is False for entity in controls)
assert all(entity.entity_category is EntityCategory.DIAGNOSTIC for entity in controls)
+
+
+# -- An adopted device exists even when it has no readings -------------------
+
+
+def test_a_device_whose_whole_declaration_is_info_still_gets_a_card(hass: HomeAssistant, registered_panel: tuple[str, str]) -> None:
+ """The gap explicit registration closes.
+
+ `info` resolves entirely to the device card by the node rule, so a vendor
+ device that advertises what it is before publishing any reading creates no
+ entity -- and devices are otherwise created as a side effect of entity
+ creation, so it used to produce *nothing at all*: no device, no entity, no
+ notification. Which is the silence adoption exists to end, reached by a
+ different route.
+ """
+ entry_id, panel_device_id = registered_panel
+ device = AdoptedDevice(
+ device_id="generator-1",
+ device_type="energy.ebus.device.generator",
+ name="Backup Generator",
+ model="GEN-9000",
+ vendor_name="Example Power",
+ software_version="3.2.1",
+ properties=(),
+ )
+ snapshot = _snapshot(device)
+
+ async_register_adopted_devices(hass, entry_id, snapshot, panel_device_id=panel_device_id)
+
+ registered = dr.async_get(hass).async_get_device(
+ identifiers={(DOMAIN, adopted_identifier(PANEL_SERIAL, "generator-1"))}
+ )
+ assert registered is not None
+ assert registered.name == "Backup Generator"
+ assert registered.model == "GEN-9000"
+ assert registered.manufacturer == "Example Power"
+ assert registered.sw_version == "3.2.1"
+
+
+def test_registration_freezes_the_anchor_before_any_entity_resolves_it(hass: HomeAssistant, registered_panel: tuple[str, str]) -> None:
+ """Registering first is what makes the freeze single-valued.
+
+ `resolve_identifier` reads the registry to decide which spelling this install
+ uses. Running it once at registration means every entity created afterwards
+ resolves against a device that already exists and cannot disagree -- including
+ on the run where a serial first arrives.
+ """
+ entry_id, panel_device_id = registered_panel
+ without_serial = _device("generator-1")
+ async_register_adopted_devices(hass, entry_id, _snapshot(without_serial), panel_device_id=panel_device_id)
+
+ with_serial = _device("generator-1", serial_number="EX-0000-0001")
+ async_register_adopted_devices(hass, entry_id, _snapshot(with_serial), panel_device_id=panel_device_id)
+
+ registry = dr.async_get(hass)
+ assert registry.async_get_device(identifiers={(DOMAIN, adopted_identifier(PANEL_SERIAL, "generator-1"))})
+ assert registry.async_get_device(identifiers={(DOMAIN, adopted_identifier(PANEL_SERIAL, "EX-0000-0001"))}) is None
+
+
+def test_a_panel_with_no_adopted_device_registers_nothing(hass: HomeAssistant, registered_panel: tuple[str, str]) -> None:
+ entry_id, panel_device_id = registered_panel
+ async_register_adopted_devices(hass, entry_id, _snapshot(), panel_device_id=panel_device_id)
+
+ adopted = [
+ device
+ for device in dr.async_get(hass).devices.values()
+ if any(ADOPTED_IDENTIFIER_TOKEN in identifier for _domain, identifier in device.identifiers)
+ ]
+ assert adopted == []
diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py
new file mode 100644
index 00000000..b395c24b
--- /dev/null
+++ b/tests/test_new_entity_announcement.py
@@ -0,0 +1,268 @@
+"""An entity this integration adds must not arrive silently, switched on or off.
+
+`battery.part_number` shipped with `entity_registry_enabled_default=False` so
+upgrading would not grow anybody's entity list uninvited. It worked, and the cost
+was that nothing told the user the sensor existed -- they found it by opening the
+device's disabled-entity list on a hunch.
+
+The first fix covered only *disabled* additions, on the reasoning that an enabled
+one is already visible in the entity list and its history. That reasoning does
+not survive contact with how anyone uses Home Assistant: nobody watches their
+entity count, so an addition that breaks nothing is indistinguishable from no
+addition at all. These cover both, and the four ways the announcement could be
+worse than nothing -- shouting on a first install, shouting a release's worth of
+history on upgrade, nagging on every restart, or naming a hundred entities at
+once.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.span_panel.additions import (
+ _read_translations,
+ async_announce_new_entities,
+ async_forget_announcements,
+)
+from custom_components.span_panel.const import DOMAIN
+
+_PART_NUMBER = "sp3-001_bess_part_number"
+
+
+@pytest.fixture
+def entry(hass: HomeAssistant) -> MockConfigEntry:
+ """Return a config entry in hass. No conftest fixture exists for this."""
+ mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001")
+ mock.add_to_hass(hass)
+ return mock
+
+
+def _register(
+ hass: HomeAssistant,
+ entry: MockConfigEntry,
+ unique_id: str,
+ *,
+ disabled: bool = True,
+ name: str | None = None,
+ device_id: str | None = None,
+) -> er.RegistryEntry:
+ """Register one entity the way a platform would."""
+ return er.async_get(hass).async_get_or_create(
+ "sensor",
+ DOMAIN,
+ unique_id,
+ config_entry=entry,
+ original_name=name,
+ device_id=device_id,
+ disabled_by=er.RegistryEntryDisabler.INTEGRATION if disabled else None,
+ )
+
+
+def _notifications(hass: HomeAssistant) -> dict[str, Any]:
+ """Every persistent notification currently standing, by id."""
+ return dict(hass.data.get("persistent_notification", {}))
+
+
+def _announcement(hass: HomeAssistant, entry: MockConfigEntry) -> Any | None:
+ return _notifications(hass).get(f"{DOMAIN}_new_entities_{entry.entry_id}")
+
+
+def _text(notification: Any) -> str:
+ message = notification.message if hasattr(notification, "message") else notification["message"]
+ return str(message)
+
+
+# -- Silence where silence is right ------------------------------------------
+
+
+async def test_a_first_install_announces_nothing(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+ """Every entity is new on a first install, so the notice would name them all.
+
+ Which would teach the user that this category is noise, and cost them the
+ real additions later.
+ """
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+
+ await async_announce_new_entities(hass, entry)
+
+ assert _announcement(hass, entry) is None
+
+
+async def test_an_install_that_predates_the_record_announces_nothing_once(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Entities that were never announced are not therefore new.
+
+ The first pass adopts what is already registered as known. Without it, the
+ release that ships this mechanism would announce every entity the integration
+ has ever created.
+ """
+ for index in range(5):
+ _register(hass, entry, f"sp3-001_existing_{index}", name=f"Existing {index}")
+
+ await async_announce_new_entities(hass, entry)
+
+ assert _announcement(hass, entry) is None
+
+
+async def test_a_restart_that_adds_nothing_announces_nothing(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+ """The failure that would make the whole thing worse than useless."""
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ await async_announce_new_entities(hass, entry)
+
+ for _ in range(3):
+ await async_announce_new_entities(hass, entry)
+
+ assert _announcement(hass, entry) is None
+
+
+# -- What it says ------------------------------------------------------------
+
+
+async def test_a_disabled_addition_is_announced_and_says_it_needs_enabling(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert "Part Number" in message
+ assert "Added but switched off" in message
+ assert "enable the ones you want" in message
+
+
+async def test_an_enabled_addition_is_announced_too(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+ """The gap this replaced the Repair to close.
+
+ An enabled entity is visible in the entity list and starts recording, which is
+ only an announcement to somebody already looking at the entity list.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ _register(hass, entry, "sp3-001_grid_state", name="Grid State", disabled=False)
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert "Grid State" in message
+ assert "Added and ready to use" in message
+
+
+async def test_both_kinds_are_split_rather_than_pooled(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+ """The split is the actionable part: one kind needs an action, the other does not."""
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ _register(hass, entry, "sp3-001_on", name="Switched On", disabled=False)
+ _register(hass, entry, "sp3-001_off", name="Switched Off")
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert message.index("Added and ready to use") < message.index("Switched On")
+ assert message.index("Added but switched off") < message.index("Switched Off")
+
+
+async def test_every_added_entity_is_named_rather_than_sampled(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+ """"What exactly was added" means all of it.
+
+ The Repair this replaced showed a count plus three examples, which tells a
+ user that something happened and not what.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ names = [f"Reading {index}" for index in range(8)]
+ for index, name in enumerate(names):
+ _register(hass, entry, f"sp3-001_new_{index}", name=name)
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert all(name in message for name in names)
+
+
+# -- Adopted devices are counted, not listed ---------------------------------
+
+
+async def test_an_adopted_device_contributes_one_line_with_a_count(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """A vendor device declaring a dozen properties must not consume the message.
+
+ Listing them would spend the whole notification on one device and teach the
+ user to skip it -- costing them the curated additions in the same message.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ device = dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, "sp3-001_adopted_generator-1")},
+ name="Backup Generator",
+ )
+ for index in range(6):
+ _register(hass, entry, f"sp3-001_adopted_{index}", name=f"Adopted {index}", device_id=device.id)
+ _register(hass, entry, "sp3-001_curated", name="Curated Addition")
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert "Backup Generator (6 entities)" in message
+ assert "Adopted 0" not in message
+ assert "Curated Addition" in message
+
+
+# -- Translations ------------------------------------------------------------
+
+
+@pytest.mark.parametrize("language", ["en", "es", "fr", "ja", "pt"])
+def test_every_shipped_locale_carries_the_notification_strings(language: str) -> None:
+ """The notification is assembled here, so nothing else checks these keys.
+
+ Home Assistant's translation helper filters to the categories it defines and a
+ persistent notification is not one of them, so these are read from this
+ component's own files. That is precisely why a missing key would fail silently
+ into English rather than being caught by the platform.
+ """
+ text = _read_translations(language)
+ assert set(text) >= {
+ "title",
+ "intro_one",
+ "intro_many",
+ "enabled_heading",
+ "disabled_heading",
+ "how_to_enable",
+ "nothing_broken",
+ }
+
+
+def test_an_unknown_language_falls_back_to_english_rather_than_to_nothing() -> None:
+ assert _read_translations("xx")["title"] == _read_translations("en")["title"]
+
+
+def test_a_regional_language_resolves_to_its_base(hass: HomeAssistant) -> None:
+ """`pt-BR` is not shipped; `pt` is, and is a better answer than English."""
+ assert _read_translations("pt-BR") == _read_translations("pt")
+
+
+# -- Removal -----------------------------------------------------------------
+
+
+async def test_removing_the_entry_forgets_what_was_announced(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Otherwise re-adding the same panel announces none of the entities it recreates."""
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ await async_announce_new_entities(hass, entry)
+
+ await async_forget_announcements(hass, entry)
+ _register(hass, entry, "sp3-001_new", name="New One")
+ await async_announce_new_entities(hass, entry)
+
+ assert _announcement(hass, entry) is None
diff --git a/tests/test_new_entity_notice.py b/tests/test_new_entity_notice.py
deleted file mode 100644
index 78da5ef8..00000000
--- a/tests/test_new_entity_notice.py
+++ /dev/null
@@ -1,364 +0,0 @@
-"""An entity that arrives disabled must not arrive silently.
-
-`battery.part_number` shipped with `entity_registry_enabled_default=False` so
-that upgrading would not grow anybody's entity list uninvited. It worked, and
-the cost was that nothing whatsoever told the user the sensor now existed --
-they found it by opening the device's disabled-entity list on a hunch.
-
-These cover the notice that closes that gap, and the four ways it could be worse
-than nothing: shouting on a first install, being swallowed by an earlier
-dismissal, nagging on every restart, or vanishing unread at the first one.
-"""
-
-from __future__ import annotations
-
-from homeassistant.helpers import entity_registry as er, issue_registry as ir
-import pytest
-from pytest_homeassistant_custom_component.common import MockConfigEntry
-
-from custom_components.span_panel.const import DOMAIN
-from custom_components.span_panel.schema_repairs import (
- _new_entities_id,
- async_clear_schema_issues,
- async_notice_new_disabled_entities,
- async_registered_unique_ids,
- async_sync_schema_issues,
-)
-from custom_components.span_panel.schema_validation import SchemaFindings
-
-_PART_NUMBER = "sp3-001_bess_part_number"
-
-
-@pytest.fixture
-def entry(hass) -> MockConfigEntry:
- """Return a config entry in hass. No conftest fixture exists for this."""
- mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001")
- mock.add_to_hass(hass)
- return mock
-
-
-def _register(
- hass,
- entry: MockConfigEntry,
- unique_id: str,
- *,
- disabled: bool = True,
- name: str | None = None,
-) -> er.RegistryEntry:
- """Register one entity the way a platform would."""
- return er.async_get(hass).async_get_or_create(
- "sensor",
- DOMAIN,
- unique_id,
- config_entry=entry,
- original_name=name,
- disabled_by=er.RegistryEntryDisabler.INTEGRATION if disabled else None,
- )
-
-
-def _notices(hass, entry: MockConfigEntry) -> list[ir.IssueEntry]:
- prefix = f"new_entities_{entry.entry_id}_"
- return [
- issue
- for (domain, issue_id), issue in ir.async_get(hass).issues.items()
- if domain == DOMAIN and issue_id.startswith(prefix)
- ]
-
-
-# --- The gap this closes --------------------------------------------------
-
-
-async def test_a_new_disabled_entity_raises_one_notice_naming_it(hass, entry) -> None:
- """The `battery.part_number` case, end to end."""
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False, name="Serial Number")
- known = async_registered_unique_ids(hass, entry)
-
- _register(hass, entry, _PART_NUMBER, name="Part Number")
- async_notice_new_disabled_entities(hass, entry, known)
-
- notices = _notices(hass, entry)
- assert len(notices) == 1
- assert notices[0].translation_key == "new_entities_disabled"
- assert notices[0].translation_placeholders == {"count": "1", "examples": "Part Number"}
-
-
-async def test_the_notice_is_informational_and_not_fixable(hass, entry) -> None:
- """Nothing is broken; data became available.
-
- `IssueSeverity` has no informational member -- CRITICAL, ERROR, WARNING --
- so WARNING is the mildest Home Assistant offers, and is what the equally
- action-free `panel_upgraded_to_ebus_v1` notice settled on.
- """
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
- _register(hass, entry, _PART_NUMBER, name="Part Number")
-
- async_notice_new_disabled_entities(hass, entry, known)
-
- notice = _notices(hass, entry)[0]
- assert notice.severity is ir.IssueSeverity.WARNING
- assert notice.is_fixable is False
-
-
-async def test_the_notice_falls_back_to_the_entity_id_when_unnamed(hass, entry) -> None:
- """A disabled entity has no state, so there is no friendly name to read."""
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
- registered = _register(hass, entry, _PART_NUMBER, name=None)
-
- async_notice_new_disabled_entities(hass, entry, known)
-
- assert _notices(hass, entry)[0].translation_placeholders["examples"] == registered.entity_id
-
-
-# --- Silent on a first install --------------------------------------------
-
-
-async def test_a_first_install_raises_no_notice(hass, entry) -> None:
- """Everything is new, so naming it would name the whole integration."""
- known = async_registered_unique_ids(hass, entry)
- assert known == frozenset()
-
- _register(hass, entry, _PART_NUMBER, name="Part Number")
- _register(hass, entry, "sp3-001_bess_model", name="Model")
- async_notice_new_disabled_entities(hass, entry, known)
-
- assert _notices(hass, entry) == []
-
-
-async def test_another_entrys_history_does_not_make_this_one_established(hass) -> None:
- """The probe is scoped per entry, so a second panel still installs quietly."""
- established = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-old")
- established.add_to_hass(hass)
- _register(hass, established, "sp3-old_bess_serial_number", disabled=False)
-
- fresh = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-new")
- fresh.add_to_hass(hass)
- known = async_registered_unique_ids(hass, fresh)
- _register(hass, fresh, "sp3-new_bess_part_number", name="Part Number")
-
- async_notice_new_disabled_entities(hass, fresh, known)
-
- assert _notices(hass, fresh) == []
-
-
-# --- Only the invisible additions -----------------------------------------
-
-
-async def test_an_entity_that_is_enabled_by_default_raises_no_notice(hass, entry) -> None:
- """It shows up in the user's entity list on its own; a notice is noise."""
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
-
- _register(hass, entry, "sp3-001_bess_model", disabled=False, name="Model")
- async_notice_new_disabled_entities(hass, entry, known)
-
- assert _notices(hass, entry) == []
-
-
-async def test_a_user_disabled_entity_is_not_a_new_addition(hass, entry) -> None:
- """Only `INTEGRATION` means "we shipped it switched off"."""
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
-
- er.async_get(hass).async_get_or_create(
- "sensor",
- DOMAIN,
- "sp3-001_bess_model",
- config_entry=entry,
- disabled_by=er.RegistryEntryDisabler.USER,
- )
- async_notice_new_disabled_entities(hass, entry, known)
-
- assert _notices(hass, entry) == []
-
-
-async def test_the_named_entities_are_bounded_and_counted(hass, entry) -> None:
- """The same rule the defect Repairs follow: a few names plus a count."""
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
-
- for n in range(12):
- _register(hass, entry, f"sp3-001_new_{n:02d}", name=f"New Sensor {n:02d}")
- async_notice_new_disabled_entities(hass, entry, known)
-
- placeholders = _notices(hass, entry)[0].translation_placeholders
- assert placeholders["count"] == "12"
- assert placeholders["examples"].count(",") < 5
- assert "New Sensor 00" in placeholders["examples"]
-
-
-# --- Dismissing one set must not swallow a later, different set -----------
-
-
-async def test_a_dismissed_notice_does_not_swallow_a_later_addition(hass, entry) -> None:
- """The trap that forced per-field ids on the degradation Repairs.
-
- `async_get_or_create`'s update branch preserves `dismissed_version` while
- replacing the placeholders, so an entry-wide id would rewrite the notice the
- user put away rather than raising a new one.
- """
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
-
- known = async_registered_unique_ids(hass, entry)
- _register(hass, entry, _PART_NUMBER, name="Part Number")
- async_notice_new_disabled_entities(hass, entry, known)
-
- first = _notices(hass, entry)[0]
- ir.async_ignore_issue(hass, DOMAIN, first.issue_id, True)
- assert ir.async_get(hass).async_get_issue(DOMAIN, first.issue_id).dismissed_version
-
- known = async_registered_unique_ids(hass, entry)
- _register(hass, entry, "sp3-001_bess_cell_temperature", name="Cell Temperature")
- async_notice_new_disabled_entities(hass, entry, known)
-
- by_id = {notice.issue_id: notice for notice in _notices(hass, entry)}
- assert len(by_id) == 2
- second = next(issue for issue_id, issue in by_id.items() if issue_id != first.issue_id)
- assert second.dismissed_version is None
- assert second.translation_placeholders["examples"] == "Cell Temperature"
- assert by_id[first.issue_id].dismissed_version is not None
-
-
-async def test_the_id_is_keyed_on_the_exact_set(hass, entry) -> None:
- """Two different sets produce two different ids; the same set, one id."""
- assert _new_entities_id("e1", ["a"]) != _new_entities_id("e1", ["a", "b"])
- assert _new_entities_id("e1", ["a"]) != _new_entities_id("e2", ["a"])
- assert _new_entities_id("e1", ["b", "a"]) == _new_entities_id("e1", ["a", "b"])
-
-
-# --- An event, not a condition --------------------------------------------
-
-
-async def test_the_notice_is_persistent(hass, entry) -> None:
- """It cannot be re-derived, so it has to survive the restart itself.
-
- On the next startup the entity is in the known set and the diff is empty by
- construction. A non-persistent issue reloads as a tombstone, so this notice
- would disappear unread at the first restart after the upgrade -- the same
- silent add it exists to prevent, one step later.
- """
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
- _register(hass, entry, _PART_NUMBER, name="Part Number")
-
- async_notice_new_disabled_entities(hass, entry, known)
-
- assert _notices(hass, entry)[0].is_persistent is True
-
-
-async def test_a_restart_with_no_new_entities_neither_duplicates_nor_re_raises(hass, entry) -> None:
- """The event property, exercised the way the field will exercise it."""
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
- _register(hass, entry, _PART_NUMBER, name="Part Number")
- async_notice_new_disabled_entities(hass, entry, known)
-
- raised = _notices(hass, entry)[0]
- created = raised.created
-
- for _ in range(3):
- # Each pass is a setup: probe the registry, forward nothing new, notice.
- async_notice_new_disabled_entities(hass, entry, async_registered_unique_ids(hass, entry))
-
- notices = _notices(hass, entry)
- assert len(notices) == 1
- assert notices[0].issue_id == raised.issue_id
- assert notices[0].created == created
-
-
-async def test_a_repeat_of_the_same_set_cannot_rewrite_the_notice(hass, entry) -> None:
- """What the already-raised guard actually buys.
-
- It buys nothing against duplication -- the shared id rules that out. It buys
- exactly this: a set that comes back cannot silently restate a notice the user
- has already read. Without the guard the repeat takes `async_get_or_create`'s
- update branch, which replaces the placeholders in place.
-
- The set comes back when an entity is removed from the registry and later
- re-registered -- a BESS taken off the panel and put back -- because the
- unique_ids, and therefore the digest, are unchanged.
- """
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
- added = _register(hass, entry, _PART_NUMBER, name="Part Number")
- async_notice_new_disabled_entities(hass, entry, known)
- issue_id = _notices(hass, entry)[0].issue_id
-
- er.async_get(hass).async_remove(added.entity_id)
- _register(hass, entry, _PART_NUMBER, name="BESS Part Number")
- async_notice_new_disabled_entities(hass, entry, known)
-
- notices = _notices(hass, entry)
- assert len(notices) == 1
- assert notices[0].issue_id == issue_id
- assert notices[0].translation_placeholders["examples"] == "Part Number"
-
-
-async def test_a_dismissed_notice_is_not_resurrected_by_a_restart(hass, entry) -> None:
- """Dismissing it must end it, not defer it to the next startup."""
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
- _register(hass, entry, _PART_NUMBER, name="Part Number")
- async_notice_new_disabled_entities(hass, entry, known)
-
- issue_id = _notices(hass, entry)[0].issue_id
- ir.async_ignore_issue(hass, DOMAIN, issue_id, True)
- dismissed = ir.async_get(hass).async_get_issue(DOMAIN, issue_id).dismissed_version
-
- async_notice_new_disabled_entities(hass, entry, async_registered_unique_ids(hass, entry))
-
- assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id).dismissed_version == dismissed
-
-
-async def test_the_defect_reconcile_pass_does_not_delete_the_notice(hass, entry) -> None:
- """`_ours` is scoped to the two defect prefixes on purpose.
-
- The reconcile pass deletes every id it did not re-derive. This notice is
- derived exactly once, so a shared scope would delete it on the same startup
- that raised it.
- """
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
- _register(hass, entry, _PART_NUMBER, name="Part Number")
- async_notice_new_disabled_entities(hass, entry, known)
- issue_id = _notices(hass, entry)[0].issue_id
-
- async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {})
-
- assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
-
-
-# --- Removal ---------------------------------------------------------------
-
-
-async def test_the_notice_is_cleared_when_the_entry_is_removed(hass, entry) -> None:
- """Persistent, so a restart would not even demote it to a tombstone."""
- _register(hass, entry, "sp3-001_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, entry)
- _register(hass, entry, _PART_NUMBER, name="Part Number")
- async_notice_new_disabled_entities(hass, entry, known)
- assert _notices(hass, entry)
-
- async_clear_schema_issues(hass, entry)
-
- assert _notices(hass, entry) == []
-
-
-async def test_removing_one_entry_leaves_another_entrys_notice(hass) -> None:
- """Two panels share the domain; one leaving must not silence the other."""
- kept = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-kept")
- kept.add_to_hass(hass)
- removed = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-removed")
- removed.add_to_hass(hass)
-
- for panel in (kept, removed):
- _register(hass, panel, f"{panel.unique_id}_bess_serial_number", disabled=False)
- known = async_registered_unique_ids(hass, panel)
- _register(hass, panel, f"{panel.unique_id}_bess_part_number", name="Part Number")
- async_notice_new_disabled_entities(hass, panel, known)
-
- async_clear_schema_issues(hass, removed)
-
- assert _notices(hass, kept)
- assert _notices(hass, removed) == []
diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py
index 18f7e15a..be66441d 100644
--- a/tests/test_setup_entry.py
+++ b/tests/test_setup_entry.py
@@ -4,7 +4,16 @@
from unittest.mock import AsyncMock, MagicMock, patch
+from homeassistant.config_entries import (
+ ConfigEntryAuthFailed,
+ ConfigEntryError,
+ ConfigEntryNotReady,
+)
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
from span_panel_api.exceptions import SpanPanelAuthError
from custom_components.span_panel import SpanPanelRuntimeData, async_setup_entry
@@ -17,19 +26,9 @@
CONF_HTTP_PORT,
DOMAIN,
)
-from homeassistant.config_entries import (
- ConfigEntryAuthFailed,
- ConfigEntryError,
- ConfigEntryNotReady,
-)
-from homeassistant.const import CONF_HOST
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
from .factories import SpanPanelSnapshotFactory
-from pytest_homeassistant_custom_component.common import MockConfigEntry
-
def _create_v2_entry(**data_overrides) -> MockConfigEntry:
"""Create a standard v2 config entry for setup-entry tests."""
@@ -341,15 +340,16 @@ async def _forward(*_args, **_kwargs) -> None:
assert order == ["forward", "sync"]
-async def test_setup_probes_the_registry_before_the_platforms_and_notices_after(
+async def test_setup_announces_additions_after_the_platforms(
hass: HomeAssistant,
) -> None:
- """The new-entity notice brackets the forward, and both halves matter.
+ """The announcement has to run after the forward, and that is the whole ordering.
- The probe has to run before forwarding, because forwarding is what registers
- the entities -- probing afterwards would find every entity already known and
- the notice could never fire. The notice has to run after, because a newly
- added entity is only in the registry once its platform has added it.
+ A newly added entity is only in the registry once its platform has added it,
+ so announcing before the forward would announce nothing, every time. The old
+ mechanism also needed a *probe* before the forward, because it diffed the
+ registry across it; the announcement record replaced that, which is what makes
+ the answer survive a restart landing between the two.
"""
entry = _create_v2_entry()
entry.add_to_hass(hass)
@@ -377,11 +377,8 @@ async def _forward(*_args, **_kwargs) -> None:
disabled_by=er.RegistryEntryDisabler.INTEGRATION,
)
- seen: list[frozenset[str]] = []
-
- def _notice(_hass, _entry, known_unique_ids) -> None:
- order.append("notice")
- seen.append(frozenset(known_unique_ids))
+ async def _announce(_hass, _entry) -> None:
+ order.append("announce")
with (
patch("custom_components.span_panel.async_register_commands"),
@@ -399,11 +396,10 @@ def _notice(_hass, _entry, known_unique_ids) -> None:
),
patch.object(hass.config_entries, "async_update_entry"),
patch(
- "custom_components.span_panel.async_notice_new_disabled_entities",
- side_effect=_notice,
+ "custom_components.span_panel.async_announce_new_entities",
+ side_effect=_announce,
),
):
assert await async_setup_entry(hass, entry) is True
- assert order == ["forward", "sync", "notice"]
- assert seen == [frozenset({"already-there"})]
+ assert order == ["forward", "sync", "announce"]
From 1c4f207a875e88c8d543e9977aa52342bc1d9ed1 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 12:38:25 -0700
Subject: [PATCH 069/116] fix(adoption): build adopted unique ids in the
integration's own grammar
The adopted id lower-cased and de-hyphenated the whole string, which mangled
the panel serial: span_sp3_242424_001_... where every curated id in the
integration says span_sp3-242424-001_.... A reader that parses an id by
position, as extract_circuit_uuid_from_unique_id does, would have met a second
grammar.
Now built through id_builder's own suffix helper, so an adopted id is
span_{serial}_adopted_{anchor}_{suffix} -- the same span_{serial}_{scope}_
{suffix} shape as every other. Only the node and property are de-hyphenated,
because that segment has to read like a curated suffix; the serial and the
anchor keep their hyphens, as a curated id keeps the serial's.
Uniform grammar does not remove the migration promotion needs, and a test says
so: curation changes the scope and the suffix, which is the change itself
rather than a formatting difference.
---
custom_components/span_panel/adoption.py | 31 +++++++++++--
tests/test_adoption.py | 56 ++++++++++++++++++++++++
2 files changed, 84 insertions(+), 3 deletions(-)
diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py
index 2c4bee52..55c0307f 100644
--- a/custom_components/span_panel/adoption.py
+++ b/custom_components/span_panel/adoption.py
@@ -47,6 +47,7 @@
from .const import DOMAIN
from .entity import SpanPanelEntity
+from .id_builder import get_user_friendly_suffix
from .util import ADOPTED_IDENTIFIER_TOKEN
if TYPE_CHECKING:
@@ -192,6 +193,32 @@ def resolve_identifier(registry: DeviceRegistry, panel_serial: str, device: Adop
return adopted_identifier(panel_serial, adopted_anchor(device))
+def adopted_unique_id(identifier: str, declaration: AdoptedProperty) -> str:
+ """Return the unique id for one adopted property, in this integration's grammar.
+
+ `span_{serial}_adopted_{anchor}_{suffix}` -- the same
+ `span_{serial}_{scope}_{suffix}` shape every curated id has, built through the
+ same suffix helper. Uniform on purpose: a reader that parses an id by
+ position, as `extract_circuit_uuid_from_unique_id` does, must not meet a
+ second grammar. An earlier version lower-cased and de-hyphenated the whole
+ string, which mangled the panel serial itself -- `span_sp3_242424_001_...`
+ where every other id in the integration says `span_sp3-242424-001_...`.
+
+ Only the node and property are de-hyphenated, because they are the segment
+ that has to end up snake_case like a curated suffix. The serial and the
+ anchor keep their hyphens, exactly as a curated id keeps the serial's.
+
+ **Uniform grammar, and still not the id curation will mint.** Two of the three
+ segments change when a device type stops being unmodelled: the scope becomes
+ that type's sub-device kind rather than `adopted_{anchor}`, and the suffix
+ becomes a human-chosen description key rather than a wire address. Those are
+ the change itself, not a formatting difference, which is why promotion needs
+ to take over the existing id rather than expecting to reproduce it.
+ """
+ wire_path = f"{declaration.node_id}.{declaration.property_id}".replace("-", "_")
+ return f"span_{identifier.lower()}_{get_user_friendly_suffix(wire_path)}"
+
+
def adopted_device_info(
identifier: str,
device: AdoptedDevice,
@@ -291,9 +318,7 @@ def __init__(
super().__init__(coordinator)
self._device_wire_id = device.device_id
self._declaration_path = declaration.path
- self._attr_unique_id = (
- f"span_{identifier}_{declaration.node_id}_{declaration.property_id}".replace("-", "_")
- )
+ self._attr_unique_id = adopted_unique_id(identifier, declaration)
self._attr_name = _humanised(declaration.property_id)
self._attr_device_info = adopted_device_info(
identifier, device, panel_device_id=panel_device_id
diff --git a/tests/test_adoption.py b/tests/test_adoption.py
index 7784103e..16b54c64 100644
--- a/tests/test_adoption.py
+++ b/tests/test_adoption.py
@@ -27,6 +27,7 @@
DEVICE_CLASS_BY_UNIT,
adopted_control_count,
adopted_identifier,
+ adopted_unique_id,
async_register_adopted_devices,
classify,
create_adopted_binary_sensors,
@@ -37,6 +38,7 @@
resolve_identifier,
)
from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.id_builder import build_panel_unique_id
from custom_components.span_panel.util import (
ADOPTED_IDENTIFIER_TOKEN,
classify_sub_device_identifier,
@@ -557,3 +559,57 @@ def test_a_panel_with_no_adopted_device_registers_nothing(hass: HomeAssistant, r
if any(ADOPTED_IDENTIFIER_TOKEN in identifier for _domain, identifier in device.identifiers)
]
assert adopted == []
+
+
+# -- The id grammar is one grammar -------------------------------------------
+
+
+def test_an_adopted_id_follows_the_same_grammar_as_a_curated_one() -> None:
+ """`span_{serial}_{scope}_{suffix}`, with the serial spelled the same way.
+
+ An earlier version lower-cased and de-hyphenated the whole string, which
+ mangled the serial into `span_sp3_242424_001_...` where every other id in the
+ integration says `span_sp3-242424-001_...`. A reader that parses an id by
+ position -- `extract_circuit_uuid_from_unique_id` does -- must not meet a
+ second grammar.
+ """
+ declaration = _property(node_id="meter", property_id="active-power")
+ identifier = adopted_identifier(PANEL_SERIAL, "generator-1")
+
+ adopted = adopted_unique_id(identifier, declaration)
+ curated = build_panel_unique_id(PANEL_SERIAL, "panel.instant_grid_power_w")
+
+ prefix = f"span_{PANEL_SERIAL}_"
+ assert adopted.startswith(prefix)
+ assert curated.startswith(prefix)
+ assert adopted == f"{prefix}{ADOPTED_IDENTIFIER_TOKEN}_generator-1_meter_active_power"
+
+
+def test_the_wire_address_is_snake_cased_and_the_anchor_is_not() -> None:
+ """The suffix has to read like a curated suffix; the anchor is an identity.
+
+ A serial keeps its hyphens in every curated id, and an adopted anchor is the
+ same kind of thing -- a name the device is known by, not a description key.
+ """
+ declaration = _property(node_id="charge-limit", property_id="owner-limit")
+ identifier = adopted_identifier(PANEL_SERIAL, "EX-0000-0001")
+
+ assert adopted_unique_id(identifier, declaration).endswith("_EX-0000-0001_charge_limit_owner_limit".lower())
+
+
+def test_curation_changes_two_of_the_three_segments(hass: HomeAssistant) -> None:
+ """Why uniform grammar does not remove the migration promotion needs.
+
+ The serial is the same either way. The scope becomes a curated sub-device kind
+ rather than `adopted_{anchor}`, and the suffix becomes a human-chosen
+ description key rather than a wire address. Both are the change itself, not a
+ formatting difference -- so a curated description cannot reproduce the adopted
+ id and has to take it over instead.
+ """
+ declaration = _property(node_id="meter", property_id="active-power")
+ adopted = adopted_unique_id(adopted_identifier(PANEL_SERIAL, "generator-1"), declaration)
+ curated_if_modelled = build_panel_unique_id(PANEL_SERIAL, "generator.active_power")
+
+ assert adopted != curated_if_modelled
+ assert ADOPTED_IDENTIFIER_TOKEN in adopted
+ assert ADOPTED_IDENTIFIER_TOKEN not in curated_if_modelled
From 52ae9e06fef616e68d32648cce762cdd90716f5a Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 13:19:16 -0700
Subject: [PATCH 070/116] test(id_builder): close the suffix mappings against
silent id moves
The three suffix dictionaries translate legacy camelCase description keys into
the suffixes their entities have carried since before 2.0.8. That suffix is
shared by the unique_id and the entity_id, so an edit moves both on every
installed panel: the unique_id costs the long-term statistics and the
entity_id breaks the user's templates.
Adding an entry was the live hazard, because it silently reroutes a key that
previously resolved verbatim. The dictionaries are now held to their exact
contents, so an added key, a removed key and a changed value all fail --
verified by mutating each rather than by inspection.
The rule for anything new is verbatim, which the sub-device builders already
follow: their keys were written snake_case and never needed translating.
This also settles going verbatim across the board. A unique_id-only migration
would not cost statistics, since those key on the entity_id it preserves --
but because the suffix is shared, verbatim everywhere would force either
verbatim entity_ids, breaking every user reference, or decoupling the two and
losing the consistency the helper exists to provide.
---
custom_components/span_panel/id_builder.py | 54 ++++++++
developer.md | 18 +++
tests/test_suffix_mappings_are_closed.py | 145 +++++++++++++++++++++
3 files changed, 217 insertions(+)
create mode 100644 tests/test_suffix_mappings_are_closed.py
diff --git a/custom_components/span_panel/id_builder.py b/custom_components/span_panel/id_builder.py
index b474a733..5d02bb8d 100644
--- a/custom_components/span_panel/id_builder.py
+++ b/custom_components/span_panel/id_builder.py
@@ -31,6 +31,24 @@
"current": "current",
"breaker_rating": "breaker_rating",
}
+"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style.
+
+Every entry here translates a legacy camelCase description key into the suffix
+its entities have carried since before 2.0.8 -- and that suffix is shared by the
+`unique_id` *and* the `entity_id`, so a changed entry moves both on every
+installed panel. A moved `unique_id` costs the statistics; a moved `entity_id`
+breaks the templates and automations a user wrote.
+
+So the rule for anything new is **verbatim**: a description key added from here on
+resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`,
+`build_mid_unique_id`, `build_evse_unique_id`) have always done. Their keys were
+written snake_case and never needed translating.
+
+`tests/test_suffix_mappings_are_closed.py` holds these dictionaries to their
+exact contents. It fails on an added key, a removed key and a changed value,
+because all three move a live id.
+"""
+
# Panel sensor API field mappings (used by get_user_friendly_suffix)
# Includes main meter/feedthrough produced, consumed, and net energy
@@ -49,6 +67,24 @@
"feedthroughNetEnergyWh": "feed_through_energy_net", # Consistent naming
"batteryPercentage": "battery_percentage",
}
+"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style.
+
+Every entry here translates a legacy camelCase description key into the suffix
+its entities have carried since before 2.0.8 -- and that suffix is shared by the
+`unique_id` *and* the `entity_id`, so a changed entry moves both on every
+installed panel. A moved `unique_id` costs the statistics; a moved `entity_id`
+breaks the templates and automations a user wrote.
+
+So the rule for anything new is **verbatim**: a description key added from here on
+resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`,
+`build_mid_unique_id`, `build_evse_unique_id`) have always done. Their keys were
+written snake_case and never needed translating.
+
+`tests/test_suffix_mappings_are_closed.py` holds these dictionaries to their
+exact contents. It fails on an added key, a removed key and a changed value,
+because all three move a live id.
+"""
+
# Panel entity suffix mappings (used by get_panel_entity_suffix)
# These are the actual entity_id/unique_id suffixes used for panel sensors
@@ -68,6 +104,24 @@
"feedthroughNetEnergyWh": "feed_through_net_energy",
"batteryPercentage": "battery_level",
}
+"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style.
+
+Every entry here translates a legacy camelCase description key into the suffix
+its entities have carried since before 2.0.8 -- and that suffix is shared by the
+`unique_id` *and* the `entity_id`, so a changed entry moves both on every
+installed panel. A moved `unique_id` costs the statistics; a moved `entity_id`
+breaks the templates and automations a user wrote.
+
+So the rule for anything new is **verbatim**: a description key added from here on
+resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`,
+`build_mid_unique_id`, `build_evse_unique_id`) have always done. Their keys were
+written snake_case and never needed translating.
+
+`tests/test_suffix_mappings_are_closed.py` holds these dictionaries to their
+exact contents. It fails on an added key, a removed key and a changed value,
+because all three move a live id.
+"""
+
# Combined mapping for general suffix lookup
ALL_SUFFIX_MAPPINGS = {**CIRCUIT_SUFFIX_MAPPING, **PANEL_SUFFIX_MAPPING}
diff --git a/developer.md b/developer.md
index dc91db48..2d959b26 100644
--- a/developer.md
+++ b/developer.md
@@ -245,6 +245,24 @@ The adapter decides "read" from four enumerations of what it addresses — the m
of the report. `tests/test_schema_one_discovery.py` in the library runs the same republish-and-diff experiment this gate uses and holds every entry to it in
both directions, so the report means "nothing reads this" rather than "nobody wrote it down".
+## The suffix mappings are closed
+
+`get_user_friendly_suffix` and `get_panel_entity_suffix` translate legacy camelCase description keys (`instantPowerW`, `instantGridPowerW`, `doorState`) into
+the suffixes their entities have carried since before 2.0.8. **Do not add entries.** A new description key needs none: it resolves to itself, which is what the
+sub-device builders (`build_bess_unique_id`, `build_mid_unique_id`, `build_evse_unique_id`) have always done, since their keys were written snake_case.
+
+The reason is that the suffix is not only in the `unique_id` — it is the segment shared with the `entity_id` (`sensor_circuit.py:213`, and
+`get_panel_entity_suffix`'s own docstring says so). So an edit here moves both on every installed panel: the `unique_id` costs the long-term statistics, and the
+`entity_id` breaks whatever templates and automations a user wrote against it.
+
+`tests/test_suffix_mappings_are_closed.py` holds all three dictionaries to their exact contents and fails on an added key, a removed key or a changed value —
+verified by mutation, not by inspection.
+
+This closes the question of whether to go verbatim everywhere. The migration mechanism exists and would not cost statistics, since those key on `statistic_id`
+(the entity_id), which a `unique_id`-only migration preserves. But because the suffix is shared, verbatim-across-the-board would force either verbatim
+`entity_id`s — `sensor.span_panel_kitchen_instantPowerW`, breaking every user reference — or a decoupling of the two, which throws away the consistency the
+helper exists to provide. Closing the mapping gets the whole benefit for none of that.
+
## Adopting a device this integration models nothing for
The section above is about properties on devices we already read. This one is about a device type nobody modelled at all — a vendor's generator, heat pump or
diff --git a/tests/test_suffix_mappings_are_closed.py b/tests/test_suffix_mappings_are_closed.py
new file mode 100644
index 00000000..56c24e2d
--- /dev/null
+++ b/tests/test_suffix_mappings_are_closed.py
@@ -0,0 +1,145 @@
+"""The suffix mappings are a closed compatibility shim, and this is what closes them.
+
+`get_user_friendly_suffix` and `get_panel_entity_suffix` translate legacy camelCase
+description keys into the suffixes their entities have carried since before 2.0.8.
+That suffix is shared by the `unique_id` **and** the `entity_id`, so a changed entry
+moves both on every installed panel: the `unique_id` costs the long-term statistics,
+and the `entity_id` breaks whatever templates and automations a user wrote against it.
+
+Three edits move a live id and all three fail here:
+
+- **adding** a key, which silently reroutes a description that previously resolved
+ verbatim -- the failure mode that prompted this file
+- **removing** a key, which sends a legacy description back to its raw camelCase
+- **changing** a value, which is the most direct version of the same thing
+
+The rule for anything new is verbatim, which is what the sub-device builders have
+always done. Nothing here needs extending to add a sensor.
+"""
+
+from __future__ import annotations
+
+from custom_components.span_panel.id_builder import (
+ ALL_SUFFIX_MAPPINGS,
+ CIRCUIT_SUFFIX_MAPPING,
+ PANEL_ENTITY_SUFFIX_MAPPING,
+ PANEL_SUFFIX_MAPPING,
+ build_bess_unique_id,
+ build_circuit_unique_id,
+ build_evse_unique_id,
+ build_mid_unique_id,
+ build_panel_unique_id,
+ get_panel_entity_suffix,
+ get_user_friendly_suffix,
+)
+
+SERIAL = "sp3-001"
+CIRCUIT = "0dad2f16cd514812ae1807b0457d473e"
+
+_CIRCUIT_SUFFIXES = {
+ "instantPowerW": "power",
+ "producedEnergyWh": "energy_produced",
+ "consumedEnergyWh": "energy_consumed",
+ "netEnergyWh": "energy_net",
+ "importedEnergyWh": "energy_imported",
+ "exportedEnergyWh": "energy_exported",
+ "circuit_priority": "priority",
+ "current": "current",
+ "breaker_rating": "breaker_rating",
+}
+
+_PANEL_SUFFIXES = {
+ "instantGridPowerW": "grid_power",
+ "feedthroughPowerW": "feed_through_power",
+ "batteryPowerW": "battery_power",
+ "pvPowerW": "pv_power",
+ "gridPowerFlowW": "grid_power_flow",
+ "sitePowerW": "site_power",
+ "mainMeterEnergyProducedWh": "main_meter_energy_produced",
+ "mainMeterEnergyConsumedWh": "main_meter_energy_consumed",
+ "mainMeterNetEnergyWh": "main_meter_energy_net",
+ "feedthroughEnergyProducedWh": "feed_through_energy_produced",
+ "feedthroughEnergyConsumedWh": "feed_through_energy_consumed",
+ "feedthroughNetEnergyWh": "feed_through_energy_net",
+ "batteryPercentage": "battery_percentage",
+}
+
+_PANEL_ENTITY_SUFFIXES = {
+ "instantGridPowerW": "current_power",
+ "feedthroughPowerW": "feed_through_power",
+ "batteryPowerW": "battery_power",
+ "pvPowerW": "pv_power",
+ "gridPowerFlowW": "grid_power_flow",
+ "sitePowerW": "site_power",
+ "mainMeterEnergyProducedWh": "main_meter_produced_energy",
+ "mainMeterEnergyConsumedWh": "main_meter_consumed_energy",
+ "mainMeterNetEnergyWh": "main_meter_net_energy",
+ "feedthroughEnergyProducedWh": "feed_through_produced_energy",
+ "feedthroughEnergyConsumedWh": "feed_through_consumed_energy",
+ "feedthroughNetEnergyWh": "feed_through_net_energy",
+ "batteryPercentage": "battery_level",
+}
+
+_CLOSED = (
+ "This mapping is closed. Adding, removing or changing an entry moves a live "
+ "unique_id and entity_id on every installed panel -- statistics and the user's "
+ "templates both. A new description key needs no entry: it resolves verbatim."
+)
+
+
+def test_the_circuit_suffix_mapping_is_frozen() -> None:
+ assert CIRCUIT_SUFFIX_MAPPING == _CIRCUIT_SUFFIXES, _CLOSED
+
+
+def test_the_panel_suffix_mapping_is_frozen() -> None:
+ assert PANEL_SUFFIX_MAPPING == _PANEL_SUFFIXES, _CLOSED
+
+
+def test_the_panel_entity_suffix_mapping_is_frozen() -> None:
+ assert PANEL_ENTITY_SUFFIX_MAPPING == _PANEL_ENTITY_SUFFIXES, _CLOSED
+
+
+def test_the_combined_mapping_is_exactly_its_two_halves() -> None:
+ """`ALL_SUFFIX_MAPPINGS` is derived, so it cannot gain an entry of its own.
+
+ Pinned because it is the one a caller reaches for, and a hand-added entry here
+ would route a key without appearing in either half above.
+ """
+ assert ALL_SUFFIX_MAPPINGS == {**_CIRCUIT_SUFFIXES, **_PANEL_SUFFIXES}
+
+
+def test_a_key_the_shim_does_not_carry_resolves_to_itself() -> None:
+ """The rule for everything added from here on.
+
+ Sampled from keys real sub-device sensors use, so this fails if the fallback
+ is ever changed to normalise, prefix or otherwise reshape an unmapped key.
+ """
+ for key in ("soe_kwh", "meter_power", "mid_grid_state", "evse_status", "grid_islandable"):
+ assert get_user_friendly_suffix(key) == key
+ assert get_panel_entity_suffix(key) == key
+
+
+def test_the_sub_device_builders_never_consult_the_shim() -> None:
+ """Verbatim by construction, which is why they are safe from an edit above.
+
+ A BESS, MID or EVSE id is its description key. Even if somebody added a
+ mapping entry for one of these keys, these builders would not read it -- and
+ the frozen dictionaries above are what stops the attempt reaching review.
+ """
+ assert build_bess_unique_id(SERIAL, "soe_kwh") == f"span_{SERIAL}_bess_soe_kwh"
+ assert build_mid_unique_id(SERIAL, "mid_grid_state") == f"span_{SERIAL}_mid_mid_grid_state"
+ assert build_evse_unique_id(SERIAL, "evse-1", "evse_status") == f"span_{SERIAL}_evse_evse-1_evse_status"
+
+
+def test_the_legacy_ids_the_shim_exists_to_preserve() -> None:
+ """What the frozen entries actually buy, spelled out as ids rather than suffixes.
+
+ These are the strings on installed panels. If one of them changes, an upgrade
+ orphans the entity, Home Assistant registers a replacement with a `_2` suffix,
+ and the history stays on the entity nobody is looking at any more.
+ """
+ assert build_circuit_unique_id(SERIAL, CIRCUIT, "instantPowerW") == f"span_{SERIAL}_{CIRCUIT}_power"
+ assert build_circuit_unique_id(SERIAL, CIRCUIT, "producedEnergyWh") == f"span_{SERIAL}_{CIRCUIT}_energy_produced"
+ assert build_circuit_unique_id(SERIAL, "unmapped_tab_32", "instantPowerW") == f"span_{SERIAL}_unmapped_tab_32_power"
+ assert build_panel_unique_id(SERIAL, "instantGridPowerW") == f"span_{SERIAL}_current_power"
+ assert build_panel_unique_id(SERIAL, "doorState") == f"span_{SERIAL}_doorstate"
From 0784d059c0a976ec7ea988306a701b5081f326de Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 13:41:11 -0700
Subject: [PATCH 071/116] feat(adoption): report whether an adopted device is
proxied by a peer
The library now carries the proxy link an adopted device declares. Diagnostics
report it as a boolean and never as the parent's id: a device id can embed a
serial, since producers derive a DER's id preferring a serial over a default
slug, so reporting the parent verbatim would leak the serial the block
deliberately withholds.
No topology changes. Every adopted device is still registered under the panel,
as every curated sub-device is. The nesting waits on python-sdk#57, where the
graph builder is still to be reconciled against the new DeviceTreeBuilder.
---
CHANGELOG.md | 3 +++
custom_components/span_panel/diagnostics.py | 10 +++++++++
developer.md | 24 +++++++++++++++++++++
tests/test_adoption.py | 22 +++++++++++++++++++
4 files changed, 59 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c08598b5..9cb974e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file.
### Added
+- **Diagnostics now say whether an adopted device is proxied by another device rather than by the panel.** Reported as a yes/no, never as the parent's
+ identifier: a device identifier can contain a serial number, and diagnostics deliberately carry no serials.
+
- **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were
mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity
list, which nobody is: an addition that breaks nothing was indistinguishable from no addition at all. The notification names every entity that was added,
diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py
index cc95bc25..d7b050f5 100644
--- a/custom_components/span_panel/diagnostics.py
+++ b/custom_components/span_panel/diagnostics.py
@@ -102,10 +102,19 @@ class AdoptedDeviceRow(TypedDict):
device card, but neither answers the question this block exists to ask -- and
a vendor-set device name is free text a household chose. The type, the shape
and the counts are what a maintainer needs.
+
+ **`proxied` rather than `parent`.** The parent is a device id, and a device id
+ can embed a serial: producers derive a DER's id preferring a serial over a
+ default slug, which is why this repository holds PV's `info/serial-number`
+ unvalued. Reporting the id verbatim would leak the serial this block
+ deliberately withholds. The boolean answers the question a maintainer
+ actually has -- has a *proxied* unmodelled device appeared -- and carries no
+ identity.
"""
device_type: str
model: str | None
+ proxied: bool
property_count: int
properties: list[AdoptedPropertyRow]
@@ -154,6 +163,7 @@ def _adoption(snapshot: SpanPanelSnapshot) -> AdoptionBlock:
{
"device_type": device.device_type,
"model": device.model,
+ "proxied": device.proxied,
"property_count": len(device.properties),
"properties": [
{
diff --git a/developer.md b/developer.md
index 2d959b26..e76bddf9 100644
--- a/developer.md
+++ b/developer.md
@@ -323,6 +323,30 @@ node rule, creates no entity, and so had nothing to call `async_get_or_create` f
Running it before the platforms also makes the identity freeze single-valued: `resolve_identifier` runs once, at registration, so every entity created
afterwards resolves against a device that already exists and cannot disagree.
+### The proxy link is recorded, and the nesting is not built
+
+`AdoptedDevice` carries `parent` (the device id it declares as its parent) and `proxied` (whether that parent is a peer rather than the tree root). Adoption
+does not act on either: every adopted device is registered under the panel with `via_device_id`, exactly as every curated sub-device is.
+
+They are carried because a _proxied_ unmodelled device is a real shape we would otherwise flatten away without leaving evidence. The library's own reference
+tree contains one — `bess-mid` declares `parent: bess`, which is the `{proxier-id}-{proxied-id}` naming of the specification's `devices/proxy.md`. A vendor
+gateway proxying its own sub-devices arrives the same way, and the parent link is the only structural information about how they relate.
+
+**Diagnostics report `proxied`, never `parent`.** A device id can embed a serial — producers derive a DER's id preferring a serial over a default slug, which is
+why the library holds PV's `info/serial-number` unvalued — so reporting the parent verbatim would leak the serial the block deliberately withholds. The boolean
+answers a maintainer's actual question, which is whether a proxied unmodelled device has appeared at all.
+
+**Why the nesting waits.** [python-sdk#49](https://github.com/electrification-bus/python-sdk/issues/49#issuecomment-5359203067) settled that proxied ids differ
+by design — several enclosures on a shared broker each proxying the same physical device produce different ids on purpose — and that consumers correlate by
+`info/serial-number`, never by device id. It also records that `ebus-sdk` 0.21.0 shipped `DeviceSpec` and `DeviceTreeBuilder`
+([python-sdk#57](https://github.com/electrification-bus/python-sdk/issues/57)), with the existing graph builder still to be reconciled against it. The tree
+model is under active reshaping upstream, so the fields capture the evidence and the topology waits.
+
+That comment also strengthens two things already here. Its deferral mechanism — a `device_id` callable returning `None` defers the device until
+`resolve_deferred()` — is the producer-side form of settling identity _before_ a device exists, which is what registering adopted devices ahead of the platforms
+does from this end. And "there is deliberately no existence predicate … expressing it by not calling `add()` is right" is the rule the capability gates already
+follow: presence in the tree is the signal, and there is no flag to consult.
+
### Identity freezes at first sighting
`resolve_identifier` looks up **both** candidate spellings — `{panel serial}_adopted_{wire id}` and `{panel serial}_adopted_{serial}` — before minting either,
diff --git a/tests/test_adoption.py b/tests/test_adoption.py
index 16b54c64..b5be5d00 100644
--- a/tests/test_adoption.py
+++ b/tests/test_adoption.py
@@ -10,6 +10,7 @@
import ast
from dataclasses import replace
+import json
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock
@@ -38,6 +39,7 @@
resolve_identifier,
)
from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.diagnostics import _adoption
from custom_components.span_panel.id_builder import build_panel_unique_id
from custom_components.span_panel.util import (
ADOPTED_IDENTIFIER_TOKEN,
@@ -613,3 +615,23 @@ def test_curation_changes_two_of_the_three_segments(hass: HomeAssistant) -> None
assert adopted != curated_if_modelled
assert ADOPTED_IDENTIFIER_TOKEN in adopted
assert ADOPTED_IDENTIFIER_TOKEN not in curated_if_modelled
+
+
+# -- Diagnostics report the proxy relationship, never the parent's id ---------
+
+
+def test_diagnostics_report_whether_a_device_is_proxied_and_not_by_whom() -> None:
+ """A device id can embed a serial, so the id must not go in the payload.
+
+ Producers derive a DER's id preferring a serial over a default slug -- which
+ is why this repository holds PV's `info/serial-number` unvalued -- so
+ reporting `parent` verbatim would leak the serial the block deliberately
+ withholds. The boolean answers the maintainer's actual question.
+ """
+ device = replace(_device("gateway-1-sensor"), parent="gateway-1", proxied=True)
+ block = _adoption(_snapshot(device))
+
+ (row,) = block["devices"]
+ assert row["proxied"] is True
+ assert "parent" not in row
+ assert "gateway-1" not in json.dumps(block)
From 530e2b83871a77d19f50eea04a4c3d103beb6fed Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 16:22:45 -0700
Subject: [PATCH 072/116] fix(sensors): disable the five unreliable panel
readings, withdraw the sixth
The eBus maintainer's r202633 conformance note names six properties this
integration surfaces. Five shipped in 2.0.8; one never shipped. That decides
the remedy for each.
Disabled by default: feedthrough produced/consumed energy (the difference of
two unrelated counters, non-monotonic, ~whole-panel figures where the truth is
zero), feedthrough power (inverted relative to every other enclosure terminal),
and both downstream currents (they report the upstream service conductors).
Disabled rather than removed because an existing install has them with history
and entities must not vanish.
This helps nobody who already has them, and the comments say so:
entity_registry_enabled_default applies at first registration only, so an
existing install keeps them enabled. It stops new installs adopting them.
Withdrawn: bess_meter_power. It was added in the v1.0 work and never released,
so no install carries it and dropping it is free -- it would not be after a
release. Shipping it disabled would mean shipping a new entity known to be
inverted on real firmware, whose wrongness a user cannot detect: both signs are
plausible and the two topics are numerically identical. The reading it provided
is not lost, since battery_power reads power-flows/battery, which the note
confirms is correct as published.
Restore it when the firmware publishes the specified frame. The condition is on
BESS_TELEMETRY_SENSORS, the assumption is named in the library's devices.py,
and the wire property stays in the unread baseline with that reason.
---
CHANGELOG.md | 15 ++
custom_components/span_panel/field_paths.py | 11 +-
custom_components/span_panel/icons.json | 3 -
.../span_panel/sensor_definitions.py | 160 +++++++-----
custom_components/span_panel/strings.json | 3 -
.../span_panel/translations/en.json | 3 -
.../span_panel/translations/es.json | 3 -
.../span_panel/translations/fr.json | 3 -
.../span_panel/translations/ja.json | 3 -
.../span_panel/translations/pt.json | 3 -
tests/test_bess_telemetry.py | 233 ++++--------------
11 files changed, 164 insertions(+), 276 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9cb974e2..580d714d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,21 @@ All notable changes to this project will be documented in this file.
### Added
+### Changed
+
+- **Five panel sensors are switched off for new installations, because the eBus specification's own maintainer has documented that their values cannot be relied
+ on.** A conformance note for SPAN firmware r202633 identifies three defects in what the panel publishes, all of which predate that release: the feedthrough
+ (downstream lugs) energy registers are computed from two unrelated counters and can decrease or go negative — on a panel with no feedthrough load they report
+ roughly whole-panel figures where the truth is zero — the feedthrough power reading is inverted relative to every other terminal, and the feedthrough currents
+ report the _upstream_ service conductors rather than a downstream measurement. The affected entities are **Feedthrough Produced Energy**, **Feedthrough
+ Consumed Energy**, **Feedthrough Power**, and the two **Downstream** current sensors.
+- **If you already have those five, nothing changes and they stay exactly where they are.** Home Assistant consults the enabled-by-default setting only when an
+ entity is first created, so an existing installation keeps them, keeps its history and keeps its entity IDs. This stops new installations picking them up; it
+ cannot reach back. If you use any of the five on a dashboard or in an automation, they are worth removing — but that is your decision to make, not something
+ an upgrade should do to you.
+- **Your other panel readings are unaffected.** The upstream lugs, the main panel meter and every circuit are in the correct frame. So is the power-flow group,
+ which the specification has now been corrected to describe the way the panel has always published it.
+
- **Diagnostics now say whether an adopted device is proxied by another device rather than by the panel.** Reported as a yes/no, never as the parent's
identifier: a device identifier can contain a serial number, and diagnostics deliberately carry no serials.
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index 791f019f..c48fbf1b 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -294,11 +294,12 @@ class FieldPathDeclarationMixin:
# is what makes these SCHEMA_1_ONLY rather than NEITHER and buys them unit
# and datatype validation against the BESS's own `$description`.
#
- # `battery.power_w` is charge-positive in the snapshot -- the library
- # negates the enclosure's meter frame -- which is the same direction
- # `battery_power` shows after negating `panel.power_flow_battery`. The two
- # sensors sit on one device card and must not disagree about which way is
- # charging.
+ # `battery.power_w` has no entity of its own since `bess_meter_power` was
+ # withdrawn -- see `BESS_TELEMETRY_SENSORS` for why and for the condition
+ # to restore it. The path stays exempt and annotated because `has_bess`
+ # still reads the field to decide whether a battery is commissioned, and
+ # the annotation is a fact about which adapter produces it rather than
+ # about who consumes it.
"battery.power_w": Producibility.SCHEMA_1_ONLY,
"battery.communication_state": Producibility.SCHEMA_1_ONLY,
# The Power Control System's result, behind `pcs_import_limit`,
diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json
index d96c4534..edf980c4 100644
--- a/custom_components/span_panel/icons.json
+++ b/custom_components/span_panel/icons.json
@@ -74,9 +74,6 @@
"bess_communication_state": {
"default": "mdi:lan-connect"
},
- "bess_meter_power": {
- "default": "mdi:battery-charging-outline"
- },
"bess_nameplate_capacity": {
"default": "mdi:battery-high"
},
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index e1a406f5..4c3adf43 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -297,32 +297,42 @@ class SpanPanelBatterySensorEntityDescription(
)
)
-DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = (
- SpanPanelDataSensorEntityDescription(
- key="downstream_l1_current",
- field_path="panel.downstream_l1_current_a",
- translation_key="downstream_l1_current",
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- entity_category=EntityCategory.DIAGNOSTIC,
- suggested_display_precision=2,
- value_fn=lambda s: s.downstream_l1_current_a,
- )
+DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
+ key="downstream_l1_current",
+ field_path="panel.downstream_l1_current_a",
+ # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
+ # note says this cannot be relied on -- reports the UPSTREAM service conductors, not a downstream measurement.
+ # Disabled rather than removed because it shipped in 2.0.8, so an existing
+ # install has it with history and entities must not vanish. This changes
+ # nothing for those installs -- the default applies at first registration
+ # only -- it stops NEW installs adopting it. See the delta document.
+ entity_registry_enabled_default=False,
+ translation_key="downstream_l1_current",
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ suggested_display_precision=2,
+ value_fn=lambda s: s.downstream_l1_current_a,
)
-DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = (
- SpanPanelDataSensorEntityDescription(
- key="downstream_l2_current",
- field_path="panel.downstream_l2_current_a",
- translation_key="downstream_l2_current",
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- entity_category=EntityCategory.DIAGNOSTIC,
- suggested_display_precision=2,
- value_fn=lambda s: s.downstream_l2_current_a,
- )
+DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
+ key="downstream_l2_current",
+ field_path="panel.downstream_l2_current_a",
+ # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
+ # note says this cannot be relied on -- reports the UPSTREAM service conductors, not a downstream measurement.
+ # Disabled rather than removed because it shipped in 2.0.8, so an existing
+ # install has it with history and entities must not vanish. This changes
+ # nothing for those installs -- the default applies at first registration
+ # only -- it stops NEW installs adopting it. See the delta document.
+ entity_registry_enabled_default=False,
+ translation_key="downstream_l2_current",
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ suggested_display_precision=2,
+ value_fn=lambda s: s.downstream_l2_current_a,
)
# Main breaker rating sensor (v2 only, conditionally created)
@@ -799,21 +809,7 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
),
)
-BESS_TELEMETRY_SENSORS: tuple[
- SpanBessMetadataSensorEntityDescription,
- SpanBessMetadataSensorEntityDescription,
-] = (
- SpanBessMetadataSensorEntityDescription(
- key="meter_power",
- field_path="battery.power_w",
- derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
- translation_key="bess_meter_power",
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=UnitOfPower.WATT,
- suggested_display_precision=0,
- value_fn=lambda b: b.power_w,
- ),
+BESS_TELEMETRY_SENSORS: tuple[SpanBessMetadataSensorEntityDescription,] = (
SpanBessMetadataSensorEntityDescription(
key="communication_state",
field_path="battery.communication_state",
@@ -830,35 +826,55 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
Separate from `BESS_METADATA_SENSORS` because these are created conditionally and
those are not. Every metadata sensor exists on any commissioned BESS, filled or
-empty. These two come from capability nodes a BESS may simply not have, so
+empty. This one comes from a capability node a BESS may simply not have, so
absence has to mean no entity rather than a permanently unknown one, and mixing
the two rules into one tuple would mean deciding per description which applied.
-**Power is enabled by default and not diagnostic; communication state is
-neither.** The battery's own charge/discharge figure is a reading a user graphs
-and automates on. Its link health is a fault signal — interesting when something
-is wrong, noise on a device card the rest of the time — so it lands the way the
-other diagnostics do, off by default and available to anyone who wants it.
-
-**`bess_meter_power` is not `battery_power`, and the names say so.** The existing
-`battery_power` sensor reads `panel.power_flow_battery`, the enclosure's own
-arbitrated flow figure; this one reads the BESS's `meter/active-power`, the
-battery's own meter. On a healthy panel they agree, and where they disagree that
-is a fact worth being able to see rather than one to hide behind a single entity.
-
-**Both read charge-positive, which is what makes them agree.** The library
-negates the BESS meter into the snapshot's frame (`SpanBatterySnapshot.power_w`
-is documented charge-positive), and `BATTERY_POWER_SENSOR` negates
-`power_flow_battery`, which the capability catalog defines as
-discharge-positive. Two negations for two opposite wire conventions, landing on
-one convention in the UI: **positive means the battery is charging**. A sensor
-whose sign contradicted the one beside it would be worse than no sensor.
+Communication state is diagnostic and off by default: link health is a fault
+signal, interesting when something is wrong and noise on a device card the rest
+of the time.
+
+**`bess_meter_power` was here and was withdrawn before it ever shipped.**
+
+It read `battery.power_w`, the BESS child's own `meter/active-power`, alongside
+`battery_power` reading the enclosure's `power-flows/battery` — two views of one
+quantity, so that a disagreement between them would be visible rather than
+hidden behind a single entity.
+
+The eBus maintainer's r202633 conformance note (2026-08-20) established that the
+two will disagree *systematically* on a real panel, for a firmware reason rather
+than a panel-health one. The BESS child publishes `meter/active-power`
+charge-positive where the specification requires discharge-positive, and
+`build_battery`'s `_charge_positive()` negates on the assumption that the
+specification holds — so `battery.power_w` inverts on that firmware. The note
+calls this the one divergence a consumer cannot detect on its own: both signs
+are physically plausible and the two topics are numerically identical, so
+nothing in the tree contradicts itself observably.
+
+Nothing here can catch it either. `ebus-panel-sim` 0.6.0 fixed the same
+inversion in the simulator, so on this one property the simulator is now correct
+where the panel is not, and every test in this repository runs against the
+simulator.
+
+Withdrawn rather than compensated, and rather than shipped documented, for the
+reason the timing allows: this description had never been released, so no
+install carries the entity and no history is at stake. Dropping it now is free.
+Once a release creates it, the rule that an existing install's entities cannot
+simply disappear makes the same decision expensive.
+
+**Restore it when the firmware publishes the specified frame.** The correct
+reading is already shipped and unaffected — `battery_power` reads
+`power-flows/battery`, which the note confirms is correct as published — so
+nothing is lost meanwhile except the ability to compare the two. The wire
+property stays in `tests/fixtures/unread_declarations_baseline.json` with that
+reason, which is what will fail when somebody surfaces it again without
+revisiting this.
**`derived` as well as `field_path`, by the producible rule.** The gate wants a
path both adapters produce, and flat's BESS device class declares neither
property — so `SCHEMA_CONDITIONAL_FIELD`, with the paths enumerated in
`RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`. `field_path` still names the source,
-which is what gives each sensor its Repair mention and its unavailability when
+which is what gives the sensor its Repair mention and its unavailability when
the panel stops resolving the property.
"""
@@ -930,6 +946,14 @@ class SpanPVMetadataSensorEntityDescription(
SpanPanelDataSensorEntityDescription(
key="feedthroughPowerW",
field_path="panel.feedthrough_power_w",
+ # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
+ # note says this cannot be relied on -- inverted: positive when power leaves the enclosure, where every other
+ # enclosure terminal is positive inbound. Negate to compensate.
+ # Disabled rather than removed because it shipped in 2.0.8, so an existing
+ # install has it with history and entities must not vanish. This changes
+ # nothing for those installs -- the default applies at first registration
+ # only -- it stops NEW installs adopting it. See the delta document.
+ entity_registry_enabled_default=False,
translation_key="feedthrough_power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -1019,6 +1043,14 @@ class SpanPVMetadataSensorEntityDescription(
SpanPanelDataSensorEntityDescription(
key="feedthroughEnergyProducedWh",
field_path="panel.feedthrough_energy_produced_wh",
+ # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
+ # note says this cannot be relied on -- the difference of two unrelated counters, non-monotonic, and ~whole-panel
+ # figures where the truth is zero. No transformation recovers a real value.
+ # Disabled rather than removed because it shipped in 2.0.8, so an existing
+ # install has it with history and entities must not vanish. This changes
+ # nothing for those installs -- the default applies at first registration
+ # only -- it stops NEW installs adopting it. See the delta document.
+ entity_registry_enabled_default=False,
translation_key="feedthrough_produced_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -1029,6 +1061,14 @@ class SpanPVMetadataSensorEntityDescription(
SpanPanelDataSensorEntityDescription(
key="feedthroughEnergyConsumedWh",
field_path="panel.feedthrough_energy_consumed_wh",
+ # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
+ # note says this cannot be relied on -- the difference of two unrelated counters, non-monotonic, and ~whole-panel
+ # figures where the truth is zero. No transformation recovers a real value.
+ # Disabled rather than removed because it shipped in 2.0.8, so an existing
+ # install has it with history and entities must not vanish. This changes
+ # nothing for those installs -- the default applies at first registration
+ # only -- it stops NEW installs adopting it. See the delta document.
+ entity_registry_enabled_default=False,
translation_key="feedthrough_consumed_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 10936f33..4f568a29 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -200,9 +200,6 @@
"unknown": "Unknown"
}
},
- "bess_meter_power": {
- "name": "Meter Power"
- },
"bess_nameplate_capacity": {
"name": "Nameplate Capacity"
},
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 10936f33..4f568a29 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -200,9 +200,6 @@
"unknown": "Unknown"
}
},
- "bess_meter_power": {
- "name": "Meter Power"
- },
"bess_nameplate_capacity": {
"name": "Nameplate Capacity"
},
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 492ba61a..1b027532 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -200,9 +200,6 @@
"unknown": "Desconocido"
}
},
- "bess_meter_power": {
- "name": "Potencia del Medidor"
- },
"bess_nameplate_capacity": {
"name": "Capacidad Nominal"
},
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 438e943c..e60a9181 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -200,9 +200,6 @@
"unknown": "Inconnu"
}
},
- "bess_meter_power": {
- "name": "Puissance du Compteur"
- },
"bess_nameplate_capacity": {
"name": "Capacité Nominale"
},
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index bf45da3d..eb363fb3 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -200,9 +200,6 @@
"unknown": "不明"
}
},
- "bess_meter_power": {
- "name": "メーター電力"
- },
"bess_nameplate_capacity": {
"name": "定格容量"
},
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index fcd4649b..71b0e6b4 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -200,9 +200,6 @@
"unknown": "Desconhecido"
}
},
- "bess_meter_power": {
- "name": "Potência do Medidor"
- },
"bess_nameplate_capacity": {
"name": "Capacidade Nominal"
},
diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py
index 7d1a206a..f544ea7c 100644
--- a/tests/test_bess_telemetry.py
+++ b/tests/test_bess_telemetry.py
@@ -1,8 +1,8 @@
-"""What the BESS reports about itself, surfaced as two sensors on its own device.
+"""What the BESS reports about itself, surfaced on its own device.
-`meter/active-power` and `status/communication-state` were declared, published
-and read by nobody. They are the battery's own view of its power and its own view
-of its link, as opposed to the enclosure's view of both.
+`status/communication-state` is the battery's own view of its link, as opposed
+to the enclosure's view of it. It was declared, published and read by nobody
+until v1.0.
Every assertion runs against a real snapshot built by the real schema_1 adapter
over the vendored capture, and every expected value is read out of that capture
@@ -10,15 +10,21 @@
pins passes whether or not the wire is ever read. Each reading is proved by
republishing it, deleting it, or dropping the node that carries it.
-**The sign is the hard part, and it is what most of this module is about.** The
-capture is of a *charging* battery, published as a negative `meter/active-power`
-because the enclosure meters the BESS the way it meters a circuit it feeds. The
-snapshot negates that into its charge-positive frame, and `battery_power` — the
-sensor already on this device, reading the enclosure's discharge-positive
-`power-flows/battery` — negates too. Two negations of two opposite wire
-conventions, landing on one convention in the UI. A sensor whose sign
-contradicted the one beside it would be worse than no sensor, so the agreement is
-asserted directly rather than inferred from the two definitions.
+**This module used to be mostly about a sign, and that sensor is gone.**
+`bess_meter_power` read the BESS child's own `meter/active-power` beside
+`battery_power`'s enclosure view, and fourteen tests here pinned their agreement.
+It was withdrawn before release: the eBus maintainer's r202633 conformance note
+established that the panel publishes that property charge-positive where the
+specification requires discharge-positive, so `_charge_positive()` inverts it on
+real firmware. Those tests could never have caught it — `ebus-panel-sim` 0.6.0
+fixed the same inversion in the simulator, so on that one property the simulator
+is correct where the panel is not, and everything here runs against the
+simulator. The agreement they asserted was real and was an agreement between two
+things that were both wrong in the same way.
+
+See `BESS_TELEMETRY_SENSORS` for the withdrawal and the condition for restoring
+it. `POWER_TOPIC` and `POWER_KEY` are kept below because the capture still
+publishes the property and the fixtures still name it.
"""
from __future__ import annotations
@@ -29,7 +35,12 @@
from typing import Any
from unittest.mock import MagicMock, patch
+from homeassistant.components.sensor import SensorDeviceClass
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers.entity import EntityCategory
+from homeassistant.helpers.typing import StateType
import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
from span_panel_api import SpanPanelSnapshot
from custom_components.span_panel import SpanPanelRuntimeData
@@ -49,16 +60,10 @@
SpanPanelBattery,
SpanPanelPowerSensor,
)
-from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
-from homeassistant.const import CONF_HOST, UnitOfPower
-from homeassistant.helpers.entity import EntityCategory
-from homeassistant.helpers.typing import StateType
from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree
from .factories import SpanPanelSnapshotFactory
-from pytest_homeassistant_custom_component.common import MockConfigEntry
-
BESS = "bess"
POWER_TOPIC = "meter/active-power"
@@ -172,56 +177,10 @@ def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime
# ---------------------------------------------------------------------------
-def test_the_capture_publishes_both_properties() -> None:
- """Guard the premise for every test below, all of which read the capture for
- their expected value: a capture that stopped publishing one would make them
- vacuously true rather than failing."""
- assert _published(BESS, POWER_TOPIC)
- assert _published(BESS, COMMS_TOPIC)
-
-
-def test_the_capture_is_a_charging_battery() -> None:
- """The premise of every sign assertion below, derived rather than assumed.
- A sign convention can only be tested against a known physical state, and
- "negative means charging" is the very claim under test, so reading the state
- off the sign would be circular. The enclosure's four power flows balance
- instead — `pv + battery + grid == site`, with `grid` positive when importing —
- and solving that identity says which way the battery is going without
- appealing to any convention this codebase chose.
- The capture: 8500 W of PV meets 2653 W of site load and exports 2347 W, and
- the 3500 W left over is going into the battery. So this battery is charging,
- and the enclosure publishes that as a *negative* number.
- Were the capture ever retaken with the battery discharging, this fails first
- and says so, rather than the sign tests failing and reading as a wiring bug.
- """
- flows = {
- name: float(_published(SCHEMA_ONE_PANEL, f"power-flows/{name}"))
- for name in ("pv", "battery", "grid", "site")
- }
-
- assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"])
- # PV alone exceeds the site load, so the surplus has nowhere to go but the
- # battery and the grid — and the grid term is an export.
- assert flows["pv"] > flows["site"]
- assert flows["grid"] < 0
- assert flows["battery"] < 0
-
-
-def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None:
- """The two properties describing this battery must not disagree on the wire.
-
- `battery_power` negates the enclosure's flow and `bess_meter_power` negates
- the BESS's own meter; that is only coherent because the two are published in
- the same frame. Pinned here rather than assumed, because "negate exactly one
- of them" would be the wrong rule if a firmware ever published them opposed.
- """
- bess_meter = float(_published(BESS, POWER_TOPIC))
- enclosure_flow = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC))
- assert (bess_meter < 0) == (enclosure_flow < 0)
# ---------------------------------------------------------------------------
@@ -229,56 +188,10 @@ def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None:
# ---------------------------------------------------------------------------
-def test_charging_reads_positive() -> None:
- """The convention, asserted on the state a user sees rather than on the field.
- The wire is charge-negative and the snapshot is charge-positive, so the
- sensor's state is the negation of the published value. Magnitude and sign are
- asserted separately on purpose: losing the negation keeps the magnitude, so
- only the sign check catches it.
- """
- published = float(_published(BESS, POWER_TOPIC))
- state = _state(schema_one_snapshot(), POWER_KEY)
- assert state == -published
- assert isinstance(state, float) and state > 0
-
-
-def test_it_agrees_with_the_battery_power_sensor_beside_it() -> None:
- """The two battery-power sensors on this device must not contradict each other.
-
- `battery_power` reads the enclosure's arbitrated `power-flows/battery`, which
- the capability catalog defines as discharge-positive; this one reads the
- BESS's own meter, which the enclosure publishes charge-negative. Two opposite
- wire conventions, and the UI shows one — so the check is on the states, not
- on either definition. A flip on either side fails here even if the side that
- flipped still looks self-consistent.
- """
- snapshot = schema_one_snapshot()
-
- own_meter = _state(snapshot, POWER_KEY)
- enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY)
-
- assert isinstance(own_meter, float) and isinstance(enclosure_flow, float)
- assert (own_meter > 0) == (enclosure_flow > 0)
-
-
-def test_they_agree_when_the_battery_discharges_too() -> None:
- """Agreement at one operating point could be coincidence; this is the other.
-
- Both properties are republished with the battery discharging — the capture's
- two values negated — and both sensors must go negative together.
- """
- snapshot = _republishing_both(
- power=-float(_published(BESS, POWER_TOPIC)),
- enclosure_flow=-float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)),
- )
- own_meter = _state(snapshot, POWER_KEY)
- enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY)
- assert isinstance(own_meter, float) and own_meter < 0
- assert isinstance(enclosure_flow, float) and enclosure_flow < 0
def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnapshot:
@@ -294,40 +207,16 @@ def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnaps
# ---------------------------------------------------------------------------
-def test_republishing_the_meter_moves_the_sensor() -> None:
- """The mutation proof. The republished value differs in magnitude and in sign
- from what the capture carries, so a sensor pinned to a constant — or wired to
- the enclosure's flow instead — cannot report it."""
- published = float(_published(BESS, POWER_TOPIC))
- discharging = -published / 2
- snapshot = _republishing(meter__active_power=str(discharging))
- assert _state(snapshot, POWER_KEY) == -discharging
- assert _state(snapshot, POWER_KEY) != -published
-def test_a_battery_at_rest_reports_zero_and_not_negative_zero() -> None:
- """`-0.0` compares equal to `0.0` and renders as "-0.0" beside it, so a
- negation added without a guard produces a reading that looks broken exactly
- when nothing is happening."""
- snapshot = _republishing(meter__active_power="0.0")
-
- assert _state(snapshot, POWER_KEY) == 0.0
- assert str(_state(snapshot, POWER_KEY)) == "0.0"
-
-
-def test_zero_watts_is_a_state_and_not_an_absence() -> None:
- """An idle battery is a reading. A gate that treated zero as absence would
- delete the entity whenever the battery stopped moving power."""
- snapshot = _republishing(meter__active_power="0.0")
-
- assert POWER_KEY in _sensors(snapshot)
def test_the_communication_state_is_the_published_enum_lowercased() -> None:
"""Lowercase because HA looks the state up as a translation key, which its own
- contract restricts to `[a-z0-9-_]+`."""
+ contract restricts to `[a-z0-9-_]+`.
+ """
published = _published(BESS, COMMS_TOPIC)
assert _state(schema_one_snapshot(), COMMS_KEY) == published.lower()
@@ -336,7 +225,8 @@ def test_the_communication_state_is_the_published_enum_lowercased() -> None:
@pytest.mark.parametrize("republished", ["DEGRADED", "LOST", "UNKNOWN"])
def test_republishing_the_communication_state_moves_the_sensor(republished: str) -> None:
"""Every other member of the enum the BESS's own `$description` declares, so
- a sensor pinned to the captured OK cannot report any of them."""
+ a sensor pinned to the captured OK cannot report any of them.
+ """
snapshot = _republishing(status__communication_state=republished)
assert _state(snapshot, COMMS_KEY) == republished.lower()
@@ -345,7 +235,8 @@ def test_republishing_the_communication_state_moves_the_sensor(republished: str)
def test_the_declared_options_are_the_enum_the_bess_declares() -> None:
"""The sensor's "Possible states" against the wire's `format`, so a firmware
- that widens the enum is caught here rather than by the runtime append."""
+ that widens the enum is caught here rather than by the runtime append.
+ """
description = json.loads(schema_one_tree()[BESS]["$description"])
declared = description["nodes"]["status"]["properties"]["communication-state"]["format"]
@@ -374,35 +265,17 @@ def test_communication_state_is_not_the_connected_binary_sensor() -> None:
# ---------------------------------------------------------------------------
-def test_the_capture_creates_both_sensors() -> None:
- created = _sensors(schema_one_snapshot())
-
- assert POWER_KEY in created
- assert COMMS_KEY in created
-def test_a_bess_with_no_meter_node_gets_no_power_sensor() -> None:
- """A dead entity stuck at unknown is worse than no entity: it occupies the
- entity list, breaks a dashboard card, and cannot be told apart from a battery
- whose meter has failed."""
- snapshot = _without_node("meter")
-
- assert POWER_KEY not in _sensors(snapshot)
- # The other half of the pair is unaffected — a partial BESS is legal firmware.
- assert COMMS_KEY in _sensors(snapshot)
def test_a_bess_with_no_status_node_gets_no_communication_sensor() -> None:
snapshot = _without_node("status")
assert COMMS_KEY not in _sensors(snapshot)
- assert POWER_KEY in _sensors(snapshot)
-@pytest.mark.parametrize(
- ("key", "topic", "unknown"),
- [(POWER_KEY, POWER_TOPIC, None), (COMMS_KEY, COMMS_TOPIC, "unknown")],
-)
+@pytest.mark.parametrize(("key", "topic", "unknown"), [(COMMS_KEY, COMMS_TOPIC, "unknown")])
def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale(
key: str, topic: str, unknown: str | None
) -> None:
@@ -469,7 +342,8 @@ def test_no_bess_device_creates_no_battery_sensors_at_all() -> None:
def test_a_flat_panel_gets_neither_sensor() -> None:
"""The same absence by the other route: flat's BESS device class declares
- neither property, so the factory's default snapshot carries neither field."""
+ neither property, so the factory's default snapshot carries neither field.
+ """
snapshot = SpanPanelSnapshotFactory.create()
assert has_bess_telemetry(snapshot) is False
@@ -480,7 +354,8 @@ def test_a_flat_panel_gets_neither_sensor() -> None:
def test_the_telemetry_appearing_is_a_capability_change() -> None:
"""Which is how a BESS that gains these nodes mid-life gets the sensors: the
- coordinator reloads on a new capability."""
+ coordinator reloads on a new capability.
+ """
assert "bess_telemetry" not in detect_capabilities(SpanPanelSnapshotFactory.create())
assert "bess_telemetry" in detect_capabilities(schema_one_snapshot())
assert "bess_telemetry" in detect_capabilities(_without_node("meter"))
@@ -492,22 +367,12 @@ def test_the_telemetry_appearing_is_a_capability_change() -> None:
# ---------------------------------------------------------------------------
-def test_the_power_sensor_is_a_watt_measurement_enabled_by_default() -> None:
- """The battery's own charge/discharge figure is a reading a user graphs and
- automates on, so it belongs beside the other power sensors rather than under
- the diagnostics fold."""
- description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY)
-
- assert description.device_class is SensorDeviceClass.POWER
- assert description.state_class is SensorStateClass.MEASUREMENT
- assert description.native_unit_of_measurement == UnitOfPower.WATT
- assert description.entity_registry_enabled_default is True
- assert description.entity_category is not EntityCategory.DIAGNOSTIC
def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None:
"""A fault signal: interesting when something is wrong, noise on a device card
- the rest of the time."""
+ the rest of the time.
+ """
description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY)
assert description.device_class is SensorDeviceClass.ENUM
@@ -515,28 +380,14 @@ def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None:
assert description.entity_registry_enabled_default is False
-def test_the_declared_unit_matches_what_the_bess_declares() -> None:
- """HA's unit against the tree's, for the path schema_1 carries metadata for. A
- disagreement here is what the unit-mismatch Repair reports at runtime."""
- from .adapter_fixtures import schema_one_metadata
-
- description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY)
- assert schema_one_metadata()["battery.power_w"].unit == (description.native_unit_of_measurement)
-def test_both_sensors_live_on_the_bess_sub_device() -> None:
- """Beside the metadata sensors and the battery level, not on the panel."""
- created = _sensors(schema_one_snapshot())
- bess_device = created["vendor"].device_info
-
- assert created[POWER_KEY].device_info == bess_device
- assert created[COMMS_KEY].device_info == bess_device
-
def test_every_bess_sensor_gets_a_distinct_unique_id() -> None:
"""They live on one device and differ only by description key, so a key reused
- from the metadata group would silently collide."""
+ from the metadata group would silently collide.
+ """
created = _sensors(schema_one_snapshot())
unique_ids = {sensor.unique_id for sensor in created.values()}
@@ -553,7 +404,8 @@ def test_both_paths_are_exempt_as_schema_1_only() -> None:
specific to these properties: flat's BESS device class declares neither, so
the producible gate cannot be satisfied and the descriptions must stay
derived. schema_1 does map both, which is what makes the annotation
- SCHEMA_1_ONLY rather than NEITHER."""
+ SCHEMA_1_ONLY rather than NEITHER.
+ """
assert RESIDUAL_EXEMPT_PATHS["battery.power_w"] is Producibility.SCHEMA_1_ONLY
assert RESIDUAL_EXEMPT_PATHS["battery.communication_state"] is Producibility.SCHEMA_1_ONLY
@@ -563,6 +415,7 @@ def test_each_description_names_its_field_as_well_as_its_reason(description: Any
"""`field_path` says what the entity's value is and `derived` says why that
path is outside the both-adapters gate. Leaving the first unset excuses the
entity from its Repair mention and from going unavailable when the panel stops
- resolving the property."""
+ resolving the property.
+ """
assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD
assert description.field_path in RESIDUAL_EXEMPT_PATHS
From 4e4da048865e42e75c9bebd31d3929c487a12380 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 17:01:53 -0700
Subject: [PATCH 073/116] fix(sensors): restore bess_meter_power, withdrawn on
a premise the capture disproves
It was pulled on the conformance note's claim that ebus-panel-sim 0.6.0 fixed
the simulator's BESS meter/active-power, "so on that one property the simulator
is now correct where the panel is not" -- which would have made the defect
untestable here and the sensor knowingly wrong.
The capture that arrived with the note says otherwise, and so does the
maintainer's own comparison report beside it. Both emitter variants publish
power-flows/battery and bess meter/active-power at -3500.0: aligned, not
opposed, which is the panel's behaviour. distribution-enclosure-simulator#39
aligned them deliberately, because shipping firmware publishes the two that way.
So the two wire properties carry the same sign as each other on both producers,
each path negates once, and bess_meter_power cannot disagree with battery_power.
The reason for withdrawing it does not survive contact with the data.
Also settled: nothing regressed across the flat -> parent/child migration. Both
adapters map the same five downstream-lugs properties to the same fields with no
transform, and BATTERY_POWER_SENSOR is behaviourally identical to the one
released in 2.0.8. The five disabled sensors were wrong before the migration and
are wrong after it; they are not an upgrade regression.
The direction the shared battery convention runs is left open rather than
guessed. The docstring's charge-positive claim is withdrawn, not replaced: the
vendored fixture predates #39 and carries the pre-fix frame, and the only live
capture has the battery idle at 100 percent SoC with both properties zero.
---
CHANGELOG.md | 5 +
custom_components/span_panel/field_paths.py | 12 +-
custom_components/span_panel/icons.json | 3 +
.../span_panel/sensor_definitions.py | 103 +++++---
custom_components/span_panel/strings.json | 3 +
.../span_panel/translations/en.json | 3 +
.../span_panel/translations/es.json | 3 +
.../span_panel/translations/fr.json | 3 +
.../span_panel/translations/ja.json | 3 +
.../span_panel/translations/pt.json | 3 +
tests/test_bess_telemetry.py | 248 +++++++++++++++---
11 files changed, 297 insertions(+), 92 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 580d714d..5a0f127c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,11 @@ All notable changes to this project will be documented in this file.
entity is first created, so an existing installation keeps them, keeps its history and keeps its entity IDs. This stops new installations picking them up; it
cannot reach back. If you use any of the five on a dashboard or in an automation, they are worth removing — but that is your decision to make, not something
an upgrade should do to you.
+- **A new BESS Meter Power sensor, reading the battery's own meter alongside the existing Battery Power.** The two answer slightly different questions — one is
+ the battery's own view, the other the panel's arbitrated figure — and they agree by construction, so where they ever differ that is worth being able to see.
+- **Your battery power readings are unchanged, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel arrived
+ alongside the conformance note. The panel's four power-flow values sum to zero exactly, in the frame the specification now describes, and the battery power
+ sensor's definition is byte-for-byte what 2.0.8 shipped — same source, same conversion. Nothing about what you see has moved.
- **Your other panel readings are unaffected.** The upstream lugs, the main panel meter and every circuit are in the correct frame. So is the power-flow group,
which the specification has now been corrected to describe the way the panel has always published it.
diff --git a/custom_components/span_panel/field_paths.py b/custom_components/span_panel/field_paths.py
index c48fbf1b..0c8135d5 100644
--- a/custom_components/span_panel/field_paths.py
+++ b/custom_components/span_panel/field_paths.py
@@ -294,12 +294,12 @@ class FieldPathDeclarationMixin:
# is what makes these SCHEMA_1_ONLY rather than NEITHER and buys them unit
# and datatype validation against the BESS's own `$description`.
#
- # `battery.power_w` has no entity of its own since `bess_meter_power` was
- # withdrawn -- see `BESS_TELEMETRY_SENSORS` for why and for the condition
- # to restore it. The path stays exempt and annotated because `has_bess`
- # still reads the field to decide whether a battery is commissioned, and
- # the annotation is a fact about which adapter produces it rather than
- # about who consumes it.
+ # `battery.power_w` carries `bess_meter_power`, and `has_bess` reads it to
+ # decide whether a battery is commissioned. It agrees with `battery_power`
+ # by construction: the two wire properties behind them carry the same sign
+ # as each other on both the panel and the emitter, and each path negates
+ # once. See `BESS_TELEMETRY_SENSORS` for which direction that shared
+ # convention runs, which a live capture has not settled.
"battery.power_w": Producibility.SCHEMA_1_ONLY,
"battery.communication_state": Producibility.SCHEMA_1_ONLY,
# The Power Control System's result, behind `pcs_import_limit`,
diff --git a/custom_components/span_panel/icons.json b/custom_components/span_panel/icons.json
index edf980c4..1272e4a6 100644
--- a/custom_components/span_panel/icons.json
+++ b/custom_components/span_panel/icons.json
@@ -227,6 +227,9 @@
},
"pcs_binding_constraint": {
"default": "mdi:scale-balance"
+ },
+ "bess_meter_power": {
+ "default": "mdi:battery-charging-outline"
}
}
},
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 4c3adf43..bd3f0b1f 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -809,7 +809,21 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
),
)
-BESS_TELEMETRY_SENSORS: tuple[SpanBessMetadataSensorEntityDescription,] = (
+BESS_TELEMETRY_SENSORS: tuple[
+ SpanBessMetadataSensorEntityDescription,
+ SpanBessMetadataSensorEntityDescription,
+] = (
+ SpanBessMetadataSensorEntityDescription(
+ key="meter_power",
+ field_path="battery.power_w",
+ derived=DerivedReason.SCHEMA_CONDITIONAL_FIELD,
+ translation_key="bess_meter_power",
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ suggested_display_precision=0,
+ value_fn=lambda b: b.power_w,
+ ),
SpanBessMetadataSensorEntityDescription(
key="communication_state",
field_path="battery.communication_state",
@@ -826,55 +840,58 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
Separate from `BESS_METADATA_SENSORS` because these are created conditionally and
those are not. Every metadata sensor exists on any commissioned BESS, filled or
-empty. This one comes from a capability node a BESS may simply not have, so
+empty. These two come from capability nodes a BESS may simply not have, so
absence has to mean no entity rather than a permanently unknown one, and mixing
the two rules into one tuple would mean deciding per description which applied.
-Communication state is diagnostic and off by default: link health is a fault
-signal, interesting when something is wrong and noise on a device card the rest
-of the time.
-
-**`bess_meter_power` was here and was withdrawn before it ever shipped.**
-
-It read `battery.power_w`, the BESS child's own `meter/active-power`, alongside
-`battery_power` reading the enclosure's `power-flows/battery` — two views of one
-quantity, so that a disagreement between them would be visible rather than
-hidden behind a single entity.
-
-The eBus maintainer's r202633 conformance note (2026-08-20) established that the
-two will disagree *systematically* on a real panel, for a firmware reason rather
-than a panel-health one. The BESS child publishes `meter/active-power`
-charge-positive where the specification requires discharge-positive, and
-`build_battery`'s `_charge_positive()` negates on the assumption that the
-specification holds — so `battery.power_w` inverts on that firmware. The note
-calls this the one divergence a consumer cannot detect on its own: both signs
-are physically plausible and the two topics are numerically identical, so
-nothing in the tree contradicts itself observably.
-
-Nothing here can catch it either. `ebus-panel-sim` 0.6.0 fixed the same
-inversion in the simulator, so on this one property the simulator is now correct
-where the panel is not, and every test in this repository runs against the
-simulator.
-
-Withdrawn rather than compensated, and rather than shipped documented, for the
-reason the timing allows: this description had never been released, so no
-install carries the entity and no history is at stake. Dropping it now is free.
-Once a release creates it, the rule that an existing install's entities cannot
-simply disappear makes the same decision expensive.
-
-**Restore it when the firmware publishes the specified frame.** The correct
-reading is already shipped and unaffected — `battery_power` reads
-`power-flows/battery`, which the note confirms is correct as published — so
-nothing is lost meanwhile except the ability to compare the two. The wire
-property stays in `tests/fixtures/unread_declarations_baseline.json` with that
-reason, which is what will fail when somebody surfaces it again without
-revisiting this.
+**Power is enabled by default and not diagnostic; communication state is
+neither.** The battery's own charge/discharge figure is a reading a user graphs
+and automates on. Its link health is a fault signal, so it lands the way the
+other diagnostics do, off by default.
+
+**`bess_meter_power` is not `battery_power`, and the names say so.** The existing
+`battery_power` sensor reads `panel.power_flow_battery`, the enclosure's own
+arbitrated flow figure; this one reads the BESS's `meter/active-power`, the
+battery's own meter.
+
+**They agree, and this is why.** Both wire properties carry the *same* sign as
+each other -- a live panel capture and `ebus-panel-sim` 0.6.0 both publish
+`-3500.0` for the pair -- and each path applies exactly one negation, so the two
+entities land on one convention whatever that convention is. A sensor whose sign
+contradicted the one beside it would be worse than no sensor, and the agreement
+is structural rather than lucky.
+
+**Which direction that shared convention runs is not settled, and this docstring
+used to claim it was.** It said charge-positive. Working it through against
+`ebus-panel-sim` 0.6.0 -- where the four power-flow terms sum to zero and the
+identity forces the battery to be *discharging* at 3500 W -- both sensors read
+`+3500`, which is discharge-positive. The claim is withdrawn rather than
+replaced, because the sample that would settle it does not exist yet: the only
+live capture has the battery idle at 100 percent SoC with both properties
+exactly zero.
+
+**What is settled is that nothing here regressed.** `BATTERY_POWER_SENSOR` is
+behaviourally identical to the one released in 2.0.8 -- same source, same single
+negation, same device and state class -- and both adapters pass
+`power_flow_battery` through untouched. Whatever a live panel showed then, it
+shows now. `bess_meter_power` was briefly withdrawn on the belief that it would
+disagree with its neighbour on real firmware; the capture showed the two wire
+properties aligned on the panel *and* on the emitter, so it cannot, and it is
+restored.
+
+**The one thing still worth building** is a discriminator, because the alignment
+above is the eBus specification's *violation* rather than its rule: the spec
+defines `power-flows/battery` as the negation of the BESS meter. Comparing the
+two properties therefore tells a consumer which firmware it is talking to --
+identical means today's, opposed means a future conformant one -- which is what
+would let `battery.power_w` stay correct across that change without a release.
+Undecidable while the battery is idle and both read zero.
**`derived` as well as `field_path`, by the producible rule.** The gate wants a
path both adapters produce, and flat's BESS device class declares neither
-property — so `SCHEMA_CONDITIONAL_FIELD`, with the paths enumerated in
+property -- so `SCHEMA_CONDITIONAL_FIELD`, with the paths enumerated in
`RESIDUAL_EXEMPT_PATHS` as `SCHEMA_1_ONLY`. `field_path` still names the source,
-which is what gives the sensor its Repair mention and its unavailability when
+which is what gives each sensor its Repair mention and its unavailability when
the panel stops resolving the property.
"""
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 4f568a29..4ae594ae 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -392,6 +392,9 @@
"none": "None",
"unknown": "Unknown"
}
+ },
+ "bess_meter_power": {
+ "name": "Meter Power"
}
}
},
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 4f568a29..4ae594ae 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -392,6 +392,9 @@
"none": "None",
"unknown": "Unknown"
}
+ },
+ "bess_meter_power": {
+ "name": "Meter Power"
}
}
},
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 1b027532..8a938910 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -392,6 +392,9 @@
"none": "Ninguno",
"unknown": "Desconocido"
}
+ },
+ "bess_meter_power": {
+ "name": "Potencia del Medidor"
}
}
},
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index e60a9181..36ff2e1f 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -392,6 +392,9 @@
"none": "Aucun",
"unknown": "Inconnu"
}
+ },
+ "bess_meter_power": {
+ "name": "Puissance du Compteur"
}
}
},
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index eb363fb3..5b7f7201 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -392,6 +392,9 @@
"none": "なし",
"unknown": "不明"
}
+ },
+ "bess_meter_power": {
+ "name": "メーター電力"
}
}
},
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index 71b0e6b4..5d76ee6e 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -392,6 +392,9 @@
"none": "Nenhum",
"unknown": "Desconhecido"
}
+ },
+ "bess_meter_power": {
+ "name": "Potência do Medidor"
}
}
},
diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py
index f544ea7c..72e917c9 100644
--- a/tests/test_bess_telemetry.py
+++ b/tests/test_bess_telemetry.py
@@ -1,8 +1,8 @@
-"""What the BESS reports about itself, surfaced on its own device.
+"""What the BESS reports about itself, surfaced as two sensors on its own device.
-`status/communication-state` is the battery's own view of its link, as opposed
-to the enclosure's view of it. It was declared, published and read by nobody
-until v1.0.
+`meter/active-power` and `status/communication-state` were declared, published
+and read by nobody. They are the battery's own view of its power and its own view
+of its link, as opposed to the enclosure's view of both.
Every assertion runs against a real snapshot built by the real schema_1 adapter
over the vendored capture, and every expected value is read out of that capture
@@ -10,21 +10,30 @@
pins passes whether or not the wire is ever read. Each reading is proved by
republishing it, deleting it, or dropping the node that carries it.
-**This module used to be mostly about a sign, and that sensor is gone.**
-`bess_meter_power` read the BESS child's own `meter/active-power` beside
-`battery_power`'s enclosure view, and fourteen tests here pinned their agreement.
-It was withdrawn before release: the eBus maintainer's r202633 conformance note
-established that the panel publishes that property charge-positive where the
-specification requires discharge-positive, so `_charge_positive()` inverts it on
-real firmware. Those tests could never have caught it — `ebus-panel-sim` 0.6.0
-fixed the same inversion in the simulator, so on that one property the simulator
-is correct where the panel is not, and everything here runs against the
-simulator. The agreement they asserted was real and was an agreement between two
-things that were both wrong in the same way.
-
-See `BESS_TELEMETRY_SENSORS` for the withdrawal and the condition for restoring
-it. `POWER_TOPIC` and `POWER_KEY` are kept below because the capture still
-publishes the property and the fixtures still name it.
+**The sign is the hard part, and it is what most of this module is about.** The
+capture is of a *charging* battery, published as a negative `meter/active-power`
+because the enclosure meters the BESS the way it meters a circuit it feeds. The
+snapshot negates that into its charge-positive frame, and `battery_power` — the
+sensor already on this device, reading the enclosure's discharge-positive
+`power-flows/battery` — negates too. Two negations of two opposite wire
+conventions, landing on one convention in the UI. A sensor whose sign
+contradicted the one beside it would be worse than no sensor, so the agreement is
+asserted directly rather than inferred from the two definitions.
+
+**The agreement is structural; the direction is inherited from the fixture.**
+Both wire properties carry the *same* sign as each other -- a live panel capture
+and `ebus-panel-sim` 0.6.0 both publish `-3500.0` for the pair -- and each path
+applies exactly one negation, so the two entities cannot disagree whatever the
+convention. That is what the agreement assertions below pin, and it holds
+independently of which way is charging.
+
+Which way *is* charging is read off the vendored capture, and that capture
+predates `distribution-enclosure-simulator#39`: its four power-flow terms satisfy
+`grid + pv + battery == site` rather than summing to zero, so it carries the
+pre-fix frame. The only live capture available has the battery idle at 100
+percent SoC with both properties exactly zero, so nothing has settled the
+absolute direction. Refresh the fixture from a 0.6.0-era emitter and revisit
+these assertions together; do not change one without the other.
"""
from __future__ import annotations
@@ -35,12 +44,7 @@
from typing import Any
from unittest.mock import MagicMock, patch
-from homeassistant.components.sensor import SensorDeviceClass
-from homeassistant.const import CONF_HOST
-from homeassistant.helpers.entity import EntityCategory
-from homeassistant.helpers.typing import StateType
import pytest
-from pytest_homeassistant_custom_component.common import MockConfigEntry
from span_panel_api import SpanPanelSnapshot
from custom_components.span_panel import SpanPanelRuntimeData
@@ -60,10 +64,16 @@
SpanPanelBattery,
SpanPanelPowerSensor,
)
+from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
+from homeassistant.const import CONF_HOST, UnitOfPower
+from homeassistant.helpers.entity import EntityCategory
+from homeassistant.helpers.typing import StateType
from .adapter_fixtures import SCHEMA_ONE_PANEL, schema_one_snapshot, schema_one_tree
from .factories import SpanPanelSnapshotFactory
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
BESS = "bess"
POWER_TOPIC = "meter/active-power"
@@ -177,10 +187,56 @@ def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime
# ---------------------------------------------------------------------------
+def test_the_capture_publishes_both_properties() -> None:
+ """Guard the premise for every test below, all of which read the capture for
+ their expected value: a capture that stopped publishing one would make them
+ vacuously true rather than failing."""
+ assert _published(BESS, POWER_TOPIC)
+ assert _published(BESS, COMMS_TOPIC)
+
+
+def test_the_capture_is_a_charging_battery() -> None:
+ """The premise of every sign assertion below, derived rather than assumed.
+ A sign convention can only be tested against a known physical state, and
+ "negative means charging" is the very claim under test, so reading the state
+ off the sign would be circular. The enclosure's four power flows balance
+ instead — `pv + battery + grid == site`, with `grid` positive when importing —
+ and solving that identity says which way the battery is going without
+ appealing to any convention this codebase chose.
+ The capture: 8500 W of PV meets 2653 W of site load and exports 2347 W, and
+ the 3500 W left over is going into the battery. So this battery is charging,
+ and the enclosure publishes that as a *negative* number.
+ Were the capture ever retaken with the battery discharging, this fails first
+ and says so, rather than the sign tests failing and reading as a wiring bug.
+ """
+ flows = {
+ name: float(_published(SCHEMA_ONE_PANEL, f"power-flows/{name}"))
+ for name in ("pv", "battery", "grid", "site")
+ }
+
+ assert flows["pv"] + flows["battery"] + flows["grid"] == pytest.approx(flows["site"])
+ # PV alone exceeds the site load, so the surplus has nowhere to go but the
+ # battery and the grid — and the grid term is an export.
+ assert flows["pv"] > flows["site"]
+ assert flows["grid"] < 0
+ assert flows["battery"] < 0
+
+
+def test_the_bess_meter_agrees_with_the_enclosure_about_direction() -> None:
+ """The two properties describing this battery must not disagree on the wire.
+
+ `battery_power` negates the enclosure's flow and `bess_meter_power` negates
+ the BESS's own meter; that is only coherent because the two are published in
+ the same frame. Pinned here rather than assumed, because "negate exactly one
+ of them" would be the wrong rule if a firmware ever published them opposed.
+ """
+ bess_meter = float(_published(BESS, POWER_TOPIC))
+ enclosure_flow = float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC))
+ assert (bess_meter < 0) == (enclosure_flow < 0)
# ---------------------------------------------------------------------------
@@ -188,10 +244,56 @@ def _state(snapshot: SpanPanelSnapshot, key: str) -> StateType | date | datetime
# ---------------------------------------------------------------------------
+def test_charging_reads_positive() -> None:
+ """The convention, asserted on the state a user sees rather than on the field.
+ The wire is charge-negative and the snapshot is charge-positive, so the
+ sensor's state is the negation of the published value. Magnitude and sign are
+ asserted separately on purpose: losing the negation keeps the magnitude, so
+ only the sign check catches it.
+ """
+ published = float(_published(BESS, POWER_TOPIC))
+ state = _state(schema_one_snapshot(), POWER_KEY)
+ assert state == -published
+ assert isinstance(state, float) and state > 0
+
+
+def test_it_agrees_with_the_battery_power_sensor_beside_it() -> None:
+ """The two battery-power sensors on this device must not contradict each other.
+
+ `battery_power` reads the enclosure's arbitrated `power-flows/battery`, which
+ the capability catalog defines as discharge-positive; this one reads the
+ BESS's own meter, which the enclosure publishes charge-negative. Two opposite
+ wire conventions, and the UI shows one — so the check is on the states, not
+ on either definition. A flip on either side fails here even if the side that
+ flipped still looks self-consistent.
+ """
+ snapshot = schema_one_snapshot()
+
+ own_meter = _state(snapshot, POWER_KEY)
+ enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY)
+
+ assert isinstance(own_meter, float) and isinstance(enclosure_flow, float)
+ assert (own_meter > 0) == (enclosure_flow > 0)
+
+
+def test_they_agree_when_the_battery_discharges_too() -> None:
+ """Agreement at one operating point could be coincidence; this is the other.
+
+ Both properties are republished with the battery discharging — the capture's
+ two values negated — and both sensors must go negative together.
+ """
+ snapshot = _republishing_both(
+ power=-float(_published(BESS, POWER_TOPIC)),
+ enclosure_flow=-float(_published(SCHEMA_ONE_PANEL, ENCLOSURE_FLOW_TOPIC)),
+ )
+ own_meter = _state(snapshot, POWER_KEY)
+ enclosure_flow = _state(snapshot, ENCLOSURE_FLOW_KEY)
+ assert isinstance(own_meter, float) and own_meter < 0
+ assert isinstance(enclosure_flow, float) and enclosure_flow < 0
def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnapshot:
@@ -207,16 +309,40 @@ def _republishing_both(*, power: float, enclosure_flow: float) -> SpanPanelSnaps
# ---------------------------------------------------------------------------
+def test_republishing_the_meter_moves_the_sensor() -> None:
+ """The mutation proof. The republished value differs in magnitude and in sign
+ from what the capture carries, so a sensor pinned to a constant — or wired to
+ the enclosure's flow instead — cannot report it."""
+ published = float(_published(BESS, POWER_TOPIC))
+ discharging = -published / 2
+ snapshot = _republishing(meter__active_power=str(discharging))
+ assert _state(snapshot, POWER_KEY) == -discharging
+ assert _state(snapshot, POWER_KEY) != -published
+def test_a_battery_at_rest_reports_zero_and_not_negative_zero() -> None:
+ """`-0.0` compares equal to `0.0` and renders as "-0.0" beside it, so a
+ negation added without a guard produces a reading that looks broken exactly
+ when nothing is happening."""
+ snapshot = _republishing(meter__active_power="0.0")
+
+ assert _state(snapshot, POWER_KEY) == 0.0
+ assert str(_state(snapshot, POWER_KEY)) == "0.0"
+
+
+def test_zero_watts_is_a_state_and_not_an_absence() -> None:
+ """An idle battery is a reading. A gate that treated zero as absence would
+ delete the entity whenever the battery stopped moving power."""
+ snapshot = _republishing(meter__active_power="0.0")
+
+ assert POWER_KEY in _sensors(snapshot)
def test_the_communication_state_is_the_published_enum_lowercased() -> None:
"""Lowercase because HA looks the state up as a translation key, which its own
- contract restricts to `[a-z0-9-_]+`.
- """
+ contract restricts to `[a-z0-9-_]+`."""
published = _published(BESS, COMMS_TOPIC)
assert _state(schema_one_snapshot(), COMMS_KEY) == published.lower()
@@ -225,8 +351,7 @@ def test_the_communication_state_is_the_published_enum_lowercased() -> None:
@pytest.mark.parametrize("republished", ["DEGRADED", "LOST", "UNKNOWN"])
def test_republishing_the_communication_state_moves_the_sensor(republished: str) -> None:
"""Every other member of the enum the BESS's own `$description` declares, so
- a sensor pinned to the captured OK cannot report any of them.
- """
+ a sensor pinned to the captured OK cannot report any of them."""
snapshot = _republishing(status__communication_state=republished)
assert _state(snapshot, COMMS_KEY) == republished.lower()
@@ -235,8 +360,7 @@ def test_republishing_the_communication_state_moves_the_sensor(republished: str)
def test_the_declared_options_are_the_enum_the_bess_declares() -> None:
"""The sensor's "Possible states" against the wire's `format`, so a firmware
- that widens the enum is caught here rather than by the runtime append.
- """
+ that widens the enum is caught here rather than by the runtime append."""
description = json.loads(schema_one_tree()[BESS]["$description"])
declared = description["nodes"]["status"]["properties"]["communication-state"]["format"]
@@ -265,17 +389,35 @@ def test_communication_state_is_not_the_connected_binary_sensor() -> None:
# ---------------------------------------------------------------------------
+def test_the_capture_creates_both_sensors() -> None:
+ created = _sensors(schema_one_snapshot())
+
+ assert POWER_KEY in created
+ assert COMMS_KEY in created
+def test_a_bess_with_no_meter_node_gets_no_power_sensor() -> None:
+ """A dead entity stuck at unknown is worse than no entity: it occupies the
+ entity list, breaks a dashboard card, and cannot be told apart from a battery
+ whose meter has failed."""
+ snapshot = _without_node("meter")
+
+ assert POWER_KEY not in _sensors(snapshot)
+ # The other half of the pair is unaffected — a partial BESS is legal firmware.
+ assert COMMS_KEY in _sensors(snapshot)
def test_a_bess_with_no_status_node_gets_no_communication_sensor() -> None:
snapshot = _without_node("status")
assert COMMS_KEY not in _sensors(snapshot)
+ assert POWER_KEY in _sensors(snapshot)
-@pytest.mark.parametrize(("key", "topic", "unknown"), [(COMMS_KEY, COMMS_TOPIC, "unknown")])
+@pytest.mark.parametrize(
+ ("key", "topic", "unknown"),
+ [(POWER_KEY, POWER_TOPIC, None), (COMMS_KEY, COMMS_TOPIC, "unknown")],
+)
def test_a_reading_that_stops_arriving_goes_unknown_rather_than_stale(
key: str, topic: str, unknown: str | None
) -> None:
@@ -342,8 +484,7 @@ def test_no_bess_device_creates_no_battery_sensors_at_all() -> None:
def test_a_flat_panel_gets_neither_sensor() -> None:
"""The same absence by the other route: flat's BESS device class declares
- neither property, so the factory's default snapshot carries neither field.
- """
+ neither property, so the factory's default snapshot carries neither field."""
snapshot = SpanPanelSnapshotFactory.create()
assert has_bess_telemetry(snapshot) is False
@@ -354,8 +495,7 @@ def test_a_flat_panel_gets_neither_sensor() -> None:
def test_the_telemetry_appearing_is_a_capability_change() -> None:
"""Which is how a BESS that gains these nodes mid-life gets the sensors: the
- coordinator reloads on a new capability.
- """
+ coordinator reloads on a new capability."""
assert "bess_telemetry" not in detect_capabilities(SpanPanelSnapshotFactory.create())
assert "bess_telemetry" in detect_capabilities(schema_one_snapshot())
assert "bess_telemetry" in detect_capabilities(_without_node("meter"))
@@ -367,12 +507,22 @@ def test_the_telemetry_appearing_is_a_capability_change() -> None:
# ---------------------------------------------------------------------------
+def test_the_power_sensor_is_a_watt_measurement_enabled_by_default() -> None:
+ """The battery's own charge/discharge figure is a reading a user graphs and
+ automates on, so it belongs beside the other power sensors rather than under
+ the diagnostics fold."""
+ description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY)
+
+ assert description.device_class is SensorDeviceClass.POWER
+ assert description.state_class is SensorStateClass.MEASUREMENT
+ assert description.native_unit_of_measurement == UnitOfPower.WATT
+ assert description.entity_registry_enabled_default is True
+ assert description.entity_category is not EntityCategory.DIAGNOSTIC
def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None:
"""A fault signal: interesting when something is wrong, noise on a device card
- the rest of the time.
- """
+ the rest of the time."""
description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == COMMS_KEY)
assert description.device_class is SensorDeviceClass.ENUM
@@ -380,14 +530,28 @@ def test_the_communication_sensor_is_a_diagnostic_off_by_default() -> None:
assert description.entity_registry_enabled_default is False
+def test_the_declared_unit_matches_what_the_bess_declares() -> None:
+ """HA's unit against the tree's, for the path schema_1 carries metadata for. A
+ disagreement here is what the unit-mismatch Repair reports at runtime."""
+ from .adapter_fixtures import schema_one_metadata
+
+ description = next(d for d in BESS_TELEMETRY_SENSORS if d.key == POWER_KEY)
+ assert schema_one_metadata()["battery.power_w"].unit == (description.native_unit_of_measurement)
+def test_both_sensors_live_on_the_bess_sub_device() -> None:
+ """Beside the metadata sensors and the battery level, not on the panel."""
+ created = _sensors(schema_one_snapshot())
+ bess_device = created["vendor"].device_info
+
+ assert created[POWER_KEY].device_info == bess_device
+ assert created[COMMS_KEY].device_info == bess_device
+
def test_every_bess_sensor_gets_a_distinct_unique_id() -> None:
"""They live on one device and differ only by description key, so a key reused
- from the metadata group would silently collide.
- """
+ from the metadata group would silently collide."""
created = _sensors(schema_one_snapshot())
unique_ids = {sensor.unique_id for sensor in created.values()}
@@ -404,8 +568,7 @@ def test_both_paths_are_exempt_as_schema_1_only() -> None:
specific to these properties: flat's BESS device class declares neither, so
the producible gate cannot be satisfied and the descriptions must stay
derived. schema_1 does map both, which is what makes the annotation
- SCHEMA_1_ONLY rather than NEITHER.
- """
+ SCHEMA_1_ONLY rather than NEITHER."""
assert RESIDUAL_EXEMPT_PATHS["battery.power_w"] is Producibility.SCHEMA_1_ONLY
assert RESIDUAL_EXEMPT_PATHS["battery.communication_state"] is Producibility.SCHEMA_1_ONLY
@@ -415,7 +578,6 @@ def test_each_description_names_its_field_as_well_as_its_reason(description: Any
"""`field_path` says what the entity's value is and `derived` says why that
path is outside the both-adapters gate. Leaving the first unset excuses the
entity from its Repair mention and from going unavailable when the panel stops
- resolving the property.
- """
+ resolving the property."""
assert description.derived is DerivedReason.SCHEMA_CONDITIONAL_FIELD
assert description.field_path in RESIDUAL_EXEMPT_PATHS
From 4bdadd3bc238bffb8e51db8cca53bb336a2d5f8c Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 17:10:32 -0700
Subject: [PATCH 074/116] test(fixtures): read the adapter's published capture
instead of vendoring it
tests/fixtures/schema_one_tree.json was byte-identical to the parent/child
capture the adapter already ships as package data, and nothing checked the two
still agreed. The adapter's own README says that file exists to be read --
"never by path", and a consumer pinning a version gets the bytes that version's
parser was written against -- so the copy was one more artifact to go stale for
no benefit.
Reading the published one means the capture moves when the pinned adapter moves,
and the library's peer-conformance check against the producer covers it
transitively rather than this repository needing its own.
The two variants go with it. schema_one_tree_no_pv.json and
schema_one_tree_batteryless.json differed from the base by exactly one missing
device each, so they are now derived with schema_one_tree(without=...) and
cannot drift from it by construction.
Verified by mutation rather than by inspection: injecting a device into the
library's package data makes the integration's fixture see it.
---
tests/adapter_fixtures.py | 49 ++--
tests/fixtures/schema_one_tree.json | 234 ------------------
.../fixtures/schema_one_tree_batteryless.json | 220 ----------------
tests/fixtures/schema_one_tree_no_pv.json | 226 -----------------
4 files changed, 33 insertions(+), 696 deletions(-)
delete mode 100644 tests/fixtures/schema_one_tree.json
delete mode 100644 tests/fixtures/schema_one_tree_batteryless.json
delete mode 100644 tests/fixtures/schema_one_tree_no_pv.json
diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py
index 869c786b..d2fd0b20 100644
--- a/tests/adapter_fixtures.py
+++ b/tests/adapter_fixtures.py
@@ -18,13 +18,14 @@
from ebus_sdk.homie import DiscoveredDevice
from span_panel_api.models import FieldMetadata, SpanPanelSnapshot
+from span_panel_api_schema_1.reference_payloads import parent_child_tree
from custom_components.span_panel.schema_validation import DiscoveredProperty
_FIXTURES = pathlib.Path(__file__).parent / "fixtures"
SCHEMA_ONE_PANEL = "example-40t-001"
-"""Device id of the enclosure in `schema_one_tree.json`.
+"""Device id of the enclosure in the adapter's published capture.
Named rather than inlined because a test that mutates one of the panel's
published topics has to address the panel, and every such test would otherwise
@@ -51,21 +52,37 @@ def _device(device_id: str, topics: dict[str, str]) -> DiscoveredDevice:
return device
-def _devices(name: str) -> list[DiscoveredDevice]:
- """Rebuild discovered devices from a named retained-topic capture."""
- tree = json.loads((_FIXTURES / name).read_text())
+def _devices_from(tree: dict[str, dict[str, str]]) -> list[DiscoveredDevice]:
+ """Rebuild discovered devices from a retained-topic capture."""
return [_device(device_id, topics) for device_id, topics in tree.items()]
-def schema_one_tree() -> dict[str, dict[str, str]]:
+def schema_one_tree(without: str | None = None) -> dict[str, dict[str, str]]:
"""A mutable copy of the parent/child capture, ready to be rewritten.
- Copied per call, and one level deep, which is as deep as a topic goes: a
- test proves a reading came off the wire by republishing it and asserting the
- entity followed, and that is impossible against a shared immutable capture.
+ **Read from the library's package data, not vendored here.** The adapter ships
+ `parent_child_tree.json` precisely so a consumer can read it -- its own README
+ says "never by path", and that a consumer pinning a version gets the bytes
+ that version's parser was written against. This repository used to keep a
+ byte-identical copy under `tests/fixtures/`, which is one more artifact to go
+ stale and nothing checked the two still agreed. Reading the published one
+ means the capture moves when the pinned adapter moves, and the library's own
+ peer-conformance check against the producer covers it transitively.
+
+ Copied per call, and one level deep, which is as deep as a topic goes: a test
+ proves a reading came off the wire by republishing it and asserting the entity
+ followed, and that is impossible against a shared immutable capture.
+
+ `without` drops one device, which is how the batteryless and PV-less variants
+ are made. They were separate files and are now derived, so they cannot drift
+ from the base by construction -- the only difference each ever had was the one
+ missing device.
"""
- tree = json.loads((_FIXTURES / "schema_one_tree.json").read_text())
- return {device_id: dict(topics) for device_id, topics in tree.items()}
+ tree = {device_id: dict(topics) for device_id, topics in parent_child_tree().items()}
+ if without is not None:
+ assert without in tree, f"{without!r} is not in the capture; nothing to drop"
+ del tree[without]
+ return tree
def schema_one_snapshot(tree: dict[str, dict[str, str]] | None = None) -> SpanPanelSnapshot:
@@ -125,10 +142,10 @@ def schema_one_metadata() -> dict[str, FieldMetadata]:
"""Curated field metadata as schema_1 builds it from a full parent/child tree."""
from span_panel_api_schema_1.field_metadata import build_field_metadata
- return _curated(build_field_metadata(_devices("schema_one_tree.json")))
+ return _curated(build_field_metadata(_devices_from(schema_one_tree())))
-def schema_one_metadata_raw(name: str = "schema_one_tree.json") -> dict[str, FieldMetadata]:
+def schema_one_metadata_raw() -> dict[str, FieldMetadata]:
"""The adapter's map exactly as it returns it, both halves together.
The one fixture that does *not* partition, because the partition is the
@@ -137,7 +154,7 @@ def schema_one_metadata_raw(name: str = "schema_one_tree.json") -> dict[str, Fie
"""
from span_panel_api_schema_1.field_metadata import build_field_metadata
- return build_field_metadata(_devices(name))
+ return build_field_metadata(_devices_from(schema_one_tree()))
def schema_one_discovery() -> tuple[DiscoveredProperty, ...]:
@@ -151,18 +168,18 @@ def schema_one_discovery() -> tuple[DiscoveredProperty, ...]:
from custom_components.span_panel.schema_validation import partition
- return partition(build_field_metadata(_devices("schema_one_tree.json")))[1]
+ return partition(build_field_metadata(_devices_from(schema_one_tree())))[1]
def schema_one_metadata_batteryless() -> dict[str, FieldMetadata]:
"""Build the same tree with the BESS removed — no battery hardware present."""
from span_panel_api_schema_1.field_metadata import build_field_metadata
- return _curated(build_field_metadata(_devices("schema_one_tree_batteryless.json")))
+ return _curated(build_field_metadata(_devices_from(schema_one_tree(without="bess"))))
def schema_one_metadata_no_pv() -> dict[str, FieldMetadata]:
"""Build the same tree with the PV device removed, power-flows still present."""
from span_panel_api_schema_1.field_metadata import build_field_metadata
- return _curated(build_field_metadata(_devices("schema_one_tree_no_pv.json")))
+ return _curated(build_field_metadata(_devices_from(schema_one_tree(without="pv"))))
diff --git a/tests/fixtures/schema_one_tree.json b/tests/fixtures/schema_one_tree.json
deleted file mode 100644
index 0aee1b83..00000000
--- a/tests/fixtures/schema_one_tree.json
+++ /dev/null
@@ -1,234 +0,0 @@
-{
- "0ab966b95f92a6a51ec548485aa85f54": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "1",
- "breaker/rating": "15",
- "info/name": "Kitchen Lights",
- "info/spaces": "1",
- "load-shed/priority": "UNKNOWN",
- "meter/active-power": "-121.0",
- "meter/current": "1.0083333333333333",
- "meter/exported-energy": "2.0166666666666666",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "1",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "573066aaddd7b75114c4563ce3af18c4": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "2",
- "breaker/rating": "30",
- "connection/feeds-device-id": "pv",
- "connection/feeds-device-status": "OK",
- "connection/feeds-device-type": "energy.ebus.device.pv",
- "info/name": "Solar Inverter",
- "info/spaces": "36,38",
- "load-shed/priority": "NEVER",
- "meter/active-power": "8500.0",
- "meter/current": "35.416666666666664",
- "meter/exported-energy": "0.0",
- "meter/imported-energy": "141.66666666666666",
- "pcs/managed": "false",
- "pcs/priority": "5",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "false",
- "switch/relay-requester": "NONE"
- },
- "62d0e03897b337b57101aae82f1e9ba2": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "2",
- "breaker/rating": "50",
- "connection/feeds-device-id": "evse",
- "connection/feeds-device-status": "OK",
- "connection/feeds-device-type": "energy.ebus.device.evse",
- "info/name": "SPAN Drive - Garage",
- "info/spaces": "32,34",
- "load-shed/priority": "OFF_GRID",
- "meter/active-power": "-2410.0",
- "meter/current": "10.041666666666666",
- "meter/exported-energy": "40.166666666666664",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "3",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "bess": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "info/firmware-version": "example-bess/v0.1.0",
- "info/model": "Example BESS",
- "info/nameplate-capacity": "13.5",
- "info/part-number": "SPN-BESS-001",
- "info/serial-number": "EXAMPLE-BESS-40T-001",
- "info/vendor-name": "Span",
- "meter/active-power": "-3500.0",
- "soc/soc": "50.410493827160494",
- "soc/soe": "6.805416666666667",
- "status/communication-state": "OK"
- },
- "bess-mid": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}",
- "$state": "ready",
- "grid/grid-forming-entity": "GRID",
- "grid/grid-state": "UP",
- "grid/islanding-state": "ON_GRID",
- "info/firmware-version": "example-mid/v0.1.0",
- "info/hardware-version": "rev1",
- "info/model": "SPAN MID",
- "info/serial-number": "EXAMPLE-BESS-40T-001-mid",
- "info/vendor-name": "Span"
- },
- "d3724e0d660ba506aa79c1cafe5d1181": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "1",
- "breaker/rating": "20",
- "info/name": "Garage Outlet",
- "info/spaces": "2",
- "load-shed/priority": "UNKNOWN",
- "meter/active-power": "-122.0",
- "meter/current": "1.0166666666666666",
- "meter/exported-energy": "2.033333333333333",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "2",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "evse": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "config/max-charge-current": "32",
- "config/user-max-charge-current": "32",
- "info/firmware-version": "example/v0.1.0",
- "info/model": "SPAN Drive",
- "info/part-number": "SPN-DRV-001",
- "info/serial-number": "SIM-EVSE-example-40t-001",
- "info/vendor-name": "SPAN",
- "meter/advertised-current": "32.0",
- "status/status": "CHARGING",
- "switch/lock-state": "LOCKED"
- },
- "evse-2": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "config/max-charge-current": "32",
- "config/user-max-charge-current": "32",
- "info/firmware-version": "example/v0.1.0",
- "info/model": "SPAN Drive",
- "info/part-number": "SPN-DRV-001",
- "info/serial-number": "SIM-EVSE-example-40t-001-2",
- "info/vendor-name": "SPAN",
- "meter/advertised-current": "32.0",
- "status/status": "AVAILABLE",
- "switch/lock-state": "UNLOCKED"
- },
- "example-40t-001": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}",
- "$state": "ready",
- "breaker/rating": "200",
- "door/state": "CLOSED",
- "info/data-model-version": "1.0",
- "info/firmware-version": "example/v0.1.0",
- "info/hardware-version": "rev2",
- "info/model": "MAIN_40",
- "info/serial-number": "example-40t-001",
- "info/vendor-name": "Span",
- "meter/voltage-a": "120.0",
- "meter/voltage-b": "120.0",
- "pcs/active": "false",
- "pcs/binding-constraint": "NONE",
- "pcs/enabled": "false",
- "pcs/feed-import-limit": "0.0",
- "pcs/feed-import-limit-active": "false",
- "pcs/feed-import-limit-enablement": "UNCONFIGURED",
- "pcs/import-limit": "0.0",
- "pcs/off-grid-import-limit": "0.0",
- "pcs/off-grid-import-limit-active": "false",
- "pcs/off-grid-import-limit-enablement": "UNCONFIGURED",
- "pcs/operator-import-limit": "0.0",
- "pcs/operator-import-limit-active": "false",
- "pcs/operator-import-limit-enablement": "UNCONFIGURED",
- "pcs/requested-import-limit": "0.0",
- "pcs/requested-import-limit-active": "false",
- "pcs/requested-import-limit-enablement": "UNCONFIGURED",
- "power-flows/battery": "-3500.0",
- "power-flows/grid": "-2347.0",
- "power-flows/pv": "8500.0",
- "power-flows/site": "2653.0",
- "shed-forecast/confidence": "HIGH",
- "shed-forecast/full-charge-time-to-priority-shed": "3038",
- "shed-forecast/full-charge-total-time-remaining": "4320",
- "shed-forecast/time-to-priority-shed": "3037",
- "shed-forecast/total-time-remaining": "4320",
- "shed/asserted-islanding-state": "NONE",
- "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}",
- "status/cloud-connection": "CONNECTED",
- "status/ethernet": "true",
- "status/postal-code": "94103",
- "status/relay": "CLOSED",
- "status/time-zone": "America/Los_Angeles",
- "status/wifi": "true",
- "status/wifi-ssid": "example-wifi"
- },
- "fe8b85c15bc9610c1b8b4ebc6f82488d": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "2",
- "breaker/rating": "50",
- "connection/feeds-device-id": "evse-2",
- "connection/feeds-device-status": "OK",
- "connection/feeds-device-type": "energy.ebus.device.evse",
- "info/name": "SPAN Drive - Driveway",
- "info/spaces": "35,37",
- "load-shed/priority": "OFF_GRID",
- "meter/active-power": "0.0",
- "meter/current": "0.0",
- "meter/exported-energy": "0.0",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "4",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "lugs-downstream": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "info/direction": "DOWNSTREAM",
- "meter/active-power": "-5847.0",
- "meter/current-a": "46.46666666666666",
- "meter/current-b": "46.474999999999994",
- "meter/exported-energy": "141.66666666666666",
- "meter/imported-energy": "44.21666666666666"
- },
- "lugs-upstream": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "connection/fed-by-device-id": "bess",
- "connection/fed-by-device-status": "OK",
- "connection/fed-by-device-type": "energy.ebus.device.bess",
- "info/direction": "UPSTREAM",
- "meter/active-power": "-5847.0",
- "meter/current-a": "46.46666666666666",
- "meter/current-b": "46.474999999999994",
- "meter/exported-energy": "141.66666666666666",
- "meter/imported-energy": "44.21666666666666"
- },
- "pv": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "info/firmware-version": "example-pv/v0.1.0",
- "info/model": "IQ8PLUS-72-2-US",
- "info/nominal-power": "10000.0",
- "info/vendor-name": "Enphase"
- }
-}
diff --git a/tests/fixtures/schema_one_tree_batteryless.json b/tests/fixtures/schema_one_tree_batteryless.json
deleted file mode 100644
index 5e67af74..00000000
--- a/tests/fixtures/schema_one_tree_batteryless.json
+++ /dev/null
@@ -1,220 +0,0 @@
-{
- "0ab966b95f92a6a51ec548485aa85f54": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "1",
- "breaker/rating": "15",
- "info/name": "Kitchen Lights",
- "info/spaces": "1",
- "load-shed/priority": "UNKNOWN",
- "meter/active-power": "-121.0",
- "meter/current": "1.0083333333333333",
- "meter/exported-energy": "2.0166666666666666",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "1",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "573066aaddd7b75114c4563ce3af18c4": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "2",
- "breaker/rating": "30",
- "connection/feeds-device-id": "pv",
- "connection/feeds-device-status": "OK",
- "connection/feeds-device-type": "energy.ebus.device.pv",
- "info/name": "Solar Inverter",
- "info/spaces": "36,38",
- "load-shed/priority": "NEVER",
- "meter/active-power": "8500.0",
- "meter/current": "35.416666666666664",
- "meter/exported-energy": "0.0",
- "meter/imported-energy": "141.66666666666666",
- "pcs/managed": "false",
- "pcs/priority": "5",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "false",
- "switch/relay-requester": "NONE"
- },
- "62d0e03897b337b57101aae82f1e9ba2": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "2",
- "breaker/rating": "50",
- "connection/feeds-device-id": "evse",
- "connection/feeds-device-status": "OK",
- "connection/feeds-device-type": "energy.ebus.device.evse",
- "info/name": "SPAN Drive - Garage",
- "info/spaces": "32,34",
- "load-shed/priority": "OFF_GRID",
- "meter/active-power": "-2410.0",
- "meter/current": "10.041666666666666",
- "meter/exported-energy": "40.166666666666664",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "3",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "bess-mid": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}",
- "$state": "ready",
- "grid/grid-forming-entity": "GRID",
- "grid/grid-state": "UP",
- "grid/islanding-state": "ON_GRID",
- "info/firmware-version": "example-mid/v0.1.0",
- "info/hardware-version": "rev1",
- "info/model": "SPAN MID",
- "info/serial-number": "EXAMPLE-BESS-40T-001-mid",
- "info/vendor-name": "Span"
- },
- "d3724e0d660ba506aa79c1cafe5d1181": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "1",
- "breaker/rating": "20",
- "info/name": "Garage Outlet",
- "info/spaces": "2",
- "load-shed/priority": "UNKNOWN",
- "meter/active-power": "-122.0",
- "meter/current": "1.0166666666666666",
- "meter/exported-energy": "2.033333333333333",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "2",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "evse": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "config/max-charge-current": "32",
- "config/user-max-charge-current": "32",
- "info/firmware-version": "example/v0.1.0",
- "info/model": "SPAN Drive",
- "info/part-number": "SPN-DRV-001",
- "info/serial-number": "SIM-EVSE-example-40t-001",
- "info/vendor-name": "SPAN",
- "meter/advertised-current": "32.0",
- "status/status": "CHARGING",
- "switch/lock-state": "LOCKED"
- },
- "evse-2": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "config/max-charge-current": "32",
- "config/user-max-charge-current": "32",
- "info/firmware-version": "example/v0.1.0",
- "info/model": "SPAN Drive",
- "info/part-number": "SPN-DRV-001",
- "info/serial-number": "SIM-EVSE-example-40t-001-2",
- "info/vendor-name": "SPAN",
- "meter/advertised-current": "32.0",
- "status/status": "AVAILABLE",
- "switch/lock-state": "UNLOCKED"
- },
- "example-40t-001": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}",
- "$state": "ready",
- "breaker/rating": "200",
- "door/state": "CLOSED",
- "info/data-model-version": "1.0",
- "info/firmware-version": "example/v0.1.0",
- "info/hardware-version": "rev2",
- "info/model": "MAIN_40",
- "info/serial-number": "example-40t-001",
- "info/vendor-name": "Span",
- "meter/voltage-a": "120.0",
- "meter/voltage-b": "120.0",
- "pcs/active": "false",
- "pcs/binding-constraint": "NONE",
- "pcs/enabled": "false",
- "pcs/feed-import-limit": "0.0",
- "pcs/feed-import-limit-active": "false",
- "pcs/feed-import-limit-enablement": "UNCONFIGURED",
- "pcs/import-limit": "0.0",
- "pcs/off-grid-import-limit": "0.0",
- "pcs/off-grid-import-limit-active": "false",
- "pcs/off-grid-import-limit-enablement": "UNCONFIGURED",
- "pcs/operator-import-limit": "0.0",
- "pcs/operator-import-limit-active": "false",
- "pcs/operator-import-limit-enablement": "UNCONFIGURED",
- "pcs/requested-import-limit": "0.0",
- "pcs/requested-import-limit-active": "false",
- "pcs/requested-import-limit-enablement": "UNCONFIGURED",
- "power-flows/battery": "-3500.0",
- "power-flows/grid": "-2347.0",
- "power-flows/pv": "8500.0",
- "power-flows/site": "2653.0",
- "shed-forecast/confidence": "HIGH",
- "shed-forecast/full-charge-time-to-priority-shed": "3038",
- "shed-forecast/full-charge-total-time-remaining": "4320",
- "shed-forecast/time-to-priority-shed": "3037",
- "shed-forecast/total-time-remaining": "4320",
- "shed/asserted-islanding-state": "NONE",
- "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}",
- "status/cloud-connection": "CONNECTED",
- "status/ethernet": "true",
- "status/postal-code": "94103",
- "status/relay": "CLOSED",
- "status/time-zone": "America/Los_Angeles",
- "status/wifi": "true",
- "status/wifi-ssid": "example-wifi"
- },
- "fe8b85c15bc9610c1b8b4ebc6f82488d": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "2",
- "breaker/rating": "50",
- "connection/feeds-device-id": "evse-2",
- "connection/feeds-device-status": "OK",
- "connection/feeds-device-type": "energy.ebus.device.evse",
- "info/name": "SPAN Drive - Driveway",
- "info/spaces": "35,37",
- "load-shed/priority": "OFF_GRID",
- "meter/active-power": "0.0",
- "meter/current": "0.0",
- "meter/exported-energy": "0.0",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "4",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "lugs-downstream": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "info/direction": "DOWNSTREAM",
- "meter/active-power": "-5847.0",
- "meter/current-a": "46.46666666666666",
- "meter/current-b": "46.474999999999994",
- "meter/exported-energy": "141.66666666666666",
- "meter/imported-energy": "44.21666666666666"
- },
- "lugs-upstream": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "connection/fed-by-device-id": "bess",
- "connection/fed-by-device-status": "OK",
- "connection/fed-by-device-type": "energy.ebus.device.bess",
- "info/direction": "UPSTREAM",
- "meter/active-power": "-5847.0",
- "meter/current-a": "46.46666666666666",
- "meter/current-b": "46.474999999999994",
- "meter/exported-energy": "141.66666666666666",
- "meter/imported-energy": "44.21666666666666"
- },
- "pv": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.pv\", \"name\": \"Solar\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nominal-power\": {\"name\": \"Nominal power\", \"datatype\": \"float\", \"unit\": \"W\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "info/firmware-version": "example-pv/v0.1.0",
- "info/model": "IQ8PLUS-72-2-US",
- "info/nominal-power": "10000.0",
- "info/vendor-name": "Enphase"
- }
-}
diff --git a/tests/fixtures/schema_one_tree_no_pv.json b/tests/fixtures/schema_one_tree_no_pv.json
deleted file mode 100644
index ac92a366..00000000
--- a/tests/fixtures/schema_one_tree_no_pv.json
+++ /dev/null
@@ -1,226 +0,0 @@
-{
- "0ab966b95f92a6a51ec548485aa85f54": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Kitchen Lights\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "1",
- "breaker/rating": "15",
- "info/name": "Kitchen Lights",
- "info/spaces": "1",
- "load-shed/priority": "UNKNOWN",
- "meter/active-power": "-121.0",
- "meter/current": "1.0083333333333333",
- "meter/exported-energy": "2.0166666666666666",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "1",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "573066aaddd7b75114c4563ce3af18c4": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Solar Inverter\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "2",
- "breaker/rating": "30",
- "connection/feeds-device-id": "pv",
- "connection/feeds-device-status": "OK",
- "connection/feeds-device-type": "energy.ebus.device.pv",
- "info/name": "Solar Inverter",
- "info/spaces": "36,38",
- "load-shed/priority": "NEVER",
- "meter/active-power": "8500.0",
- "meter/current": "35.416666666666664",
- "meter/exported-energy": "0.0",
- "meter/imported-energy": "141.66666666666666",
- "pcs/managed": "false",
- "pcs/priority": "5",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "false",
- "switch/relay-requester": "NONE"
- },
- "62d0e03897b337b57101aae82f1e9ba2": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "2",
- "breaker/rating": "50",
- "connection/feeds-device-id": "evse",
- "connection/feeds-device-status": "OK",
- "connection/feeds-device-type": "energy.ebus.device.evse",
- "info/name": "SPAN Drive - Garage",
- "info/spaces": "32,34",
- "load-shed/priority": "OFF_GRID",
- "meter/active-power": "-2410.0",
- "meter/current": "10.041666666666666",
- "meter/exported-energy": "40.166666666666664",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "3",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "bess": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.bess\", \"name\": \"Battery\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"nameplate-capacity\": {\"name\": \"Nameplate capacity\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"soc\": {\"name\": \"soc\", \"type\": \"energy.ebus.capability.soc\", \"properties\": {\"soc\": {\"name\": \"State of charge\", \"datatype\": \"float\", \"unit\": \"%\"}, \"soe\": {\"name\": \"State of energy\", \"datatype\": \"float\", \"unit\": \"kWh\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"communication-state\": {\"name\": \"Communication state\", \"datatype\": \"enum\", \"format\": \"OK,DEGRADED,LOST,UNKNOWN\"}}}}, \"children\": [\"bess-mid\"], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "info/firmware-version": "example-bess/v0.1.0",
- "info/model": "Example BESS",
- "info/nameplate-capacity": "13.5",
- "info/part-number": "SPN-BESS-001",
- "info/serial-number": "EXAMPLE-BESS-40T-001",
- "info/vendor-name": "Span",
- "meter/active-power": "-3500.0",
- "soc/soc": "50.410493827160494",
- "soc/soe": "6.805416666666667",
- "status/communication-state": "OK"
- },
- "bess-mid": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.mid\", \"name\": \"Microgrid Interconnect Device\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}}}, \"grid\": {\"name\": \"grid\", \"type\": \"energy.ebus.capability.grid\", \"properties\": {\"islanding-state\": {\"name\": \"Islanding state of the BESS-integrated grid-forming device\", \"datatype\": \"enum\", \"format\": \"ON_GRID,OFF_GRID,UNKNOWN\"}, \"grid-state\": {\"name\": \"Sensed grid condition\", \"datatype\": \"enum\", \"format\": \"UP,DOWN,DEGRADED,UNKNOWN\"}, \"grid-forming-entity\": {\"name\": \"Identity of the currently grid-forming entity\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"bess\", \"extensions\": []}",
- "$state": "ready",
- "grid/grid-forming-entity": "GRID",
- "grid/grid-state": "UP",
- "grid/islanding-state": "ON_GRID",
- "info/firmware-version": "example-mid/v0.1.0",
- "info/hardware-version": "rev1",
- "info/model": "SPAN MID",
- "info/serial-number": "EXAMPLE-BESS-40T-001-mid",
- "info/vendor-name": "Span"
- },
- "d3724e0d660ba506aa79c1cafe5d1181": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"Garage Outlet\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "1",
- "breaker/rating": "20",
- "info/name": "Garage Outlet",
- "info/spaces": "2",
- "load-shed/priority": "UNKNOWN",
- "meter/active-power": "-122.0",
- "meter/current": "1.0166666666666666",
- "meter/exported-energy": "2.033333333333333",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "2",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "evse": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Garage\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "config/max-charge-current": "32",
- "config/user-max-charge-current": "32",
- "info/firmware-version": "example/v0.1.0",
- "info/model": "SPAN Drive",
- "info/part-number": "SPN-DRV-001",
- "info/serial-number": "SIM-EVSE-example-40t-001",
- "info/vendor-name": "SPAN",
- "meter/advertised-current": "32.0",
- "status/status": "CHARGING",
- "switch/lock-state": "LOCKED"
- },
- "evse-2": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.evse\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"string\"}, \"part-number\": {\"name\": \"Part number\", \"datatype\": \"string\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}}}, \"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"lock-state\": {\"name\": \"Lock state\", \"datatype\": \"enum\", \"format\": \"UNLOCKED,LOCKED\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"status\": {\"name\": \"Status\", \"datatype\": \"enum\", \"format\": \"AVAILABLE,PREPARING,CHARGING,UNAVAILABLE\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"advertised-current\": {\"name\": \"Current EVSE is advertising to the EV\", \"datatype\": \"float\", \"unit\": \"A\"}}}, \"config\": {\"name\": \"config\", \"type\": \"energy.ebus.capability.config\", \"properties\": {\"user-max-charge-current\": {\"name\": \"User-configured maximum EVSE charge current (ceiling)\", \"datatype\": \"integer\", \"settable\": true, \"unit\": \"A\"}, \"max-charge-current\": {\"name\": \"Commissioned maximum EVSE charge current (installer-configured)\", \"datatype\": \"integer\", \"unit\": \"A\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "config/max-charge-current": "32",
- "config/user-max-charge-current": "32",
- "info/firmware-version": "example/v0.1.0",
- "info/model": "SPAN Drive",
- "info/part-number": "SPN-DRV-001",
- "info/serial-number": "SIM-EVSE-example-40t-001-2",
- "info/vendor-name": "SPAN",
- "meter/advertised-current": "32.0",
- "status/status": "AVAILABLE",
- "switch/lock-state": "UNLOCKED"
- },
- "example-40t-001": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.distribution-enclosure\", \"name\": \"Example 40-tab Panel\", \"nodes\": {\"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"vendor-name\": {\"name\": \"Vendor name\", \"datatype\": \"string\"}, \"model\": {\"name\": \"Model\", \"datatype\": \"enum\", \"format\": \"MAIN_16,MLO_24,MAIN_32,MAIN_40,MLO_48\"}, \"serial-number\": {\"name\": \"Serial number\", \"datatype\": \"string\"}, \"hardware-version\": {\"name\": \"Hardware version\", \"datatype\": \"string\"}, \"firmware-version\": {\"name\": \"Firmware version\", \"datatype\": \"string\"}, \"data-model-version\": {\"name\": \"eBus data-model version (parent/child schema discriminator)\", \"datatype\": \"string\"}}}, \"door\": {\"name\": \"door\", \"type\": \"energy.ebus.capability.door\", \"properties\": {\"state\": {\"name\": \"Door state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"voltage-a\": {\"name\": \"L1 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}, \"voltage-b\": {\"name\": \"L2 voltage\", \"datatype\": \"float\", \"unit\": \"V\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Main breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"enabled\": {\"name\": \"PCS system enabled\", \"datatype\": \"boolean\"}, \"active\": {\"name\": \"PCS system actively controlling one (or more) loads\", \"datatype\": \"boolean\"}, \"import-limit\": {\"name\": \"The power import limit currently being managed to\", \"datatype\": \"float\", \"unit\": \"A\"}, \"binding-constraint\": {\"name\": \"Which constraint class currently sets the import limit\", \"datatype\": \"enum\", \"format\": \"FSR,DOE,VOLTAGE,OFF_GRID,REQUESTED,OPERATOR,NONE,UNKNOWN\"}, \"feed-import-limit\": {\"name\": \"Limit of maximum power feeding the distribution enclosure\", \"datatype\": \"float\", \"unit\": \"A\"}, \"feed-import-limit-enablement\": {\"name\": \"Enablement status of the feed-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"feed-import-limit-active\": {\"name\": \"Is feed-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"operator-import-limit\": {\"name\": \"Operator-imposed maximum import limit\", \"datatype\": \"float\", \"unit\": \"A\"}, \"operator-import-limit-enablement\": {\"name\": \"Enablement status of the operator-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"operator-import-limit-active\": {\"name\": \"Is operator-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"off-grid-import-limit\": {\"name\": \"Off-Grid limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"off-grid-import-limit-enablement\": {\"name\": \"Enablement status of the off-grid-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"off-grid-import-limit-active\": {\"name\": \"Is off-grid-import-limit currently being enforced?\", \"datatype\": \"boolean\"}, \"requested-import-limit\": {\"name\": \"Requested limit maximum import power\", \"datatype\": \"float\", \"unit\": \"A\"}, \"requested-import-limit-enablement\": {\"name\": \"Enablement status of the requested-import-limit\", \"datatype\": \"enum\", \"format\": \"UNSPECIFIED,UNCONFIGURED,DISABLED,ENABLED\"}, \"requested-import-limit-active\": {\"name\": \"Is requested-import-limit currently being enforced?\", \"datatype\": \"boolean\"}}}, \"shed-forecast\": {\"name\": \"shed-forecast\", \"type\": \"energy.ebus.capability.shed-forecast\", \"properties\": {\"total-time-remaining\": {\"name\": \"Estimated total time before all sheddable circuits are shed (off-grid runtime)\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"time-to-priority-shed\": {\"name\": \"Estimated time before the next priority tier is shed\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-total-time-remaining\": {\"name\": \"Estimated total time assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"full-charge-time-to-priority-shed\": {\"name\": \"Estimated time to next priority shed assuming BESS starts at full charge\", \"datatype\": \"integer\", \"unit\": \"min\"}, \"confidence\": {\"name\": \"Confidence of the shed-forecast estimate\", \"datatype\": \"enum\", \"format\": \"LOW,MEDIUM,HIGH\"}}}, \"shed\": {\"name\": \"shed\", \"type\": \"energy.ebus.capability.shed\", \"properties\": {\"asserted-islanding-state\": {\"name\": \"Consumer-asserted islanding-state (grid-state override during MID/BESS comm-loss)\", \"datatype\": \"enum\", \"format\": \"NONE,ON_GRID,OFF_GRID\", \"settable\": true}, \"policy\": {\"name\": \"Shed policy (algorithm and parameters)\", \"datatype\": \"json\", \"format\": \"{\\\"$id\\\":\\\"soc-priority.v1\\\",\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"algorithm\\\",\\\"parameters\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"algorithm\\\":{\\\"const\\\":\\\"soc-priority.v1\\\"},\\\"parameters\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"soc-threshold-shed\\\",\\\"soc-threshold-release\\\"],\\\"additionalProperties\\\":false,\\\"properties\\\":{\\\"soc-threshold-shed\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent below which SOC_THRESHOLD circuits shed\\\"},\\\"soc-threshold-release\\\":{\\\"type\\\":\\\"integer\\\",\\\"minimum\\\":0,\\\"maximum\\\":100,\\\"description\\\":\\\"SoC percent above which shed SOC_THRESHOLD circuits restore\\\"}}}}}\"}}}, \"power-flows\": {\"name\": \"power-flows\", \"type\": \"energy.ebus.capability.power-flows\", \"properties\": {\"pv\": {\"name\": \"PV power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"battery\": {\"name\": \"Battery/BESS power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"grid\": {\"name\": \"Grid power flow\", \"datatype\": \"float\", \"unit\": \"W\"}, \"site\": {\"name\": \"Site power flow\", \"datatype\": \"float\", \"unit\": \"W\"}}}, \"status\": {\"name\": \"status\", \"type\": \"energy.ebus.capability.status\", \"properties\": {\"relay\": {\"name\": \"Main relay\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\"}, \"ethernet\": {\"name\": \"Is Ethernet network interface operational?\", \"datatype\": \"boolean\"}, \"wifi\": {\"name\": \"Is Wi-Fi network interface operational?\", \"datatype\": \"boolean\"}, \"wifi-ssid\": {\"name\": \"SSID to which Wi-Fi network interface is connected\", \"datatype\": \"string\"}, \"cloud-connection\": {\"name\": \"Device connected to vendor cloud?\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,UNCONNECTED,CONNECTED\"}, \"postal-code\": {\"name\": \"Postal (Zip) code\", \"datatype\": \"string\"}, \"time-zone\": {\"name\": \"Time zone\", \"datatype\": \"string\"}}}}, \"children\": [\"bess\", \"0ab966b95f92a6a51ec548485aa85f54\", \"d3724e0d660ba506aa79c1cafe5d1181\", \"62d0e03897b337b57101aae82f1e9ba2\", \"fe8b85c15bc9610c1b8b4ebc6f82488d\", \"573066aaddd7b75114c4563ce3af18c4\", \"evse\", \"evse-2\", \"lugs-upstream\", \"lugs-downstream\", \"pv\"], \"extensions\": []}",
- "$state": "ready",
- "breaker/rating": "200",
- "door/state": "CLOSED",
- "info/data-model-version": "1.0",
- "info/firmware-version": "example/v0.1.0",
- "info/hardware-version": "rev2",
- "info/model": "MAIN_40",
- "info/serial-number": "example-40t-001",
- "info/vendor-name": "Span",
- "meter/voltage-a": "120.0",
- "meter/voltage-b": "120.0",
- "pcs/active": "false",
- "pcs/binding-constraint": "NONE",
- "pcs/enabled": "false",
- "pcs/feed-import-limit": "0.0",
- "pcs/feed-import-limit-active": "false",
- "pcs/feed-import-limit-enablement": "UNCONFIGURED",
- "pcs/import-limit": "0.0",
- "pcs/off-grid-import-limit": "0.0",
- "pcs/off-grid-import-limit-active": "false",
- "pcs/off-grid-import-limit-enablement": "UNCONFIGURED",
- "pcs/operator-import-limit": "0.0",
- "pcs/operator-import-limit-active": "false",
- "pcs/operator-import-limit-enablement": "UNCONFIGURED",
- "pcs/requested-import-limit": "0.0",
- "pcs/requested-import-limit-active": "false",
- "pcs/requested-import-limit-enablement": "UNCONFIGURED",
- "power-flows/battery": "-3500.0",
- "power-flows/grid": "-2347.0",
- "power-flows/pv": "8500.0",
- "power-flows/site": "2653.0",
- "shed-forecast/confidence": "HIGH",
- "shed-forecast/full-charge-time-to-priority-shed": "3038",
- "shed-forecast/full-charge-total-time-remaining": "4320",
- "shed-forecast/time-to-priority-shed": "3037",
- "shed-forecast/total-time-remaining": "4320",
- "shed/asserted-islanding-state": "NONE",
- "shed/policy": "{\"algorithm\": \"soc-priority.v1\", \"parameters\": {\"soc-threshold-shed\": 20, \"soc-threshold-release\": 30}}",
- "status/cloud-connection": "CONNECTED",
- "status/ethernet": "true",
- "status/postal-code": "94103",
- "status/relay": "CLOSED",
- "status/time-zone": "America/Los_Angeles",
- "status/wifi": "true",
- "status/wifi-ssid": "example-wifi"
- },
- "fe8b85c15bc9610c1b8b4ebc6f82488d": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.circuit\", \"name\": \"SPAN Drive - Driveway\", \"nodes\": {\"switch\": {\"name\": \"switch\", \"type\": \"energy.ebus.capability.switch\", \"properties\": {\"relay\": {\"name\": \"Circuit relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OPEN,CLOSED\", \"settable\": true}, \"relay-requester\": {\"name\": \"Actor requesting the relay state\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,NONE,LOAD_SHED,USER,PCS,CONFIGURATION,FAULT\"}, \"relay-controllable\": {\"name\": \"Can the circuit's relay be commanded by the user?\", \"datatype\": \"boolean\"}}}, \"breaker\": {\"name\": \"breaker\", \"type\": \"energy.ebus.capability.breaker\", \"properties\": {\"rating\": {\"name\": \"Circuit breaker rating\", \"datatype\": \"integer\", \"unit\": \"A\"}, \"poles\": {\"name\": \"Number of breaker poles\", \"datatype\": \"integer\", \"format\": \"1:4:1\"}}}, \"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current\": {\"name\": \"Measured current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Measured active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Measured energy imported\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Measured energy exported\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"load-shed\": {\"name\": \"load-shed\", \"type\": \"energy.ebus.capability.load-shed\", \"properties\": {\"priority\": {\"name\": \"Configured priority of circuit shedding when off-grid (dominant-power-source != GRID)\", \"datatype\": \"enum\", \"format\": \"UNKNOWN,OFF_GRID,SOC_THRESHOLD,NEVER\", \"settable\": true}}}, \"pcs\": {\"name\": \"pcs\", \"type\": \"energy.ebus.capability.pcs\", \"properties\": {\"managed\": {\"name\": \"Is circuit managed by PCS?\", \"datatype\": \"boolean\"}, \"priority\": {\"name\": \"Circuit PCS priority ranking\", \"datatype\": \"integer\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this circuit\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated downstream (e.g. microinverters, packs)\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"name\": {\"name\": \"Circuit name\", \"datatype\": \"string\"}, \"spaces\": {\"name\": \"Circuit breaker space number(s) within the load center (comma-separated for multi-pole)\", \"datatype\": \"string\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "breaker/poles": "2",
- "breaker/rating": "50",
- "connection/feeds-device-id": "evse-2",
- "connection/feeds-device-status": "OK",
- "connection/feeds-device-type": "energy.ebus.device.evse",
- "info/name": "SPAN Drive - Driveway",
- "info/spaces": "35,37",
- "load-shed/priority": "OFF_GRID",
- "meter/active-power": "0.0",
- "meter/current": "0.0",
- "meter/exported-energy": "0.0",
- "meter/imported-energy": "0.0",
- "pcs/managed": "true",
- "pcs/priority": "4",
- "switch/relay": "CLOSED",
- "switch/relay-controllable": "true",
- "switch/relay-requester": "NONE"
- },
- "lugs-downstream": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Downstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "info/direction": "DOWNSTREAM",
- "meter/active-power": "-5847.0",
- "meter/current-a": "46.46666666666666",
- "meter/current-b": "46.474999999999994",
- "meter/exported-energy": "141.66666666666666",
- "meter/imported-energy": "44.21666666666666"
- },
- "lugs-upstream": {
- "$description": "{\"homie\": \"5.0\", \"version\": 1785909496596, \"type\": \"energy.ebus.device.lugs\", \"name\": \"Upstream lugs\", \"nodes\": {\"meter\": {\"name\": \"meter\", \"type\": \"energy.ebus.capability.meter\", \"properties\": {\"current-a\": {\"name\": \"L1 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"current-b\": {\"name\": \"L2 current\", \"datatype\": \"float\", \"unit\": \"A\"}, \"active-power\": {\"name\": \"Active power\", \"datatype\": \"float\", \"unit\": \"W\"}, \"imported-energy\": {\"name\": \"Imported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}, \"exported-energy\": {\"name\": \"Exported energy\", \"datatype\": \"float\", \"unit\": \"Wh\"}}}, \"connection\": {\"name\": \"connection\", \"type\": \"energy.ebus.capability.connection\", \"properties\": {\"fed-by-device-id\": {\"name\": \"Homie device-id of the upstream device feeding this lugs\", \"datatype\": \"string\"}, \"fed-by-device-type\": {\"name\": \"Homie $type of the upstream device\", \"datatype\": \"string\"}, \"fed-by-device-status\": {\"name\": \"Panel's view of comm health to the upstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"feeds-device-id\": {\"name\": \"Homie device-id of the downstream device fed by this lugs\", \"datatype\": \"string\"}, \"feeds-device-type\": {\"name\": \"Homie $type of the downstream device\", \"datatype\": \"string\"}, \"feeds-device-status\": {\"name\": \"Panel's view of comm health to the downstream device\", \"datatype\": \"enum\", \"format\": \"OK,LOST,DEGRADED\"}, \"count\": {\"name\": \"Number of physical units aggregated up/downstream\", \"datatype\": \"integer\"}}}, \"info\": {\"name\": \"info\", \"type\": \"energy.ebus.capability.info\", \"properties\": {\"direction\": {\"name\": \"Lugs feed direction: upstream or downstream\", \"datatype\": \"enum\", \"format\": \"UPSTREAM,DOWNSTREAM\"}}}}, \"children\": [], \"root\": \"example-40t-001\", \"parent\": \"example-40t-001\", \"extensions\": []}",
- "$state": "ready",
- "connection/fed-by-device-id": "bess",
- "connection/fed-by-device-status": "OK",
- "connection/fed-by-device-type": "energy.ebus.device.bess",
- "info/direction": "UPSTREAM",
- "meter/active-power": "-5847.0",
- "meter/current-a": "46.46666666666666",
- "meter/current-b": "46.474999999999994",
- "meter/exported-energy": "141.66666666666666",
- "meter/imported-energy": "44.21666666666666"
- }
-}
From 0a8741dc4816cbd51ab21f7b325c88f9f7d97e3f Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 18:02:23 -0700
Subject: [PATCH 075/116] docs(sensors): the shared battery convention is
discharge-positive, measured
The docstring withdrew the charge-positive claim rather than replacing it,
because nothing had settled the direction. A producer driven into
self-consumption settles it: with the grid at zero, PV 4181 W plus battery
1917 W meeting a 6099 W load, the battery is discharging and both sensors read
+1917.49.
Positive means discharging, and that is the same convention the other three
power-flow sensors follow -- pv_power positive while producing, grid_power_flow
positive while importing. Every one is "positive means power flowing toward the
house", each reached by one negation of a wire value in the opposite frame.
Prose only. No value, no id and no state class moves; the library's rename of
_charge_positive to _discharge_positive is the same kind of change.
---
.../span_panel/sensor_definitions.py | 21 ++++++++++++-------
1 file changed, 13 insertions(+), 8 deletions(-)
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index bd3f0b1f..56e1c8f0 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -861,14 +861,19 @@ class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKey
contradicted the one beside it would be worse than no sensor, and the agreement
is structural rather than lucky.
-**Which direction that shared convention runs is not settled, and this docstring
-used to claim it was.** It said charge-positive. Working it through against
-`ebus-panel-sim` 0.6.0 -- where the four power-flow terms sum to zero and the
-identity forces the battery to be *discharging* at 3500 W -- both sensors read
-`+3500`, which is discharge-positive. The claim is withdrawn rather than
-replaced, because the sample that would settle it does not exist yet: the only
-live capture has the battery idle at 100 percent SoC with both properties
-exactly zero.
+**The shared convention is discharge-positive, and that was measured.** This
+docstring used to claim charge-positive. Driving the producer into
+self-consumption with the grid at zero forces the direction -- PV 4181 W plus
+battery 1917 W meeting a 6099 W load, so the battery is discharging -- and both
+sensors read `+1917.49`. Positive means the battery is *discharging*.
+
+That is the same convention the other three power-flow sensors follow, which is
+why it is right rather than merely consistent: `pv_power` is positive while
+producing, `grid_power_flow` positive while importing, `battery_power` positive
+while discharging. Every one is "positive means power flowing toward the house",
+and each is reached by the same single negation of a wire value in the opposite
+frame. The library's helper was renamed `_charge_positive` -> `_discharge_positive`
+for the same reason.
**What is settled is that nothing here regressed.** `BATTERY_POWER_SENSOR` is
behaviourally identical to the one released in 2.0.8 -- same source, same single
From 3ef4b26eeabf4ad5a2e6e6ac202aeb803c1eda12 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 18:06:39 -0700
Subject: [PATCH 076/116] fix(additions): tell sub-device entities apart in the
notification
Two commissioned chargers each gained a charge-current limit and a panel link,
and the notification listed "EVSE Charge Current Limit" twice with nothing to
distinguish them. The battery's meter read as a bare "Meter Power".
Every platform sets _attr_has_entity_name, so the registry stores only the
entity half and Home Assistant prepends the device in its own UI. A flat list
does not, so entities that differ only by which sub-device they sit on
collapsed to the same string. Two identical rows is what teaches somebody to
skip the category, which costs them the additions that matter.
Sub-device entities now carry their device's name; the panel's own do not,
because a notification that already says which panel it is about does not need
the panel name on every line. The prefix breaks collisions, and the panel is
the one device with no sibling to collide with.
Verified by mutation: reverting to the shipped behaviour fails the collision
test and nothing else.
---
custom_components/span_panel/additions.py | 38 +++++++++++--
tests/test_new_entity_announcement.py | 66 +++++++++++++++++++++++
2 files changed, 101 insertions(+), 3 deletions(-)
diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py
index 83a87681..2abd67ad 100644
--- a/custom_components/span_panel/additions.py
+++ b/custom_components/span_panel/additions.py
@@ -189,7 +189,7 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str,
if registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
else enabled
)
- target.append(_label(registry_entry))
+ target.append(_label(devices, registry_entry))
# Two keys rather than one with a plural placeholder: a plural rule the caller
# picks a *word* for is an English rule, and the five languages here do not
@@ -234,15 +234,47 @@ def _adopted_device_name(
return device.name_by_user or device.name or registry_entry.entity_id
-def _label(registry_entry: er.RegistryEntry) -> str:
+def _label(devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry) -> str:
"""Return what to call an entity the user has never seen.
A disabled entity has no state, so there is no friendly name on the state
machine -- only what the registry recorded when the platform added it. The
entity_id is the last resort rather than the first choice, because it is the
name the user will *not* see in the device's entity list.
+
+ **Sub-device entities carry their device's name.** Every platform here sets
+ `_attr_has_entity_name`, so the registry stores only the entity half and Home
+ Assistant prepends the device in its own UI. A flat list does not, and the
+ result was two chargers contributing "EVSE Charge Current Limit" twice with
+ nothing to tell them apart, and the battery's meter reading as a bare "Meter
+ Power". Two identical rows is what teaches somebody to skip the category.
+
+ The panel's own entities are left bare. Prefixing them would put the panel
+ name on every line of a notification that already says which panel it is
+ about, which is noise rather than disambiguation -- so the prefix is added
+ only where the device is a sub-device, which is exactly where the collision
+ happens.
+ """
+ name = registry_entry.name or registry_entry.original_name
+ if name is None:
+ return registry_entry.entity_id
+ device_name = _sub_device_name(devices, registry_entry)
+ return f"{device_name} {name}" if device_name else name
+
+
+def _sub_device_name(devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry) -> str | None:
+ """Return the sub-device this entity sits on, or None for the panel itself.
+
+ A sub-device is one that hangs off another with `via_device_id` -- the
+ battery, the MID, each charger, the solar inverter. The panel is the one
+ device with no parent, and its entities need no prefix.
"""
- return registry_entry.name or registry_entry.original_name or registry_entry.entity_id
+ if registry_entry.device_id is None:
+ return None
+ device = devices.async_get(registry_entry.device_id)
+ if device is None or device.via_device_id is None:
+ return None
+ return device.name_by_user or device.name
async def async_forget_announcements(hass: HomeAssistant, entry: ConfigEntry) -> None:
diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py
index b395c24b..36ce604e 100644
--- a/tests/test_new_entity_announcement.py
+++ b/tests/test_new_entity_announcement.py
@@ -266,3 +266,69 @@ async def test_removing_the_entry_forgets_what_was_announced(
await async_announce_new_entities(hass, entry)
assert _announcement(hass, entry) is None
+
+
+# -- Sub-device entities are told apart --------------------------------------
+
+
+def _sub_device(hass: HomeAssistant, entry: MockConfigEntry, name: str, ident: str) -> str:
+ """A device hanging off the panel, the way every SPAN sub-device does."""
+ devices = dr.async_get(hass)
+ panel = devices.async_get_or_create(
+ config_entry_id=entry.entry_id, identifiers={(DOMAIN, "sp3-001")}, name="SPAN Panel"
+ )
+ child = devices.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, ident)},
+ name=name,
+ via_device_id=panel.id,
+ )
+ return str(child.id)
+
+
+async def test_two_sub_devices_with_one_entity_name_are_told_apart(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The defect this fixes, in the shape it actually shipped in.
+
+ Two commissioned chargers each gain a charge-current limit. Every platform
+ sets `_attr_has_entity_name`, so the registry stores only "Charge Current
+ Limit" for both and Home Assistant prepends the device in its own UI. A flat
+ list does not, so the notification showed the same row twice with nothing to
+ tell them apart -- which is what teaches somebody to skip the category.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ first = _sub_device(hass, entry, "SPAN Drive 1", "sp3-001_evse_1")
+ second = _sub_device(hass, entry, "SPAN Drive 2", "sp3-001_evse_2")
+ _register(hass, entry, "sp3-001_evse_1_limit", name="Charge Current Limit", device_id=first)
+ _register(hass, entry, "sp3-001_evse_2_limit", name="Charge Current Limit", device_id=second)
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert "SPAN Drive 1 Charge Current Limit" in message
+ assert "SPAN Drive 2 Charge Current Limit" in message
+ assert message.count("Charge Current Limit") == 2
+
+
+async def test_a_panel_entity_is_not_prefixed(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+ """The panel needs no prefix, and adding one would be noise.
+
+ A notification that already says which panel it is about does not need the
+ panel's name on every line. The prefix exists to break collisions, and the
+ panel is the one device that cannot collide with a sibling.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ devices = dr.async_get(hass)
+ panel = devices.async_get_or_create(
+ config_entry_id=entry.entry_id, identifiers={(DOMAIN, "sp3-001")}, name="SPAN Panel"
+ )
+ _register(hass, entry, "sp3-001_dsm", name="DSM State", device_id=panel.id)
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert "DSM State" in message
+ assert "SPAN Panel DSM State" not in message
From 4e8a7640008acc7690f827c8e374b0cb2c162212 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 18:25:19 -0700
Subject: [PATCH 077/116] refactor(notices): the firmware upgrade is a
notification, not a repair
A panel that upgraded its data model announced itself twice: a hardcoded English
persistent notification about the reload, then a translated Repair about what
the reload changed. The Repair is what the user saw first, stamped Warning and
offering to Ignore, telling them something about their panel was broken. Nothing
was. It said so in its own second sentence.
The Repair was chosen for durability, and that reason was sound: a persistent
notification lives in memory, so one raised while the owner was away died at the
next restart with nobody having read it. But durability and severity are
separate properties and only one of them was wanted.
`notices` supplies durability directly. A notice is recorded per config entry,
re-raised at every setup, and dropped only when the user dismisses it -- observed
through the registration callback rather than assumed from a restart count or an
age. That is the Repair's one real advantage with none of its meaning.
The two messages become one, and it is translated in all five shipped languages
rather than only the half that was.
Standing Repairs are cleared at setup rather than left to expire with the next
restart, because the user is looking at one now.
New-entity announcements move onto the same primitive; they had the same hole,
and worse, because their own store already recorded the entities as announced --
a restart in the wrong second meant nobody would ever be told.
The reload race is the sharp edge and is covered directly. A schema upgrade
raises a notice and schedules a reload in the same breath, so the setup that
follows can arrive before the delayed write has landed. Re-reading the store
there would drop the notice while it sat on screen; the setup keeps the live view
instead, and the test asserts on that view because the store hands a pending
write back to its own reader and would hide the bug until the next restart.
Twenty new tests, none of which existed: the schema-change handler had no
coverage at all, which is how a notification and a Repair for one event survived.
Three mutations verified -- re-reading the store on reload, dropping the entry
filter on dismissal, and dropping the REMOVED guard -- each killed by the tests
that claim them.
---
custom_components/span_panel/__init__.py | 15 +-
custom_components/span_panel/additions.py | 46 +--
custom_components/span_panel/coordinator.py | 94 ++++--
custom_components/span_panel/notices.py | 280 ++++++++++++++++++
.../span_panel/schema_repairs.py | 30 ++
custom_components/span_panel/strings.json | 8 +-
.../span_panel/translations/en.json | 8 +-
.../span_panel/translations/es.json | 8 +-
.../span_panel/translations/fr.json | 8 +-
.../span_panel/translations/ja.json | 8 +-
.../span_panel/translations/pt.json | 8 +-
tests/test_new_entity_announcement.py | 12 +-
tests/test_notices.py | 259 ++++++++++++++++
tests/test_schema_repairs.py | 17 +-
tests/test_schema_upgrade_notice.py | 169 +++++++++++
15 files changed, 869 insertions(+), 101 deletions(-)
create mode 100644 custom_components/span_panel/notices.py
create mode 100644 tests/test_notices.py
create mode 100644 tests/test_schema_upgrade_notice.py
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index aa5c298d..aba6954b 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -53,8 +53,13 @@
)
from .graph_horizon import GraphHorizonManager
from .migrations import CURRENT_CONFIG_VERSION, async_migrate_entry # noqa: F401
+from .notices import async_forget, async_restore
from .options import SNAPSHOT_UPDATE_INTERVAL
-from .schema_repairs import async_clear_retired_new_entity_notices, async_clear_schema_issues
+from .schema_repairs import (
+ async_clear_retired_new_entity_notices,
+ async_clear_retired_upgrade_notice,
+ async_clear_schema_issues,
+)
from .services import ( # noqa: F401
_async_register_favorites_services,
_async_register_graph_horizon_services,
@@ -135,6 +140,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
"Please reauthenticate with your panel passphrase or proximity."
)
+ # Before the coordinator, because the coordinator can raise a notice as soon
+ # as it starts streaming -- a panel that upgraded its firmware while Home
+ # Assistant was down announces itself on the first snapshot. Restoring after
+ # that would overwrite the record with a view that never saw it.
+ await async_restore(hass, entry)
+
coordinator: SpanPanelCoordinator | None = None
try:
@@ -280,6 +291,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
# nothing reaches the user through this and nothing else -- whether or
# not it arrived switched on.
async_clear_retired_new_entity_notices(hass, entry)
+ async_clear_retired_upgrade_notice(hass, entry)
await async_announce_new_entities(hass, entry)
except Exception:
if coordinator is not None:
@@ -325,6 +337,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) -
"""
async_clear_schema_issues(hass, entry)
await async_forget_announcements(hass, entry)
+ await async_forget(hass, entry)
async def async_remove_config_entry_device(
diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py
index 2abd67ad..724c8e68 100644
--- a/custom_components/span_panel/additions.py
+++ b/custom_components/span_panel/additions.py
@@ -27,16 +27,14 @@
from __future__ import annotations
-import json
import logging
-from pathlib import Path
from typing import TYPE_CHECKING, Any, Final
-from homeassistant.components.persistent_notification import async_create
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.storage import Store
from .const import DOMAIN
+from .notices import async_raise, read_translations
from .util import ADOPTED_IDENTIFIER_TOKEN
if TYPE_CHECKING:
@@ -48,6 +46,11 @@
_STORE_VERSION: Final = 1
_ANNOUNCED: Final = "announced_unique_ids"
+_SECTION: Final = "new_entities"
+"""Names both the translation section and the notice id. They describe the same
+thing, and keeping them one symbol means a rename cannot leave a standing notice
+orphaned from the strings that render it."""
+
_FALLBACK: Final[dict[str, str]] = {
"title": "SPAN Panel added new entities",
"intro_one": "This update added 1 new entity to your SPAN Panel.",
@@ -78,34 +81,6 @@ def _store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]:
return Store(hass, _STORE_VERSION, f"{DOMAIN}.announced.{entry.entry_id}")
-def _read_translations(language: str) -> dict[str, str]:
- """Our own notification strings for one language, or an empty mapping.
-
- Read from this component's `translations/` directory rather than through
- `homeassistant.helpers.translation`, because that helper filters to the
- categories Home Assistant defines and a persistent notification is not one of
- them -- a custom category loads as nothing at all. These are this
- integration's own package files, so reading them is not reaching into
- somebody else's layout.
-
- Blocking file I/O. Callers run it in an executor.
- """
- directory = Path(__file__).parent / "translations"
- for candidate in (f"{language}.json", f"{language.split('-')[0]}.json", "en.json"):
- path = directory / candidate
- if not path.is_file():
- continue
- try:
- loaded = json.loads(path.read_text(encoding="utf-8"))
- except (OSError, ValueError):
- _LOGGER.debug("Could not read notification strings from %s", path, exc_info=True)
- continue
- section = loaded.get("notifications", {}).get("new_entities", {})
- if section:
- return {str(key): str(value) for key, value in section.items()}
- return {}
-
-
async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Tell the user which entities this setup added, and which need enabling.
@@ -141,12 +116,13 @@ async def async_announce_new_entities(hass: HomeAssistant, entry: ConfigEntry) -
if not added:
return
- text = await hass.async_add_executor_job(_read_translations, hass.config.language)
- async_create(
+ text = await hass.async_add_executor_job(read_translations, hass.config.language, _SECTION)
+ async_raise(
hass,
- message=_message(hass, added, text),
+ entry,
+ _SECTION,
title=_text(text, "title"),
- notification_id=f"{DOMAIN}_new_entities_{entry.entry_id}",
+ message=_message(hass, added, text),
)
await store.async_save({_ANNOUNCED: sorted(announced | registered)})
_LOGGER.debug("Announced %d new entities for %s", len(added), entry.entry_id)
diff --git a/custom_components/span_panel/coordinator.py b/custom_components/span_panel/coordinator.py
index cc7824dc..a312cd1d 100644
--- a/custom_components/span_panel/coordinator.py
+++ b/custom_components/span_panel/coordinator.py
@@ -6,7 +6,7 @@
from datetime import timedelta
import logging
from time import time as _epoch_time
-from typing import TYPE_CHECKING, Protocol
+from typing import TYPE_CHECKING, Final, Protocol
if TYPE_CHECKING:
from . import SpanPanelConfigEntry
@@ -21,7 +21,6 @@
HomeAssistantError,
)
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from span_panel_api import SpanMqttClient, SpanPanelClientProtocol, SpanPanelSnapshot
from span_panel_api.exceptions import SpanPanelAuthError, SpanPanelStaleDataError
@@ -29,6 +28,7 @@
from .const import DOMAIN
from .helpers import detect_capabilities
from .id_builder import build_circuit_unique_id
+from .notices import async_raise, read_translations
from .schema_repairs import async_sync_schema_issues
from .schema_validation import SchemaFindings, evaluate_field_metadata
from .sensor_definitions import sensor_descriptions_by_field_path
@@ -45,6 +45,35 @@ def energy_offset(self) -> float:
_LOGGER = logging.getLogger(__name__)
+_UPGRADE_NOTICE: Final = "panel_upgraded"
+"""Names both the notice id and its translation section.
+
+One symbol for both because they are the same notice: a rename that moved only
+one of them would leave a standing notice pointing at strings that no longer
+exist, and the notice cannot be re-derived once the upgrade is over.
+"""
+
+_UPGRADE_FALLBACK: Final[dict[str, str]] = {
+ "title": "SPAN Panel firmware upgraded",
+ "body": (
+ "Your SPAN Panel reported a new eBus data model (**{previous} \u2192 {current}**), "
+ "which happens after a firmware upgrade. The integration reloaded so its devices "
+ "and entities match what the panel now publishes.\n\n"
+ "Nothing you rely on has gone away, and no automation changes are required.\n\n"
+ "**DSM Grid State** keeps its entity ID and its history, and now reads the "
+ "islanding state the Microgrid Interconnect Device (MID) senses rather than "
+ "inferring it. **Grid Islandable** now reflects whether a MID is present.\n\n"
+ "Entities that were renamed or replaced by the upgrade may need to be removed "
+ "manually if they remain unavailable."
+ ),
+}
+"""English text, used when no translation file can be read.
+
+Shorter than the translated body on purpose: this is the copy nobody proofreads,
+and the paragraphs it drops are elaboration rather than the facts a user needs.
+"""
+
+
# Suppress the noisy "Manually updated span_panel data" DEBUG message that
# HA's DataUpdateCoordinator emits on every async_set_updated_data() call.
# In push/streaming mode this fires every ~1s and drowns out useful debug logs.
@@ -266,26 +295,13 @@ def _on_schema_generation_change(self, previous: str | None, current: str | None
previous or "absent (flat)",
current or "absent (flat)",
)
- async_create(
- self.hass,
- (
- f"Your SPAN Panel reported a new eBus data model "
- f"(**{previous or 'flat'} → {current or 'flat'}**), which happens after a "
- "firmware upgrade.\n\n"
- "The integration is reloading so its devices and entities match what the "
- "panel now publishes. New devices — such as the Microgrid Interconnect "
- "Device — appear once that finishes.\n\n"
- "Entities that were renamed or replaced by the upgrade may need to be "
- "removed manually if they remain unavailable."
- ),
- title="SPAN Panel firmware upgraded",
- notification_id=f"span_schema_upgrade_{self.config_entry.entry_id}",
+ self.hass.async_create_task(
+ self._explain_the_upgrade(previous, current), "span_panel_upgrade_notice"
)
- self._explain_the_upgrade(current)
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
- def _explain_the_upgrade(self, current: str | None) -> None:
- """Raise a repair describing what the new schema changed for the user.
+ async def _explain_the_upgrade(self, previous: str | None, current: str | None) -> None:
+ """Tell the user what the new schema changed for them, once and durably.
Nothing they depend on goes away, which is worth saying plainly because a
firmware upgrade invites the opposite assumption.
@@ -306,10 +322,24 @@ def _explain_the_upgrade(self, current: str | None) -> None:
What is genuinely new is the MID device itself and its `grid-state`, the health
of the utility supply, which flat did not report at all.
- A repair rather than only a notification because notifications are dismissed
- and gone: a user who was away when the panel upgraded should still find out
- that a device appeared and why a sensor changed provenance. It asks for no
- action, which is why it is the mildest severity available.
+ **One notice, not two.** This used to be a hardcoded English notification
+ about the reload followed immediately by a translated Repair about the
+ consequences -- two rows in two different places for one event, which is
+ the same duplication that makes people stop reading either. They are now
+ one message, and it is translated.
+
+ **A notification, not a Repair.** The Repairs list is where defects go: it
+ stamped this with a severity and offered to ignore it, so an upgrade that
+ took nothing away arrived looking like a fault. The reason it was a Repair
+ was durability -- a plain notification dies with the process, and somebody
+ away when their panel upgraded would never have learned a device appeared.
+ `notices` supplies that durability directly, so the classification no
+ longer has to be paid for with a lie about severity.
+
+ Scheduled as a task because reading the translations is blocking file I/O
+ and the caller is the client's own synchronous callback fan-out. It races
+ the reload scheduled alongside it and is written to lose safely either
+ way; see `notices.async_restore`.
"""
# Absent means flat, present means parent/child -- the migration guide's own
# detection rule.
@@ -317,17 +347,21 @@ def _explain_the_upgrade(self, current: str | None) -> None:
# Guarding on the direction even though panel firmware does not roll back:
# once a panel is on v1.0 it stays there, so in the field this only ever fires
# one way. The reverse happens solely in the upgrade rehearsal, where the two
- # simulators are swapped under a live client, and raising a retirement repair
+ # simulators are swapped under a live client, and announcing a retirement
# there would be noise about a transition no user experiences.
if current is None:
return
- async_create_issue(
+ text = await self.hass.async_add_executor_job(
+ read_translations, self.hass.config.language, _UPGRADE_NOTICE
+ )
+ async_raise(
self.hass,
- DOMAIN,
- f"panel_upgraded_to_ebus_v1_{self.config_entry.entry_id}",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="panel_upgraded_to_ebus_v1",
+ self.config_entry,
+ _UPGRADE_NOTICE,
+ title=text.get("title") or _UPGRADE_FALLBACK["title"],
+ message=(text.get("body") or _UPGRADE_FALLBACK["body"]).format(
+ previous=previous or "flat", current=current
+ ),
)
def _on_connection_change(self, connected: bool) -> None:
diff --git a/custom_components/span_panel/notices.py b/custom_components/span_panel/notices.py
new file mode 100644
index 00000000..2b9480ab
--- /dev/null
+++ b/custom_components/span_panel/notices.py
@@ -0,0 +1,280 @@
+"""Notifications that outlive a restart.
+
+A persistent notification lives in memory. Raise one, restart Home Assistant,
+and it is gone whether or not anybody read it. That is why the things worth
+saying exactly once have historically been filed as Repairs instead: a Repair is
+stored, so it stands until the user dismisses it.
+
+That trade is a bad one. The Repairs list means "something wants your attention
+because it went wrong" -- it is stamped with a severity, it offers to fix or
+ignore, and everything else filed there is a defect. Putting good news in it
+tells the user their panel is broken. A firmware upgrade that took nothing away
+arrived looking like a warning, which is the opposite of what it was for.
+
+This module keeps the notification and buys back the one property the Repair had
+that it lacked. A notice raised here is recorded per config entry and re-raised
+at every setup until it is dismissed, and the dismissal is *observed* rather than
+assumed: Home Assistant reports removals to a registered callback, so "the user
+has seen it" is a fact rather than a hope. A notice therefore survives a restart,
+a reload, and an owner who was on holiday when their panel upgraded.
+
+Which is durable and which is not is a real distinction, not a default. A notice
+belongs here when it reports something that happened once and cannot be
+re-derived -- a firmware upgrade, an entity that appeared. Anything re-derived
+from live state on every refresh does not belong here; it belongs in
+`schema_repairs`, which reconciles instead, and where being a defect is the
+point.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from functools import partial
+import json
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING, Final, TypedDict
+
+from homeassistant.components.persistent_notification import (
+ Notification,
+ UpdateType,
+ async_create,
+ async_dismiss,
+ async_register_callback,
+)
+from homeassistant.core import callback
+from homeassistant.helpers.storage import Store
+from homeassistant.util.hass_dict import HassKey
+
+from .const import DOMAIN
+
+if TYPE_CHECKING:
+ from homeassistant.config_entries import ConfigEntry
+ from homeassistant.core import HomeAssistant
+
+_LOGGER = logging.getLogger(__name__)
+
+_STORE_VERSION: Final = 1
+
+_SAVE_DELAY: Final = 1.0
+"""Seconds to coalesce writes.
+
+Raising and dismissing both write, and a reload raises every standing notice in
+a burst. Delayed saves collapse that into one file write, and Home Assistant
+flushes anything outstanding at shutdown.
+"""
+
+
+class StandingNotice(TypedDict):
+ """A notice the user has been shown and has not dismissed.
+
+ The rendered text is stored, not the arguments that produced it. A notice can
+ outlive the state that raised it -- the schema upgrade that raised one is over
+ by the time it is re-raised -- so re-deriving the wording is not possible, and
+ a notice that changed its story between restarts would be worse than one that
+ did not survive at all.
+ """
+
+ title: str
+ message: str
+
+
+class StoredNotices(TypedDict):
+ """One config entry's undismissed notices, by notice id."""
+
+ standing: dict[str, StandingNotice]
+
+
+@dataclass(slots=True)
+class _Notices:
+ """One entry's live view of its notices.
+
+ The store is held rather than rebuilt per call because `async_delay_save`
+ schedules on the instance: two `Store` objects over the same key would each
+ hold a pending write and race to be last.
+ """
+
+ store: Store[StoredNotices]
+ standing: dict[str, StandingNotice]
+
+
+_DATA: HassKey[dict[str, _Notices]] = HassKey(f"{DOMAIN}_standing_notices")
+
+
+def _notification_id(entry: ConfigEntry, notice_id: str) -> str:
+ """Namespace a notice id by domain and entry.
+
+ Per entry, not per domain: two panels upgrading are two notices, and a shared
+ id would let the second overwrite the first's text and the first's dismissal
+ silence the second.
+ """
+ return f"{DOMAIN}_{notice_id}_{entry.entry_id}"
+
+
+async def async_restore(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Re-raise this entry's undismissed notices and watch for dismissals.
+
+ Call before anything that can raise a notice. Raising needs the live view
+ this builds, and building it afterwards would overwrite what was on disk with
+ only what this run happened to raise.
+
+ The watch is registered through `entry.async_on_unload`, so a reload replaces
+ it rather than accumulating one per setup -- the dispatcher fans every
+ notification change out to every registered callback, so a leaked
+ registration would be a leaked handler running against a dead entry.
+
+ A reload keeps the live view it already had rather than re-reading the file,
+ because memory is the fresher of the two and the gap between them is exactly
+ where the interesting case lives: a schema upgrade raises a notice and
+ schedules a reload in the same breath, so the setup that follows can easily
+ arrive before the delayed write has landed. Re-reading there would drop the
+ notice from the standing set while it sat on the user's screen, and it would
+ not come back after the next restart.
+ """
+ known = hass.data.setdefault(_DATA, {})
+ notices = known.get(entry.entry_id)
+ if notices is None:
+ store: Store[StoredNotices] = Store(
+ hass, _STORE_VERSION, f"{DOMAIN}.notices.{entry.entry_id}"
+ )
+ stored = await store.async_load()
+ standing = dict(stored.get("standing", {})) if stored else {}
+ notices = _Notices(store=store, standing=standing)
+ known[entry.entry_id] = notices
+
+ for notice_id, notice in notices.standing.items():
+ _LOGGER.debug("Re-raising undismissed notice %s for %s", notice_id, entry.entry_id)
+ async_create(
+ hass,
+ notice["message"],
+ title=notice["title"],
+ notification_id=_notification_id(entry, notice_id),
+ )
+
+ entry.async_on_unload(async_register_callback(hass, partial(_on_change, hass, entry)))
+
+
+@callback
+def async_raise(
+ hass: HomeAssistant, entry: ConfigEntry, notice_id: str, *, title: str, message: str
+) -> None:
+ """Show a notice, and keep showing it until the user dismisses it.
+
+ A callback rather than a coroutine because the callers are: a schema-change
+ notice is raised from the MQTT client's own callback fan-out, which is not a
+ place that can await.
+
+ Re-raising the same notice id replaces the text in place, which is what makes
+ the restore above a no-op when it lands on a notice already on screen.
+ """
+ async_create(hass, message, title=title, notification_id=_notification_id(entry, notice_id))
+ notices = hass.data.get(_DATA, {}).get(entry.entry_id)
+ if notices is None:
+ # The entry was removed while this notice was in flight. Showing it is
+ # still right -- it describes something that happened -- but there is
+ # nothing left to record it against, and recreating the record here would
+ # resurrect the file `async_forget` just deleted.
+ _LOGGER.debug(
+ "Raised %s for untracked entry %s; it will not survive a restart",
+ notice_id,
+ entry.entry_id,
+ )
+ return
+ notices.standing[notice_id] = StandingNotice(title=title, message=message)
+ _persist(notices)
+
+
+@callback
+def _on_change(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ change: UpdateType,
+ notifications: dict[str, Notification],
+) -> None:
+ """Forget a notice once it is dismissed.
+
+ Dismissal is the whole acknowledgement mechanism. There is no "read" signal
+ for a notification, and inventing one -- a restart count, an age -- would
+ either nag somebody who read it on day one or drop it before somebody on
+ holiday got back. Dismissing is the act of a user who has seen it.
+
+ Filtered to this entry's own ids because the dispatcher delivers every
+ notification change in the system, most of which belong to other
+ integrations.
+ """
+ if change is not UpdateType.REMOVED:
+ return
+ notices = hass.data.get(_DATA, {}).get(entry.entry_id)
+ if notices is None:
+ return
+ dismissed = [
+ notice_id
+ for notice_id in notices.standing
+ if _notification_id(entry, notice_id) in notifications
+ ]
+ if not dismissed:
+ return
+ for notice_id in dismissed:
+ _LOGGER.debug("Notice %s dismissed for %s", notice_id, entry.entry_id)
+ del notices.standing[notice_id]
+ _persist(notices)
+
+
+@callback
+def _persist(notices: _Notices) -> None:
+ """Queue a write of the current standing set.
+
+ The snapshot is taken now rather than in the callback: `async_delay_save`
+ calls its argument when the write fires, and by then the live dict may have
+ moved on -- so the deferred write would record a state nobody was ever shown.
+ """
+ written = StoredNotices(standing=dict(notices.standing))
+ notices.store.async_delay_save(lambda: written, _SAVE_DELAY)
+
+
+async def async_forget(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Drop this entry's notices when the entry is removed.
+
+ Both halves matter. The record has to go, or re-adding the same panel would
+ restore notices about an upgrade the new entry never saw. The notifications
+ themselves have to go too: nothing else clears them, so a removed panel would
+ leave the user reading about a device that is no longer in their system.
+ """
+ notices = hass.data.get(_DATA, {}).pop(entry.entry_id, None)
+ if notices is None:
+ return
+ for notice_id in notices.standing:
+ async_dismiss(hass, _notification_id(entry, notice_id))
+ await notices.store.async_remove()
+
+
+def read_translations(language: str, section: str) -> dict[str, str]:
+ """One notification's strings for one language, or an empty mapping.
+
+ Read from this component's `translations/` directory rather than through
+ `homeassistant.helpers.translation`, because that helper filters to the
+ categories Home Assistant defines and a persistent notification is not one of
+ them -- a custom category loads as nothing at all. These are this
+ integration's own package files, so reading them is not reaching into
+ somebody else's layout.
+
+ Falls back along the language chain -- `pt-BR`, then `pt`, then `en` -- so a
+ regional variant with no file of its own still gets its language rather than
+ English.
+
+ Blocking file I/O. Callers run it in an executor.
+ """
+ directory = Path(__file__).parent / "translations"
+ for candidate in (f"{language}.json", f"{language.split('-')[0]}.json", "en.json"):
+ path = directory / candidate
+ if not path.is_file():
+ continue
+ try:
+ loaded = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ _LOGGER.debug("Could not read notification strings from %s", path, exc_info=True)
+ continue
+ strings = loaded.get("notifications", {}).get(section, {})
+ if strings:
+ return {str(key): str(value) for key, value in strings.items()}
+ return {}
diff --git a/custom_components/span_panel/schema_repairs.py b/custom_components/span_panel/schema_repairs.py
index e7184da5..dade2f0c 100644
--- a/custom_components/span_panel/schema_repairs.py
+++ b/custom_components/span_panel/schema_repairs.py
@@ -52,6 +52,19 @@
"""
+_RETIRED_UPGRADE_ID = "panel_upgraded_to_ebus_v1"
+"""Id stem of the retired firmware-upgrade Repair.
+
+Nothing raises one any more. An upgrade that took nothing away is not a defect,
+and the Repairs list stamped it with a severity and offered to ignore it, which
+told the user their panel was broken; `notices` carries it as a notification that
+survives a restart, which is the only property being a Repair was buying.
+
+Unlike the other ids here this one is a whole id rather than a prefix -- it was
+raised as `{stem}_{entry_id}` with nothing after -- so it is cleared by name.
+"""
+
+
def _unresolved_id(entry_id: str, field_path: str) -> str:
return f"unresolved_{entry_id}_{field_path}"
@@ -226,6 +239,20 @@ def async_clear_retired_new_entity_notices(hass: HomeAssistant, entry: ConfigEnt
ir.async_delete_issue(hass, DOMAIN, issue_id)
+@callback
+def async_clear_retired_upgrade_notice(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Delete the firmware-upgrade Repair raised before it became a notification.
+
+ Cleared at setup rather than left to expire: it was raised non-persistent, so
+ a restart would sweep it away on its own, but the user is looking at it now
+ and has no reason to restart. The dismissal tombstone goes with it, which
+ costs nothing -- there is no longer an issue for it to suppress.
+ """
+ if ir.async_get(hass).async_get_issue(DOMAIN, f"{_RETIRED_UPGRADE_ID}_{entry.entry_id}"):
+ _LOGGER.debug("Clearing retired upgrade notice for %s", entry.entry_id)
+ ir.async_delete_issue(hass, DOMAIN, f"{_RETIRED_UPGRADE_ID}_{entry.entry_id}")
+
+
@callback
def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove one entry's issues. Core does not do this on entry removal.
@@ -239,3 +266,6 @@ def async_clear_schema_issues(hass: HomeAssistant, entry: ConfigEntry) -> None:
every_prefix = (*_DEFECT_PREFIXES, _NEW_ENTITIES_PREFIX)
for issue_id in _scoped_issue_ids(registry, entry.entry_id, every_prefix):
ir.async_delete_issue(hass, DOMAIN, issue_id)
+ # Setup normally clears this one, but an entry removed before it ever set up
+ # successfully never got there.
+ async_clear_retired_upgrade_notice(hass, entry)
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 4ae594ae..71a4b769 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "panel_upgraded_to_ebus_v1": {
- "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
- "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look."
- },
"schema_field_unresolved": {
"title": "SPAN Panel data field unavailable",
"description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware."
@@ -779,6 +775,10 @@
}
},
"notifications": {
+ "panel_upgraded": {
+ "title": "SPAN Panel firmware upgraded",
+ "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look."
+ },
"new_entities": {
"title": "SPAN Panel added new entities",
"intro_one": "This update added 1 new entity to your SPAN Panel.",
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 4ae594ae..71a4b769 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "panel_upgraded_to_ebus_v1": {
- "title": "Your SPAN Panel now reports islanding from its Microgrid Interconnect Device",
- "description": "Your SPAN Panel upgraded to the eBus v1.0 data model. Nothing you rely on has gone away, and no automation changes are required — this is a note about what improved.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. v1.0 publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how v1.0 says backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report.\n\nDismiss this once you have had a look."
- },
"schema_field_unresolved": {
"title": "SPAN Panel data field unavailable",
"description": "Your panel's firmware no longer provides `{field_path}`. Entities affected: {count} (for example: {examples}). They may become unavailable, or report incomplete data, until an integration update adds support for the new firmware."
@@ -779,6 +775,10 @@
}
},
"notifications": {
+ "panel_upgraded": {
+ "title": "SPAN Panel firmware upgraded",
+ "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look."
+ },
"new_entities": {
"title": "SPAN Panel added new entities",
"intro_one": "This update added 1 new entity to your SPAN Panel.",
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 8a938910..8d2ed679 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "panel_upgraded_to_ebus_v1": {
- "title": "Su Panel SPAN ahora informa del aislamiento desde su Dispositivo de Interconexión de Microrred",
- "description": "Su Panel SPAN se ha actualizado al modelo de datos eBus v1.0. Nada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha mejorado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. La v1.0 no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como la v1.0 determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba.\n\nPuede descartar este aviso cuando lo haya revisado."
- },
"schema_field_unresolved": {
"title": "Campo de datos del Panel SPAN no disponible",
"description": "El firmware de su panel ya no proporciona `{field_path}`. Entidades afectadas: {count} (por ejemplo: {examples}). Pueden quedar no disponibles, o informar datos incompletos, hasta que una actualización de la integración añada compatibilidad con el nuevo firmware."
@@ -779,6 +775,10 @@
}
},
"notifications": {
+ "panel_upgraded": {
+ "title": "Firmware del Panel SPAN actualizado",
+ "body": "Su Panel SPAN ha comunicado un nuevo modelo de datos eBus (**{previous} → {current}**), lo que ocurre tras una actualización de firmware. La integración se ha recargado para que sus dispositivos y entidades coincidan con lo que el panel publica ahora.\n\nNada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha cambiado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. El nuevo modelo de datos no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como se determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba. Las entidades que la actualización haya añadido se enumeran en su propio aviso.\n\nLas entidades renombradas o sustituidas por la actualización pueden tener que eliminarse manualmente si siguen sin estar disponibles.\n\nPuede descartar este aviso cuando lo haya revisado."
+ },
"new_entities": {
"title": "El Panel SPAN ha añadido nuevas entidades",
"intro_one": "Esta actualización añadió 1 entidad nueva a tu Panel SPAN.",
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 36ff2e1f..ab154286 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "panel_upgraded_to_ebus_v1": {
- "title": "Votre Panneau SPAN signale désormais l'îlotage depuis son Dispositif d'Interconnexion de Micro-réseau",
- "description": "Votre Panneau SPAN est passé au modèle de données eBus v1.0. Rien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui s'est amélioré.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. La v1.0 ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont v1.0 détermine la capacité de secours.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas.\n\nVous pouvez ignorer cet avis après consultation."
- },
"schema_field_unresolved": {
"title": "Champ de données du Panneau SPAN indisponible",
"description": "Le micrologiciel de votre panneau ne fournit plus `{field_path}`. Entités concernées : {count} (par exemple : {examples}). Elles peuvent devenir indisponibles, ou renvoyer des données incomplètes, jusqu'à ce qu'une mise à jour de l'intégration prenne en charge le nouveau micrologiciel."
@@ -779,6 +775,10 @@
}
},
"notifications": {
+ "panel_upgraded": {
+ "title": "Micrologiciel du Panneau SPAN mis à jour",
+ "body": "Votre Panneau SPAN a signalé un nouveau modèle de données eBus (**{previous} → {current}**), ce qui se produit après une mise à jour du micrologiciel. L'intégration a été rechargée afin que ses appareils et ses entités correspondent à ce que le panneau publie désormais.\n\nRien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui a changé.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. Le nouveau modèle de données ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont la capacité de secours est déterminée.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas. Les entités ajoutées par la mise à jour sont répertoriées dans un avis distinct.\n\nLes entités renommées ou remplacées par la mise à jour peuvent devoir être supprimées manuellement si elles restent indisponibles.\n\nVous pouvez ignorer cet avis après consultation."
+ },
"new_entities": {
"title": "Le Panneau SPAN a ajouté de nouvelles entités",
"intro_one": "Cette mise à jour a ajouté 1 nouvelle entité à votre Panneau SPAN.",
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 5b7f7201..2e174645 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "panel_upgraded_to_ebus_v1": {
- "title": "SPAN パネルは自立運転状態をマイクログリッド相互接続装置から取得するようになりました",
- "description": "SPAN パネルが eBus v1.0 データモデルにアップグレードされました。ご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は改善点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。v1.0 ではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これが v1.0 におけるバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。\n\n内容を確認したら、この通知は閉じて構いません。"
- },
"schema_field_unresolved": {
"title": "SPAN パネルのデータフィールドが利用できません",
"description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは利用不可になるか、不完全なデータを報告する可能性があります。"
@@ -779,6 +775,10 @@
}
},
"notifications": {
+ "panel_upgraded": {
+ "title": "SPAN パネルのファームウェアが更新されました",
+ "body": "SPAN パネルが新しい eBus データモデル (**{previous} → {current}**) を報告しました。これはファームウェアの更新後に発生します。パネルが現在公開している内容にデバイスとエンティティを合わせるため、統合を再読み込みしました。\n\nご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は変更点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。新しいデータモデルではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これがバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。今回の更新で追加されたエンティティは、別の通知に一覧表示されます。\n\n更新によって名称が変更された、または置き換えられたエンティティが利用不可のままの場合は、手動で削除する必要があることがあります。\n\n内容を確認したら、この通知は閉じて構いません。"
+ },
"new_entities": {
"title": "SPAN パネルに新しいエンティティが追加されました",
"intro_one": "このアップデートで SPAN パネルに新しいエンティティが 1 個追加されました。",
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index 5d76ee6e..501a80ef 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -765,10 +765,6 @@
}
},
"issues": {
- "panel_upgraded_to_ebus_v1": {
- "title": "O seu Painel SPAN comunica agora o ilhamento a partir do Dispositivo de Interligação de Microrrede",
- "description": "O seu Painel SPAN foi atualizado para o modelo de dados eBus v1.0. Nada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que melhorou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. A v1.0 não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a v1.0 determina a capacidade de reserva.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava.\n\nPode dispensar este aviso depois de o consultar."
- },
"schema_field_unresolved": {
"title": "Campo de dados do Painel SPAN indisponível",
"description": "O firmware do seu painel já não fornece `{field_path}`. Entidades afetadas: {count} (por exemplo: {examples}). Podem ficar indisponíveis, ou comunicar dados incompletos, até que uma atualização da integração adicione suporte ao novo firmware."
@@ -779,6 +775,10 @@
}
},
"notifications": {
+ "panel_upgraded": {
+ "title": "Firmware do Painel SPAN atualizado",
+ "body": "O seu Painel SPAN comunicou um novo modelo de dados eBus (**{previous} → {current}**), o que acontece após uma atualização de firmware. A integração foi recarregada para que os seus dispositivos e entidades correspondam ao que o painel publica agora.\n\nNada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que mudou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. O novo modelo de dados não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a capacidade de reserva é determinada.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava. As entidades que a atualização tenha adicionado são listadas num aviso próprio.\n\nAs entidades renomeadas ou substituídas pela atualização podem ter de ser removidas manualmente se continuarem indisponíveis.\n\nPode dispensar este aviso depois de o consultar."
+ },
"new_entities": {
"title": "O Painel SPAN adicionou novas entidades",
"intro_one": "Esta atualização adicionou 1 nova entidade ao seu Painel SPAN.",
diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py
index 36ce604e..97064f0c 100644
--- a/tests/test_new_entity_announcement.py
+++ b/tests/test_new_entity_announcement.py
@@ -25,11 +25,12 @@
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.span_panel.additions import (
- _read_translations,
+ _SECTION,
async_announce_new_entities,
async_forget_announcements,
)
from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.notices import read_translations
_PART_NUMBER = "sp3-001_bess_part_number"
@@ -230,7 +231,7 @@ def test_every_shipped_locale_carries_the_notification_strings(language: str) ->
component's own files. That is precisely why a missing key would fail silently
into English rather than being caught by the platform.
"""
- text = _read_translations(language)
+ text = read_translations(language, _SECTION)
assert set(text) >= {
"title",
"intro_one",
@@ -243,12 +244,15 @@ def test_every_shipped_locale_carries_the_notification_strings(language: str) ->
def test_an_unknown_language_falls_back_to_english_rather_than_to_nothing() -> None:
- assert _read_translations("xx")["title"] == _read_translations("en")["title"]
+ assert (
+ read_translations("xx", _SECTION)["title"]
+ == read_translations("en", _SECTION)["title"]
+ )
def test_a_regional_language_resolves_to_its_base(hass: HomeAssistant) -> None:
"""`pt-BR` is not shipped; `pt` is, and is a better answer than English."""
- assert _read_translations("pt-BR") == _read_translations("pt")
+ assert read_translations("pt-BR", _SECTION) == read_translations("pt", _SECTION)
# -- Removal -----------------------------------------------------------------
diff --git a/tests/test_notices.py b/tests/test_notices.py
new file mode 100644
index 00000000..b0172329
--- /dev/null
+++ b/tests/test_notices.py
@@ -0,0 +1,259 @@
+"""Notices survive a restart without pretending to be defects.
+
+The property under test is the one that used to cost a Repair: a notice raised
+while nobody was looking is still there when they look. Every test here either
+restarts (drop the in-memory view, restore from the store) or dismisses, because
+those are the only two things that decide whether a notice comes back.
+"""
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import Any
+
+from homeassistant.components.persistent_notification import async_dismiss
+from homeassistant.core import HomeAssistant
+from homeassistant.util import dt as dt_util
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry, async_fire_time_changed
+
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.notices import (
+ _DATA,
+ async_forget,
+ async_raise,
+ async_restore,
+)
+
+_NOTICE = "panel_upgraded"
+
+
+@pytest.fixture
+def entry(hass: HomeAssistant) -> MockConfigEntry:
+ """Return a config entry in hass."""
+ mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001")
+ mock.add_to_hass(hass)
+ return mock
+
+
+@pytest.fixture
+def other(hass: HomeAssistant) -> MockConfigEntry:
+ """Return a second panel. Notices are per entry, and this is what proves it."""
+ mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-002")
+ mock.add_to_hass(hass)
+ return mock
+
+
+def _standing(hass: HomeAssistant) -> dict[str, Any]:
+ """Every persistent notification currently on screen, by id."""
+ return dict(hass.data.get("persistent_notification", {}))
+
+
+def _id(entry: MockConfigEntry, notice_id: str = _NOTICE) -> str:
+ return f"{DOMAIN}_{notice_id}_{entry.entry_id}"
+
+
+async def _flush(hass: HomeAssistant) -> None:
+ """Let the delayed store writes fire. Home Assistant does this at shutdown."""
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
+ await hass.async_block_till_done()
+
+
+async def _restart(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+ """Restart Home Assistant, as far as this module can tell.
+
+ The store is on disk and survives; the live view and the notifications are in
+ memory and do not. Dropping exactly those two is what a restart does.
+
+ Cleared rather than dismissed, which is the distinction the whole module turns
+ on: a restart destroys a notification without anybody having read it, and
+ dismissing here would quietly test the acknowledged path instead and report
+ that notices vanish when they should not.
+ """
+ await _flush(hass)
+ hass.data.get(_DATA, {}).pop(entry.entry_id, None)
+ hass.data.get("persistent_notification", {}).clear()
+ await async_restore(hass, entry)
+
+
+# -- Raising -----------------------------------------------------------------
+
+
+async def test_a_raised_notice_is_shown(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ notification = _standing(hass)[_id(entry)]
+ assert notification["title"] == "Upgraded"
+ assert notification["message"] == "Body"
+
+
+async def test_a_notice_raised_before_a_restart_is_shown_after_it(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The whole point. A panel that upgrades while the owner is away still tells them."""
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ await _restart(hass, entry)
+
+ assert _standing(hass)[_id(entry)]["message"] == "Body"
+
+
+async def test_a_notice_survives_more_than_one_restart(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Restoring must re-record, not consume. Undismissed is undismissed."""
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ await _restart(hass, entry)
+ await _restart(hass, entry)
+
+ assert _id(entry) in _standing(hass)
+
+
+async def test_raising_the_same_notice_again_replaces_its_text(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Restoring a notice already on screen must not stack a second copy."""
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="First")
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Second")
+
+ assert [key for key in _standing(hass) if key.startswith(DOMAIN)] == [_id(entry)]
+ assert _standing(hass)[_id(entry)]["message"] == "Second"
+
+
+# -- Dismissal ---------------------------------------------------------------
+
+
+async def test_a_dismissed_notice_does_not_come_back(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Dismissal is the acknowledgement. Ignoring it would turn a notice into a nag."""
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ async_dismiss(hass, _id(entry))
+ await hass.async_block_till_done()
+ hass.data.get(_DATA, {}).pop(entry.entry_id, None)
+ await async_restore(hass, entry)
+
+ assert _id(entry) not in _standing(hass)
+
+
+async def test_dismissing_someone_elses_notification_leaves_ours_standing(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The dispatcher delivers every notification change in the system, not just ours."""
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ async_dismiss(hass, "some_other_integration_thing")
+ await _restart(hass, entry)
+
+ assert _id(entry) in _standing(hass)
+
+
+async def test_dismissing_one_panels_notice_leaves_the_other_panels_alone(
+ hass: HomeAssistant, entry: MockConfigEntry, other: MockConfigEntry
+) -> None:
+ """Two panels upgrading are two notices; one owner reading is not both."""
+ await async_restore(hass, entry)
+ await async_restore(hass, other)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="First panel")
+ async_raise(hass, other, _NOTICE, title="Upgraded", message="Second panel")
+
+ async_dismiss(hass, _id(entry))
+ await _restart(hass, entry)
+ await _restart(hass, other)
+
+ assert _id(entry) not in _standing(hass)
+ assert _standing(hass)[_id(other)]["message"] == "Second panel"
+
+
+# -- The reload race ---------------------------------------------------------
+
+
+async def test_a_notice_raised_moments_before_a_reload_is_not_lost(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """A schema upgrade raises a notice and schedules a reload in the same breath.
+
+ Writes are delayed to coalesce them, so the setup that follows can arrive
+ before the file has been written. Re-reading the store there would drop the
+ notice from the standing set while it sat on the user's screen -- it would
+ look fine until the next restart, and then be gone.
+ """
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ await async_restore(hass, entry) # the reload, before the delayed write lands
+
+ # Asserted on the live view rather than on what comes back after a restart,
+ # because the store is what makes this hard to see: it hands a pending write
+ # back to its own reader, so a reload that re-read it would look correct here
+ # and lose the notice only on the restart after the process ended.
+ assert _NOTICE in hass.data[_DATA][entry.entry_id].standing
+
+ await _restart(hass, entry)
+ assert _standing(hass)[_id(entry)]["message"] == "Body"
+
+
+# -- Removal -----------------------------------------------------------------
+
+
+async def test_removing_the_entry_takes_its_notices_off_the_screen(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Nothing else clears them, so the user would keep reading about a panel they removed."""
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ await async_forget(hass, entry)
+
+ assert _id(entry) not in _standing(hass)
+
+
+async def test_re_adding_a_removed_panel_does_not_restore_its_old_notices(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The record has to go with the entry, or a new panel inherits an old upgrade."""
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+ await async_forget(hass, entry)
+
+ await async_restore(hass, entry)
+
+ assert _id(entry) not in _standing(hass)
+
+
+async def test_removing_one_panel_leaves_the_others_notices_alone(
+ hass: HomeAssistant, entry: MockConfigEntry, other: MockConfigEntry
+) -> None:
+ await async_restore(hass, entry)
+ await async_restore(hass, other)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="First panel")
+ async_raise(hass, other, _NOTICE, title="Upgraded", message="Second panel")
+
+ await async_forget(hass, entry)
+
+ assert _id(entry) not in _standing(hass)
+ assert _standing(hass)[_id(other)]["message"] == "Second panel"
+
+
+async def test_raising_against_an_untracked_entry_still_reaches_the_user(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """A notice in flight when the entry is removed describes something that happened.
+
+ Showing it is right; recreating the record `async_forget` just deleted is not.
+ """
+ await async_restore(hass, entry)
+ await async_forget(hass, entry)
+
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ assert _standing(hass)[_id(entry)]["message"] == "Body"
+ assert entry.entry_id not in hass.data.get(_DATA, {})
diff --git a/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
index b1a21798..82e6d229 100644
--- a/tests/test_schema_repairs.py
+++ b/tests/test_schema_repairs.py
@@ -430,22 +430,25 @@ async def test_clearing_removes_only_this_entry(hass) -> None:
assert registry.async_get_issue(DOMAIN, _issue_id(kept))
-async def test_reconciliation_leaves_other_domain_issues_alone(hass, entry) -> None:
- """The upgrade repair shares our domain and must survive a reconcile pass."""
+async def test_reconciliation_leaves_our_other_issues_alone(hass, entry) -> None:
+ """The reconcile pass deletes every id it did not re-derive, within its own scope.
+
+ Anything else this integration raises under the same domain -- a one-shot
+ notice, a condition derived somewhere other than this pass -- has to survive
+ it, or the pass silently owns issues it has no way to recreate.
+ """
ir.async_create_issue(
hass,
DOMAIN,
- f"panel_upgraded_to_ebus_v1_{entry.entry_id}",
+ f"something_else_{entry.entry_id}",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
- translation_key="panel_upgraded_to_ebus_v1",
+ translation_key="something_else",
)
async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {})
- async_clear_schema_issues(hass, entry)
- registry = ir.async_get(hass)
- assert registry.async_get_issue(DOMAIN, f"panel_upgraded_to_ebus_v1_{entry.entry_id}")
+ assert ir.async_get(hass).async_get_issue(DOMAIN, f"something_else_{entry.entry_id}")
async def test_remove_entry_clears_this_entry_issues(hass, entry) -> None:
diff --git a/tests/test_schema_upgrade_notice.py b/tests/test_schema_upgrade_notice.py
new file mode 100644
index 00000000..30785cb2
--- /dev/null
+++ b/tests/test_schema_upgrade_notice.py
@@ -0,0 +1,169 @@
+"""What the user is told when their panel's firmware changes data model.
+
+This used to be two things at once: a hardcoded English notification about the
+reload, and a translated Repair about the consequences. Neither was covered, so
+the pairing survived until a screenshot showed it -- an upgrade that took nothing
+away, filed under Warning, offering to be ignored.
+
+The tests below pin the three properties that fixes it: one message, it is a
+notification, and it outlives the restart that was the Repair's only advantage.
+"""
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import Any, cast
+from unittest.mock import MagicMock
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import issue_registry as ir
+from homeassistant.util import dt as dt_util
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry, async_fire_time_changed
+from span_panel_api import SpanMqttClient
+
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.coordinator import SpanPanelCoordinator
+from custom_components.span_panel.notices import _DATA, async_restore
+from custom_components.span_panel.schema_repairs import async_clear_retired_upgrade_notice
+
+_RETIRED_ISSUE = "panel_upgraded_to_ebus_v1"
+
+
+@pytest.fixture
+def entry(hass: HomeAssistant) -> MockConfigEntry:
+ """Return a config entry in hass."""
+ mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-001", title="SPAN Panel")
+ mock.add_to_hass(hass)
+ return mock
+
+
+@pytest.fixture
+async def coordinator(hass: HomeAssistant, entry: MockConfigEntry) -> SpanPanelCoordinator:
+ """Return a coordinator whose notices are tracked, as setup would leave it."""
+ await async_restore(hass, entry)
+ return SpanPanelCoordinator(hass, cast(SpanMqttClient, MagicMock()), entry)
+
+
+def _notice(hass: HomeAssistant, entry: MockConfigEntry) -> dict[str, Any] | None:
+ standing = hass.data.get("persistent_notification", {})
+ return standing.get(f"{DOMAIN}_panel_upgraded_{entry.entry_id}")
+
+
+async def test_the_upgrade_is_a_notification(
+ hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator
+) -> None:
+ await coordinator._explain_the_upgrade(None, "1.0")
+
+ notice = _notice(hass, entry)
+ assert notice is not None
+ assert "flat" in notice["message"] and "1.0" in notice["message"]
+
+
+async def test_the_upgrade_is_not_a_repair(
+ hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator
+) -> None:
+ """Nothing is broken, so nothing belongs in the list of things that are."""
+ await coordinator._explain_the_upgrade(None, "1.0")
+
+ assert not [issue_id for (domain, issue_id) in ir.async_get(hass).issues if domain == DOMAIN]
+
+
+async def test_the_upgrade_is_told_in_one_message_not_two(
+ hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator
+) -> None:
+ """The reload and its consequences are one event and read as one.
+
+ Two rows for one event is what teaches somebody to skim past both.
+ """
+ await coordinator._explain_the_upgrade(None, "1.0")
+
+ ours = [key for key in hass.data.get("persistent_notification", {}) if DOMAIN in key]
+ assert len(ours) == 1
+
+
+async def test_the_message_says_what_changed_and_what_did_not(
+ hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator
+) -> None:
+ """A firmware upgrade invites the assumption that something was lost."""
+ await coordinator._explain_the_upgrade(None, "1.0")
+
+ message = _notice(hass, entry)["message"] # type: ignore[index]
+ assert "DSM Grid State" in message
+ assert "Grid Islandable" in message
+ assert "Microgrid Interconnect Device" in message
+ assert "has gone away" in message
+
+
+async def test_the_notice_survives_a_restart(
+ hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator
+) -> None:
+ """Durability was the Repair's one real advantage, and it has to be kept.
+
+ A panel upgrades on its own schedule. Somebody away for the weekend must
+ still find out that a device appeared and why a sensor changed provenance.
+ """
+ await coordinator._explain_the_upgrade(None, "1.0")
+
+ # Writes are delayed so a burst of them collapses into one; Home Assistant
+ # flushes at shutdown, and a restart that skipped it would test nothing.
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
+ await hass.async_block_till_done()
+ hass.data[_DATA].pop(entry.entry_id)
+ hass.data["persistent_notification"].clear()
+ await async_restore(hass, entry)
+
+ assert _notice(hass, entry) is not None
+
+
+async def test_a_downgrade_says_nothing(
+ hass: HomeAssistant, entry: MockConfigEntry, coordinator: SpanPanelCoordinator
+) -> None:
+ """Panel firmware does not roll back; the upgrade rehearsal swaps simulators.
+
+ Announcing a retirement there would be noise about a transition no user has.
+ """
+ await coordinator._explain_the_upgrade("1.0", None)
+
+ assert _notice(hass, entry) is None
+
+
+async def test_the_retired_repair_is_cleared_from_an_upgraded_install(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """It is on screen now, and its owner has no reason to restart to be rid of it."""
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"{_RETIRED_ISSUE}_{entry.entry_id}",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key=_RETIRED_ISSUE,
+ )
+
+ async_clear_retired_upgrade_notice(hass, entry)
+
+ assert ir.async_get(hass).async_get_issue(DOMAIN, f"{_RETIRED_ISSUE}_{entry.entry_id}") is None
+
+
+async def test_clearing_the_retired_repair_leaves_another_panels_alone(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Two panels share the domain, and one upgrading is not both."""
+ other = MockConfigEntry(domain=DOMAIN, data={}, unique_id="sp3-002")
+ other.add_to_hass(hass)
+ for target in (entry, other):
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ f"{_RETIRED_ISSUE}_{target.entry_id}",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key=_RETIRED_ISSUE,
+ )
+
+ async_clear_retired_upgrade_notice(hass, entry)
+
+ registry = ir.async_get(hass)
+ assert registry.async_get_issue(DOMAIN, f"{_RETIRED_ISSUE}_{entry.entry_id}") is None
+ assert registry.async_get_issue(DOMAIN, f"{_RETIRED_ISSUE}_{other.entry_id}")
From d12f867286567a22fbe6003923f4da066aea1c2a Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 18:58:08 -0700
Subject: [PATCH 078/116] fix(additions,notices): adopted entities are switched
off, and a bad store file is not fatal
Two defects found by review, both in code from this release set.
**Adopted entities were announced as ready to use.** `AdoptedEntity` registers
every one of them disabled, so the notification said the opposite of the truth.
They were counted apart from the disabled ones and emitted between the two
headings, which meant a release whose only additions were adopted -- a vendor
device appearing, the case adoption exists for -- rendered no heading at all and
suppressed the how-to-enable text, the one string in the message with an action
attached. They now sit under the switched-off heading, which needs no new
translation key because it is where they always belonged.
**A malformed notice store took the integration down permanently.** The TypedDicts
are compile-time only and on-disk data violates them freely: a list where the
mapping should be, a notice with no message, a string at the top level. Each
raised straight out of setup, and out of the part above the try, so the entry
went to SETUP_ERROR with no retry and stayed dead until somebody found and
deleted the file. Home Assistant handles undecodable JSON already; the gap is
valid JSON of the wrong shape, which it hands back intact. Bookkeeping for a
notification is not worth an integration that will not load, so the load falls
back to empty, keeps whatever rows are well formed, and self-heals on the next
raise. `read_translations` gets the same guard for the same reason.
Three mutations that survived the previous round now die: recording only the
previously-announced set (which re-announced every entity on every restart,
forever -- the nothing-added path returns before that line, so the test that
looked like it covered it never reached it), dropping the `async_on_unload`
wrapper on the dismissal watch, and dropping the store shape guard.
One mutation still survives and the docstring was wrong rather than the code: the
snapshot in `_persist` is defensive, not load-bearing. A Store keeps one pending
write and every mutation site re-persists, so the difference is unobservable.
Said so plainly instead of claiming a property no test can hold.
`requirements_test.txt` pinned b4 against the manifest's b6/b5/b6. Nothing reads
it, which is how it drifted; it now matches.
Also dropped a redundant `_attr_name` assignment in `AdoptedSensor` that restated
what its base class had already set.
---
custom_components/span_panel/additions.py | 16 ++-
custom_components/span_panel/adoption.py | 1 -
custom_components/span_panel/notices.py | 71 +++++++++--
requirements_test.txt | 6 +-
tests/test_new_entity_announcement.py | 80 ++++++++++++-
tests/test_notices.py | 138 ++++++++++++++++++++++
6 files changed, 296 insertions(+), 16 deletions(-)
diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py
index 724c8e68..b591ba40 100644
--- a/custom_components/span_panel/additions.py
+++ b/custom_components/span_panel/additions.py
@@ -149,6 +149,16 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str,
needs nothing; a disabled one records nothing at all until the user turns it
on, and saying so is the difference between a notice they can act on and one
they can only acknowledge.
+
+ **Adopted devices belong on the switched-off side of that split**, because
+ every adopted entity registers disabled -- `AdoptedEntity` sets
+ `_attr_entity_registry_enabled_default = False` for all of them. Listing them
+ anywhere else said the opposite of the truth twice over: under the
+ ready-to-use heading they read as already recording, and being counted apart
+ from `disabled` meant a release whose only additions were adopted rendered no
+ heading and, worse, suppressed `how_to_enable` -- the one actionable string in
+ the message. A vendor device appearing is the case adoption exists for, and it
+ was the case that told the user least.
"""
devices = dr.async_get(hass)
enabled: list[str] = []
@@ -178,12 +188,10 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str,
lines += [f"**{_text(text, 'enabled_heading')}**", ""]
lines += [f"- {label}" for label in sorted(enabled)]
lines.append("")
- if adopted:
- lines += [f"- {name} ({count} entities)" for name, count in sorted(adopted.items())]
- lines.append("")
- if disabled:
+ if disabled or adopted:
lines += [f"**{_text(text, 'disabled_heading')}**", ""]
lines += [f"- {label}" for label in sorted(disabled)]
+ lines += [f"- {name} ({count} entities)" for name, count in sorted(adopted.items())]
lines += ["", _text(text, "how_to_enable"), ""]
lines.append(_text(text, "nothing_broken"))
return "\n".join(lines)
diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py
index 55c0307f..09869d57 100644
--- a/custom_components/span_panel/adoption.py
+++ b/custom_components/span_panel/adoption.py
@@ -362,7 +362,6 @@ def __init__(
device_class=DEVICE_CLASS_BY_UNIT.get(declaration.unit or ""),
native_unit_of_measurement=declaration.unit,
)
- self._attr_name = _humanised(declaration.property_id)
@property
def native_value(self) -> str | float | None:
diff --git a/custom_components/span_panel/notices.py b/custom_components/span_panel/notices.py
index 2b9480ab..9acb6e83 100644
--- a/custom_components/span_panel/notices.py
+++ b/custom_components/span_panel/notices.py
@@ -137,9 +137,7 @@ async def async_restore(hass: HomeAssistant, entry: ConfigEntry) -> None:
store: Store[StoredNotices] = Store(
hass, _STORE_VERSION, f"{DOMAIN}.notices.{entry.entry_id}"
)
- stored = await store.async_load()
- standing = dict(stored.get("standing", {})) if stored else {}
- notices = _Notices(store=store, standing=standing)
+ notices = _Notices(store=store, standing=_load(await store.async_load(), entry))
known[entry.entry_id] = notices
for notice_id, notice in notices.standing.items():
@@ -154,6 +152,54 @@ async def async_restore(hass: HomeAssistant, entry: ConfigEntry) -> None:
entry.async_on_unload(async_register_callback(hass, partial(_on_change, hass, entry)))
+def _load(stored: object, entry: ConfigEntry) -> dict[str, StandingNotice]:
+ """Read the standing set off disk, tolerating a file that is not what we wrote.
+
+ `StoredNotices` and `StandingNotice` are compile-time only. On-disk data
+ violates them freely -- a hand edit, a partially restored backup, a file
+ written by a later version and read after a rollback -- and every such shape
+ used to raise straight out of setup. Home Assistant already handles
+ *undecodable* JSON by renaming the file and raising a core repair; the gap is
+ valid JSON of the wrong shape, which it hands back intact.
+
+ Failing there is the wrong trade by a wide margin. This module exists to tell
+ the user about something that already happened; a panel that will not load
+ because its *notification bookkeeping* is malformed has turned a cosmetic
+ record into a dead integration, and one that stays dead, because setup is not
+ retried on a bad shape. Falling back to empty loses only the memory of which
+ notices were standing, and the next raise overwrites the file with a valid
+ one -- so it self-heals rather than needing the user to find and delete it.
+ """
+ if stored is None:
+ return {}
+ standing = stored.get("standing") if isinstance(stored, dict) else None
+ if not isinstance(standing, dict):
+ _LOGGER.warning(
+ "Ignoring the notice record for %s: expected an object with a 'standing' "
+ "mapping, found %s. Any notice already dismissed stays dismissed; one still "
+ "standing may be shown again.",
+ entry.entry_id,
+ type(standing if isinstance(stored, dict) else stored).__name__,
+ )
+ return {}
+ kept: dict[str, StandingNotice] = {}
+ for notice_id, notice in standing.items():
+ if (
+ isinstance(notice, dict)
+ and isinstance(notice.get("title"), str)
+ and isinstance(notice.get("message"), str)
+ ):
+ kept[str(notice_id)] = StandingNotice(title=notice["title"], message=notice["message"])
+ else:
+ _LOGGER.warning(
+ "Dropping malformed notice %s for %s: a notice needs a title and a "
+ "message, both strings",
+ notice_id,
+ entry.entry_id,
+ )
+ return kept
+
+
@callback
def async_raise(
hass: HomeAssistant, entry: ConfigEntry, notice_id: str, *, title: str, message: str
@@ -224,9 +270,18 @@ def _on_change(
def _persist(notices: _Notices) -> None:
"""Queue a write of the current standing set.
- The snapshot is taken now rather than in the callback: `async_delay_save`
- calls its argument when the write fires, and by then the live dict may have
- moved on -- so the deferred write would record a state nobody was ever shown.
+ The snapshot is taken now rather than read through in the callback, because
+ `async_delay_save` calls its argument when the write fires rather than when it
+ is queued. Defensive rather than load-bearing, and worth being precise about:
+ a `Store` keeps only one pending write, so the newest queued function is the
+ one that runs, and every site that mutates the standing set calls this
+ immediately afterwards. There is therefore no reachable state today in which
+ the live dict has moved on and no newer write has superseded this one -- a
+ mutation test confirms the difference is unobservable through the store.
+
+ It stays a snapshot anyway. The cost is one shallow dict copy, and the
+ property it buys -- what is queued is what was true when it was queued -- does
+ not then depend on every future mutation site remembering to re-persist.
"""
written = StoredNotices(standing=dict(notices.standing))
notices.store.async_delay_save(lambda: written, _SAVE_DELAY)
@@ -274,7 +329,9 @@ def read_translations(language: str, section: str) -> dict[str, str]:
except (OSError, ValueError):
_LOGGER.debug("Could not read notification strings from %s", path, exc_info=True)
continue
+ if not isinstance(loaded, dict):
+ continue
strings = loaded.get("notifications", {}).get(section, {})
- if strings:
+ if isinstance(strings, dict) and strings:
return {str(key): str(value) for key, value in strings.items()}
return {}
diff --git a/requirements_test.txt b/requirements_test.txt
index 0ecdb95d..ebddc177 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0b4
-span-panel-api-schema-0==1.0.0b4
-span-panel-api-schema-1==0.1.0b4
+span-panel-api==3.0.0b6
+span-panel-api-schema-0==1.0.0b5
+span-panel-api-schema-1==0.1.0b6
diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py
index 97064f0c..801ee3b4 100644
--- a/tests/test_new_entity_announcement.py
+++ b/tests/test_new_entity_announcement.py
@@ -19,6 +19,7 @@
from typing import Any
+from homeassistant.components.persistent_notification import async_dismiss
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
import pytest
@@ -219,6 +220,83 @@ async def test_an_adopted_device_contributes_one_line_with_a_count(
assert "Curated Addition" in message
+async def test_an_adopted_device_is_listed_as_switched_off(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Every adopted entity registers disabled, so it must not read as ready to use.
+
+ `AdoptedEntity` sets `_attr_entity_registry_enabled_default = False` for all of
+ them. Listing them beside the enabled additions said the opposite of the truth.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ device = dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, "sp3-001_adopted_generator-1")},
+ name="Backup Generator",
+ )
+ _register(hass, entry, "sp3-001_adopted_0", name="Adopted 0", device_id=device.id)
+ _register(hass, entry, "sp3-001_ready", name="Ready Sensor", disabled=False)
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ enabled_at = message.index("Added and ready to use")
+ disabled_at = message.index("Added but switched off")
+ assert enabled_at < message.index("Ready Sensor") < disabled_at
+ assert disabled_at < message.index("Backup Generator (1 entities)")
+
+
+async def test_an_adopted_only_release_still_says_how_to_switch_them_on(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The case adoption exists for was the case that told the user least.
+
+ A vendor device appearing is often the *only* addition in a release. Counting
+ adopted entities apart from the disabled ones meant no heading rendered and,
+ worse, `how_to_enable` was suppressed -- the one string in the message with an
+ action attached.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ device = dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, "sp3-001_adopted_generator-1")},
+ name="Backup Generator",
+ )
+ _register(hass, entry, "sp3-001_adopted_0", name="Adopted 0", device_id=device.id)
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert "Added but switched off" in message
+ assert "show its disabled entities" in message
+ assert "Added and ready to use" not in message
+
+
+async def test_an_announced_entity_is_not_announced_again_after_a_restart(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The record must grow by what was registered, not be replaced by what it held.
+
+ Recording only the previously-announced set would leave every entity announced
+ in this pass still absent from the record, so the next startup would find them
+ new again -- and every startup after that, forever. The nothing-added path
+ returns before the record is written, so it cannot catch this.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ _register(hass, entry, _PART_NUMBER, name="Part Number")
+ await async_announce_new_entities(hass, entry)
+ assert _announcement(hass, entry) is not None
+
+ async_dismiss(hass, f"{DOMAIN}_new_entities_{entry.entry_id}")
+ await async_announce_new_entities(hass, entry)
+
+ assert _announcement(hass, entry) is None
+
+
# -- Translations ------------------------------------------------------------
@@ -276,7 +354,7 @@ async def test_removing_the_entry_forgets_what_was_announced(
def _sub_device(hass: HomeAssistant, entry: MockConfigEntry, name: str, ident: str) -> str:
- """A device hanging off the panel, the way every SPAN sub-device does."""
+ """Return a device hanging off the panel, the way every SPAN sub-device does."""
devices = dr.async_get(hass)
panel = devices.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={(DOMAIN, "sp3-001")}, name="SPAN Panel"
diff --git a/tests/test_notices.py b/tests/test_notices.py
index b0172329..91c5b348 100644
--- a/tests/test_notices.py
+++ b/tests/test_notices.py
@@ -20,9 +20,11 @@
from custom_components.span_panel.const import DOMAIN
from custom_components.span_panel.notices import (
_DATA,
+ _STORE_VERSION,
async_forget,
async_raise,
async_restore,
+ read_translations,
)
_NOTICE = "panel_upgraded"
@@ -257,3 +259,139 @@ async def test_raising_against_an_untracked_entry_still_reaches_the_user(
assert _standing(hass)[_id(entry)]["message"] == "Body"
assert entry.entry_id not in hass.data.get(_DATA, {})
+
+
+# -- A store file that is not what we wrote -------------------------------------
+#
+# `StoredNotices` and `StandingNotice` are compile-time only, so the disk can hold
+# anything. Every shape below used to raise straight out of `async_setup_entry` --
+# and out of the part of it above the `try`, so the entry went to SETUP_ERROR with
+# no retry and stayed dead until somebody found and deleted the file by hand.
+# Bookkeeping for a notification is not worth an integration that will not load.
+
+
+def _seed(hass_storage: dict[str, Any], entry: MockConfigEntry, data: object) -> None:
+ key = f"{DOMAIN}.notices.{entry.entry_id}"
+ hass_storage[key] = {"version": _STORE_VERSION, "key": key, "data": data}
+
+
+@pytest.mark.parametrize(
+ ("shape", "description"),
+ [
+ ({"standing": ["panel_upgraded"]}, "a list where the mapping should be"),
+ ({"standing": {"panel_upgraded": "oops"}}, "a string where the notice should be"),
+ ({"standing": {"panel_upgraded": {"title": "T"}}}, "a notice with no message"),
+ ({"standing": {"panel_upgraded": {"title": 1, "message": 2}}}, "non-string text"),
+ (["standing"], "a list at the top level"),
+ ({}, "an object with no standing key"),
+ ("nonsense", "a bare string"),
+ ],
+)
+async def test_a_malformed_store_does_not_take_the_integration_down(
+ hass: HomeAssistant,
+ hass_storage: dict[str, Any],
+ entry: MockConfigEntry,
+ shape: object,
+ description: str,
+) -> None:
+ """Setup must survive every one of these; the record is recoverable, the entry is not."""
+ _seed(hass_storage, entry, shape)
+
+ await async_restore(hass, entry)
+
+ assert hass.data[_DATA][entry.entry_id].standing == {}
+
+
+async def test_a_malformed_store_is_overwritten_by_the_next_notice(
+ hass: HomeAssistant, hass_storage: dict[str, Any], entry: MockConfigEntry
+) -> None:
+ """Self-healing is what makes falling back to empty the right trade.
+
+ The user never has to find the file, because the next raise replaces it.
+ """
+ _seed(hass_storage, entry, {"standing": "nonsense"})
+ await async_restore(hass, entry)
+
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+ await _restart(hass, entry)
+
+ assert _standing(hass)[_id(entry)]["message"] == "Body"
+
+
+async def test_one_malformed_notice_does_not_discard_its_healthy_neighbours(
+ hass: HomeAssistant, hass_storage: dict[str, Any], entry: MockConfigEntry
+) -> None:
+ """Dropping the whole file for one bad row would lose notices that are still valid."""
+ _seed(
+ hass_storage,
+ entry,
+ {
+ "standing": {
+ "panel_upgraded": {"title": "Upgraded", "message": "Body"},
+ "broken": {"title": "no message"},
+ }
+ },
+ )
+
+ await async_restore(hass, entry)
+
+ assert _standing(hass)[_id(entry)]["message"] == "Body"
+ assert "broken" not in hass.data[_DATA][entry.entry_id].standing
+
+
+# -- The dismissal watch is unregistered on unload ------------------------------
+
+
+async def test_unloading_stops_the_dismissal_watch(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Every setup registers a dispatcher handler, and the dispatcher fans out to all of them.
+
+ Without `entry.async_on_unload` a reload leaves the previous handler behind,
+ bound to an entry that is gone, and the leak grows by one per reload.
+
+ Asserted through what a leaked handler would *do* rather than by counting
+ subscribers: after unload nothing of ours is listening, so a dismissal reaches
+ no one and the standing set is untouched.
+ """
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ await entry._async_process_on_unload(hass)
+ async_dismiss(hass, _id(entry))
+ await hass.async_block_till_done()
+
+ assert _NOTICE in hass.data[_DATA][entry.entry_id].standing
+
+
+# -- A queued write records what was shown, not what came after -----------------
+
+
+async def test_a_queued_write_records_the_set_as_it_was_when_raised(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """`async_delay_save` calls its argument when the write fires, not when it is queued.
+
+ Closing over the live dict would record whatever the set had become by then --
+ a state nobody was ever shown.
+ """
+ await async_restore(hass, entry)
+ async_raise(hass, entry, _NOTICE, title="Upgraded", message="Body")
+
+ # A second notice queues its own write; the first write must still be able to
+ # produce the snapshot it was given rather than reading through to this one.
+ async_raise(hass, entry, "second", title="Second", message="Also")
+ await _flush(hass)
+ hass.data[_DATA].pop(entry.entry_id)
+ await async_restore(hass, entry)
+
+ assert _standing(hass)[_id(entry)]["message"] == "Body"
+ assert _standing(hass)[_id(entry, "second")]["message"] == "Also"
+
+
+# -- Translations off disk ------------------------------------------------------
+
+
+def test_a_translation_file_of_the_wrong_shape_falls_through_to_english() -> None:
+ """Same class as the store: our own files are fine, and the disk is not ours."""
+ assert read_translations("xx", "panel_upgraded")["title"]
From 54e84affe14229e8a50691836a847cf8b3010a55 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 20:28:27 -0700
Subject: [PATCH 079/116] feat(sensor): the grid power sensor says whether it
is metering the grid
`sensor.*_instant_grid_power` reads the upstream lugs' meter, and that is grid
flow only where those lugs are the utility connection point. A BESS wired ahead
of the main lugs, or a panel fed by another panel, leaves it metering that
panel's own feed while `Grid Power Flow` stays site-level. Both readings are
correct and they stop being the same number, so somebody comparing them has been
unable to tell a topology from a fault. `at_service_entrance` answers that on the
sensor they are already looking at.
An attribute rather than an entity, deliberately. Topology is static -- a panel's
position in a chain does not change without an electrician -- so a binary sensor
would be a permanent row recording one unchanging boolean into the database
forever, which is the stats pollution we have refused elsewhere. It is also
additive on an entity that already exists, so it reaches an upgraded install
without touching the registry: no entity_id, no unique_id, no state_class, no
statistics. Attributes are announced by the release notes rather than by the
additions notification, which is the exception already recorded in the delta
document.
Not a Repair, because nothing is broken and there is nothing to act on. The panel
is wired the way it is wired.
Confined to the grid sensor. `SpanPanelPowerSensor` also backs feedthrough,
battery and PV, and those are what they say they are wherever the panel sits --
an attribute qualifying the grid label would be noise on them, and worse, would
read as qualifying their value.
Diagnostics carries the same fact next to both grid figures, since it is the
first thing worth knowing on a "my grid sensor reads wrong" report.
Two mutations verified dead: dropping the key check so every power sensor carries
it, and hardcoding the value True.
---
custom_components/span_panel/diagnostics.py | 7 +++
custom_components/span_panel/sensor_panel.py | 35 ++++++++++++-
tests/factories.py | 6 +++
tests/test_diagnostics.py | 9 ++++
tests/test_sensor_entities.py | 53 +++++++++++++++++++-
uv.lock | 7 ++-
6 files changed, 112 insertions(+), 5 deletions(-)
diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py
index d7b050f5..b033ba5d 100644
--- a/custom_components/span_panel/diagnostics.py
+++ b/custom_components/span_panel/diagnostics.py
@@ -193,6 +193,13 @@ async def async_get_config_entry_diagnostics(
"serial_number": snapshot.serial_number,
"firmware_version": snapshot.firmware_version,
"panel_size": snapshot.panel_size,
+ # Whether the upstream lugs are the utility connection point. First thing
+ # worth knowing on a "my grid sensor reads wrong" report: where this is
+ # False, `instant_grid_power_w` is this panel's feed rather than the
+ # site's grid, and the two grid figures differ legitimately.
+ "lugs_at_service_entrance": snapshot.lugs_at_service_entrance,
+ "instant_grid_power_w": snapshot.instant_grid_power_w,
+ "power_flow_grid": snapshot.power_flow_grid,
}
if snapshot.wifi_ssid is not None:
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index efc64516..79d5bc90 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -460,8 +460,38 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot:
return snapshot.battery
+_GRID_POWER_KEY = "instantGridPowerW"
+"""The one power sensor whose name is conditional on topology.
+
+This class backs four sensors -- grid, feedthrough, battery and PV -- and only
+the grid one reads a meter whose meaning depends on where the panel sits.
+"""
+
+
class SpanPanelPowerSensor(SpanSensorBase[SpanPanelDataSensorEntityDescription, SpanPanelSnapshot]):
- """Panel power sensor with calculated amperage attribute."""
+ """Panel power sensor with calculated amperage attribute.
+
+ **The grid sensor carries `at_service_entrance`.** It reads the upstream lugs'
+ meter, which is grid flow only where those lugs are the utility connection
+ point. A BESS wired ahead of the main lugs, or a panel fed by another panel,
+ leaves it metering that panel's own feed instead, and `power_flow_grid` --
+ the `Grid Power Flow` sensor -- is then the site-level figure. Both readings
+ are correct; they simply stop being the same number.
+
+ That disagreement is what this attribute exists for. Someone whose two grid
+ figures differ has no way to tell a topology from a fault, and the answer now
+ sits on the sensor they are already looking at.
+
+ An attribute rather than an entity, deliberately. Topology is static -- a
+ panel's position in a chain does not change without an electrician -- so a
+ binary sensor would be a permanent row recording one unchanging boolean into
+ the database forever. It is also additive on an entity that already exists,
+ so it reaches an upgraded install without touching the registry: no
+ `entity_id`, no `unique_id`, no `state_class`, no statistics.
+
+ Not a Repair, for the reason the Repairs list means something: nothing is
+ broken and there is nothing to act on. The panel is wired the way it is wired.
+ """
def __init__(
self,
@@ -523,6 +553,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
else:
attributes["amperage"] = 0.0
+ if self._description_key == _GRID_POWER_KEY:
+ attributes["at_service_entrance"] = self.coordinator.data.lugs_at_service_entrance
+
return attributes
diff --git a/tests/factories.py b/tests/factories.py
index 2594a44c..2420529a 100644
--- a/tests/factories.py
+++ b/tests/factories.py
@@ -243,6 +243,11 @@ def create(
downstream_l2_current_a: float | None = None,
pv: SpanPVSnapshot | None = None,
evse: dict[str, SpanEvseSnapshot] | None = None,
+ # Defaults True for the same reason the library field does: a panel at the
+ # service entrance is the ordinary case, and flat firmware cannot be
+ # anything else. A fixture opts out to model a chained panel or one behind
+ # an upstream DER.
+ lugs_at_service_entrance: bool = True,
) -> SpanPanelSnapshot:
"""Create a SpanPanelSnapshot with reasonable defaults."""
if circuits is None:
@@ -287,6 +292,7 @@ def create(
panel_size=panel_size,
power_flow_pv=power_flow_pv,
power_flow_grid=power_flow_grid,
+ lugs_at_service_entrance=lugs_at_service_entrance,
upstream_l1_current_a=upstream_l1_current_a,
upstream_l2_current_a=upstream_l2_current_a,
downstream_l1_current_a=downstream_l1_current_a,
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
index 6d010a63..f036a610 100644
--- a/tests/test_diagnostics.py
+++ b/tests/test_diagnostics.py
@@ -93,6 +93,9 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data(
"serial_number": "sp3-diag-001",
"firmware_version": "spanos2/r202603/05",
"panel_size": 32,
+ "lugs_at_service_entrance": True,
+ "instant_grid_power_w": 2500.75,
+ "power_flow_grid": None,
"wifi_ssid": "Span WiFi",
"eth0_link": True,
"wlan_link": False,
@@ -155,6 +158,9 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable
evse={},
battery=None,
adopted_devices=(),
+ lugs_at_service_entrance=True,
+ instant_grid_power_w=0.0,
+ power_flow_grid=None,
)
coordinator = MagicMock()
coordinator.data = snapshot
@@ -171,6 +177,9 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable
"serial_number": "sp3-diag-002",
"firmware_version": "spanos2/r202603/06",
"panel_size": None,
+ "lugs_at_service_entrance": True,
+ "instant_grid_power_w": 0.0,
+ "power_flow_grid": None,
}
assert result["circuits"]["uuid_minimal"] == {
"name": None,
diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py
index ff2f5a9f..e3aa3090 100644
--- a/tests/test_sensor_entities.py
+++ b/tests/test_sensor_entities.py
@@ -110,7 +110,11 @@ def test_panel_power_sensor_extra_state_attributes_include_amperage() -> None:
sensor._update_native_value()
assert sensor.native_value == 480.0
- assert sensor.extra_state_attributes == {"voltage": 240, "amperage": 2.0}
+ assert sensor.extra_state_attributes == {
+ "voltage": 240,
+ "amperage": 2.0,
+ "at_service_entrance": True,
+ }
def test_panel_power_sensor_defaults_amperage_when_value_not_numeric() -> None:
@@ -123,7 +127,52 @@ def test_panel_power_sensor_defaults_amperage_when_value_not_numeric() -> None:
sensor._attr_native_value = STATE_UNKNOWN
- assert sensor.extra_state_attributes == {"voltage": 240, "amperage": 0.0}
+ assert sensor.extra_state_attributes == {
+ "voltage": 240,
+ "amperage": 0.0,
+ "at_service_entrance": True,
+ }
+
+
+def test_grid_power_says_when_the_lugs_are_not_the_utility_connection() -> None:
+ """The attribute exists for the case where the two grid figures disagree.
+
+ A BESS ahead of the main lugs, or a panel fed by another panel, leaves this
+ sensor metering that panel's own feed while `power_flow_grid` stays
+ site-level. Both readings are right and they stop being the same number, and
+ without this the user cannot tell that from a fault.
+ """
+ snapshot = SpanPanelSnapshotFactory.create(
+ instant_grid_power_w=480.0, lugs_at_service_entrance=False
+ )
+ coordinator = _make_coordinator(snapshot)
+ description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "instantGridPowerW")
+
+ sensor = SpanPanelPowerSensor(coordinator, description, snapshot)
+ sensor._update_native_value()
+
+ assert sensor.extra_state_attributes["at_service_entrance"] is False
+ # The label is conditional; the measurement is not.
+ assert sensor.native_value == 480.0
+
+
+def test_only_the_grid_sensor_carries_the_topology_attribute() -> None:
+ """The same class backs four sensors and only one reads a topology-dependent meter.
+
+ Feedthrough, battery and PV are what they say they are wherever the panel
+ sits, so an attribute qualifying the grid label would be noise on them --
+ and worse, would read as qualifying *their* value.
+ """
+ snapshot = SpanPanelSnapshotFactory.create(
+ instant_grid_power_w=480.0, feedthrough_power_w=120.0, lugs_at_service_entrance=False
+ )
+ coordinator = _make_coordinator(snapshot)
+ description = next(desc for desc in PANEL_POWER_SENSORS if desc.key == "feedthroughPowerW")
+
+ sensor = SpanPanelPowerSensor(coordinator, description, snapshot)
+ sensor._update_native_value()
+
+ assert "at_service_entrance" not in (sensor.extra_state_attributes or {})
def test_panel_sensor_default_friendly_names_cover_fallback_branches() -> None:
diff --git a/uv.lock b/uv.lock
index 04f4fe2d..63f55678 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2560,10 +2560,13 @@ dependencies = [
[package.metadata]
requires-dist = [
- { name = "httpx", specifier = ">=0.28.1" },
+ { name = "httpx", specifier = ">=0.28.1,<1.0" },
{ name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
+ { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api-p3/packages/schema-0" },
+ { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api-p3/packages/schema-1" },
]
+provides-extras = ["schema-0", "schema-1"]
[package.metadata.requires-dev]
dev = [
@@ -2607,7 +2610,7 @@ dependencies = [
[package.metadata]
requires-dist = [
- { name = "ebus-sdk", specifier = ">=0.19.0,<1.0" },
+ { name = "ebus-sdk", specifier = ">=0.19.0,<0.23" },
{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
]
From cda28ab050481e77e89db4d15d31d8187d278dcf Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 22:43:24 -0700
Subject: [PATCH 080/116] feat: hand the panel client Home Assistant's shared
HTTP client
`quality_scale.yaml` has declared `inject-websession: done` for a while, and it
was true of the config flow and of nothing that ran after it. Every runtime
schema read built a throwaway client: once at connect, and once per attempt
inside the library's retry loop, which runs while a panel finishes rebooting
after a firmware upgrade and can go five times in a row. Home Assistant already
owns a client, shares it across integrations, and closes it at shutdown.
The library gained the parameter in 3.0.0b7; this passes it. Requires that
release, so all three pin sites move together -- manifest, pyproject and
requirements_test -- since the sync script's regex stops at the first
non-numeric character and is inert on beta versions.
`get_async_client(hass)` rather than the config flow's
`verify_ssl=False` variant, and not because one is safer: the library's
bootstrap URLs are plain `http://`, so TLS never happens on this path either
way, which also means the config flow's `verify_ssl=False` is inert today. The
default client is the one every other integration already shares; asking for the
no-verify variant would stand up a second connection pool for this integration
alone and imply a TLS decision nobody is making.
The test asserts identity rather than presence, because a test that only checked
something was passed would pass equally for a fresh client built here -- which is
the thing being removed. Both mutations die: dropping the argument, and asking
for HA's other client.
---
custom_components/span_panel/__init__.py | 14 +++++++
custom_components/span_panel/manifest.json | 2 +-
pyproject.toml | 2 +-
requirements_test.txt | 2 +-
tests/test_setup_entry.py | 44 ++++++++++++++++++++++
uv.lock | 2 +-
6 files changed, 62 insertions(+), 4 deletions(-)
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index aba6954b..3944c531 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -17,6 +17,7 @@
ConfigEntryNotReady,
)
from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType
from span_panel_api import SpanMqttClient, SpanPanelSnapshot
from span_panel_api.exceptions import (
@@ -202,6 +203,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
broker_config,
snapshot_interval=snapshot_interval,
panel_http_port=panel_http_port,
+ # Home Assistant's shared client, which it owns and closes at
+ # shutdown. Without this the library built one per schema read --
+ # once at connect, and once per retry while a panel finishes
+ # rebooting after a firmware upgrade. `quality_scale.yaml` claims
+ # `inject-websession: done`, and until now that was true of the
+ # config flow and of nothing that ran afterwards.
+ #
+ # Verification is not the reason for the default client rather
+ # than the config flow's `verify_ssl=False` one: the library's
+ # bootstrap URLs are plain `http://`, so TLS never happens on
+ # this path either way. It is the default client because that is
+ # the one every other integration already shares.
+ httpx_client=get_async_client(hass),
)
try:
await client.connect()
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 9a7ca8d3..c7f04a54 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,7 +22,7 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b6",
+ "span-panel-api==3.0.0b7",
"span-panel-api-schema-0==1.0.0b5",
"span-panel-api-schema-1==0.1.0b6"
],
diff --git a/pyproject.toml b/pyproject.toml
index 9c496798..6e49956e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,7 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b6",
+ "span-panel-api==3.0.0b7",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
diff --git a/requirements_test.txt b/requirements_test.txt
index ebddc177..c47eaa23 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0b6
+span-panel-api==3.0.0b7
span-panel-api-schema-0==1.0.0b5
span-panel-api-schema-1==0.1.0b6
diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py
index be66441d..c49707e3 100644
--- a/tests/test_setup_entry.py
+++ b/tests/test_setup_entry.py
@@ -12,6 +12,7 @@
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.httpx_client import get_async_client
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from span_panel_api.exceptions import SpanPanelAuthError
@@ -102,6 +103,49 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title(
mock_update_entry.assert_called_once_with(entry, title="SPAN Panel")
+async def test_the_panel_client_is_given_home_assistants_shared_http_client(
+ hass: HomeAssistant,
+) -> None:
+ """Not a client of its own, and not a copy: the one instance HA hands out.
+
+ Without this the library builds a throwaway client per schema read -- once at
+ connect, and once per retry while a panel finishes rebooting after a firmware
+ upgrade. `quality_scale.yaml` declares `inject-websession: done`, and that was
+ true of the config flow and of nothing that ran afterwards.
+
+ Asserted by identity rather than by type. A test that only checked something
+ was passed would pass just as well for a fresh client built here, which is
+ the thing being removed -- HA owns this one and closes it at shutdown.
+ """
+ entry = _create_v2_entry()
+ entry.add_to_hass(hass)
+ client = MagicMock()
+ client.connect = AsyncMock()
+ coordinator = MagicMock()
+ coordinator.async_config_entry_first_refresh = AsyncMock()
+ coordinator.async_setup_streaming = AsyncMock()
+ coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-setup-001")
+
+ with (
+ patch("custom_components.span_panel.async_register_commands"),
+ patch(
+ "custom_components.span_panel.SpanMqttClient", return_value=client
+ ) as mock_client_cls,
+ patch(
+ "custom_components.span_panel.SpanPanelCoordinator", return_value=coordinator
+ ),
+ patch(
+ "custom_components.span_panel.ensure_device_registered",
+ AsyncMock(return_value="panel-device-id"),
+ ),
+ patch.object(hass.config_entries, "async_forward_entry_setups", AsyncMock()),
+ patch.object(hass.config_entries, "async_update_entry"),
+ ):
+ assert await async_setup_entry(hass, entry) is True
+
+ assert mock_client_cls.call_args.kwargs["httpx_client"] is get_async_client(hass)
+
+
async def test_async_setup_entry_v2_missing_mqtt_credentials_raises_auth_failed(
hass: HomeAssistant,
) -> None:
diff --git a/uv.lock b/uv.lock
index 63f55678..152ce486 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b6"
+version = "3.0.0b7"
source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
From 8eb8b31fcfcc5b552d50308ea1b187efd616a021 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 23:27:18 -0700
Subject: [PATCH 081/116] fix(scripts): make the dependency sync actually match
a version
It has been inert for its entire life and said so to nobody. Versions were
matched with `[0-9.]+`, so `3.0.0b7` was read as `3.0.0`, which matched nothing
and rewrote nothing -- and every version this project has ever shipped is a
pre-release, so this was not an edge case, it was every case. It also only
looked for the bootstrap package, entering that branch for
`span-panel-api-schema-0` on the shared prefix and then failing its own regex on
the `-schema-0` that followed, so neither adapter was ever synced by anything.
And it had never heard of `requirements_test.txt`, which is how that file came
to pin b4 against a manifest requiring b7.
It runs on every commit through `prek.toml` with `always_run`, and again in CI
under `prek run --all-files`, reporting success each time.
The fault is the shape rather than the regex. Nothing parsed, so nothing could
report that it had not matched, and a hook that matches nothing produces no
changes -- which is indistinguishable from a tree that was already correct. This
version parses each requirement, looks the name up in the manifest, and then
reads the files back and checks them against the manifest, so a miss is an error
rather than a silent no-op.
Scoped to `[project] dependencies` rather than the whole file, and that scoping
is load-bearing: `pyproject.toml` names these same packages as editable path
overrides under `[tool.uv.sources]`, in mypy's search path, and potentially as a
uv constraint -- a specifier someone set deliberately to a different value, which
a whole-file rewrite would silently drag to the manifest's pin.
Nine tests, in `tests/` rather than beside the script in `tests/scripts/`, which
`pytest.ini` excludes with `norecursedirs`: a test that is never collected is the
same failure as a hook that never matches. Two mutations verified dead -- the old
`[0-9.]+` version pattern, and dropping the scoping.
---
scripts/sync-dependencies.py | 257 +++++++++++++++++++++++-----------
tests/test_dependency_sync.py | 215 ++++++++++++++++++++++++++++
2 files changed, 391 insertions(+), 81 deletions(-)
create mode 100644 tests/test_dependency_sync.py
diff --git a/scripts/sync-dependencies.py b/scripts/sync-dependencies.py
index f318e568..f6d92c4a 100755
--- a/scripts/sync-dependencies.py
+++ b/scripts/sync-dependencies.py
@@ -1,105 +1,200 @@
#!/usr/bin/env python3
-"""Synchronize dependency versions from manifest.json to pyproject.toml.
-
-This script reads the dependency versions from custom_components/span_panel/manifest.json
-and updates the corresponding dependencies in pyproject.toml to match.
-
-Used as a pre-commit hook to ensure pyproject.toml stays in sync with manifest versions.
+"""Hold every declaration of a library pin to the one in manifest.json.
+
+Home Assistant installs what `custom_components/span_panel/manifest.json`
+requires, so that file is the only one whose pins reach a user. Every other
+place the same library is named -- `pyproject.toml` for local development,
+`requirements_test.txt` for a bare pip setup -- is a copy, and a copy that drifts
+is worse than no copy: the tests pass against one version while the integration
+ships another.
+
+Run as a pre-commit hook. Rewrites the copies and exits non-zero when it changed
+something, so the commit stops and the corrected files are re-staged.
+
+**Why this was rewritten.** The previous version matched versions with `[0-9.]+`
+and so stopped at the first letter: `3.0.0b7` was seen as `3.0.0`, which matched
+nothing and silently rewrote nothing. Every version this project has ever shipped
+is a pre-release, so the hook has been inert for its whole life while reporting
+success on every commit. It also only ever looked for the bootstrap package --
+`span-panel-api-schema-0` starts with `span-panel-api`, so it entered the branch
+and then failed its own regex -- and it never knew about `requirements_test.txt`
+at all, which is how that file came to pin `b4` against a manifest requiring `b7`.
+
+The lesson is in the shape rather than the regex: nothing parsed, so nothing
+could report that it had not matched. This version parses the requirement, looks
+the name up, and verifies the result, so a miss is an error rather than a
+no-change.
"""
+from __future__ import annotations
+
import json
from pathlib import Path
import re
import sys
+import tomllib
+REPO = Path(__file__).resolve().parent.parent
+MANIFEST = REPO / "custom_components" / "span_panel" / "manifest.json"
+PYPROJECT = REPO / "pyproject.toml"
+REQUIREMENTS_TEST = REPO / "requirements_test.txt"
-def get_manifest_versions():
- """Extract dependency versions from manifest.json."""
- manifest_path = Path("custom_components/span_panel/manifest.json")
-
- if not manifest_path.exists():
- return None
-
- try:
- with open(manifest_path) as f:
- manifest = json.load(f)
-
- requirements = manifest.get("requirements", [])
- versions = {}
-
- for req in requirements:
- if req.startswith("span-panel-api"):
- # Extract full specifier (e.g. ==2.3.0, >=2.0.0, ~=1.1.0)
- match = re.search(r"span-panel-api([>~=!]+[0-9.]+)", req)
- if match:
- versions["span-panel-api"] = match.group(1)
- elif req.startswith("ha-synthetic-sensors"):
- # Extract full specifier (e.g. >=1.0.8, ~=1.0.8)
- match = re.search(r"ha-synthetic-sensors([>~=!]+[0-9.]+)", req)
- if match:
- versions["ha-synthetic-sensors"] = match.group(1)
-
- return versions
+# One requirement: a PEP 508 name, then a specifier that runs to the end. The
+# version half is deliberately unconstrained -- `3.0.0b7`, `1.0.0rc1`, `2.6.4`,
+# `1.0.0.post1` and `>=1,<2` all have to survive it, and enumerating version
+# grammar is what broke the last one.
+REQUIREMENT = re.compile(
+ r"^(?P[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\s*(?P[<>=!~].*)$"
+)
- except Exception:
- return None
+class SyncError(Exception):
+ """Something is wrong with the inputs, as opposed to merely out of date."""
-def update_pyproject_dependencies(versions):
- """Update pyproject.toml dependencies with manifest versions."""
- pyproject_path = Path("pyproject.toml")
- if not pyproject_path.exists():
- return False
+def manifest_pins() -> dict[str, str]:
+ """Return the manifest's requirements, keyed by package name.
+ Raises rather than returning empty on a bad manifest. An unreadable source of
+ truth is not a reason to leave every copy alone and report success.
+ """
+ if not MANIFEST.is_file():
+ raise SyncError(f"no manifest at {MANIFEST.relative_to(REPO)}")
try:
- with open(pyproject_path) as f:
- content = f.read()
-
- original_content = content
-
- # Update span-panel-api version in [project] dependencies
- if "span-panel-api" in versions:
- span_spec = versions["span-panel-api"]
- content = re.sub(
- r'"span-panel-api[><=~!]+[0-9.]+"',
- f'"span-panel-api{span_spec}"',
- content,
+ manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
+ except ValueError as exc:
+ raise SyncError(f"{MANIFEST.relative_to(REPO)} is not valid JSON: {exc}") from exc
+
+ pins: dict[str, str] = {}
+ for requirement in manifest.get("requirements", []):
+ match = REQUIREMENT.match(str(requirement).strip())
+ if match is None:
+ raise SyncError(
+ f"manifest requirement {requirement!r} has no version specifier; "
+ "Home Assistant installs these verbatim, so an unpinned one is a bug "
+ "rather than something to copy"
)
-
- # Update ha-synthetic-sensors version in [project] dependencies
- if "ha-synthetic-sensors" in versions:
- ha_spec = versions["ha-synthetic-sensors"]
- content = re.sub(
- r'"ha-synthetic-sensors[><=~!]+[0-9.]+"',
- f'"ha-synthetic-sensors{ha_spec}"',
- content,
+ pins[match["name"]] = match.group(0)
+ if not pins:
+ raise SyncError(f"{MANIFEST.relative_to(REPO)} declares no requirements")
+ return pins
+
+
+def _project_dependencies_span(text: str) -> tuple[int, int]:
+ """Return the character span of `[project]`'s `dependencies = [...]` array.
+
+ Scoped rather than global on purpose. `pyproject.toml` names these same
+ packages twice more -- as editable path overrides under `[tool.uv.sources]`,
+ and inside mypy's search path -- and rewriting either would replace a
+ filesystem path with a version specifier.
+ """
+ project = re.search(r"^\[project\]\s*$", text, re.MULTILINE)
+ if project is None:
+ raise SyncError("pyproject.toml has no [project] table")
+ next_table = re.search(r"^\[", text[project.end() :], re.MULTILINE)
+ end_of_project = project.end() + (
+ next_table.start() if next_table else len(text) - project.end()
+ )
+
+ array = re.search(
+ r"^dependencies\s*=\s*\[", text[project.start() : end_of_project], re.MULTILINE
+ )
+ if array is None:
+ raise SyncError("[project] declares no dependencies array")
+ start = project.start() + array.end()
+ closing = text.find("]", start)
+ if closing == -1:
+ raise SyncError("[project] dependencies array is never closed")
+ return start, closing
+
+
+def sync_pyproject(pins: dict[str, str]) -> list[str]:
+ """Rewrite pinned requirements inside `[project] dependencies`. Return the changes."""
+ text = PYPROJECT.read_text(encoding="utf-8")
+ start, end = _project_dependencies_span(text)
+ changes: list[str] = []
+
+ def replace(match: re.Match[str]) -> str:
+ requirement = REQUIREMENT.match(match["req"])
+ if requirement is None or requirement["name"] not in pins:
+ return match.group(0)
+ wanted = pins[requirement["name"]]
+ if match["req"] == wanted:
+ return match.group(0)
+ changes.append(f"pyproject.toml: {match['req']} -> {wanted}")
+ return f'"{wanted}"'
+
+ body = re.sub(r'"(?P[^"]+)"', replace, text[start:end])
+ if changes:
+ PYPROJECT.write_text(text[:start] + body + text[end:], encoding="utf-8")
+ return changes
+
+
+def sync_requirements_test(pins: dict[str, str]) -> list[str]:
+ """Rewrite pinned requirements in `requirements_test.txt`. Return the changes."""
+ if not REQUIREMENTS_TEST.is_file():
+ return []
+ lines = REQUIREMENTS_TEST.read_text(encoding="utf-8").splitlines()
+ changes: list[str] = []
+
+ for index, line in enumerate(lines):
+ requirement = REQUIREMENT.match(line.strip())
+ if requirement is None or requirement["name"] not in pins:
+ continue
+ wanted = pins[requirement["name"]]
+ if line.strip() != wanted:
+ changes.append(f"requirements_test.txt: {line.strip()} -> {wanted}")
+ lines[index] = wanted
+
+ if changes:
+ REQUIREMENTS_TEST.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ return changes
+
+
+def verify(pins: dict[str, str]) -> None:
+ """Read the rewritten files back and confirm they say what was intended.
+
+ The point of the whole rewrite. A regex that matches nothing produces no
+ changes, which is indistinguishable from a file that was already correct --
+ that is exactly how the previous version stayed silently broken. Checking the
+ result against the manifest turns a miss into a failure.
+ """
+ declared = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"]["dependencies"]
+ for requirement in declared:
+ match = REQUIREMENT.match(str(requirement).strip())
+ if match and match["name"] in pins and match.group(0) != pins[match["name"]]:
+ raise SyncError(
+ f"pyproject.toml still declares {requirement!r} after syncing; "
+ f"the manifest pins {pins[match['name']]!r}"
)
- if content != original_content:
- with open(pyproject_path, "w") as f:
- f.write(content)
- return True
-
- return False
+ if REQUIREMENTS_TEST.is_file():
+ for line in REQUIREMENTS_TEST.read_text(encoding="utf-8").splitlines():
+ match = REQUIREMENT.match(line.strip())
+ if match and match["name"] in pins and match.group(0) != pins[match["name"]]:
+ raise SyncError(
+ f"requirements_test.txt still declares {line.strip()!r} after syncing; "
+ f"the manifest pins {pins[match['name']]!r}"
+ )
- except Exception:
- return False
+def main() -> int:
+ """Sync, verify, and fail the commit if anything moved."""
+ try:
+ pins = manifest_pins()
+ changes = sync_pyproject(pins) + sync_requirements_test(pins)
+ verify(pins)
+ except SyncError as exc:
+ print(f"sync-dependencies: {exc}", file=sys.stderr)
+ return 1
-def main():
- """Main function."""
- versions = get_manifest_versions()
- if not versions:
- sys.exit(1)
-
- changes_made = update_pyproject_dependencies(versions)
-
- if changes_made:
- sys.exit(1) # Exit with error to fail pre-commit
- else:
- sys.exit(0)
+ if not changes:
+ return 0
+ for change in changes:
+ print(f"sync-dependencies: {change}")
+ print("sync-dependencies: files updated to match the manifest; re-stage and commit again")
+ return 1
if __name__ == "__main__":
- main()
+ sys.exit(main())
diff --git a/tests/test_dependency_sync.py b/tests/test_dependency_sync.py
new file mode 100644
index 00000000..d0cae0ff
--- /dev/null
+++ b/tests/test_dependency_sync.py
@@ -0,0 +1,215 @@
+"""The pin sync must fail loudly rather than quietly do nothing.
+
+`scripts/sync-dependencies.py` holds `pyproject.toml` and `requirements_test.txt`
+to the pins in `manifest.json`, which is the only file whose versions reach a
+user. It runs on every commit through `prek.toml` and again in CI.
+
+Lives here rather than beside the script in `tests/scripts/`, which `pytest.ini`
+excludes with `norecursedirs`. A test that is never collected is the same failure
+as a hook that never matches: green, silent, and worth nothing.
+
+It is tested because the previous version was inert for its entire life and said
+so to nobody. It matched versions with `[0-9.]+`, so `3.0.0b7` was read as
+`3.0.0`, which matched nothing and rewrote nothing -- and every version this
+project ships is a pre-release. A hook that matches nothing produces no changes,
+which is indistinguishable from a tree that was already correct. That is the
+failure these tests exist to catch, so most of them drift a file on purpose and
+check that the hook notices.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import subprocess
+import sys
+
+import pytest
+
+REPO = Path(__file__).resolve().parent.parent
+SCRIPT = REPO / "scripts" / "sync-dependencies.py"
+
+
+def _run(cwd: Path) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ [sys.executable, str(SCRIPT)], cwd=cwd, capture_output=True, text=True, check=False
+ )
+
+
+@pytest.fixture
+def repo(tmp_path: Path) -> Path:
+ """Return a miniature of this repository: a manifest and the two files it governs.
+
+ Built rather than copied so a test cannot pass by accident of what the real
+ tree happens to hold today, and so the pins can be pre-release versions --
+ the case the old implementation could not see.
+ """
+ component = tmp_path / "custom_components" / "span_panel"
+ component.mkdir(parents=True)
+ (component / "manifest.json").write_text(
+ json.dumps(
+ {
+ "domain": "span_panel",
+ "requirements": [
+ "span-panel-api==3.0.0b7",
+ "span-panel-api-schema-0==1.0.0b5",
+ "span-panel-api-schema-1==0.1.0b6",
+ ],
+ },
+ indent=2,
+ )
+ )
+ (tmp_path / "pyproject.toml").write_text(
+ "[project]\n"
+ 'name = "span-panel"\n'
+ "dependencies = [\n"
+ ' "span-panel-api==3.0.0b7",\n'
+ ' "span-panel-api-schema-0==1.0.0b5",\n'
+ ' "span-panel-api-schema-1==0.1.0b6",\n'
+ ' "homeassistant>=2026.8.0",\n'
+ "]\n"
+ "\n"
+ "[tool.uv.sources]\n"
+ 'span-panel-api = { path = "../span-panel-api", editable = true }\n'
+ "\n"
+ "[tool.uv]\n"
+ "# A deliberately different pin, in a table the manifest does not govern.\n"
+ 'constraint-dependencies = ["span-panel-api==3.0.0b3"]\n'
+ )
+ (tmp_path / "requirements_test.txt").write_text(
+ "pytest>=9.0.3\nspan-panel-api==3.0.0b7\nspan-panel-api-schema-0==1.0.0b5\n"
+ "span-panel-api-schema-1==0.1.0b6\n"
+ )
+ return tmp_path
+
+
+def _write_script_into(repo: Path) -> None:
+ """Give the miniature its own copy, since the script locates files from its own path."""
+ scripts = repo / "scripts"
+ scripts.mkdir(exist_ok=True)
+ (scripts / "sync-dependencies.py").write_text(SCRIPT.read_text())
+
+
+def _sync(repo: Path) -> subprocess.CompletedProcess[str]:
+ _write_script_into(repo)
+ return subprocess.run(
+ [sys.executable, str(repo / "scripts" / "sync-dependencies.py")],
+ cwd=repo,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+
+def test_an_already_synced_tree_passes(repo: Path) -> None:
+ result = _sync(repo)
+ assert result.returncode == 0, result.stderr
+
+
+def test_a_pre_release_pin_is_synced(repo: Path) -> None:
+ """The exact case the previous implementation could not see.
+
+ `3.0.0b7` was read as `3.0.0` by a `[0-9.]+` version pattern, so the
+ substitution never matched and the hook reported success on a stale file.
+ Every version this project has shipped is a pre-release, so this was not an
+ edge case -- it was every case.
+ """
+ pyproject = repo / "pyproject.toml"
+ pyproject.write_text(pyproject.read_text().replace("3.0.0b7", "3.0.0b4"))
+
+ result = _sync(repo)
+
+ assert result.returncode == 1, "a stale pin must stop the commit"
+ assert "span-panel-api==3.0.0b7" in pyproject.read_text()
+ assert "3.0.0b4" not in pyproject.read_text()
+
+
+def test_the_adapter_packages_are_synced_too(repo: Path) -> None:
+ """`span-panel-api-schema-0` starts with `span-panel-api`, which is how it was missed.
+
+ The old implementation entered its bootstrap branch on the prefix and then
+ failed its own regex on the `-schema-0` that followed, so the adapters were
+ never synced by anything.
+ """
+ pyproject = repo / "pyproject.toml"
+ pyproject.write_text(pyproject.read_text().replace("0.1.0b6", "0.1.0b3"))
+
+ assert _sync(repo).returncode == 1
+ assert "span-panel-api-schema-1==0.1.0b6" in pyproject.read_text()
+
+
+def test_requirements_test_is_governed(repo: Path) -> None:
+ """It drifted to a stale beta precisely because nothing looked at it."""
+ requirements = repo / "requirements_test.txt"
+ requirements.write_text(requirements.read_text().replace("3.0.0b7", "3.0.0b4"))
+
+ assert _sync(repo).returncode == 1
+ assert "span-panel-api==3.0.0b7" in requirements.read_text()
+
+
+def test_only_the_project_dependencies_are_governed(repo: Path) -> None:
+ """The manifest governs `[project] dependencies` and no other table.
+
+ `pyproject.toml` names these same packages in several places: as editable
+ path overrides under `[tool.uv.sources]`, in mypy's search path, and -- the
+ case that makes scoping load-bearing rather than merely tidy -- as a uv
+ constraint, which is a version specifier someone set deliberately and to a
+ different value. A rewrite that walked the whole file would silently drag it
+ to the manifest's pin and undo the constraint.
+ """
+ pyproject = repo / "pyproject.toml"
+ pyproject.write_text(
+ pyproject.read_text().replace(
+ '"span-panel-api==3.0.0b7",\n', '"span-panel-api==3.0.0b4",\n', 1
+ )
+ )
+
+ assert _sync(repo).returncode == 1
+ body = pyproject.read_text()
+
+ assert '"span-panel-api==3.0.0b7",' in body, "the governed pin is synced"
+ assert 'constraint-dependencies = ["span-panel-api==3.0.0b3"]' in body, (
+ "the ungoverned one is not"
+ )
+ assert '{ path = "../span-panel-api", editable = true }' in body
+
+
+def test_a_dependency_the_manifest_does_not_pin_is_untouched(repo: Path) -> None:
+ """The manifest governs its own requirements and nothing else."""
+ pyproject = repo / "pyproject.toml"
+ pyproject.write_text(pyproject.read_text().replace("3.0.0b7", "3.0.0b4"))
+
+ _sync(repo)
+
+ assert '"homeassistant>=2026.8.0"' in pyproject.read_text()
+
+
+def test_an_unparseable_manifest_fails_rather_than_reporting_success(repo: Path) -> None:
+ """An unreadable source of truth is not a reason to leave every copy alone."""
+ (repo / "custom_components" / "span_panel" / "manifest.json").write_text("{not json")
+
+ result = _sync(repo)
+
+ assert result.returncode == 1
+ assert "not valid JSON" in result.stderr
+
+
+def test_an_unpinned_manifest_requirement_is_an_error(repo: Path) -> None:
+ """Home Assistant installs these verbatim, so a missing specifier is a bug."""
+ manifest = repo / "custom_components" / "span_panel" / "manifest.json"
+ manifest.write_text(json.dumps({"requirements": ["span-panel-api"]}, indent=2))
+
+ result = _sync(repo)
+
+ assert result.returncode == 1
+ assert "no version specifier" in result.stderr
+
+
+def test_the_real_repository_is_in_sync() -> None:
+ """The hook runs on every commit, so this should already be true.
+
+ Here so that a stale pin is reported by the test suite as well as by the
+ hook: CI runs both, and a developer who bypasses hooks still gets told.
+ """
+ result = _run(REPO)
+ assert result.returncode == 0, f"{result.stdout}{result.stderr}"
From b659d573c4d6c08863fd26616b055bccc8886fae Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Thu, 20 Aug 2026 23:28:48 -0700
Subject: [PATCH 082/116] chore: declare 2.1.0b1, so the version reads as the
beta it is
`manifest.json` and `pyproject.toml` both said `2.1.0`, a final version string,
while the thing being cut is a beta. HACS lists the release tag and Home
Assistant reports the manifest version on the device page, so shipping a beta
that calls itself `2.1.0` means a tester's diagnostics are indistinguishable
from the eventual release -- and `v2.1.0` is the tag the real release needs.
Both declarations move together, and `uv.lock` records the project's own version
so it moves with them. There is no fourth: the component has no `__version__` or
`VERSION` constant, and nothing in the suite asserts this number (the `2.1.0`
strings in the test factories are panel firmware `sw_version`, unrelated).
Unlike the library, this repository has no release workflow that checks a tag
against the committed version, so the agreement is maintained by hand.
---
custom_components/span_panel/manifest.json | 2 +-
pyproject.toml | 2 +-
uv.lock | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index c7f04a54..06cce2f4 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0b5",
"span-panel-api-schema-1==0.1.0b6"
],
- "version": "2.1.0",
+ "version": "2.1.0b1",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/pyproject.toml b/pyproject.toml
index 6e49956e..6c326002 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "span"
-version = "2.1.0"
+version = "2.1.0b1"
description = "Span Panel Custom Integration for Home Assistant"
authors = [{name = "SpanPanel"}]
license = {text = "MIT"}
diff --git a/uv.lock b/uv.lock
index 152ce486..0249d4e9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2491,7 +2491,7 @@ wheels = [
[[package]]
name = "span"
-version = "2.1.0"
+version = "2.1.0b1"
source = { virtual = "." }
dependencies = [
{ name = "homeassistant" },
From 8b3d1e6b7748ce60984f9c10689322c510fc0d79 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 00:01:50 -0700
Subject: [PATCH 083/116] chore: 2.1.0b2, because b1 was cut from the wrong
branch
`v2.1.0b1` exists on the remote and points at `94d55ae`, the head of
`feat/schema-1-adapter`. That branch is an ancestor of this one -- zero commits
this branch does not have, twenty behind it -- and its manifest pins
`span-panel-api==3.0.0b5`, `schema-0==1.0.0b4` and `schema-1==0.1.0b5`, none of
which were ever published. So HACS offered the tag, Home Assistant tried to
install three versions that do not exist, and setup never ran.
A tag that has been published to users is not worth reusing even when it points
somewhere wrong: anyone who already pulled it has that content cached under that
name. Burn the number and move on.
`2.1.0b2` sorts above `2.1.0b1` under AwesomeVersion, which is what HACS
compares, so an install sitting on the bad tag will be offered this as an
upgrade rather than having to be removed first.
---
custom_components/span_panel/manifest.json | 2 +-
pyproject.toml | 2 +-
uv.lock | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 06cce2f4..c5902420 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0b5",
"span-panel-api-schema-1==0.1.0b6"
],
- "version": "2.1.0b1",
+ "version": "2.1.0b2",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/pyproject.toml b/pyproject.toml
index 6c326002..3e682d96 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "span"
-version = "2.1.0b1"
+version = "2.1.0b2"
description = "Span Panel Custom Integration for Home Assistant"
authors = [{name = "SpanPanel"}]
license = {text = "MIT"}
diff --git a/uv.lock b/uv.lock
index 0249d4e9..faa716bd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2491,7 +2491,7 @@ wheels = [
[[package]]
name = "span"
-version = "2.1.0b1"
+version = "2.1.0b2"
source = { virtual = "." }
dependencies = [
{ name = "homeassistant" },
From b294509c02aeb85051a6b8d23453014c89083415 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 02:06:37 -0700
Subject: [PATCH 084/116] feat(diagnostics): report the entity registry,
because the UI will not
An upgrade complaint is settled in the entity registry and none of it is
reachable from the UI. Home Assistant shows "This entity is disabled" without
saying by what, and `.storage/core.entity_registry` needs shell access that a
Home Assistant OS user does not have. Four causes look identical on screen --
the registration default applied to something the registry considers new, a user
action, a disabled config entry, a disabled device -- and they need four
different fixes.
`disabled_by` names which one it was. `unique_id` answers the question
underneath, because an entity whose id changed is a new entity however familiar
its name, and that is the difference between an upgrade defect and a surprise.
`hidden_by` and `device_id` come along for the same reason: both are registry
state an upgrade can move, and neither is legible from a screenshot.
Safe by construction rather than by redaction, which is the rule this payload is
already built to: every field is registry bookkeeping, and the serial embedded in
`unique_id` is already carried by `panel.serial_number`.
Written after spending an afternoon guessing between those four causes from
cropped screenshots and getting it wrong twice.
---
custom_components/span_panel/diagnostics.py | 51 +++++++++++++++++++++
tests/test_diagnostics.py | 41 +++++++++++++++++
2 files changed, 92 insertions(+)
diff --git a/custom_components/span_panel/diagnostics.py b/custom_components/span_panel/diagnostics.py
index b033ba5d..51057836 100644
--- a/custom_components/span_panel/diagnostics.py
+++ b/custom_components/span_panel/diagnostics.py
@@ -7,6 +7,7 @@
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
from span_panel_api import SpanPanelSnapshot
from . import SpanPanelConfigEntry
@@ -35,6 +36,55 @@
"""
+class EntityRow(TypedDict):
+ """One registry entry, as it appears in the payload."""
+
+ entity_id: str
+ unique_id: str
+ disabled_by: str | None
+ hidden_by: str | None
+ device_id: str | None
+
+
+def _entity_registry_rows(hass: HomeAssistant, entry: SpanPanelConfigEntry) -> list[EntityRow]:
+ """Every registry entry this config entry owns, with the fields that explain it.
+
+ The registry is where an upgrade complaint is actually settled, and none of it
+ is reachable from the UI: Home Assistant shows "This entity is disabled" and
+ not *by what*, and a user without shell access to `.storage` has no way to
+ read it. Diagnosing a sensor that came back disabled after an upgrade meant
+ guessing between four causes that look identical on screen.
+
+ `disabled_by` names which of them it was -- `integration` is the registration
+ default having been applied, which only happens to an entity the registry
+ considers new; `user`, `config_entry` and `device` are three different things
+ and three different fixes. `unique_id` answers the question underneath it,
+ because an entity whose id changed is a new entity no matter how familiar its
+ name looks, and that is the one answer that would make an upgrade a defect
+ rather than a surprise.
+
+ Safe by construction rather than by redaction, which is the rule this payload
+ is built to: every field is registry bookkeeping. `unique_id` embeds the panel
+ serial, which `panel.serial_number` already carries.
+ """
+ registry = er.async_get(hass)
+ return sorted(
+ (
+ EntityRow(
+ entity_id=registry_entry.entity_id,
+ unique_id=registry_entry.unique_id,
+ disabled_by=registry_entry.disabled_by.value
+ if registry_entry.disabled_by
+ else None,
+ hidden_by=registry_entry.hidden_by.value if registry_entry.hidden_by else None,
+ device_id=registry_entry.device_id,
+ )
+ for registry_entry in er.async_entries_for_config_entry(registry, entry.entry_id)
+ ),
+ key=lambda row: row["entity_id"],
+ )
+
+
class DiscoveredRow(TypedDict):
"""One declared-but-unread property, as it appears in the payload."""
@@ -248,6 +298,7 @@ async def async_get_config_entry_diagnostics(
return {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
+ "entities": _entity_registry_rows(hass, entry),
"panel": panel_data,
"circuits": circuit_data,
"evse": evse_data,
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
index f036a610..cda1abc1 100644
--- a/tests/test_diagnostics.py
+++ b/tests/test_diagnostics.py
@@ -18,6 +18,7 @@
)
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
from .factories import (
SpanBatterySnapshotFactory,
@@ -198,3 +199,43 @@ async def test_config_entry_diagnostics_omits_optional_sections_when_unavailable
"panel_offline": True,
"last_update_success": False,
}
+
+
+async def test_diagnostics_reports_the_entity_registry(hass: HomeAssistant) -> None:
+ """The registry is where an upgrade complaint is settled, and the UI hides it.
+
+ Home Assistant says "This entity is disabled" without saying by what, and a
+ user without shell access to `.storage` cannot read `disabled_by` at all. Four
+ causes look identical on screen and need four different fixes, so the field
+ that distinguishes them has to leave the machine somehow.
+
+ `unique_id` rides along because it answers the question underneath: an entity
+ whose id changed is a new entity however familiar its name, and that is the
+ difference between an upgrade defect and a surprise.
+ """
+ entry = MockConfigEntry(domain=DOMAIN, data={}, title="SPAN Panel")
+ entry.add_to_hass(hass)
+ registry = er.async_get(hass)
+ registry.async_get_or_create(
+ "sensor",
+ DOMAIN,
+ "span_sp3_diag_003_l1_voltage",
+ config_entry=entry,
+ suggested_object_id="span_panel_l1_voltage",
+ disabled_by=er.RegistryEntryDisabler.INTEGRATION,
+ )
+
+ coordinator = MagicMock()
+ coordinator.data = SpanPanelSnapshotFactory.create(serial_number="sp3-diag-003")
+ coordinator.panel_offline = False
+ coordinator.last_update_success = True
+ coordinator.schema_findings = None
+ entry.runtime_data = SimpleNamespace(coordinator=coordinator)
+
+ result = await async_get_config_entry_diagnostics(hass, entry)
+
+ rows = {row["entity_id"]: row for row in result["entities"]}
+ assert "sensor.span_panel_l1_voltage" in rows
+ row = rows["sensor.span_panel_l1_voltage"]
+ assert row["disabled_by"] == "integration"
+ assert row["unique_id"] == "span_sp3_diag_003_l1_voltage"
From faf7e49a88a59e9a1f51390bd5052975406f9c64 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 02:43:00 -0700
Subject: [PATCH 085/116] docs(changelog): one 2.1.0b2 section, and the PV area
move it was missing
`[Unreleased]` and `[2.1.0]` described the same unreleased release. 2.1.0 was
never cut, so they are merged under the version actually being published rather
than left as two lists a reader has to combine.
**The PV entry was wrong in a way worth naming.** It said the five relocated
entities keep their ids "so dashboards, automations and history follow them
across untouched. Only the card they appear on changes." The ids do survive and
that half was checked three ways. The area does not: an entity takes its area
from its device, and the new solar device starts with none, so on an upgraded
install these leave whatever area the panel is in and land in no area at all.
Area-scoped dashboards, automations and voice targeting stop matching them
silently. "Only the card changes" is exactly the sentence that would send someone
looking in the wrong place, so it now says what to do instead: assign the Solar
device an area.
Added the two things this release surfaces that had no entry: the
`at_service_entrance` attribute on Grid Power, and the entity registry in
diagnostics.
---
CHANGELOG.md | 85 +++++++++++++++++++++++++++-------------------------
1 file changed, 44 insertions(+), 41 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a0f127c..f951d373 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,12 +2,44 @@
All notable changes to this project will be documented in this file.
-## [Unreleased]
+## [2.1.0b2] - 8/2026
+
+Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does.
+
+### Requires Home Assistant 2026.8.0 or newer
+
+This release raises the minimum from 2026.5.4. Home Assistant 2026.8 replaced the two device-registry calls this integration relies on — the old forms stop
+working entirely in 2027.8 — and their replacements do not exist in 2026.5 through 2026.7, so there is no version of this release that runs on both. If you are
+on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not offer you this release.
### Added
+- **Your panel's Microgrid Interconnect Device appears as its own device** on the new data model, carrying **Grid State** — the health of the utility supply
+ itself, which the previous firmware never reported. Everything about it is additive; no existing entity moves or changes id.
+- **The integration notices a firmware upgrade and reloads itself.** A panel that becomes v1.0 while Home Assistant is running used to keep reading the tree
+ with the old parser, reporting every circuit as missing until you reloaded by hand. It now detects the change, reloads, writes a log line, and raises a
+ one-time notice explaining what changed.
+- **Grid-forming device name** as an attribute on the GFE sensor.
+- **Grid Power now says whether it is really measuring the grid**, through a new `at_service_entrance` attribute. That sensor reads your panel's upstream lugs,
+ which is grid flow only when those lugs are where the utility actually connects. Put a battery between the utility and your main lugs, or feed the panel from
+ another panel, and the same reading becomes that panel's own supply while **Grid Power Flow** stays the whole-site figure — so the two legitimately disagree,
+ and until now there was no way to tell that apart from a fault. The eBus specification was corrected on 2026-08-20 to say exactly this, after this project
+ supplied the capture that prompted it. Both readings were always correct; only the label was ever conditional.
+- **Diagnostics now include your entity registry.** Every entity this integration owns, with its unique id and — the part you cannot get anywhere else — what
+ disabled it, if anything. Home Assistant tells you an entity is disabled without telling you by what, and reading that yourself needs shell access to
+ `.storage` that a Home Assistant OS install does not give you. Four causes look identical on screen and need four different answers.
+
### Changed
+- **`DSM Grid State` is now more trustworthy on the new data model.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the
+ battery if one was fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the
+ Microgrid Interconnect Device actually senses.
+- **`Grid Islandable` keeps working** across the upgrade. v1.0 publishes no panel-level islandable property, so the entity now reflects whether a Microgrid
+ Interconnect Device is present, which is how v1.0 says backup capability is detected.
+- **Battery model** may read differently after upgrading: the new data model separates the human-readable designation from the SKU, and this entity now shows
+ the designation. This is a library-level normalisation applied to both data models, so it happens once, at this release, rather than unpredictably during a
+ firmware update.
+
- **Five panel sensors are switched off for new installations, because the eBus specification's own maintainer has documented that their values cannot be relied
on.** A conformance note for SPAN firmware r202633 identifies three defects in what the panel publishes, all of which predate that release: the feedthrough
(downstream lugs) energy registers are computed from two unrelated counters and can decrease or go negative — on a panel with no feedthrough load they report
@@ -84,8 +116,11 @@ All notable changes to this project will be documented in this file.
enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been
publishing all along.
- **If you already have these sensors, nothing about them changes.** The five entities that move to the new card — PV Power, PV Vendor, PV Product, PV Nameplate
- Capacity and PV Panel Link — keep the entity ids and unique ids they have today, so dashboards, automations and history follow them across untouched. Only the
- card they appear on changes.
+ Capacity and PV Panel Link — keep the entity ids and unique ids they have today, so dashboards, automations and history follow them across untouched.
+- **They do lose the panel's area, though, and nothing warns you.** An entity takes its area from the device it sits on unless you set one, and the new solar
+ device starts with no area. So if your panel is assigned to an area, these entities were in it yesterday and are in no area today — which quietly stops them
+ matching area-scoped dashboards, area-scoped automations and scripts, and voice commands that target a room. Assign the new **Solar** device to an area and
+ they behave as before. Worth doing before you go looking for what broke.
- **New installations get different entity ids for these five, and that is intended.** Home Assistant derives a new entity's id from the name of the device it
sits on, so a system installed from now on gets `sensor.span_panel_solar_pv_vendor` where a system installed before this release keeps
`sensor.span_panel_pv_vendor`. Both are correct and neither will change again: an existing system must never have an id renamed under it, and a new one gets
@@ -165,6 +200,12 @@ All notable changes to this project will be documented in this file.
### Fixed
+- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed
+ "Possible states: Unknown". The lists are now derived from the translations and checked against them by a test.
+- **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model.
+- **Sub-devices link to the panel by registry id**, replacing a form Home Assistant deprecated in 2026.8. No user-visible effect; required for the version bump
+ above.
+
- **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on
the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a
value you had, silently gone, with no error and nothing in the log. It is read again, and it is now published on the Wi-Fi Link binary sensor rather than on
@@ -177,44 +218,6 @@ All notable changes to this project will be documented in this file.
- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation
was wrong. No entity changed.
-## [2.1.0] - 8/2026
-
-Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does.
-
-### Requires Home Assistant 2026.8.0 or newer
-
-This release raises the minimum from 2026.5.4. Home Assistant 2026.8 replaced the two device-registry calls this integration relies on — the old forms stop
-working entirely in 2027.8 — and their replacements do not exist in 2026.5 through 2026.7, so there is no version of this release that runs on both. If you are
-on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not offer you this release.
-
-### Added
-
-- **Your panel's Microgrid Interconnect Device appears as its own device** on the new data model, carrying **Grid State** — the health of the utility supply
- itself, which the previous firmware never reported. Everything about it is additive; no existing entity moves or changes id.
-- **The integration notices a firmware upgrade and reloads itself.** A panel that becomes v1.0 while Home Assistant is running used to keep reading the tree
- with the old parser, reporting every circuit as missing until you reloaded by hand. It now detects the change, reloads, writes a log line, and raises a
- one-time notice explaining what changed.
-- **Grid-forming device name** as an attribute on the GFE sensor.
-
-### Changed
-
-- **`DSM Grid State` is now more trustworthy on the new data model.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the
- battery if one was fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the
- Microgrid Interconnect Device actually senses.
-- **`Grid Islandable` keeps working** across the upgrade. v1.0 publishes no panel-level islandable property, so the entity now reflects whether a Microgrid
- Interconnect Device is present, which is how v1.0 says backup capability is detected.
-- **Battery model** may read differently after upgrading: the new data model separates the human-readable designation from the SKU, and this entity now shows
- the designation. This is a library-level normalisation applied to both data models, so it happens once, at this release, rather than unpredictably during a
- firmware update.
-
-### Fixed
-
-- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed
- "Possible states: Unknown". The lists are now derived from the translations and checked against them by a test.
-- **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model.
-- **Sub-devices link to the panel by registry id**, replacing a form Home Assistant deprecated in 2026.8. No user-visible effect; required for the version bump
- above.
-
## [2.0.8] - 5/2026
### Fixed
From 7da9603fd3665b61641c37cf968c0ae0f4640180 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 02:54:11 -0700
Subject: [PATCH 086/116] docs: warn before the firmware lands, and correct the
battery sign in both docs
**The README's headline is now the consequence, not the mechanism.** Firmware
`r202633` breaks every release up to 2.0.8, and the failure is quiet: the
integration stays connected, stays loaded, and reports every circuit as missing.
Nobody outside SPAN knows when a given panel updates and it cannot be deferred
from Home Assistant, so the only safe position is to be on 2.1.x already.
Upgrading early costs nothing -- on current firmware 2.1.x reads the panel
exactly as 2.0.8 does -- and the changeover is then seamless, with entity ids,
unique ids and statistics all surviving. What actually changes in the wire format
is still recorded, in a collapsed section, because it explains the failure rather
than motivating the action.
**The battery sign was documented backwards in both user-facing documents, and
this file had "corrected" it in the wrong direction.** Release 2.0.5 established
discharge-positive (#184); a later entry claimed the sensor had always been
charge-positive and the README was edited to match, so both told the reader to
expect the wrong sign. The code never moved: `bess_meter_power` returns
`battery.power_w` and `battery_power` negates `power_flow_battery`, and a
measured panel -- battery in self-consumption, grid at exactly zero, PV 4181 W
plus battery 1917 W meeting a 6099 W load -- reads `+1917.49` on both while
discharging. No entity changed and no reading moved; only the documentation was
ever wrong, in both directions.
**The changelog is user-facing only now.** `[Unreleased]` and `[2.1.0]` described
the same unreleased release and are one section. Nine bullets that documented how
something is built rather than what it does are gone: proxy reporting in
diagnostics, the announcement record's restart behaviour, `info`/`connection`
handling, adopted-identity freezing, notice counting, adoption diagnostics, the
write-authorisation argument, and the device-registry link form.
**README sensor documentation caught up with the code.** PV metadata moved to the
Solar sub-device and loses the panel's area, which nothing warned about; Grid
Power gained `at_service_entrance` and the reason it needs one; the five
unreliable sensors are marked off-by-default with the conformance note behind it;
adopted devices are documented for the first time.
---
CHANGELOG.md | 53 +++++++++++++-----------------
README.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 108 insertions(+), 38 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f951d373..862b05e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,19 @@ All notable changes to this project will be documented in this file.
## [2.1.0b2] - 8/2026
-Support for the eBus v1.0 (parent/child) data model your panel moves to on firmware r202633 and later, and a clean transition when it does.
+### You will need this release when SPAN updates your panel
+
+SPAN firmware `r202633` replaces the way the panel publishes its data — the flat model every release up to 2.0.8 reads is retired in the same update that
+introduces the new one. There is no overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports
+every circuit as missing, and shows nothing useful.
+
+We do not control when that update reaches you, and nobody has published a schedule. Panels update on SPAN's timing, not on yours or ours. So the safe order is
+to be on this release **before** your panel changes rather than after, because afterwards you are looking at a blank integration while you work out why.
+
+**The transition itself is seamless, and that is the point of this release.** Install it and it keeps reading your panel exactly as before, on either firmware.
+When your panel does change over, the integration notices on the wire, reloads itself, and carries on — no reconfiguration, no re-pairing, no lost history. Your
+entities keep their entity ids, their unique ids and their statistics across the change. New things appear because the new firmware genuinely publishes more;
+nothing you already had goes away.
### Requires Home Assistant 2026.8.0 or newer
@@ -58,22 +70,12 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
- **Your other panel readings are unaffected.** The upstream lugs, the main panel meter and every circuit are in the correct frame. So is the power-flow group,
which the specification has now been corrected to describe the way the panel has always published it.
-- **Diagnostics now say whether an adopted device is proxied by another device rather than by the panel.** Reported as a yes/no, never as the parent's
- identifier: a device identifier can contain a serial number, and diagnostics deliberately carry no serials.
-
- **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were
mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity
list, which nobody is: an addition that breaks nothing was indistinguishable from no addition at all. The notification names every entity that was added,
splits them by whether they are ready to use or still switched off, and tells you where to turn the switched-off ones on.
- **It is a notification rather than a Repair, because an addition is not a repair.** Nothing is broken and nothing needs fixing. Any new-entity item still
sitting in your Repairs list from a previous version is removed on upgrade.
-- **And it can no longer be missed by a restart landing in the wrong place.** The old notice compared the entity registry before and after startup, which
- answers correctly exactly once — on the next startup the entity is already registered and the comparison is empty. What has been announced is now recorded, so
- the question is whether you have been told rather than whether it happened in the last few seconds. It stays quiet on a first install, and quiet once more on
- the first startup after this change, so upgrading does not announce a release's worth of history.
-- **An adopted device now appears even when it publishes no readings at all.** A device that advertises what it is — its vendor, model and firmware — before
- publishing any measurement resolves entirely to its device card, so it created no entities, and because devices were created as a side effect of entity
- creation it produced nothing whatsoever: no device, no entity, no notification. It is now registered in its own right.
- **A device the panel publishes that this integration has never modelled now appears, instead of appearing nowhere.** SPAN positions the panel as the hub for
whatever plugs into it and the eBus schema is explicitly vendor-extensible, so a device type nobody modelled is an expected arrival — and until now it
@@ -89,24 +91,10 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
`TOTAL_INCREASING`, same unit and same device class — and a wrong one writes corrupt statistics that fixing the panel afterwards does not repair. Enrolling a
property nobody asked for into long-term statistics is also a permanent write to your recorder database. If you want statistics from an adopted reading, wrap
it in a template sensor, a Riemann-sum integration or a utility meter: that is your call, made on an entity you chose to enable.
-- **`info` becomes the device card and `connection` becomes the device link — neither becomes entities.** A panel publishing its own build metadata and its own
- wiring topology should not arrive as a handful of sensors holding version strings and opaque device ids. The split is keyed on the Homie node rather than on
- property names, because the capability catalogs carry no marker for "this value is a device reference" and a hard-coded name list goes stale silently.
-- **An adopted device keeps the identity it was first seen under.** A serial number arriving after the device was adopted is recorded on its card and moves
- nothing, and a device adopted under its serial survives its wire id changing. Either move would read to Home Assistant as a device _replacement_ and would
- take the device's entities and their history with it.
-- **The new-entity notice counts an adopted device rather than listing its entities.** A vendor device declaring a dozen properties would otherwise spend the
- whole notice on itself and teach you that the category is noise — which would cost you the curated additions too.
-- **Diagnostics report which device types and properties were adopted, and never their values.** The same rule the declared-but-unread report follows: a
- diagnostics attachment leaves the house, and the redaction that protects your config entry is key-based and knows nothing about wire property names.
- **A property an adopted device accepts writes to becomes a control, not just a reading.** A declared `boolean` becomes a switch, an `enum` with its option
list becomes a select, and a number with its `min:max:step` becomes a number entity — all disabled and diagnostic like every other adopted entity, so a
control appears only if you go and enable it. A settable property that declares no value domain stays a reading: a select with no options and a number with no
bounds are broken controls, not safe ones.
-- **The write cannot reach a device this integration does model, by construction.** It is authorised by looking the property up in the current snapshot rather
- than by its arguments, and a modelled device produces no adopted record to find. That matters because the curated controls do real work on the way out — the
- islanding assertion translates its value, and the EV charge limit refuses one above what your charger was commissioned for — and a generic write would route
- around both.
- **Your panel stays the authority on the value.** Nothing is translated or clamped on the way out: this integration knows an adopted property's declaration and
nothing else, so inventing a bound would be inventing a fact about your hardware. The control constrains you to what the device declared, and the panel
accepts or refuses.
@@ -161,8 +149,11 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
discharging at, as distinct from the panel's **Battery Power**, which is the enclosure's arbitrated figure. Both have been on the wire since firmware r202633
and nothing read either. Meter Power is enabled by default; **Communication State** — the BESS's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` report on its link
— is a diagnostic and is off by default, since it is only interesting when something is wrong.
-- **Both battery power sensors read positive when the battery is charging.** They come off the wire in opposite sign conventions and are normalised to one, so
- the two sitting side by side on the BESS device can never point opposite ways.
+- **Both battery power sensors read positive when the battery is _discharging_**, and that direction was settled by measurement rather than by reading. With the
+ battery driven into self-consumption and the grid at exactly zero — PV 4181 W plus battery 1917 W meeting a 6099 W load, so the battery can only be supplying
+ — both sensors read `+1917.49`. The two values arrive from the panel in opposite sign conventions and are normalised to this one, so they agree with each
+ other and with the sensors beside them: PV Power is positive while producing, Grid Power Flow positive while importing, Battery Power positive while
+ discharging. Every one is "positive means power flowing toward the house".
- **Communication State is not the same thing as BESS Connected.** The binary sensor is the _panel's_ view of the link, from the enclosure's connection record;
the new sensor is the _battery's_ view of it. A BESS can report its own link lost while the panel still claims it, and now you can see that.
- Both sensors are created only where the BESS publishes the reading behind them — a battery on the older data model, or one whose firmware publishes only one
@@ -203,8 +194,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed
"Possible states: Unknown". The lists are now derived from the translations and checked against them by a test.
- **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model.
-- **Sub-devices link to the panel by registry id**, replacing a form Home Assistant deprecated in 2026.8. No user-visible effect; required for the version bump
- above.
- **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on
the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a
@@ -215,8 +204,10 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
Home Assistant was running published the data, matched every rule for creating the entities, and asked for no reload — so the new entities appeared only the
next time you restarted. This affected the Microgrid Interconnect Device before this release too.
-- **The README described Battery Power's sign backwards** (`+discharge, -charge`). The sensor has always reported charging as positive; only the documentation
- was wrong. No entity changed.
+- **The README described Battery Power's sign backwards, and an earlier entry in this file "corrected" it the wrong way.** The sensor reports **discharging** as
+ positive and always has — that is what release 2.0.5 established (#184) and what a measured panel confirms. A later note claimed the opposite and the README
+ was edited to match it, so both documents told you to expect the wrong sign. Both are now right. **No entity changed and no reading moved**; only the
+ documentation was ever wrong, in both directions.
## [2.0.8] - 5/2026
diff --git a/README.md b/README.md
index 0029b23a..10bf9df7 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,40 @@ This integration communicates with the SPAN Panel over your local network using
infrastructure. eBus uses the [Homie Convention](https://homieiot.github.io/) for MQTT topics and messages, with the panel's built-in MQTT broker delivering
real-time state updates without polling.
+## ⚠️ Upgrade to 2.1.x before your panel's firmware updates, or the integration will stop working
+
+**SPAN firmware `r202633` breaks every integration release up to and including 2.0.8.** When your panel takes that update, 2.0.8 stops being able to read it.
+The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go
+blank. It does not fail loudly. It goes quiet.
+
+**Nobody outside SPAN knows when your panel will update, and you cannot defer it from Home Assistant.** Panels update on SPAN's timing. There is no schedule to
+plan around, which is why the safe move is to be on 2.1.x already rather than to wait for a signal that never comes.
+
+**Upgrading first costs you nothing.** On your current firmware, 2.1.x reads your panel exactly as 2.0.8 does — same entities, same ids, same history. It simply
+also knows how to read the new format when it arrives.
+
+**And then the changeover is seamless.** The integration detects the new format on the wire, reloads itself, and carries on:
+
+- No reconfiguration, no re-pairing, no re-authentication.
+- Entity ids, unique ids and long-term statistics all survive. Dashboards, automations and history follow.
+- New entities appear because the new firmware genuinely publishes more. Nothing you already had is removed.
+
+Upgrade afterwards instead and you reach the same place — after however long it takes you to notice, and to work out that a firmware update you were never told
+about is the reason your panel went silent.
+
+
+What actually changes in the firmware
+
+`r202633` rewrites how the panel publishes everything, rather than adding to it. The MQTT topic structure changes, and the panel stops presenting itself as a
+single flat device with a long list of properties. It presents a tree instead: the enclosure, with its lugs, circuits, battery, chargers and Microgrid
+Interconnect Device as separate devices beneath it. Every topic an integration reads moves. The old format is retired in the same update that introduces the new
+one — there is no overlap and no setting to keep the old behaviour.
+
+This has happened before, in exactly this shape. Firmware `spanos2/r202603/05` removed the v1 REST API this integration was originally built on, which is what
+the 2.0.x breaking-change notice below is about. `r202633` does the same thing to the flat MQTT format that replaced it.
+
+
+
## 1.1.x Integration Sunset (v1)
Users MUST upgrade by the end of 2026 to avoid disruption. Upgrade to the latest 1.1.x version BEFORE upgrading to 2.0.x.
@@ -145,9 +179,20 @@ The following terms appear throughout this document and in the integration's sen
| L2 Voltage | Voltage | V | L2 leg actual voltage |
| Upstream L1 Current | Current | A | Upstream lugs L1 current |
| Upstream L2 Current | Current | A | Upstream lugs L2 current |
-| Downstream L1 Current | Current | A | Downstream lugs L1 current |
-| Downstream L2 Current | Current | A | Downstream lugs L2 current |
-| Main Breaker Rating | Current | A | Main breaker amperage |
+| Downstream L1 Current | Current | A | Downstream lugs L1 current. Off by default from 2.1.x — see below |
+| Downstream L2 Current | Current | A | Downstream lugs L2 current. Off by default from 2.1.x — see below |
+| Main Breaker Rating | Current | A | Main breaker amperage. Off by default |
+
+L1/L2 Voltage and Main Breaker Rating have always been off by default; enable them from the panel's device page if you want them.
+
+**The two Downstream current sensors are off by default from 2.1.x, and so are the three Feedthrough sensors.** The eBus specification's maintainer has
+documented that the panel's feedthrough (downstream lugs) figures cannot be relied on: the energy registers are computed from unrelated counters and can
+decrease or go negative, the power reading is inverted relative to every other terminal, and the downstream currents report the **upstream** service conductors
+rather than a downstream measurement. These defects predate `r202633` and are not introduced by it.
+
+If you already have any of those five they stay exactly where they are, with their history and their entity ids — Home Assistant applies the off-by-default
+setting only when an entity is first created, so this reaches new installations only. If you use them on a dashboard or in an automation they are worth
+removing, but that is your call, not something an upgrade should do to you.
### Shed Forecast Sensors (v1.0 data model only)
@@ -198,16 +243,23 @@ On **Import Limit**, and present only when the panel publishes them. These are t
| ------------- | ------------ | ---- | ------------------------------------------------------------------------------- |
| Grid Power | Power | W | Grid power flow |
| Site Power | Power | W | Total site power (grid + PV + battery) |
-| Battery Power | Power | W | Battery charge/discharge (+charging, -discharging). Only when BESS commissioned |
+| Battery Power | Power | W | Battery charge/discharge (**+discharging, −charging**). Only when BESS commissioned |
| PV Power | Power | W | PV generation (+producing). Only when PV commissioned |
-### PV Metadata Sensors (v2 only, on main panel device)
+### PV Metadata Sensors (v2 only, on the Solar sub-device)
+
+From 2.1.x these live on a **Solar** device of their own rather than on the panel's card, alongside PV Power and PV Panel Link.
| Sensor | Device Class | Unit | Notes |
| ------------------ | ------------ | ---- | --------------------------------------------- |
| PV Vendor | — | — | PV inverter vendor (e.g., "Enphase", "Other") |
| PV Product | — | — | PV inverter product (e.g., "IQ8+") |
-| Nameplate Capacity | Power | kW | Rated inverter capacity |
+| Nameplate Capacity | Power | kW | Rated inverter capacity. Off by default |
+
+If you upgraded, these keep their entity ids, unique ids and history — only the card changes. **They do not keep the panel's area.** An entity takes its area
+from its device, and the new Solar device starts without one, so anything area-scoped (dashboards, automations, voice targeting a room) stops matching them
+until you assign the Solar device to an area. New installations get ids derived from the new device name, so a system installed from 2.1.x onward has
+`sensor.span_panel_solar_pv_vendor` where an upgraded one keeps `sensor.span_panel_pv_vendor`. Both are correct and neither changes again.
**Deprecated:**
@@ -215,6 +267,23 @@ On **Import Limit**, and present only when the panel publishes them. These are t
| -------------- | ------------------------------------------------------------------------------------------------------------------------- |
| DSM Grid State | Deprecated — still available, but users should rely on `DSM State` as `DSM Grid State` may be removed in a future version |
+### Adopted Devices (v1.0 data model only)
+
+The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration
+gives it a card of its own hanging off the panel, carrying whatever identity it publishes, with its readings as entities beneath it.
+
+Everything adopted arrives **disabled and diagnostic**, so nothing reaches a dashboard uninvited, and the new-entity notification names the device so you can
+find it. A property the device accepts writes to becomes a control rather than a reading — a `boolean` becomes a switch, an enumeration becomes a select, a
+number becomes a number entity — and those arrive switched off too.
+
+Two things worth knowing before you build on one:
+
+- **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing
+ wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template
+ sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled.
+- **Devices this integration already models are never adopted.** A new property on a circuit, the battery, a charger or the panel is curated in a release
+ instead, because that is where the judgement lives about whether it should be an entity, an attribute or a line on a device card.
+
### Power Sensor Attributes
Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Power, and Site Power sensors.
@@ -224,6 +293,16 @@ Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Powe
| `voltage` | string | Nominal panel voltage ("240") |
| `amperage` | string | Calculated current (power / voltage) |
+**Grid Power** carries one more, because its name is only true in some wiring:
+
+| Attribute | Type | Notes |
+| ---------------------- | ------- | --------------------------------------------------------------------------- |
+| `at_service_entrance` | boolean | Whether this panel's upstream lugs are where the utility actually connects |
+
+Grid Power reads the upstream lugs. That is grid flow when those lugs are the utility connection point, which is the ordinary case. Put a battery between the
+utility and your main lugs, or feed this panel from another panel, and the same reading becomes **this panel's** supply while **Grid Power Flow** stays the
+whole-site figure — so the two legitimately disagree. When `at_service_entrance` is `false`, use Grid Power Flow for site-level grid import and export.
+
### Software Version Sensor Attributes
| Attribute | Type | Notes |
@@ -309,7 +388,7 @@ device uses manufacturer, model, serial number, and software version from batter
| ------------------- | -------------- | ---- | ------------------------------------------------------------------------------------------------ |
| Battery Level | Battery | % | State of energy as percentage |
| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device |
-| Meter Power | Power | W | The BESS's own meter (+charging, -discharging). v1.0 data model only |
+| Meter Power | Power | W | The BESS's own meter (**+discharging, −charging**), agreeing with Battery Power. v1.0 data model only |
| Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default). v1.0 data model only |
| BESS Vendor | — | — | Battery system vendor (diagnostic) |
| BESS Model | — | — | Battery system model (diagnostic) |
From 218285a682075d9cf0fcdcf5b192680fced76200 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 11:02:24 -0700
Subject: [PATCH 087/116] docs(readme): the entities the reference tables never
caught up with
Cross-checked every `name` in `strings.json` against the reference tables rather
than reading for plausibility. Sixty-one entity names, seven absent, five of
those label mismatches where the README says "BESS Firmware" for an entity
displayed as "Firmware Version". Two were real.
**The Microgrid Interconnect Device had no entity documentation at all** -- only a
Key Terms definition of what a MID is. Its `Grid State` sensor is the one
genuinely new reading on the v1.0 data model, since the previous firmware never
reported the state of the utility supply, and it is easy to confuse with `DSM
Grid State`, which is whether the *home* is islanded. The grid can be down while
the house runs happily off the battery. Now documented together so the
distinction is visible where someone would look for it.
**The circuit select is named "Circuit Priority"**, not "Circuit Shed Priority" as
the table said. A reader searching their entity list for the documented name
found nothing.
Also missing from the tables: EVSE Part Number entirely, and BESS Part Number.
Both are off by default, as are BESS Nameplate Capacity and PV Nameplate
Capacity, which the tables described without saying so -- the difference between
"I do not have this sensor" and "I have not switched it on".
---
README.md | 21 +++++++++++++++++++--
uv.lock | 4 ++--
2 files changed, 21 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 10bf9df7..9051960b 100644
--- a/README.md
+++ b/README.md
@@ -267,6 +267,21 @@ until you assign the Solar device to an area. New installations get ids derived
| -------------- | ------------------------------------------------------------------------------------------------------------------------- |
| DSM Grid State | Deprecated — still available, but users should rely on `DSM State` as `DSM Grid State` may be removed in a future version |
+### Microgrid Interconnect Device (v1.0 data model only)
+
+Panels on the v1.0 data model publish their Microgrid Interconnect Device — the switch that disconnects your home from the utility during an outage — as a
+device of its own, linked to the panel. It appears automatically where the panel reports one; nothing existing moves onto it.
+
+| Sensor | Device Class | Unit | Notes |
+| ---------- | ------------ | ---- | --------------------------------------------------------------------------- |
+| Grid State | Enum | — | Health of the utility supply itself: `up`, `down`, `degraded` or `unknown` |
+
+This is genuinely new information — the previous firmware did not report the state of the utility supply at all. It is not the same thing as **DSM Grid State**,
+which is whether **your home** is islanded. The grid can be down while your home runs happily off the battery.
+
+`DSM Grid State` also becomes more trustworthy on this data model. It keeps its entity id and its history, but where it used to be inferred — from the battery
+when one was fitted, otherwise from the dominant power source — it now reads the islanding state the MID actually senses.
+
### Adopted Devices (v1.0 data model only)
The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration
@@ -344,6 +359,7 @@ feature. A display suffix differentiates multiple chargers on the same panel:
| Charger Status | Enum | — | OCPP-based states: AVAILABLE, PREPARING, CHARGING, SUSPENDED_EV, etc. Translated |
| Advertised Current | Current | A | Amps offered to the vehicle |
| Lock State | Enum | — | LOCKED, UNLOCKED, UNKNOWN. Translated |
+| Part Number | — | — | Charger part number (diagnostic, **off by default**). v1.0 data model only |
#### EVSE Binary Sensors (per charger)
@@ -392,9 +408,10 @@ device uses manufacturer, model, serial number, and software version from batter
| Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default). v1.0 data model only |
| BESS Vendor | — | — | Battery system vendor (diagnostic) |
| BESS Model | — | — | Battery system model (diagnostic) |
+| BESS Part Number | — | — | Battery system part number (diagnostic, **off by default**). v1.0 data model only |
| BESS Serial Number | — | — | Battery system serial number (diagnostic) |
| BESS Firmware | — | — | Battery system firmware (diagnostic) |
-| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic) |
+| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic, **off by default**) |
| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) |
#### BESS Binary Sensors
@@ -466,7 +483,7 @@ Applies to Main Meter and Feed Through energy sensors.
| Entity | Type | Notes |
| --------------------- | ------ | -------------------------------------------------------------------------- |
| Breaker | Switch | On/off relay control |
-| Circuit Shed Priority | Select | (v2) Controls when circuit is shed during off-grid (translated, see below) |
+| Circuit Priority | Select | (v2) Controls when the circuit is shed during off-grid (translated, see below) |
### Panel Controls
diff --git a/uv.lock b/uv.lock
index faa716bd..d6d4dc0b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap
[[package]]
name = "span-panel-api-schema-1"
-version = "0.1.0b6"
+version = "0.1.0b7"
source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
@@ -2610,7 +2610,7 @@ dependencies = [
[package.metadata]
requires-dist = [
- { name = "ebus-sdk", specifier = ">=0.19.0,<0.23" },
+ { name = "ebus-sdk", specifier = ">=0.19.0,<0.24" },
{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
]
From de1a3059504a43a2fdc35528dc8edce70151750a Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 12:02:41 -0700
Subject: [PATCH 088/116] chore: 2.1.0b3, on the library release that fixes the
upgrade reload
`v2.1.0.b2` was cut before the diagnostics and documentation work, so b3 carries
four commits it does not have plus the library fix that matters most.
Pins move to `span-panel-api==3.0.0b8` and `span-panel-api-schema-1==0.1.0b7`;
schema-0 is unchanged at `1.0.0b5` because nothing in it moved. b8 is the release
where a panel answering 502 during its reboot is understood as "not ready yet"
rather than as a hard failure -- the path that cost a live upgrade its automatic
reload on two Home Assistant instances, and the one thing this release exists to
promise.
The pin propagation was done by `scripts/sync-dependencies.py` rather than by
hand, which is the first real use since it was repaired. It found and rewrote
both copies and failed the commit so they would be re-staged, which is what it
was always supposed to do and never once did.
---
CHANGELOG.md | 28 ++++++++++++++++++++++
custom_components/span_panel/manifest.json | 6 ++---
pyproject.toml | 6 ++---
requirements_test.txt | 4 ++--
uv.lock | 4 ++--
5 files changed, 38 insertions(+), 10 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 862b05e7..e23006a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,34 @@
All notable changes to this project will be documented in this file.
+## [2.1.0b3] - 8/2026
+
+### Fixed
+
+- **The integration now reloads itself after the firmware upgrade in the case that actually happens.** This is the whole promise of the release, and a live
+ upgrade found the one path where it did not hold. When a panel takes the new firmware it drops its MQTT connection, comes back a few minutes later, and
+ starts serving HTTP a little after that — and while it is still starting, it answers with `502` rather than refusing the connection, because a booting device
+ brings its network front end up before the application behind it. The integration retried a refused connection and a timeout, but treated an answered-with-502
+ as a hard failure and gave up on the first try, leaving the old reader in place. It was caught on two Home Assistant instances watching one panel through the
+ same upgrade: both went quiet and neither recovered until reloaded by hand. A `502` is now understood as **not ready yet** and waited out, for as long as a real
+ reboot takes.
+- **If following the upgrade ever fails for some other reason, you are now told what to do about it.** Previously that surfaced as a bare error in the log with
+ no indication that anything needed doing, while the integration carried on reading the panel with the wrong reader. It now says plainly that a reload is
+ needed once the panel is back up.
+
+### Added
+
+- **Diagnostics include your entity registry.** Every entity this integration owns, with its unique id and — the part you cannot get anywhere else — what
+ disabled it, if anything. Home Assistant tells you an entity is disabled without telling you by what, and reading that yourself needs shell access that a
+ Home Assistant OS install does not give you.
+
+### Changed
+
+- **The README now leads with the upgrade warning**, and documents the Microgrid Interconnect Device, adopted devices, the `at_service_entrance` attribute on
+ Grid Power, and which sensors arrive switched off.
+- **The battery power sign was documented backwards in both the README and this file, and is corrected.** Positive means **discharging**, which is what release
+ 2.0.5 established and what a measured panel confirms. No entity changed and no reading moved; only the documentation was wrong.
+
## [2.1.0b2] - 8/2026
### You will need this release when SPAN updates your panel
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index c5902420..23d4c60e 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,11 +22,11 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b7",
+ "span-panel-api==3.0.0b8",
"span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b6"
+ "span-panel-api-schema-1==0.1.0b7"
],
- "version": "2.1.0b2",
+ "version": "2.1.0b3",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/pyproject.toml b/pyproject.toml
index 3e682d96..f8aad226 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "span"
-version = "2.1.0b2"
+version = "2.1.0b3"
description = "Span Panel Custom Integration for Home Assistant"
authors = [{name = "SpanPanel"}]
license = {text = "MIT"}
@@ -8,13 +8,13 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b7",
+ "span-panel-api==3.0.0b8",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
# and that is the list Home Assistant actually reads.
"span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b6",
+ "span-panel-api-schema-1==0.1.0b7",
]
[dependency-groups]
diff --git a/requirements_test.txt b/requirements_test.txt
index c47eaa23..a3abaaa0 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0b7
+span-panel-api==3.0.0b8
span-panel-api-schema-0==1.0.0b5
-span-panel-api-schema-1==0.1.0b6
+span-panel-api-schema-1==0.1.0b7
diff --git a/uv.lock b/uv.lock
index d6d4dc0b..b6ef41dd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2491,7 +2491,7 @@ wheels = [
[[package]]
name = "span"
-version = "2.1.0b2"
+version = "2.1.0b3"
source = { virtual = "." }
dependencies = [
{ name = "homeassistant" },
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b7"
+version = "3.0.0b8"
source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
From cc8e2844f9e1f863d6622745378807b93342ac94 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 12:13:42 -0700
Subject: [PATCH 089/116] chore: pin span-panel-api 3.0.0b9
b8's 502 handling shipped without its retry window: the exception was caught and
the loop still gave up after about twenty-three seconds, against a panel that
took four minutes to come back. b9 carries the widening -- twelve attempts
backing off to thirty seconds -- and a test that pins the total against the
observed reboot.
Still 2.1.0b3; the integration itself has not changed, only what it installs.
---
custom_components/span_panel/manifest.json | 2 +-
pyproject.toml | 2 +-
requirements_test.txt | 2 +-
uv.lock | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 23d4c60e..c3694efa 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,7 +22,7 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b8",
+ "span-panel-api==3.0.0b9",
"span-panel-api-schema-0==1.0.0b5",
"span-panel-api-schema-1==0.1.0b7"
],
diff --git a/pyproject.toml b/pyproject.toml
index f8aad226..c0b0b1d7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,7 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b8",
+ "span-panel-api==3.0.0b9",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
diff --git a/requirements_test.txt b/requirements_test.txt
index a3abaaa0..43d3b88c 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0b8
+span-panel-api==3.0.0b9
span-panel-api-schema-0==1.0.0b5
span-panel-api-schema-1==0.1.0b7
diff --git a/uv.lock b/uv.lock
index b6ef41dd..8e577d5d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b8"
+version = "3.0.0b9"
source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
From d15f36792e3f2e723bc5724eddb754e6a45ea2f3 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 12:49:27 -0700
Subject: [PATCH 090/116] chore: 2.1.0b4 on span-panel-api 3.0.0b10
b10 stops the wait for a rebooting panel from giving up. The bound was wrong
twice for the same reason -- sized against a reboot somebody had measured, and
the next reboot was not that reboot -- and exhausting it meant stranded rather
than "try again later", because nothing starts the attempt again. It also
recognises four more mid-boot answers as "still starting" rather than as failure:
a connection reset mid-request, a gateway closing without replying, and a reply
that arrives truncated.
This release also catches that condition at setup. A panel that is not ready when
Home Assistant starts now produces a retrying `ConfigEntryNotReady` instead of a
dead entry needing a human -- which matters because the two events correlate: one
power interruption takes out both the house's electrical panel and the machine
watching it, and they race each other back up.
Pins propagated by `scripts/sync-dependencies.py`, third consecutive release it
has done correctly.
---
CHANGELOG.md | 18 ++++++++++++
custom_components/span_panel/__init__.py | 17 +++++++++--
custom_components/span_panel/manifest.json | 4 +--
pyproject.toml | 4 +--
requirements_test.txt | 2 +-
tests/test_setup_entry.py | 34 ++++++++++++++++++++++
uv.lock | 4 +--
7 files changed, 74 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e23006a9..e94a9f07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file.
+## [2.1.0b4] - 8/2026
+
+### Fixed
+
+- **A panel that is still booting no longer needs a manual reload, however long it takes.** b3 taught the integration to keep waiting when a rebooting panel
+ answers with an error instead of refusing outright, but it still gave up after a few minutes — and giving up was worse than it sounds, because nothing starts
+ it again. A panel that finished booting after that point stayed unread until you reloaded by hand. It now waits for as long as your panel takes, checking
+ every thirty seconds once it settles, so a slow reboot is just a slow reboot.
+- **Your energy history is unaffected by that wait.** Energy sensors already hold their last reading through an outage for the grace period you configure
+ (fifteen minutes by default), which is what stops a gap becoming an `unknown` and a spike in your statistics. Waiting longer for the panel does not change
+ that.
+- **Several other ways a booting panel answers are now recognised as "still starting" rather than as a fault** — a connection reset mid-request, a gateway that
+ closes without replying, and a reply that arrives truncated. Each of those previously stopped the integration following the upgrade, the same way the original
+ error did.
+- **A panel that is not ready when Home Assistant starts now retries instead of failing setup.** Previously that left the integration in an error state needing
+ a human, which matters because the two events correlate: one power interruption takes out both your panel and the machine watching it, and they come back at
+ their own pace.
+
## [2.1.0b3] - 8/2026
### Fixed
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index 3944c531..dedf71f2 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -23,6 +23,7 @@
from span_panel_api.exceptions import (
SpanPanelAuthError,
SpanPanelConnectionError,
+ SpanPanelServerError,
SpanPanelTimeoutError,
)
from span_panel_api.mqtt.models import MqttClientConfig
@@ -222,9 +223,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
except SpanPanelAuthError as err:
await client.close()
raise ConfigEntryAuthFailed(f"MQTT authentication failed: {err}") from err
- except (SpanPanelConnectionError, SpanPanelTimeoutError) as err:
+ except (
+ SpanPanelConnectionError,
+ SpanPanelTimeoutError,
+ # A panel part-way through a reboot answers rather than refusing:
+ # 5xx from the front end while the application behind it starts,
+ # or a 200 with nothing usable in it. That is not a broken
+ # install, it is a panel that is not up yet, and the two arrive
+ # together more often than they look -- one power event takes out
+ # the house's panel and the Home Assistant host that watches it,
+ # and they race each other back. Uncaught it produced a dead entry
+ # needing a human, for a condition that clears itself in minutes.
+ SpanPanelServerError,
+ ) as err:
await client.close()
- raise ConfigEntryNotReady(f"Failed to connect to SPAN panel: {err}") from err
+ raise ConfigEntryNotReady(f"SPAN panel is not ready yet: {err}") from err
coordinator = SpanPanelCoordinator(hass, client, entry)
await coordinator.async_config_entry_first_refresh()
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index c3694efa..dded056b 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,11 +22,11 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b9",
+ "span-panel-api==3.0.0b10",
"span-panel-api-schema-0==1.0.0b5",
"span-panel-api-schema-1==0.1.0b7"
],
- "version": "2.1.0b3",
+ "version": "2.1.0b4",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/pyproject.toml b/pyproject.toml
index c0b0b1d7..e89c752f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "span"
-version = "2.1.0b3"
+version = "2.1.0b4"
description = "Span Panel Custom Integration for Home Assistant"
authors = [{name = "SpanPanel"}]
license = {text = "MIT"}
@@ -8,7 +8,7 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b9",
+ "span-panel-api==3.0.0b10",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
diff --git a/requirements_test.txt b/requirements_test.txt
index 43d3b88c..ab9cbb6f 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0b9
+span-panel-api==3.0.0b10
span-panel-api-schema-0==1.0.0b5
span-panel-api-schema-1==0.1.0b7
diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py
index c49707e3..86c11752 100644
--- a/tests/test_setup_entry.py
+++ b/tests/test_setup_entry.py
@@ -11,6 +11,7 @@
)
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
+from span_panel_api.exceptions import SpanPanelServerError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.httpx_client import get_async_client
import pytest
@@ -146,6 +147,39 @@ async def test_the_panel_client_is_given_home_assistants_shared_http_client(
assert mock_client_cls.call_args.kwargs["httpx_client"] is get_async_client(hass)
+async def test_a_panel_that_is_not_ready_yet_retries_rather_than_dying(
+ hass: HomeAssistant,
+) -> None:
+ """A rebooting panel answers rather than refusing, and that is not a broken install.
+
+ 5xx from its front end while the application behind it starts, or a 200 with
+ nothing usable in it, both arrive as `SpanPanelServerError`. Uncaught they
+ produced SETUP_ERROR with a traceback and no automatic retry — a human needed,
+ for a condition that clears itself in minutes.
+
+ The two conditions correlate more than they look: one power event takes out
+ the house's electrical panel and the Home Assistant host watching it, and they
+ race each other back up. `ConfigEntryNotReady` is what makes the race
+ survivable.
+ """
+ entry = _create_v2_entry()
+ entry.add_to_hass(hass)
+ client = MagicMock()
+ client.connect = AsyncMock(
+ side_effect=SpanPanelServerError("Panel not ready: HTTP 502", 502)
+ )
+ client.close = AsyncMock()
+
+ with (
+ patch("custom_components.span_panel.async_register_commands"),
+ patch("custom_components.span_panel.SpanMqttClient", return_value=client),
+ pytest.raises(ConfigEntryNotReady),
+ ):
+ await async_setup_entry(hass, entry)
+
+ client.close.assert_awaited_once()
+
+
async def test_async_setup_entry_v2_missing_mqtt_credentials_raises_auth_failed(
hass: HomeAssistant,
) -> None:
diff --git a/uv.lock b/uv.lock
index 8e577d5d..a61ad09d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2491,7 +2491,7 @@ wheels = [
[[package]]
name = "span"
-version = "2.1.0b3"
+version = "2.1.0b4"
source = { virtual = "." }
dependencies = [
{ name = "homeassistant" },
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b9"
+version = "3.0.0b10"
source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
From 9b5c1a67b2a823fd3192238329774de4dda99b5e Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 14:10:47 -0700
Subject: [PATCH 091/116] fix(binary_sensor): create Grid Islandable when the
answer is knowable
Observed on a live upgrade: the entity read `Off` on flat and went `Unavailable`
the moment the panel took r202633, on an install with no battery. Nothing about
the site changed.
This gate has now been narrowed twice for the same symptom. Gating on the flat
property alone left the entity unavailable after a v1.0 upgrade -- the panel had
not lost the capability, only the property that used to report it -- and its own
comment says so. Adding `has_mid` rescued panels that have a battery and left
every battery-less one exactly where it was, because it admitted the entity only
when the answer was going to be `True`.
For a boolean whose `False` is informative that is backwards. No MID is not
missing information, it is the information: `devices/bess.md` makes the signal
structural -- "a MID `grid` child means premises-segment backup ... neither means
no backup", and "there is no single 'islanded?' bit to reconcile" -- so a panel
without one does not island, and saying so is the answer rather than the absence
of one. `_grid_islandable` has always computed exactly that; the gate refused to
create the entity precisely when it would have said `False`.
Flat is untouched, which is the constraint that matters until the fleet moves. A
flat panel publishes the property, so the value comes from the panel as it always
has and MID presence is never consulted. A flat panel that stops publishing it
has a metadata row that fails to resolve, which is what makes the entity
unavailable and raises the Repair -- that path is deliberately left alone,
because it is the one that must not become a default presented as a reading.
Two wrong diagnoses on the way here, both discarded after reading rather than
shipped: the availability probe, which skips a field path with no metadata row at
all, and the `SCHEMA_CONDITIONAL_FIELD` labelling on three descriptions, which is
harmless for the same reason. It was the creation gate.
Marked `DUAL-SCHEMA` so the retirement is mechanical: nothing here is removed
when flat goes, since an unconditional append is already the end state -- the
branch that goes is the flat property read inside `_grid_islandable`.
Both mutations verified: withholding the v1.0 answer fails, and ignoring the flat
property fails.
---
custom_components/span_panel/binary_sensor.py | 31 ++++++++++++---
tests/test_binary_sensor_platform.py | 39 +++++++++++++++++++
uv.lock | 4 +-
3 files changed, 66 insertions(+), 8 deletions(-)
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index f25ce6db..78c16461 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -582,13 +582,32 @@ async def async_setup_entry(
for description in BINARY_SENSORS
]
- # Add grid islandable binary sensor when v2 data is available
snapshot: SpanPanelSnapshot = coordinator.data
- # Created on either generation. Gating on the flat property alone is what left this
- # entity `unavailable` after a v1.0 upgrade -- the panel had not lost the
- # capability, only the property that used to report it.
- if snapshot.grid_islandable is not None or has_mid(snapshot):
- entities.append(SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR))
+
+ # Created unconditionally, because on both generations the answer is knowable.
+ #
+ # This gate has been narrowed twice for the same symptom. Gating on the flat
+ # property alone left the entity `unavailable` after a v1.0 upgrade -- the
+ # panel had not lost the capability, only the property that used to report
+ # it. Adding `has_mid` rescued panels that have a battery and left every
+ # battery-less one exactly where it was, because it admitted the entity only
+ # when the answer was going to be `True`. For a boolean whose `False` is
+ # informative that is backwards: no MID is not missing information, it is the
+ # information. `devices/bess.md` makes the signal structural -- "a MID `grid`
+ # child means premises-segment backup ... neither means no backup" -- so a
+ # panel without one does not island, and saying so is the answer rather than
+ # the absence of one.
+ #
+ # Nothing about flat changes. Its property is published, so `_grid_islandable`
+ # returns it exactly as before; and a flat panel that stops publishing it has
+ # a metadata row that fails to resolve, which is what makes the entity
+ # unavailable and raises the Repair. That path is untouched, and it is the one
+ # that must not become a default presented as a reading.
+ #
+ # DUAL-SCHEMA: nothing to remove here when the flat path retires -- an
+ # unconditional append is already the end state. The branch that goes is the
+ # first one in `_grid_islandable`, which is where the flat property is read.
+ entities.append(SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR))
# Add BESS connected sensor on the BESS sub-device when battery is commissioned
if has_bess(snapshot):
diff --git a/tests/test_binary_sensor_platform.py b/tests/test_binary_sensor_platform.py
index 297160ef..60d57e97 100644
--- a/tests/test_binary_sensor_platform.py
+++ b/tests/test_binary_sensor_platform.py
@@ -102,6 +102,45 @@ def test_evse_binary_sensor_reports_unknown_when_panel_offline() -> None:
assert entity.is_on is None
+def test_grid_islandable_is_created_on_a_v1_panel_with_no_battery() -> None:
+ """No MID is the answer, not the absence of one.
+
+ Observed on a live upgrade: this entity read `Off` on flat and went
+ `Unavailable` the moment the panel took v1.0, on an install with no battery.
+ Nothing about the site changed — v1.0 simply retired the property, and the
+ gate admitted the entity only when the answer was going to be `True`.
+
+ `devices/bess.md` makes the signal structural: "a MID `grid` child means
+ premises-segment backup ... neither means no backup". So a panel without one
+ does not island, and `False` is a reading rather than a default.
+ """
+ snapshot = SpanPanelSnapshotFactory.create(grid_islandable=None, mid=None)
+ coordinator = _make_coordinator(snapshot)
+ entity = SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR)
+ entity.async_write_ha_state = MagicMock()
+
+ entity._handle_coordinator_update()
+
+ assert entity.is_on is False, "no MID means it cannot island, which is an answer"
+ assert entity.available is True, "and the entity must exist to give it"
+
+
+def test_grid_islandable_reads_the_flat_property_when_the_panel_publishes_one() -> None:
+ """Flat is unchanged, which is the constraint that matters until the fleet moves.
+
+ A flat panel publishes `core/grid-islandable`, so the value comes from the
+ panel exactly as it always has, and MID presence is never consulted.
+ """
+ snapshot = SpanPanelSnapshotFactory.create(grid_islandable=True, mid=None)
+ coordinator = _make_coordinator(snapshot)
+ entity = SpanPanelBinarySensor(coordinator, GRID_ISLANDABLE_SENSOR)
+ entity.async_write_ha_state = MagicMock()
+
+ entity._handle_coordinator_update()
+
+ assert entity.is_on is True, "the published value wins over the structural fallback"
+
+
def test_grid_islandable_sensor_uses_online_status_value() -> None:
"""Non-status binary sensors should mirror their live boolean value when online."""
snapshot = SpanPanelSnapshotFactory.create(grid_islandable=False)
diff --git a/uv.lock b/uv.lock
index a61ad09d..6389a52f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b10"
+version = "3.0.0b11"
source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
@@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap
[[package]]
name = "span-panel-api-schema-1"
-version = "0.1.0b7"
+version = "0.1.0b8"
source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
From 0442c04a56c653abbbd2cd5cf7f9b82ea2b3743a Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 15:25:12 -0700
Subject: [PATCH 092/116] chore: 2.1.0b5 on 3.0.0b11, and document the two
blank entities
Pins move to `span-panel-api==3.0.0b11` and `span-panel-api-schema-1==0.1.0b8`,
which carry the `dominant_power_source` fix; schema-0 stays at `1.0.0b5` because
nothing in it moved.
README gains the battery-less case in both places a reader would look: the Grid
Forming Entity deep dive now states that GFE is Grid on a panel with no battery
on both firmware generations, with the elimination that makes it so, and the MID
section says that having no MID is itself the answer for Grid Islandable rather
than a missing reading. The Grid Islandable table row no longer says "only when
reported", which was true of the gate and not of the entity.
The delta document records both, and the pattern behind them: v1.0 expresses by
*structure* what flat expressed by *property*, and code written against the
property reads the structural form as ignorance. Three instances in two days --
`grid_islandable`, `dominant_power_source`, and the PV entities' area -- so the
question to ask of every remaining `SCHEMA_CONDITIONAL_FIELD` is not "does this
field still resolve" but "is there an answer the absence itself supplies".
Both wrong diagnoses are recorded too, since the second would have quietly
changed flat's degraded behaviour had it shipped.
---
CHANGELOG.md | 18 ++++++++++++++++++
README.md | 12 +++++++++++-
custom_components/span_panel/manifest.json | 6 +++---
pyproject.toml | 6 +++---
requirements_test.txt | 4 ++--
uv.lock | 2 +-
6 files changed, 38 insertions(+), 10 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e94a9f07..ea5f7f73 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file.
+## [2.1.0b5] - 8/2026
+
+### Fixed
+
+Two diagnostic entities went blank on a battery-less panel after the firmware upgrade, both because v1.0 retired the property they were reading and the
+integration treated the absence as ignorance rather than as an answer. Nothing about either site changed; the panel simply stopped publishing a value it now
+expresses another way.
+
+- **Grid Forming Entity reads `Grid` again instead of `Unknown`.** Flat published a source class outright; v1.0 names the forming **device**, and it names it on
+ the Microgrid Interconnect Device — which a panel without a battery does not have. But the answer is settled by what cannot be there: a battery needs a MID, a
+ solar inverter cannot form a grid on its own, and a panel supplying nothing is not publishing. What remains is a generator, and SPAN with no generator
+ interface treats one as the grid. So on a panel with no MID the grid is what is forming, which is exactly what flat had been reporting all along.
+- **Grid Islandable reads `Off` again instead of going unavailable.** v1.0 publishes no islandable property at all — the specification makes it structural
+ instead, where the presence of a MID is what says a panel can island. Having no MID is therefore the answer rather than the absence of one, and the entity was
+ being withheld precisely when it would have said "no".
+- **Neither changes anything on a panel still running the older firmware.** Both keep reading the published property exactly as before, and both still go
+ unavailable if a panel that should publish one stops — which is a real fault worth surfacing, as distinct from a property the newer firmware never had.
+
## [2.1.0b4] - 8/2026
### Fixed
diff --git a/README.md b/README.md
index 9051960b..92887ba5 100644
--- a/README.md
+++ b/README.md
@@ -279,6 +279,10 @@ device of its own, linked to the panel. It appears automatically where the panel
This is genuinely new information — the previous firmware did not report the state of the utility supply at all. It is not the same thing as **DSM Grid State**,
which is whether **your home** is islanded. The grid can be down while your home runs happily off the battery.
+**A panel with no battery has no MID, and that is itself an answer.** The specification makes backup capability structural rather than a property — the presence
+of a MID is what says a panel can island — so **Grid Islandable** reads `Off` on such a panel rather than going unavailable, and **Grid Forming Entity** reads
+`Grid`. Neither is a default standing in for a missing reading; both are what the absence of islanding hardware means.
+
`DSM Grid State` also becomes more trustworthy on this data model. It keeps its entity id and its history, but where it used to be inferred — from the battery
when one was fitted, otherwise from the dominant power source — it now reads the islanding state the MID actually senses.
@@ -468,7 +472,7 @@ Applies to Main Meter and Feed Through energy sensors.
| Ethernet Link | Connectivity | Wired network status |
| Wi-Fi Link | Connectivity | Wireless network status |
| Panel Status | Connectivity | Overall panel online/offline |
-| Grid Islandable | — | (v2) Whether the panel can island from the grid. Only when reported |
+| Grid Islandable | — | (v2) Whether the panel can island from the grid. Off on a panel with no MID — see below |
| PCS Active | Running | (v1.0) Whether the Power Control System is limiting import right now. Only when the panel runs one |
| PV Panel Link | Connectivity | (v1.0) Whether the panel can reach the solar inverter. Only when the feeding circuit reports it |
@@ -515,6 +519,12 @@ When a BESS is installed, the panel relies on the BESS to determine whether the
while the panel is islanded, the GFE value becomes stale — it may show Battery when the grid has actually been restored, causing unnecessary shedding to
continue.
+**On a panel with no battery, GFE is Grid, on both firmware generations.** The newer firmware moves this value onto the Microgrid Interconnect Device, which is
+part of a battery system — so a panel without one has nothing publishing it. The answer is still settled, by what cannot be there: Battery needs a BESS (which
+brings a MID), PV cannot form a grid on its own (anything that can is a grid-forming inverter, which is a MID), and None describes a panel supplying nothing,
+which is a panel that is not reporting at all. What remains is a generator, and SPAN with no generator interface treats one as the grid. So Grid is what a
+battery-less panel reports, which is what the older firmware reported too.
+
#### What the Panel Can Detect
**Grid loss** — The panel independently detects grid loss via its own voltage monitoring, even if BESS communication is already lost. The MID is still closed at
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index dded056b..0b528d96 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,11 +22,11 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b10",
+ "span-panel-api==3.0.0b11",
"span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b7"
+ "span-panel-api-schema-1==0.1.0b8"
],
- "version": "2.1.0b4",
+ "version": "2.1.0b5",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/pyproject.toml b/pyproject.toml
index e89c752f..dbf1c442 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "span"
-version = "2.1.0b4"
+version = "2.1.0b5"
description = "Span Panel Custom Integration for Home Assistant"
authors = [{name = "SpanPanel"}]
license = {text = "MIT"}
@@ -8,13 +8,13 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b10",
+ "span-panel-api==3.0.0b11",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
# and that is the list Home Assistant actually reads.
"span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b7",
+ "span-panel-api-schema-1==0.1.0b8",
]
[dependency-groups]
diff --git a/requirements_test.txt b/requirements_test.txt
index ab9cbb6f..7ea9a044 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0b10
+span-panel-api==3.0.0b11
span-panel-api-schema-0==1.0.0b5
-span-panel-api-schema-1==0.1.0b7
+span-panel-api-schema-1==0.1.0b8
diff --git a/uv.lock b/uv.lock
index 6389a52f..3ea09101 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2491,7 +2491,7 @@ wheels = [
[[package]]
name = "span"
-version = "2.1.0b4"
+version = "2.1.0b5"
source = { virtual = "." }
dependencies = [
{ name = "homeassistant" },
From 08c9142652574171cf743108cb4cba2095f90976 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 15:36:46 -0700
Subject: [PATCH 093/116] refactor(sensor): file Import Limit as a diagnostic,
with its siblings
It was the only panel-level ampere value on the primary card, and the only
member of its own PCS group outside the Diagnostic section -- both of its
siblings were already there.
This reverses a deliberate decision, which had a test and a rationale: "a PCS
throttling import is a fact about the user's electricity supply, not about the
integration's health, so it is not filed as a diagnostic". That reads
`DIAGNOSTIC` as being about the integration. Home Assistant's category is about
the *device* -- "a configuration parameter or diagnostics of a device" -- and by
the earlier standard `l1_voltage` and `main_breaker_rating` are facts about the
user's supply too, and both are diagnostic.
The line this codebase actually draws is not "amps are diagnostic", which would
misfile two entities that are correctly primary: `circuit_current` and
`evse_advertised_current` are amps and are what a circuit or a charger is doing
right now. It is that the panel's own voltages, lug currents, breaker ratings and
limits describe the installation rather than its activity. Import Limit is one of
those -- a ceiling the panel arbitrated, carrying the four limits it was
arbitrated from as attributes.
Presentational only. The entity id, its history and its long-term statistics are
untouched, `state_class` stays, and automations and templates are unaffected;
the category groups it on the device page and keeps it out of auto-generated
dashboards.
Done now because the entity is days old and has appeared in exactly one release's
addition notice, so no installation has a dashboard or history depending on where
it sits. Later would cost something; today costs nothing.
---
CHANGELOG.md | 8 +++++
README.md | 2 +-
.../span_panel/sensor_definitions.py | 18 +++++++++++
tests/test_pcs.py | 32 ++++++++++++++++---
4 files changed, 55 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea5f7f73..bce3906b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
## [2.1.0b5] - 8/2026
+### Changed
+
+- **Import Limit moves to the Diagnostic section of the device page.** It sits with the panel's other electrical characteristics — voltages, lug currents,
+ breaker ratings — rather than beside the power readings, which is where the rest of the integration files a limit the panel arbitrated as opposed to a
+ measurement of what is flowing. Both of its Power Control System siblings were already there. Nothing else changes: the entity id, its history and its
+ long-term statistics are untouched, and automations and templates referring to it are unaffected. Moved now because the entity is days old and has appeared in
+ one release, so no dashboard depends on where it sits.
+
### Fixed
Two diagnostic entities went blank on a battery-less panel after the firmware upgrade, both because v1.0 retired the property they were reading and the
diff --git a/README.md b/README.md
index 92887ba5..19db6460 100644
--- a/README.md
+++ b/README.md
@@ -220,7 +220,7 @@ state, not an absence.
| Sensor | Device Class | Unit | Notes |
| ------------------ | ------------ | ---- | -------------------------------------------------------------------------------------------------------------------------- |
-| Import Limit | Current | A | The limit actually being enforced: the most restrictive of every active constraint |
+| Import Limit | Current | A | The limit actually being enforced: the most restrictive active constraint (diagnostic) |
| Binding Constraint | Enum | — | Which constraint sets that limit: Firm Service Rating, Grid Envelope, Voltage Support, Off-Grid, Requested, Operator, None |
#### Power Control System Sensor Attributes
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 56e1c8f0..4de27762 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -609,6 +609,24 @@ class SpanPcsSensorEntityDescription(SensorEntityDescription, SpanPcsRequiredKey
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ # Diagnostic, which is where this integration files a panel-level
+ # electrical characteristic. The line is not "amps are diagnostic" --
+ # `circuit_current` and `evse_advertised_current` are amps and are
+ # correctly primary, because they are what a circuit or a charger is doing
+ # right now. It is that the panel's own voltages, lug currents, breaker
+ # ratings and limits describe the installation rather than its activity,
+ # and all of those are diagnostic. `main_breaker_rating` is the closest
+ # analogue -- also an ampere ceiling on the panel -- and so are both of
+ # this sensor's own PCS siblings, which left this the only member of its
+ # group on the primary card.
+ #
+ # It is a ceiling the panel arbitrated, not a measurement of what is
+ # flowing, and it carries the four constraint limits it was arbitrated
+ # from as attributes -- which is what a diagnostic looks like. The
+ # category costs nothing operationally: it groups the entity on the device
+ # page and keeps it out of auto-generated dashboards, and automations,
+ # templates and long-term statistics are unaffected.
+ entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
value_fn=lambda p: p.import_limit_a,
attributes_fn=pcs_arbitration_attributes,
diff --git a/tests/test_pcs.py b/tests/test_pcs.py
index 53755d61..d4500660 100644
--- a/tests/test_pcs.py
+++ b/tests/test_pcs.py
@@ -733,16 +733,40 @@ def test_a_flat_circuit_shows_neither_attribute() -> None:
# ---------------------------------------------------------------------------
-def test_the_import_limit_is_an_ampere_measurement_enabled_by_default() -> None:
- """A PCS throttling import is a fact about the user's electricity supply,
- not about the integration's health, so it is not filed as a diagnostic."""
+def test_the_import_limit_is_an_ampere_measurement_filed_as_a_diagnostic() -> None:
+ """Filed where every other ampere value in this integration is filed.
+
+ This reverses an earlier decision, which read `DIAGNOSTIC` as "about the
+ integration's health" and reasoned that a PCS throttling import is a fact
+ about the user's electricity supply instead. Home Assistant's category is
+ about the *device* — "a configuration parameter or diagnostics of a device" —
+ and by that earlier standard `l1_voltage` and `main_breaker_rating` are facts
+ about the user's supply too, and both are diagnostic.
+
+ The line this codebase actually draws is not "amps are diagnostic":
+ `circuit_current` and `evse_advertised_current` are amps and are correctly
+ primary, because they are what a circuit or a charger is doing right now. It
+ is that the panel's own voltages, lug currents, breaker ratings and limits
+ describe the installation rather than its activity. Import Limit is one of
+ those, and was the only entity outside the rule — and the only member of its
+ own PCS group on the primary card.
+
+ It is a ceiling the panel arbitrated rather than a measurement of what is
+ flowing, and it carries the four limits it was arbitrated from as attributes.
+ Changed while the entity was days old and had appeared in exactly one
+ release's addition notice, so no installation has a dashboard or history
+ depending on where it sits.
+
+ The category is presentational: automations, templates and long-term
+ statistics are unaffected, which is why `state_class` stays.
+ """
description = next(d for d in PCS_SENSORS if d.key == IMPORT_LIMIT_KEY)
assert description.device_class is SensorDeviceClass.CURRENT
assert description.state_class is SensorStateClass.MEASUREMENT
assert description.native_unit_of_measurement == UnitOfElectricCurrent.AMPERE
assert description.entity_registry_enabled_default is True
- assert description.entity_category is not EntityCategory.DIAGNOSTIC
+ assert description.entity_category is EntityCategory.DIAGNOSTIC
def test_the_binding_constraint_is_an_enum_diagnostic_enabled_by_default() -> None:
From e1413de6f021cbe02b1314f95e27906488c72d9f Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 15:41:53 -0700
Subject: [PATCH 094/116] chore: 2.1.0b6, so the Import Limit move ships
`v2.1.0b5` was tagged at `0442c04`, one commit before the category change, so it
went out without it. The changelog credited the move to b5, which was wrong the
moment that tag was cut -- an entry describing something a release does not
contain is worse than no entry, because it is the release notes that a user
checks when the thing they read about is not there.
Moved to its own `[2.1.0b6]` section. Library pins are unchanged at
`3.0.0b11` / `1.0.0b5` / `0.1.0b8`; nothing in them moved.
Also dropped "has appeared in one release" from the rationale, which stopped
being true when b5 shipped. The argument does not depend on it: the entity is
days old either way, and the same change after general firmware rollout would
cost something.
---
CHANGELOG.md | 8 +++++---
custom_components/span_panel/manifest.json | 2 +-
pyproject.toml | 2 +-
uv.lock | 2 +-
4 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bce3906b..e17e9d10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,15 +2,17 @@
All notable changes to this project will be documented in this file.
-## [2.1.0b5] - 8/2026
+## [2.1.0b6] - 8/2026
### Changed
- **Import Limit moves to the Diagnostic section of the device page.** It sits with the panel's other electrical characteristics — voltages, lug currents,
breaker ratings — rather than beside the power readings, which is where the rest of the integration files a limit the panel arbitrated as opposed to a
measurement of what is flowing. Both of its Power Control System siblings were already there. Nothing else changes: the entity id, its history and its
- long-term statistics are untouched, and automations and templates referring to it are unaffected. Moved now because the entity is days old and has appeared in
- one release, so no dashboard depends on where it sits.
+ long-term statistics are untouched, and automations and templates referring to it are unaffected. Moved now because the entity is days old, so no dashboard
+ depends on where it sits; the same change after general firmware rollout would cost something.
+
+## [2.1.0b5] - 8/2026
### Fixed
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 0b528d96..25d9a1f0 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0b5",
"span-panel-api-schema-1==0.1.0b8"
],
- "version": "2.1.0b5",
+ "version": "2.1.0b6",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/pyproject.toml b/pyproject.toml
index dbf1c442..29510978 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "span"
-version = "2.1.0b5"
+version = "2.1.0b6"
description = "Span Panel Custom Integration for Home Assistant"
authors = [{name = "SpanPanel"}]
license = {text = "MIT"}
diff --git a/uv.lock b/uv.lock
index 3ea09101..0193b512 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2491,7 +2491,7 @@ wheels = [
[[package]]
name = "span"
-version = "2.1.0b5"
+version = "2.1.0b6"
source = { virtual = "." }
dependencies = [
{ name = "homeassistant" },
From 973c6154502cb17cdeed4f2689bd89554aa7b7be Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 22:12:03 -0700
Subject: [PATCH 095/116] fix(circuits): report the voltage we can justify, not
a default
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`construct_voltage_attribute` returned None for a breaker occupying more than
two positions, and every caller turned that into 240 with `or 240` — so the one
case we cannot infer became the one case we asserted most confidently. The
topology service had its own copy of the inference and answered 120 for
everything that was not exactly two-pole.
Callers now omit the attribute when the pole count does not determine the
voltage, and the topology service reads the same helper as the entities.
`construct_tabs_attribute` and the circuit-number entity id builder no longer
branch on one-or-two positions either: they name every position the breaker
occupies. Three positions used to drop the attribute and log that the hardware
was "not valid for US electrical system", which is a poor way to meet input we
have merely not seen. SPAN publishes only 1- and 2-pole breakers, so the wider
form is unreachable on SPAN hardware and exists so unexpected input degrades
into something readable.
---
.../span_panel/entity_resolver.py | 18 ++--
custom_components/span_panel/helpers.py | 97 +++++++++++--------
custom_components/span_panel/select.py | 5 +-
.../span_panel/sensor_circuit.py | 10 +-
custom_components/span_panel/switch.py | 5 +-
custom_components/span_panel/websocket.py | 8 +-
tests/test_tabs_attribute.py | 44 +++++++--
7 files changed, 121 insertions(+), 66 deletions(-)
diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py
index 88458876..bf637cc9 100644
--- a/custom_components/span_panel/entity_resolver.py
+++ b/custom_components/span_panel/entity_resolver.py
@@ -330,14 +330,18 @@ def construct_single_circuit_entity_id(
use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False)
if use_circuit_numbers:
- # Check if this is a 240V circuit (2 tabs) or 120V circuit (1 tab)
- if circuit_data.tabs and len(circuit_data.tabs) == 2:
- # 240V circuit - use both tab numbers
+ # Every position the breaker occupies, joined in order: `circuit_30_32`
+ # for a two-pole breaker, `circuit_28` for a single-pole. Naming the
+ # positions rather than branching on how many there are keeps this
+ # consistent with `construct_tabs_attribute`, which formats the same
+ # list. A breaker occupying three or more is not something SPAN
+ # publishes -- it has stated its panels "are split-phase and publish
+ # only 1- or 2-pole breakers" -- so the wider form is unreachable on
+ # SPAN hardware and exists so unexpected input degrades into a readable
+ # id instead of falling through to the circuit UUID.
+ if circuit_data.tabs:
sorted_tabs = sorted(circuit_data.tabs)
- circuit_part = f"circuit_{sorted_tabs[0]}_{sorted_tabs[1]}"
- elif circuit_data.tabs and len(circuit_data.tabs) == 1:
- # 120V circuit - use single tab number
- circuit_part = f"circuit_{circuit_data.tabs[0]}"
+ circuit_part = "circuit_" + "_".join(str(tab) for tab in sorted_tabs)
else:
# No tabs available — use the API circuit_id as fallback
circuit_part = (
diff --git a/custom_components/span_panel/helpers.py b/custom_components/span_panel/helpers.py
index 8609a208..ea07f873 100644
--- a/custom_components/span_panel/helpers.py
+++ b/custom_components/span_panel/helpers.py
@@ -149,75 +149,92 @@ def construct_circuit_identifier_from_tabs(tabs: list[int], circuit_id: str = ""
Used as a fallback when a circuit has no panel-assigned name.
Args:
- tabs: List of tab numbers (1 for 120V, 2 for 240V dipole)
+ tabs: Every panel position the breaker occupies
circuit_id: Fallback identifier when tabs are unavailable
Returns:
- String like "Circuit 30 32" for 240V or "Circuit 15" for 120V
+ String like "Circuit 30 32" for a two-pole breaker or "Circuit 15" for a
+ single-pole one, naming every position however many there are
"""
- if tabs and len(tabs) == 2:
- sorted_tabs = sorted(tabs)
- return f"Circuit {sorted_tabs[0]} {sorted_tabs[1]}"
- if tabs and len(tabs) == 1:
- return f"Circuit {tabs[0]}"
+ if tabs:
+ return "Circuit " + " ".join(str(tab) for tab in sorted(tabs))
return f"Circuit {circuit_id}"
def construct_tabs_attribute(circuit: SpanCircuitSnapshot) -> str | None:
"""Construct tabs attribute string from circuit data.
- For US electrical systems, circuits can only have 1 tab (120V) or 2 tabs (240V).
+ Names every position the breaker occupies, however many that is. v1.0
+ publishes them literally in ``info/spaces``; the flat schema published one
+ space plus a ``dipole`` flag and its adapter recovers the pair from that. So
+ this sees at most two positions on flat, and on v1.0 exactly what the panel
+ reported.
+
+ **Accepting more than two is defensive, not a fix for observed hardware.**
+ SPAN has stated that its panels "are split-phase and publish only 1- or
+ 2-pole breakers", and no circuit on any panel captured so far occupies more
+ than two positions. The ``1:4:1`` range on ``breaker/poles`` is the generic
+ eBus catalog, which covers load centres that are not SPAN. What this
+ replaces is a hard failure: three positions used to drop the attribute
+ entirely and log that the hardware was "not valid for US electrical
+ system", which is a poor way to meet input we merely have not seen.
Args:
circuit: SpanCircuitSnapshot object with tabs information
Returns:
- Tabs attribute string like "tabs [30:32]" for 240V or "tabs [28]" for 120V,
- or None if no tabs information is available
+ Tabs attribute string like "tabs [30:32]", or None if no tabs
+ information is available
Examples:
- Single tab (120V): "tabs [28]"
- Two tabs (240V): "tabs [30:32]"
+ Single tab: "tabs [28]"
+ Two tabs: "tabs [30:32]"
+ Three tabs: "tabs [17:19:21]"
No tabs: None
"""
if not circuit.tabs:
return None
- # Sort tabs for consistent ordering
- sorted_tabs = sorted(circuit.tabs)
-
- if len(sorted_tabs) == 1:
- # Single tab (120V)
- return f"tabs [{sorted_tabs[0]}]"
- if len(sorted_tabs) == 2:
- # Two tabs (240V) - format as range
- return f"tabs [{sorted_tabs[0]}:{sorted_tabs[1]}]"
- # More than 2 tabs is not valid for US electrical system
- _LOGGER.warning(
- "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)",
- circuit.circuit_id,
- len(sorted_tabs),
- )
- return None
+ return f"tabs [{':'.join(str(tab) for tab in sorted(circuit.tabs))}]"
def construct_voltage_attribute(circuit: SpanCircuitSnapshot) -> int | None:
- """Construct voltage attribute for a circuit based on tab count.
-
- For US electrical systems, circuits can only have 1 tab (120V) or 2 tabs (240V).
+ """Return the nominal voltage for a circuit, inferred from its pole count.
+
+ **Nominal, not measured, and there is nothing better to read.** The eBus
+ circuit ``meter`` capability publishes current, active power and energy
+ only; voltage is a panel-level quantity, published as the enclosure's
+ ``meter/voltage-a`` / ``voltage-b``. No per-circuit voltage exists on the
+ wire.
+
+ **It is derived from the pole count, not from the positions.** Those are
+ different claims and only the second would be unsound: the specification
+ defines ``spaces`` as identifying every occupied slot "without assuming a
+ numbering convention", so reading a leg out of a position *number* is
+ exactly what the property exists to make unnecessary. The count comes from
+ ``breaker/poles``, published outright. Given the count, SPAN supplies the
+ rest -- it has stated that its panels "are split-phase and publish only 1-
+ or 2-pole breakers" -- and on a split-phase service a two-pole breaker is
+ line-to-line across both legs. So 240 rests on a vendor statement about
+ service type, not on a layout convention.
+
+ **Which is also why it stops at two poles.** Three or more is not a
+ split-phase circuit at all -- 208V line-to-line on a three-phase wye
+ service, 240V on a high-leg delta -- and nothing published distinguishes
+ them. Deriving it from ``P / I`` does not rescue it either: that yields
+ ``V * pf`` through a 0.1A quantiser, which on real circuits lands within 1%
+ once in 27 and reads 0V for any circuit drawing standby current at zero
+ real power. None means we do not know, and callers omit the attribute
+ rather than publish a guess.
Args:
circuit: SpanCircuitSnapshot object with tabs information
Returns:
- Voltage in volts (120 for single tab, 240 for double tab), or None if no tabs information
-
- Examples:
- Single tab (120V): 120
- Two tabs (240V): 240
- No tabs: None
+ 120 for a single-pole circuit, 240 for a two-pole one, or None when
+ there is no tab information or the pole count does not determine it
"""
if not circuit.tabs:
@@ -227,12 +244,6 @@ def construct_voltage_attribute(circuit: SpanCircuitSnapshot) -> int | None:
return 120
if len(circuit.tabs) == 2:
return 240
- # More than 2 tabs is not valid for US electrical system
- _LOGGER.warning(
- "Circuit %s has %d tabs, which is not valid for US electrical system (expected 1 or 2)",
- circuit.circuit_id,
- len(circuit.tabs),
- )
return None
diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py
index 42f76e2d..ebf87070 100644
--- a/custom_components/span_panel/select.py
+++ b/custom_components/span_panel/select.py
@@ -274,8 +274,9 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None:
if tabs_result is not None:
attributes["tabs"] = tabs_result
- voltage = construct_voltage_attribute(circuit) or 240
- attributes["voltage"] = voltage
+ voltage = construct_voltage_attribute(circuit)
+ if voltage is not None:
+ attributes["voltage"] = voltage
if circuit.priority_target is not None:
attributes["priority_target"] = circuit.priority_target
diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py
index 0e06c6c1..83cba192 100644
--- a/custom_components/span_panel/sensor_circuit.py
+++ b/custom_components/span_panel/sensor_circuit.py
@@ -244,8 +244,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
attributes["tabs"] = tabs_result
# Voltage derived from tab count
- voltage = construct_voltage_attribute(circuit) or 240
- attributes["voltage"] = voltage
+ voltage = construct_voltage_attribute(circuit)
+ if voltage is not None:
+ attributes["voltage"] = voltage
attributes["always_on"] = circuit.always_on
attributes["relay_state"] = circuit.relay_state
@@ -452,8 +453,9 @@ def extra_state_attributes(self) -> dict[str, Any] | None:
if tabs is not None:
attributes["tabs"] = tabs
- voltage = construct_voltage_attribute(circuit) or 240
- attributes["voltage"] = voltage
+ voltage = construct_voltage_attribute(circuit)
+ if voltage is not None:
+ attributes["voltage"] = voltage
return attributes or None
diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py
index a8ea3e73..a4dba851 100644
--- a/custom_components/span_panel/switch.py
+++ b/custom_components/span_panel/switch.py
@@ -247,8 +247,9 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None:
if tabs_result is not None:
attributes["tabs"] = tabs_result
- voltage = construct_voltage_attribute(circuit) or 240
- attributes["voltage"] = voltage
+ voltage = construct_voltage_attribute(circuit)
+ if voltage is not None:
+ attributes["voltage"] = voltage
if circuit.relay_state_target is not None:
attributes["relay_state_target"] = circuit.relay_state_target
diff --git a/custom_components/span_panel/websocket.py b/custom_components/span_panel/websocket.py
index b969bf8b..e06611e6 100644
--- a/custom_components/span_panel/websocket.py
+++ b/custom_components/span_panel/websocket.py
@@ -11,7 +11,7 @@
import voluptuous as vol
from .const import DOMAIN
-from .helpers import build_panel_unique_id
+from .helpers import build_panel_unique_id, construct_voltage_attribute
from .id_builder import build_binary_sensor_unique_id
from .util import classify_sub_device_identifier
@@ -157,7 +157,11 @@ async def handle_panel_topology(
circuits[circuit_id] = {
"tabs": tabs,
"name": circuit.name or None,
- "voltage": 240 if len(tabs) == 2 else 120,
+ # Same inference as the entity attribute, from the same helper: a
+ # pole count answers this for one and two poles and not beyond, and
+ # null says so rather than the previous 120, which this branch
+ # returned for every circuit that was not exactly two-pole.
+ "voltage": construct_voltage_attribute(circuit),
"device_type": circuit.device_type,
"relay_state": circuit.relay_state,
"relay_state_target": circuit.relay_state_target,
diff --git a/tests/test_tabs_attribute.py b/tests/test_tabs_attribute.py
index f1358ac6..27b747ef 100644
--- a/tests/test_tabs_attribute.py
+++ b/tests/test_tabs_attribute.py
@@ -8,9 +8,7 @@
)
-def _make_circuit(
- tabs: list[int], instant_power_w: float = 100.0
-) -> SpanCircuitSnapshot:
+def _make_circuit(tabs: list[int], instant_power_w: float = 100.0) -> SpanCircuitSnapshot:
"""Create a minimal SpanCircuitSnapshot for tab/voltage tests."""
return SpanCircuitSnapshot(
circuit_id="test",
@@ -38,8 +36,27 @@ def test_tabs_attribute_construction() -> None:
# No tabs
assert construct_tabs_attribute(_make_circuit([])) is None
- # More than 2 tabs (invalid for US electrical system)
- assert construct_tabs_attribute(_make_circuit([1, 3, 5])) is None
+ # Unsorted input is ordered, whatever the pole count
+ assert construct_tabs_attribute(_make_circuit([32, 30])) == "tabs [30:32]"
+
+
+def test_tabs_attribute_names_every_position_of_a_multipole_breaker() -> None:
+ """A 3- or 4-pole breaker keeps every position it occupies.
+
+ Defensive rather than observed: SPAN has stated its panels "are split-phase
+ and publish only 1- or 2-pole breakers", and no circuit on any captured
+ panel occupies more than two positions. The `1:4:1` range on `breaker/poles`
+ belongs to the generic eBus catalog, which covers load centres that are not
+ SPAN.
+
+ What is tested here is that unexpected input degrades instead of failing.
+ Three positions used to drop the attribute entirely and log that the
+ hardware was "not valid for US electrical system".
+ """
+ assert construct_tabs_attribute(_make_circuit([17, 19, 21])) == "tabs [17:19:21]"
+ assert construct_tabs_attribute(_make_circuit([2, 4, 6, 8])) == "tabs [2:4:6:8]"
+ # Ordering is by position, not by arrival.
+ assert construct_tabs_attribute(_make_circuit([21, 17, 19])) == "tabs [17:19:21]"
def test_voltage_attribute_construction() -> None:
@@ -47,7 +64,22 @@ def test_voltage_attribute_construction() -> None:
assert construct_voltage_attribute(_make_circuit([28])) == 120
assert construct_voltage_attribute(_make_circuit([30, 32])) == 240
assert construct_voltage_attribute(_make_circuit([])) is None
- assert construct_voltage_attribute(_make_circuit([1, 3, 5])) is None
+
+
+def test_voltage_is_not_claimed_for_a_multipole_breaker() -> None:
+ """Three or more poles is not a split-phase circuit, so we do not guess.
+
+ 208V line-to-line on a three-phase wye service and 240V on a high-leg delta
+ are both plausible and nothing published distinguishes them. The position
+ numbers cannot settle it either: `spaces` is specified as identifying every
+ occupied slot "without assuming a numbering convention".
+
+ This is the half that must NOT follow the tabs fix. Naming three positions
+ is reporting what the panel published; naming a voltage for them would be
+ inventing one.
+ """
+ assert construct_voltage_attribute(_make_circuit([17, 19, 21])) is None
+ assert construct_voltage_attribute(_make_circuit([2, 4, 6, 8])) is None
def test_end_to_end_tabs_workflow() -> None:
From 66624a5cb4429844b027f1ab98b63be141676e7e Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Fri, 21 Aug 2026 22:12:38 -0700
Subject: [PATCH 096/116] docs: one 2.1.0 entry, written for the people
upgrading
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The changelog carried five separate beta sections whose combined story was
mostly internal: a retry that was fixed three times, documentation corrected in
both directions, entities promised in b2 and repaired in b5. A reader upgrading
from 2.0.8 sees none of that history — they see one release. So the betas are
collapsed into a single 2.1.0 entry stating final behaviour, keeping the SPAN
firmware transition and the self-reloading upgrade path, which are the reasons
the release exists. Added is one sentence per item rather than a paragraph.
Both documents also framed the schema generation as something a reader chooses
between. The v2 eBus prerequisites were repeated at the top of 2.0.2 through
2.0.6 as live cautions and are now stated once, in past tense, on the 2.0.1
entry that introduced them. Every `v1.0 data model only` and `v2 only` marker is
gone from the README's headings, tables and prose: it is just the firmware the
panel will be running, and the conditions that genuinely vary per install —
"only when BESS commissioned", "only when the feeding circuit reports it" —
were already stated where they matter.
---
CHANGELOG.md | 364 +++++++++++++++------------------------------------
README.md | 286 ++++++++++++++++++++--------------------
2 files changed, 242 insertions(+), 408 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e17e9d10..6b729d30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,95 +2,35 @@
All notable changes to this project will be documented in this file.
-## [2.1.0b6] - 8/2026
+## [2.1.0] - 8/2026
-### Changed
-
-- **Import Limit moves to the Diagnostic section of the device page.** It sits with the panel's other electrical characteristics — voltages, lug currents,
- breaker ratings — rather than beside the power readings, which is where the rest of the integration files a limit the panel arbitrated as opposed to a
- measurement of what is flowing. Both of its Power Control System siblings were already there. Nothing else changes: the entity id, its history and its
- long-term statistics are untouched, and automations and templates referring to it are unaffected. Moved now because the entity is days old, so no dashboard
- depends on where it sits; the same change after general firmware rollout would cost something.
-
-## [2.1.0b5] - 8/2026
-
-### Fixed
-
-Two diagnostic entities went blank on a battery-less panel after the firmware upgrade, both because v1.0 retired the property they were reading and the
-integration treated the absence as ignorance rather than as an answer. Nothing about either site changed; the panel simply stopped publishing a value it now
-expresses another way.
-
-- **Grid Forming Entity reads `Grid` again instead of `Unknown`.** Flat published a source class outright; v1.0 names the forming **device**, and it names it on
- the Microgrid Interconnect Device — which a panel without a battery does not have. But the answer is settled by what cannot be there: a battery needs a MID, a
- solar inverter cannot form a grid on its own, and a panel supplying nothing is not publishing. What remains is a generator, and SPAN with no generator
- interface treats one as the grid. So on a panel with no MID the grid is what is forming, which is exactly what flat had been reporting all along.
-- **Grid Islandable reads `Off` again instead of going unavailable.** v1.0 publishes no islandable property at all — the specification makes it structural
- instead, where the presence of a MID is what says a panel can island. Having no MID is therefore the answer rather than the absence of one, and the entity was
- being withheld precisely when it would have said "no".
-- **Neither changes anything on a panel still running the older firmware.** Both keep reading the published property exactly as before, and both still go
- unavailable if a panel that should publish one stops — which is a real fault worth surfacing, as distinct from a property the newer firmware never had.
-
-## [2.1.0b4] - 8/2026
-
-### Fixed
-
-- **A panel that is still booting no longer needs a manual reload, however long it takes.** b3 taught the integration to keep waiting when a rebooting panel
- answers with an error instead of refusing outright, but it still gave up after a few minutes — and giving up was worse than it sounds, because nothing starts
- it again. A panel that finished booting after that point stayed unread until you reloaded by hand. It now waits for as long as your panel takes, checking
- every thirty seconds once it settles, so a slow reboot is just a slow reboot.
-- **Your energy history is unaffected by that wait.** Energy sensors already hold their last reading through an outage for the grace period you configure
- (fifteen minutes by default), which is what stops a gap becoming an `unknown` and a spike in your statistics. Waiting longer for the panel does not change
- that.
-- **Several other ways a booting panel answers are now recognised as "still starting" rather than as a fault** — a connection reset mid-request, a gateway that
- closes without replying, and a reply that arrives truncated. Each of those previously stopped the integration following the upgrade, the same way the original
- error did.
-- **A panel that is not ready when Home Assistant starts now retries instead of failing setup.** Previously that left the integration in an error state needing
- a human, which matters because the two events correlate: one power interruption takes out both your panel and the machine watching it, and they come back at
- their own pace.
-
-## [2.1.0b3] - 8/2026
-
-### Fixed
-
-- **The integration now reloads itself after the firmware upgrade in the case that actually happens.** This is the whole promise of the release, and a live
- upgrade found the one path where it did not hold. When a panel takes the new firmware it drops its MQTT connection, comes back a few minutes later, and
- starts serving HTTP a little after that — and while it is still starting, it answers with `502` rather than refusing the connection, because a booting device
- brings its network front end up before the application behind it. The integration retried a refused connection and a timeout, but treated an answered-with-502
- as a hard failure and gave up on the first try, leaving the old reader in place. It was caught on two Home Assistant instances watching one panel through the
- same upgrade: both went quiet and neither recovered until reloaded by hand. A `502` is now understood as **not ready yet** and waited out, for as long as a real
- reboot takes.
-- **If following the upgrade ever fails for some other reason, you are now told what to do about it.** Previously that surfaced as a bare error in the log with
- no indication that anything needed doing, while the integration carried on reading the panel with the wrong reader. It now says plainly that a reload is
- needed once the panel is back up.
-
-### Added
-
-- **Diagnostics include your entity registry.** Every entity this integration owns, with its unique id and — the part you cannot get anywhere else — what
- disabled it, if anything. Home Assistant tells you an entity is disabled without telling you by what, and reading that yourself needs shell access that a
- Home Assistant OS install does not give you.
+### You will need this release when SPAN updates your panel
-### Changed
+SPAN firmware `r202633` replaces the way the panel publishes its data — the wire model every release up to 2.0.8 is retired in the same update that introduces
+the new one. There is no wire overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports every
+circuit as missing, and shows nothing useful.
-- **The README now leads with the upgrade warning**, and documents the Microgrid Interconnect Device, adopted devices, the `at_service_entrance` attribute on
- Grid Power, and which sensors arrive switched off.
-- **The battery power sign was documented backwards in both the README and this file, and is corrected.** Positive means **discharging**, which is what release
- 2.0.5 established and what a measured panel confirms. No entity changed and no reading moved; only the documentation was wrong.
+We do not control when that update reaches you, and the schedule belongs exclusively to SPAN. Panels update on SPAN's timing, not on yours or ours. A mandatory
+upgrade that keeps working therefore demands a release that can adapt on connection, and adapt again as necessary. If uninterrupted integration matters to you,
+be on this release **before** your panel changes — afterwards you are looking at a blank integration while you work out why.
-## [2.1.0b2] - 8/2026
+**The transition itself is seamless.** Install 2.1.0 early and your only outage is the firmware update itself. When your panel changes over, the integration
+notices on the wire, reloads itself, notifies you, and carries on — no reconfiguration, no re-pairing, no lost history. Your entities keep their entity ids,
+their unique ids and their statistics across the change. New things appear because the new firmware publishes a bit more and because devices appropriate to your
+install are added; nothing you already had goes away. A log line and a one-time notification tell you exactly what happened, so take a screenshot. If you
+experience an extended delay, reload the integration.
-### You will need this release when SPAN updates your panel
+**A panel that is still rebooting is waited out, for as long as your panel takes.** Taking the firmware upgrade drops the panel's connection for several minutes
+— four or more is not uncommon once the panel receives its upgrade file. The integration keeps checking until the panel answers properly.
-SPAN firmware `r202633` replaces the way the panel publishes its data — the flat model every release up to 2.0.8 reads is retired in the same update that
-introduces the new one. There is no overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports
-every circuit as missing, and shows nothing useful.
+**Your energy history is unaffected by that wait.** Energy sensors hold their last reading through an outage for the grace period you configure (fifteen minutes
+by default), which is what stops a gap becoming an `unknown` and a spike in your statistics.
-We do not control when that update reaches you, and nobody has published a schedule. Panels update on SPAN's timing, not on yours or ours. So the safe order is
-to be on this release **before** your panel changes rather than after, because afterwards you are looking at a blank integration while you work out why.
+**A firmware upgrade that adds a capability reloads as well**, so hardware your panel starts reporting — the Microgrid Interconnect Device, the shed forecast,
+the power control system, battery telemetry, DER link health — turns into entities when it appears rather than at your next restart.
-**The transition itself is seamless, and that is the point of this release.** Install it and it keeps reading your panel exactly as before, on either firmware.
-When your panel does change over, the integration notices on the wire, reloads itself, and carries on — no reconfiguration, no re-pairing, no lost history. Your
-entities keep their entity ids, their unique ids and their statistics across the change. New things appear because the new firmware genuinely publishes more;
-nothing you already had goes away.
+**If following the upgrade ever fails for some other reason, you are told what to do about it**: plainly, that a reload is needed once the panel is back up,
+rather than a bare error in the log while the integration carries on reading the panel with the wrong reader.
### Requires Home Assistant 2026.8.0 or newer
@@ -100,31 +40,80 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
### Added
-- **Your panel's Microgrid Interconnect Device appears as its own device** on the new data model, carrying **Grid State** — the health of the utility supply
- itself, which the previous firmware never reported. Everything about it is additive; no existing entity moves or changes id.
-- **The integration notices a firmware upgrade and reloads itself.** A panel that becomes v1.0 while Home Assistant is running used to keep reading the tree
- with the old parser, reporting every circuit as missing until you reloaded by hand. It now detects the change, reloads, writes a log line, and raises a
- one-time notice explaining what changed.
-- **Grid-forming device name** as an attribute on the GFE sensor.
-- **Grid Power now says whether it is really measuring the grid**, through a new `at_service_entrance` attribute. That sensor reads your panel's upstream lugs,
- which is grid flow only when those lugs are where the utility actually connects. Put a battery between the utility and your main lugs, or feed the panel from
- another panel, and the same reading becomes that panel's own supply while **Grid Power Flow** stays the whole-site figure — so the two legitimately disagree,
- and until now there was no way to tell that apart from a fault. The eBus specification was corrected on 2026-08-20 to say exactly this, after this project
- supplied the capture that prompted it. Both readings were always correct; only the label was ever conditional.
-- **Diagnostics now include your entity registry.** Every entity this integration owns, with its unique id and — the part you cannot get anywhere else — what
- disabled it, if anything. Home Assistant tells you an entity is disabled without telling you by what, and reading that yourself needs shell access to
- `.storage` that a Home Assistant OS install does not give you. Four causes look identical on screen and need four different answers.
+- **Your panel's Microgrid Interconnect Device appears as its own device**, carrying **Grid State** — the health of the utility supply itself, which your panel
+ never reported before.
+- **Grid Power now says whether it is really measuring the grid**, through a new `at_service_entrance` attribute: false where a battery or another panel sits
+ between the utility and your main lugs, which is when it and **Grid Power Flow** legitimately disagree.
+
+- **Your solar inverter gets a device of its own** rather than rendering as diagnostic sensors on the panel's card; the five PV entities keep the entity ids,
+ unique ids and history they have today.
+- **Assign the new Solar device to an area** — those five no longer inherit the panel's area, which quietly drops them from area-scoped dashboards, automations
+ and voice commands until you do.
+- New installations get `sensor.span_panel_solar_pv_vendor` and friends where existing ones keep `sensor.span_panel_pv_vendor`; both are correct and neither
+ will change again.
+
+- **EVSE Charge Current Limit** — a settable ceiling on each commissioned SPAN Drive, the first control this integration has that changes something on a charger
+ rather than on the panel.
+- The maximum is the one your installer commissioned, read from the panel: a higher value is refused rather than quietly rounded down, and the control is
+ unavailable rather than invented where the rating is not published.
+- The control appears only where the panel says the limit can be changed, and reports a change still in flight as a `charge_current_limit_target` attribute.
+
+- **PV Panel Link and EVSE Panel Link** — the panel's own report of the link to your inverter and to each charger, which is the fact **BESS Connected** has
+ always shown for the battery.
+- **EVSE Panel Link is not EV Connected**: one is the panel reaching the charger at all, the other is the charger reporting a plugged-in vehicle, and they can
+ disagree mid-session.
+
+- **BESS Meter Power** — what the battery itself reports it is charging or discharging at, beside the panel's arbitrated **Battery Power**. Enabled by default.
+- **BESS Communication State** — the battery's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` view of its link, which can differ from the panel's. Diagnostic, off
+ by default.
+- **Both battery power sensors read positive when discharging**, settled by measurement on a live panel — the same convention as PV Power producing and Grid
+ Power Flow importing.
+
+- **Time to Priority Shed and Backup Time Remaining** — how long before the panel starts shedding circuits and how long before the battery is spent. Enabled by
+ default.
+- Each forecast carries its full-charge equivalent and the panel's own `forecast_confidence` as attributes, rather than as two more near-constant entities.
+
+- **Import Limit, Binding Constraint and PCS Active** — the current limit your panel enforces, which rule set it, and whether anything is being throttled right
+ now. Filed under Diagnostics with the panel's other electrical characteristics.
+- **Import Limit carries the whole arbitration as attributes**: the four constraint limits the panel reconciled, each one's `_enablement` and `_active` flag,
+ and `pcs_enabled`.
+- **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them — the shed order when an import limit binds, which is
+ not the backup tier the existing `shed_priority` names.
+- All three entities appear even when the PCS is switched off, because that is a state, and the state most panels are in.
+
+- **New kinds of device your panel gains no longer wait for a release.** SPAN's data model is vendor-extensible, so a device type nobody has modelled can turn
+ up at any time; one now gets a card of its own hanging off the panel, with whatever it publishes as entities beneath it, all disabled and diagnostic.
+- **A property such a device accepts writes to becomes a control**: a boolean becomes a switch, an enum a select, a bounded number a number entity, constrained
+ to what the device declared and nothing invented.
+- **Nothing adopted enters long-term statistics** — `state_class` is not declared on the wire and a wrong guess writes corrupt statistics; wrap an adopted
+ reading in a template sensor or utility meter if you want them.
+- Devices this integration does model are deliberately not adopted: a new property on a circuit, the battery, a charger or the panel stays curated in a release,
+ where the judgement about it lives.
+
+- **Your panel's own card shows what the panel says it is** — manufacturer, model and hardware revision read from the enclosure rather than assumed, once your
+ panel publishes them.
+- **Part Number** diagnostic sensor on every SPAN Drive, matching the one the battery already has. Off by default.
+- **Circuit Priority's shed policy is readable**: `dsm_state` gains `shed_algorithm` and the two state-of-charge thresholds that decide when circuits shed and
+ when they come back.
+- **Grid-forming device name** as an attribute on the Grid Forming Entity sensor.
+- **Diagnostics include your entity registry** — every entity this integration owns, its unique id, and what disabled it, which Home Assistant will not
+ otherwise tell you.
### Changed
-- **`DSM Grid State` is now more trustworthy on the new data model.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the
- battery if one was fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the
- Microgrid Interconnect Device actually senses.
-- **`Grid Islandable` keeps working** across the upgrade. v1.0 publishes no panel-level islandable property, so the entity now reflects whether a Microgrid
- Interconnect Device is present, which is how v1.0 says backup capability is detected.
-- **Battery model** may read differently after upgrading: the new data model separates the human-readable designation from the SKU, and this entity now shows
- the designation. This is a library-level normalisation applied to both data models, so it happens once, at this release, rather than unpredictably during a
- firmware update.
+- **`DSM Grid State` is now more trustworthy.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the battery if one was
+ fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the Microgrid
+ Interconnect Device actually senses.
+- **`Grid Islandable` keeps working** across the upgrade. The new firmware publishes no panel-level islandable property — it makes the question structural
+ instead, where the presence of a Microgrid Interconnect Device is what says a panel can island — so the entity now reflects whether that device is present. A
+ panel without one reads `Off`, which is an answer, rather than going unavailable.
+- **`Grid Forming Entity` keeps working** across the upgrade too. Your panel used to publish a source class outright; the new firmware names the forming
+ _device_, and it names it on the Microgrid Interconnect Device, which a panel without a battery does not have. On such a panel the answer is settled by what
+ cannot be there — a battery needs that device, a solar inverter cannot form a grid on its own, and a panel supplying nothing is not publishing — so the entity
+ reads `Grid`, which is exactly what your panel reported before.
+- **Battery model** may read differently after upgrading: the new firmware separates the human-readable designation from the SKU, and this entity now shows the
+ designation. The normalisation happens in the library on both sides of the upgrade, so it lands once, at this release, rather than unpredictably when your
+ panel changes over.
- **Five panel sensors are switched off for new installations, because the eBus specification's own maintainer has documented that their values cannot be relied
on.** A conformance note for SPAN firmware r202633 identifies three defects in what the panel publishes, all of which predate that release: the feedthrough
@@ -136,13 +125,10 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
entity is first created, so an existing installation keeps them, keeps its history and keeps its entity IDs. This stops new installations picking them up; it
cannot reach back. If you use any of the five on a dashboard or in an automation, they are worth removing — but that is your decision to make, not something
an upgrade should do to you.
-- **A new BESS Meter Power sensor, reading the battery's own meter alongside the existing Battery Power.** The two answer slightly different questions — one is
- the battery's own view, the other the panel's arbitrated figure — and they agree by construction, so where they ever differ that is worth being able to see.
-- **Your battery power readings are unchanged, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel arrived
- alongside the conformance note. The panel's four power-flow values sum to zero exactly, in the frame the specification now describes, and the battery power
- sensor's definition is byte-for-byte what 2.0.8 shipped — same source, same conversion. Nothing about what you see has moved.
-- **Your other panel readings are unaffected.** The upstream lugs, the main panel meter and every circuit are in the correct frame. So is the power-flow group,
- which the specification has now been corrected to describe the way the panel has always published it.
+- **Your other panel readings are unaffected, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel arrived
+ alongside the conformance note: the panel's four power-flow values sum to zero exactly, and the battery power sensor's definition is byte-for-byte what 2.0.8
+ shipped. The upstream lugs, the main panel meter and every circuit are in the correct frame, as is the power-flow group, which the specification has now been
+ corrected to describe the way the panel has always published it.
- **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were
mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity
@@ -151,112 +137,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
- **It is a notification rather than a Repair, because an addition is not a repair.** Nothing is broken and nothing needs fixing. Any new-entity item still
sitting in your Repairs list from a previous version is removed on upgrade.
-- **A device the panel publishes that this integration has never modelled now appears, instead of appearing nowhere.** SPAN positions the panel as the hub for
- whatever plugs into it and the eBus schema is explicitly vendor-extensible, so a device type nobody modelled is an expected arrival — and until now it
- produced no device, no entity and no sign it was there. Such a device now gets a card of its own hanging off the panel, carrying whatever `info` it publishes,
- with its readings as entities beneath it. Everything adopted arrives **disabled and diagnostic**: nothing reaches a dashboard uninvited, and the new-entity
- notice names the device so you can find it.
-- **Devices this integration does model are left alone, deliberately.** A new property on a circuit, the battery, a charger or the panel is not adopted — it is
- curated in a release, because that is where the judgement lives about whether it should be an entity, an attribute, or a line on a device card. Automatic
- adoption would spend an `entity_id` permanently on a machine-derived shape before anyone made that call. The sixteen Power Control System properties that
- curation collapsed into one entity and thirteen attributes are the worked example of what a rule cannot produce.
-- **Nothing adopted enters long-term statistics, and that is a decision rather than an omission.** No adopted entity carries a `state_class`. It is not declared
- on the wire and cannot be derived from one — this integration ships `feedthroughEnergyProducedWh` as `TOTAL` beside `mainMeterEnergyProducedWh` as
- `TOTAL_INCREASING`, same unit and same device class — and a wrong one writes corrupt statistics that fixing the panel afterwards does not repair. Enrolling a
- property nobody asked for into long-term statistics is also a permanent write to your recorder database. If you want statistics from an adopted reading, wrap
- it in a template sensor, a Riemann-sum integration or a utility meter: that is your call, made on an entity you chose to enable.
-- **A property an adopted device accepts writes to becomes a control, not just a reading.** A declared `boolean` becomes a switch, an `enum` with its option
- list becomes a select, and a number with its `min:max:step` becomes a number entity — all disabled and diagnostic like every other adopted entity, so a
- control appears only if you go and enable it. A settable property that declares no value domain stays a reading: a select with no options and a number with no
- bounds are broken controls, not safe ones.
-- **Your panel stays the authority on the value.** Nothing is translated or clamped on the way out: this integration knows an adopted property's declaration and
- nothing else, so inventing a bound would be inventing a fact about your hardware. The control constrains you to what the device declared, and the panel
- accepts or refuses.
-
-- **Your solar inverter gets a device of its own, on panels running the v1.0 data model.** Its vendor, model and nameplate capacity used to render as diagnostic
- sensors on the _panel's_ card, beside the panel's own manufacturer and model — so the card whose job is telling you which enclosure this is read as though the
- enclosure were an Enphase inverter. It now has a card like the battery and the chargers already do, carrying the firmware version the panel has been
- publishing all along.
-- **If you already have these sensors, nothing about them changes.** The five entities that move to the new card — PV Power, PV Vendor, PV Product, PV Nameplate
- Capacity and PV Panel Link — keep the entity ids and unique ids they have today, so dashboards, automations and history follow them across untouched.
-- **They do lose the panel's area, though, and nothing warns you.** An entity takes its area from the device it sits on unless you set one, and the new solar
- device starts with no area. So if your panel is assigned to an area, these entities were in it yesterday and are in no area today — which quietly stops them
- matching area-scoped dashboards, area-scoped automations and scripts, and voice commands that target a room. Assign the new **Solar** device to an area and
- they behave as before. Worth doing before you go looking for what broke.
-- **New installations get different entity ids for these five, and that is intended.** Home Assistant derives a new entity's id from the name of the device it
- sits on, so a system installed from now on gets `sensor.span_panel_solar_pv_vendor` where a system installed before this release keeps
- `sensor.span_panel_pv_vendor`. Both are correct and neither will change again: an existing system must never have an id renamed under it, and a new one gets
- the id Home Assistant would give it. If you are comparing two SPAN systems and their PV entity ids differ, install date is why. The unique ids are the same on
- both, and both sets of entities sit on the same new card.
-
-- **Your panel's own card now shows what the panel says it is** — manufacturer, model and hardware revision, read from the enclosure rather than assumed. A
- panel on the older data model publishes none of the three and keeps exactly the card it has today; the hardware revision row is left off rather than shown
- blank where no revision is published.
-- **Every SPAN Drive gets a Part Number** diagnostic sensor, matching the one the battery already has. Off by default.
-- **Circuit Priority's shed policy is readable.** The `dsm_state` sensor gains `shed_algorithm` and the two state-of-charge thresholds that decide when circuits
- shed and when they come back — the numbers that make the panel's shed behaviour predictable rather than surprising. A policy this integration does not
- recognise keeps its name and carries the panel's raw document beside it, so you can read what a parser could not.
-
-- **A charge-current limit you can set, on panels running the v1.0 data model.** Each commissioned SPAN Drive gets an **EVSE Charge Current Limit** number on
- its own device — the ceiling the charger offers your vehicle, which you can lower to charge more slowly and raise back. It is the first control this
- integration has that changes something on a charger rather than on the panel.
-- The maximum you can ask for is the one your installer commissioned, read from the panel rather than assumed: the box will not accept a value above the
- charger's rated current, and neither will anything else — a value beyond it is refused before it is sent, not quietly rounded down to something you did not
- ask for. If the panel has not yet published what the charger is rated for, the control reports unavailable instead of offering an invented range.
-- The control appears only where the panel says the limit can be changed. A charger that publishes its limit as read-only gets no control, which is the same
- distinction **Circuit Priority** already makes for a circuit commissioned never-backup.
-- While the panel is acknowledging a change it has not yet applied, the requested value shows as a `charge_current_limit_target` attribute and the state stays
- the limit the charger is still enforcing — the same way Circuit Priority reports a priority change in flight.
-
-- **Whether your panel can reach your solar inverter and each of your chargers, on panels running the v1.0 data model.** **PV Panel Link** and **EVSE Panel
- Link** are the same fact **BESS Connected** has always shown for the battery: the panel's own report of the link to a device it feeds. The battery's version
- worked because the panel publishes it on the main lugs; the inverter's and each charger's are published by the circuit that feeds them, and nothing read that
- half — so one of your three device classes had a link sensor and the others did not.
-- **EVSE Panel Link is not EV Connected.** EV Connected is the charger reporting that a vehicle is plugged in. EVSE Panel Link is the panel reporting that it
- can reach the charger at all. A charger part-way through a session behind a link the panel has lost shows a plugged-in vehicle and a dead link at the same
- time, which is exactly the case where you want to know which of the two you are looking at. The new sensors are diagnostics; EV Connected is unchanged.
-- Each sensor is created only where a circuit publishes the link record for that device, and per charger rather than per panel — two chargers whose circuits
- report differently get two sensors that say different things. A circuit that feeds ordinary loads publishes no such record, which is normal and is not
- reported as a fault, and a panel that starts publishing one picks the sensors up on the reload the integration already performs.
-
-- **Your battery's own meter and its own link health, on panels running the v1.0 data model.** **Meter Power** is what the BESS itself reports it is charging or
- discharging at, as distinct from the panel's **Battery Power**, which is the enclosure's arbitrated figure. Both have been on the wire since firmware r202633
- and nothing read either. Meter Power is enabled by default; **Communication State** — the BESS's own `OK` / `DEGRADED` / `LOST` / `UNKNOWN` report on its link
- — is a diagnostic and is off by default, since it is only interesting when something is wrong.
-- **Both battery power sensors read positive when the battery is _discharging_**, and that direction was settled by measurement rather than by reading. With the
- battery driven into self-consumption and the grid at exactly zero — PV 4181 W plus battery 1917 W meeting a 6099 W load, so the battery can only be supplying
- — both sensors read `+1917.49`. The two values arrive from the panel in opposite sign conventions and are normalised to this one, so they agree with each
- other and with the sensors beside them: PV Power is positive while producing, Grid Power Flow positive while importing, Battery Power positive while
- discharging. Every one is "positive means power flowing toward the house".
-- **Communication State is not the same thing as BESS Connected.** The binary sensor is the _panel's_ view of the link, from the enclosure's connection record;
- the new sensor is the _battery's_ view of it. A BESS can report its own link lost while the panel still claims it, and now you can see that.
-- Both sensors are created only where the BESS publishes the reading behind them — a battery on the older data model, or one whose firmware publishes only one
- of the two, gets no entity for what it cannot report rather than one permanently unknown, and a BESS that gains the capability on a firmware upgrade picks the
- sensors up on the reload the integration already performs.
-- **Backup planning, in minutes: two new sensors on panels running the v1.0 data model.** **Time to Priority Shed** is how long before the panel starts shedding
- circuits, and **Backup Time Remaining** is how long before the battery is spent. Your panel has been publishing both since firmware r202633 and nothing read
- them; they are the numbers you would actually set an alarm on, so they are enabled by default and sit beside the power and energy sensors rather than under
- diagnostics.
-- **Each forecast sensor carries the refinements that qualify it** as attributes: `full_charge_time_to_priority_shed` / `full_charge_total_time_remaining` —
- what the same estimate would be from a full battery — and `forecast_confidence`, the panel's own `LOW` / `MEDIUM` / `HIGH` assessment of the estimate. They
- refine a number already on screen rather than adding two near-constant entities to your entity list.
-- Both sensors are created only where the panel publishes the estimate behind them. A panel on the older data model, or one whose firmware publishes only part
- of the forecast, gets no entity for what it cannot report rather than one permanently unknown — and a panel that gains the capability on a firmware upgrade
- picks the sensors up on the reload the integration already performs.
-- **What is limiting your import, in amps: three new entities on panels running the v1.0 data model.** **Import Limit** is the current limit your panel is
- actually enforcing, **Binding Constraint** names which rule set it — your service rating, a utility envelope, an operator cap, a limit you asked for — and
- **PCS Active** says whether anything is being throttled right now. Your panel has published all of this since firmware r202633 and nothing read it.
-- **Import Limit carries the whole arbitration as attributes**: the four constraint limits the panel reconciled (`feed_import_limit`, `operator_import_limit`,
- `off_grid_import_limit`, `requested_import_limit`), each one's `_enablement` and `_active` flag, and `pcs_enabled`. They explain the enforced number rather
- than being numbers to watch, and most of them change only when somebody reconfigures the panel — so they refine an entity you already have instead of adding
- twelve to your entity list.
-- **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them: whether the Power Control System manages that circuit,
- and where it sits in the shed order when an import limit binds. `pcs_priority` is a different thing from the existing `shed_priority`, which is the backup
- tier — a circuit may take part in one policy, both, or neither.
-- All three entities are created wherever the panel publishes the capability, **including when the PCS is switched off**. A panel reporting a 0 A limit with
- everything unconfigured is reporting a state, and that is the state most panels are in; entities that vanished until somebody configured a limit would be
- entities nobody could build a dashboard on.
-
- **The Wi-Fi network name moved to the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells
you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID.
- **It is no longer an attribute of the Software Version sensor.** A network name on a firmware-version sensor never made sense — it sat there because
@@ -266,22 +146,9 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
### Fixed
- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed
- "Possible states: Unknown". The lists are now derived from the translations and checked against them by a test.
-- **The GFE override button reads the right signal** for deciding when it applies, so it is no longer permanently enabled on the new data model.
-
-- **The Wi-Fi network name came back.** Panels on the older data model report the SSID they are joined to, and this integration has shown it as an attribute on
- the panel status sensor for as long as it has existed. On the v1.0 data model nothing read it, so the attribute quietly emptied when your panel upgraded — a
- value you had, silently gone, with no error and nothing in the log. It is read again, and it is now published on the Wi-Fi Link binary sensor rather than on
- the Software Version sensor — see above.
-- **A firmware upgrade that adds a capability now actually reloads.** The check that decides whether new hardware warrants a reload knew about four capabilities
- where the rest of the integration knew about nine. A panel that gained the shed forecast, the power control system, battery telemetry or DER link health while
- Home Assistant was running published the data, matched every rule for creating the entities, and asked for no reload — so the new entities appeared only the
- next time you restarted. This affected the Microgrid Interconnect Device before this release too.
-
-- **The README described Battery Power's sign backwards, and an earlier entry in this file "corrected" it the wrong way.** The sensor reports **discharging** as
- positive and always has — that is what release 2.0.5 established (#184) and what a measured panel confirms. A later note claimed the opposite and the README
- was edited to match it, so both documents told you to expect the wrong sign. Both are now right. **No entity changed and no reading moved**; only the
- documentation was ever wrong, in both directions.
+ "Possible states: Unknown".
+- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has — that is what release 2.0.5
+ established (#184) and what a measured panel confirms. **No entity changed and no reading moved**; only the documentation was ever wrong.
## [2.0.8] - 5/2026
@@ -316,11 +183,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
## [2.0.6] - 4/2026
-**Important** 2.0.x cautions still apply — read those carefully if not already on 2.0.x BEFORE proceeding:
-
-- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT)
-- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading
-
### Added
- **By Activity and By Area views** — Two new circuit views available in both the integration panel and the Lovelace card (span-card 0.9.2):
@@ -342,11 +204,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
## [2.0.5] - 4/2026
-**Important** 2.0.x cautions still apply — read those carefully if not already on 2.0.x BEFORE proceeding:
-
-- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT)
-- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading
-
### Added
- **Current monitoring and dashboard** — Real-time monitoring of circuit and mains current draw, managed from a new sidebar panel with Panel, Monitoring, and
@@ -380,11 +237,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
## [2.0.4] - 3/2026
-**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding:
-
-- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT)
-- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading
-
### Added
- **Grid Power sensor** — New `Grid Power`. Previously only `Current Power` (upstream lugs measurement) was available; the new sensor surfaces the panel's own
@@ -414,11 +266,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
## [2.0.3] - 3/2026
-**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding:
-
-- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT)
-- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading
-
### Fixed
- **Force dependency re-resolution** — Version bump to ensure HACS re-installs `span-panel-api` for users who had the earlier 2.0.2 release. Users upgrading HA
@@ -426,11 +273,6 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
## [2.0.2] - 3/2026
-**Important** 2.0.1 cautions still apply — read those carefully if not already on 2.0.1 BEFORE proceeding:
-
-- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT)
-- You _must_ already be on v1.3.x or later of the SpanPanel/span integration if upgrading
-
### Fixed
- **Panel size always available** — `panel_size` is now sourced from the Homie schema by the underlying `span-panel-api` Previously some users could see fewer
@@ -446,13 +288,13 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
## [2.0.1] - 3/2026
-⚠️ **STOP — If your SPAN panel is not on firmware `spanos2/r202603/05` or later, do not upgrade. Ensure you are on v1.3.0 or later BEFORE upgrading to 2.0. This
-upgrade migrates to the SPAN official eBus API. Make a backup first.** ⚠️
+**This is the release that moved the integration to the SPAN official eBus API**, which every release since reads and every panel now runs. The prerequisites
+below applied while panels were still being updated to `spanos2/r202603/05`; they are recorded here for history.
### Breaking Changes
-- Requires firmware `spanos2/r202603/05` or later (v2 eBus MQTT)
-- You _must_ already be on v1.3.0 or later of the SpanPanel/span integration if upgrading
+- Required firmware `spanos2/r202603/05` or later (the eBus MQTT API)
+- You had to already be on v1.3.0 or later of the SpanPanel/span integration to upgrade
- After upgrading, you must re-authenticate using your **panel passphrase** (found in the SPAN mobile app under On-premise settings) or **proof of proximity**
(open and close the panel door 3 times). See the [README](README.md) for details.
- If you were running a beta or RC, ensure you reload the integration after upgrade
diff --git a/README.md b/README.md
index 19db6460..9de13b52 100644
--- a/README.md
+++ b/README.md
@@ -44,47 +44,41 @@ This integration communicates with the SPAN Panel over your local network using
infrastructure. eBus uses the [Homie Convention](https://homieiot.github.io/) for MQTT topics and messages, with the panel's built-in MQTT broker delivering
real-time state updates without polling.
-## ⚠️ Upgrade to 2.1.x before your panel's firmware updates, or the integration will stop working
+## ⚠️ Backup and Upgrade to 2.1.x before your panel's firmware updates, or the integration will stop working (upgrade only from v2.0.8!)
-**SPAN firmware `r202633` breaks every integration release up to and including 2.0.8.** When your panel takes that update, 2.0.8 stops being able to read it.
-The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go
+**SPAN firmware `r202633` changes the API in a non-compatible way after the firmware hits** When your panel takes that update, 2.0.8 stops being able to read
+it. The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go
blank. It does not fail loudly. It goes quiet.
**Nobody outside SPAN knows when your panel will update, and you cannot defer it from Home Assistant.** Panels update on SPAN's timing. There is no schedule to
-plan around, which is why the safe move is to be on 2.1.x already rather than to wait for a signal that never comes.
+plan around, which is why the safe move is to be on 2.1.x already rather than to wait for a signal that is less appealing.
-**Upgrading first costs you nothing.** On your current firmware, 2.1.x reads your panel exactly as 2.0.8 does — same entities, same ids, same history. It simply
-also knows how to read the new format when it arrives.
+**Upgrading first costs you nothing.** On your current firmware, 2.1.x reads your panel exactly as 2.0.8 does.
-**And then the changeover is seamless.** The integration detects the new format on the wire, reloads itself, and carries on:
+The changeover is designed to be seamless.** The integration detects the new format on the wire, reloads itself, and carries on:
-- No reconfiguration, no re-pairing, no re-authentication.
-- Entity ids, unique ids and long-term statistics all survive. Dashboards, automations and history follow.
+- no re-pairing, no re-authentication.
+- Entity ids, unique ids and long-term statistics survive. Dashboards, automations and history follow.
- New entities appear because the new firmware genuinely publishes more. Nothing you already had is removed.
-Upgrade afterwards instead and you reach the same place — after however long it takes you to notice, and to work out that a firmware update you were never told
-about is the reason your panel went silent.
+Upgrade afterwards instead and you reach the same place — after however long it takes you to notice, and to work out that a firmware update is the reason your
+panel went silent. Worst case should be a reload.
+
+Take another backup after the upgrade.
-What actually changes in the firmware
+What changes in the firmware
-`r202633` rewrites how the panel publishes everything, rather than adding to it. The MQTT topic structure changes, and the panel stops presenting itself as a
-single flat device with a long list of properties. It presents a tree instead: the enclosure, with its lugs, circuits, battery, chargers and Microgrid
-Interconnect Device as separate devices beneath it. Every topic an integration reads moves. The old format is retired in the same update that introduces the new
-one — there is no overlap and no setting to keep the old behaviour.
+The firmware upgrade `r202633` rewrites _how_ the panel publishes its self-describing BOM. The MQTT topic structure changes, and the panel stops presenting
+itself as a long list of properties. It presents a tree that can proxy other devices instead. Every topic moves.
-This has happened before, in exactly this shape. Firmware `spanos2/r202603/05` removed the v1 REST API this integration was originally built on, which is what
-the 2.0.x breaking-change notice below is about. `r202633` does the same thing to the flat MQTT format that replaced it.
+The old format is retired in the same update that introduces the new one — there is no overlap and no setting to keep the old behaviour.
-## 1.1.x Integration Sunset (v1)
-
-Users MUST upgrade by the end of 2026 to avoid disruption. Upgrade to the latest 1.1.x version BEFORE upgrading to 2.0.x.
-
## 2.0.x Breaking Changes (v2)
-**Do NOT upgrade unless your panel is running firmware `spanos2/r202603/05` or later.**
+**Do NOT upgrade unless your panel is running firmware `spanos2/r202603/05` or later and you are on 2.0.8 of the integration.**
**What you need:**
@@ -166,35 +160,33 @@ The following terms appear throughout this document and in the integration's sen
| Feed Through Net Energy | Energy | Wh | Feedthrough net energy |
| DSM State | — | — | dsm_on_grid (grid connected), dsm_off_grid (islanded), unknown. Derived from multiple eBus signals |
| Current Run Config | — | — | panel_on_grid (grid connected), panel_off_grid (islanded on PV/generator), panel_backup (islanded on battery), unknown |
-| Grid Forming Entity | — | — | (v2) GRID, BATTERY, PV, GENERATOR, NONE, UNKNOWN. See [Grid Forming Entity](#grid-forming-entity) |
+| Grid Forming Entity | — | — | GRID, BATTERY, PV, GENERATOR, NONE, UNKNOWN. See[Grid Forming Entity](#grid-forming-entity) |
| Main Relay State | — | — | closed (power flowing), open (disconnected), unknown |
-| Vendor Cloud | — | — | (v2) CONNECTED, UNCONNECTED, UNKNOWN |
+| Vendor Cloud | — | — | CONNECTED, UNCONNECTED, UNKNOWN |
| Software Version | — | — | Firmware version string |
-### Panel Diagnostic Sensors (v2 only)
+### Panel Diagnostic Sensors
-| Sensor | Device Class | Unit | Notes |
-| --------------------- | ------------ | ---- | -------------------------- |
-| L1 Voltage | Voltage | V | L1 leg actual voltage |
-| L2 Voltage | Voltage | V | L2 leg actual voltage |
-| Upstream L1 Current | Current | A | Upstream lugs L1 current |
-| Upstream L2 Current | Current | A | Upstream lugs L2 current |
+| Sensor | Device Class | Unit | Notes |
+| --------------------- | ------------ | ---- | ----------------------------------------------------------------- |
+| L1 Voltage | Voltage | V | L1 leg actual voltage |
+| L2 Voltage | Voltage | V | L2 leg actual voltage |
+| Upstream L1 Current | Current | A | Upstream lugs L1 current |
+| Upstream L2 Current | Current | A | Upstream lugs L2 current |
| Downstream L1 Current | Current | A | Downstream lugs L1 current. Off by default from 2.1.x — see below |
| Downstream L2 Current | Current | A | Downstream lugs L2 current. Off by default from 2.1.x — see below |
-| Main Breaker Rating | Current | A | Main breaker amperage. Off by default |
+| Main Breaker Rating | Current | A | Main breaker amperage. Off by default |
L1/L2 Voltage and Main Breaker Rating have always been off by default; enable them from the panel's device page if you want them.
-**The two Downstream current sensors are off by default from 2.1.x, and so are the three Feedthrough sensors.** The eBus specification's maintainer has
-documented that the panel's feedthrough (downstream lugs) figures cannot be relied on: the energy registers are computed from unrelated counters and can
-decrease or go negative, the power reading is inverted relative to every other terminal, and the downstream currents report the **upstream** service conductors
-rather than a downstream measurement. These defects predate `r202633` and are not introduced by it.
+**The three Feedthrough sensors and the two Downstream current sensors are off by default from 2.1.x.** The eBus specification's maintainer has documented that
+the panel's feedthrough (downstream lugs) figures cannot be relied on: the energy registers can decrease or go negative, the power reading is inverted relative
+to every other terminal, and the downstream currents report the **upstream** service conductors. The defects predate `r202633`.
-If you already have any of those five they stay exactly where they are, with their history and their entity ids — Home Assistant applies the off-by-default
-setting only when an entity is first created, so this reaches new installations only. If you use them on a dashboard or in an automation they are worth
-removing, but that is your call, not something an upgrade should do to you.
+Existing installations keep all five, with their history and entity ids — Home Assistant applies the setting only when an entity is first created. If you use
+them somewhere, removing them is worth considering, but that is your call.
-### Shed Forecast Sensors (v1.0 data model only)
+### Shed Forecast Sensors
Created only when your panel publishes the `shed-forecast` capability, and only for the estimates it actually publishes.
@@ -207,46 +199,46 @@ Created only when your panel publishes the `shed-forecast` capability, and only
Present only when the panel publishes them.
-| Attribute | Type | On | Notes |
-| ----------------------------------- | ------ | --------------------- | ------------------------------------------------------ |
-| `full_charge_time_to_priority_shed` | int | Time to Priority Shed | The same estimate assuming the battery starts full |
-| `full_charge_total_time_remaining` | int | Backup Time Remaining | The same estimate assuming the battery starts full |
-| `forecast_confidence` | string | both | The panel's own assessment: `LOW`, `MEDIUM`, or `HIGH` |
+| Attribute | Type | On | Notes |
+| ----------------------------------- | ------ | --------------------- | ----------------------------------------------------- |
+| `full_charge_time_to_priority_shed` | int | Time to Priority Shed | The same estimate assuming the battery starts full |
+| `full_charge_total_time_remaining` | int | Backup Time Remaining | The same estimate assuming the battery starts full |
+| `forecast_confidence` | string | both | The panel's own assessment:`LOW`, `MEDIUM`, or `HIGH` |
-### Power Control System Sensors (v1.0 data model only)
+### Power Control System Sensors
Created only when your panel publishes the `pcs` capability, and created whether or not the PCS is switched on — a PCS reporting a limit of 0 A is reporting a
state, not an absence.
| Sensor | Device Class | Unit | Notes |
| ------------------ | ------------ | ---- | -------------------------------------------------------------------------------------------------------------------------- |
-| Import Limit | Current | A | The limit actually being enforced: the most restrictive active constraint (diagnostic) |
+| Import Limit | Current | A | The limit actually being enforced: the most restrictive active constraint (diagnostic) |
| Binding Constraint | Enum | — | Which constraint sets that limit: Firm Service Rating, Grid Envelope, Voltage Support, Off-Grid, Requested, Operator, None |
#### Power Control System Sensor Attributes
On **Import Limit**, and present only when the panel publishes them. These are the inputs the panel reconciled to produce the enforced limit above.
-| Attribute | Type | Notes |
-| ------------------------ | ------ | --------------------------------------------------------------- |
-| `pcs_enabled` | bool | Whether the panel's PCS is enabled at all |
-| `feed_import_limit` | float | The Firm Service Rating: the commissioned, always-on floor (A) |
-| `operator_import_limit` | float | A cap imposed by a fleet or aggregator operator (A) |
-| `off_grid_import_limit` | float | The import cap while islanded (A) |
-| `requested_import_limit` | float | A voluntary limit requested by the owner or installer (A) |
-| `_enablement` | string | Per limit: `UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED` |
-| `_active` | bool | Per limit: whether that constraint is currently enforcing |
-
-### Power Flow Sensors (v2 only)
-
-| Sensor | Device Class | Unit | Notes |
-| ------------- | ------------ | ---- | ------------------------------------------------------------------------------- |
-| Grid Power | Power | W | Grid power flow |
-| Site Power | Power | W | Total site power (grid + PV + battery) |
+| Attribute | Type | Notes |
+| ------------------------ | ------ | -------------------------------------------------------------- |
+| `pcs_enabled` | bool | Whether the panel's PCS is enabled at all |
+| `feed_import_limit` | float | The Firm Service Rating: the commissioned, always-on floor (A) |
+| `operator_import_limit` | float | A cap imposed by a fleet or aggregator operator (A) |
+| `off_grid_import_limit` | float | The import cap while islanded (A) |
+| `requested_import_limit` | float | A voluntary limit requested by the owner or installer (A) |
+| `_enablement` | string | Per limit:`UNSPECIFIED`, `UNCONFIGURED`, `DISABLED`, `ENABLED` |
+| `_active` | bool | Per limit: whether that constraint is currently enforcing |
+
+### Power Flow Sensors
+
+| Sensor | Device Class | Unit | Notes |
+| ------------- | ------------ | ---- | ----------------------------------------------------------------------------------- |
+| Grid Power | Power | W | Grid power flow |
+| Site Power | Power | W | Total site power (grid + PV + battery) |
| Battery Power | Power | W | Battery charge/discharge (**+discharging, −charging**). Only when BESS commissioned |
-| PV Power | Power | W | PV generation (+producing). Only when PV commissioned |
+| PV Power | Power | W | PV generation (+producing). Only when PV commissioned |
-### PV Metadata Sensors (v2 only, on the Solar sub-device)
+### PV Metadata Sensors (on the Solar sub-device)
From 2.1.x these live on a **Solar** device of their own rather than on the panel's card, alongside PV Power and PV Panel Link.
@@ -254,39 +246,37 @@ From 2.1.x these live on a **Solar** device of their own rather than on the pane
| ------------------ | ------------ | ---- | --------------------------------------------- |
| PV Vendor | — | — | PV inverter vendor (e.g., "Enphase", "Other") |
| PV Product | — | — | PV inverter product (e.g., "IQ8+") |
-| Nameplate Capacity | Power | kW | Rated inverter capacity. Off by default |
+| Nameplate Capacity | Power | kW | Rated inverter capacity. Off by default |
-If you upgraded, these keep their entity ids, unique ids and history — only the card changes. **They do not keep the panel's area.** An entity takes its area
-from its device, and the new Solar device starts without one, so anything area-scoped (dashboards, automations, voice targeting a room) stops matching them
-until you assign the Solar device to an area. New installations get ids derived from the new device name, so a system installed from 2.1.x onward has
-`sensor.span_panel_solar_pv_vendor` where an upgraded one keeps `sensor.span_panel_pv_vendor`. Both are correct and neither changes again.
+If you upgraded, these keep their entity ids, unique ids and history — but not the panel's area, since an entity takes its area from its device and the Solar
+device starts without one. Assign it an area, or anything area-scoped (dashboards, automations, voice targeting a room) stops matching them. New installations
+get ids from the new device name — `sensor.span_panel_solar_pv_vendor` rather than `sensor.span_panel_pv_vendor`. Both are correct and neither changes again.
**Deprecated:**
-| Sensor | Reason |
-| -------------- | ------------------------------------------------------------------------------------------------------------------------- |
-| DSM Grid State | Deprecated — still available, but users should rely on `DSM State` as `DSM Grid State` may be removed in a future version |
+| Sensor | Reason |
+| -------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| DSM Grid State | Deprecated — still available, but users should rely on`DSM State` as `DSM Grid State` may be removed in a future version |
-### Microgrid Interconnect Device (v1.0 data model only)
+### Microgrid Interconnect Device
-Panels on the v1.0 data model publish their Microgrid Interconnect Device — the switch that disconnects your home from the utility during an outage — as a
-device of its own, linked to the panel. It appears automatically where the panel reports one; nothing existing moves onto it.
+Your panel publishes its Microgrid Interconnect Device — the switch that disconnects your home from the utility during an outage — as a device of its own,
+linked to the panel. It appears automatically where the panel reports one; nothing existing moves onto it.
-| Sensor | Device Class | Unit | Notes |
-| ---------- | ------------ | ---- | --------------------------------------------------------------------------- |
-| Grid State | Enum | — | Health of the utility supply itself: `up`, `down`, `degraded` or `unknown` |
+| Sensor | Device Class | Unit | Notes |
+| ---------- | ------------ | ---- | ------------------------------------------------------------------------- |
+| Grid State | Enum | — | Health of the utility supply itself:`up`, `down`, `degraded` or `unknown` |
-This is genuinely new information — the previous firmware did not report the state of the utility supply at all. It is not the same thing as **DSM Grid State**,
-which is whether **your home** is islanded. The grid can be down while your home runs happily off the battery.
+This is new information — the previous firmware did not report the utility supply at all. It is not **DSM Grid State**, which is whether **your home** is
+islanded: the grid can be down while your home runs happily off the battery.
-**A panel with no battery has no MID, and that is itself an answer.** The specification makes backup capability structural rather than a property — the presence
-of a MID is what says a panel can island — so **Grid Islandable** reads `Off` on such a panel rather than going unavailable, and **Grid Forming Entity** reads
-`Grid`. Neither is a default standing in for a missing reading; both are what the absence of islanding hardware means.
+**A panel with no battery has no MID, and that is itself an answer.** The specification makes backup capability structural — having a MID is what says a panel
+can island — so **Grid Islandable** reads `Off` and **Grid Forming Entity** reads `Grid` rather than either going unavailable.
-`DSM Grid State` also becomes more trustworthy on this data model. It keeps its entity id and its history, but where it used to be inferred — from the battery
-when one was fitted, otherwise from the dominant power source — it now reads the islanding state the MID actually senses.
+`DSM Grid State` keeps its entity id and history but is no longer inferred from the battery or the dominant power source; it now reads the islanding state the
+MID actually senses.
-### Adopted Devices (v1.0 data model only)
+### Adopted Devices
The eBus schema is vendor-extensible, so your panel can publish a device type this integration has never modelled. Rather than ignoring it, the integration
gives it a card of its own hanging off the panel, carrying whatever identity it publishes, with its readings as entities beneath it.
@@ -314,9 +304,9 @@ Applies to Current Power, Feed Through Power, Battery Power, PV Power, Grid Powe
**Grid Power** carries one more, because its name is only true in some wiring:
-| Attribute | Type | Notes |
-| ---------------------- | ------- | --------------------------------------------------------------------------- |
-| `at_service_entrance` | boolean | Whether this panel's upstream lugs are where the utility actually connects |
+| Attribute | Type | Notes |
+| --------------------- | ------- | -------------------------------------------------------------------------- |
+| `at_service_entrance` | boolean | Whether this panel's upstream lugs are where the utility actually connects |
Grid Power reads the upstream lugs. That is grid flow when those lugs are the utility connection point, which is the ordinary case. Put a battery between the
utility and your main lugs, or feed this panel from another panel, and the same reading becomes **this panel's** supply while **Grid Power Flow** stays the
@@ -363,21 +353,21 @@ feature. A display suffix differentiates multiple chargers on the same panel:
| Charger Status | Enum | — | OCPP-based states: AVAILABLE, PREPARING, CHARGING, SUSPENDED_EV, etc. Translated |
| Advertised Current | Current | A | Amps offered to the vehicle |
| Lock State | Enum | — | LOCKED, UNLOCKED, UNKNOWN. Translated |
-| Part Number | — | — | Charger part number (diagnostic, **off by default**). v1.0 data model only |
+| Part Number | — | — | Charger part number (diagnostic,**off by default**) |
#### EVSE Binary Sensors (per charger)
-| Sensor | Device Class | Notes |
-| --------------- | ---------------- | ------------------------------------------------------------------------------------------------------- |
-| Charging | Battery Charging | ON when status is CHARGING |
-| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING — a vehicle is plugged in |
-| EVSE Panel Link | Connectivity | (v1.0) Whether the panel can reach the charger. A different fact from EV Connected, and it can disagree |
+| Sensor | Device Class | Notes |
+| --------------- | ---------------- | ------------------------------------------------------------------------------------------------ |
+| Charging | Battery Charging | ON when status is CHARGING |
+| EV Connected | Plug | ON when status is PREPARING, CHARGING, SUSPENDED\_\*, or FINISHING — a vehicle is plugged in |
+| EVSE Panel Link | Connectivity | Whether the panel can reach the charger. A different fact from EV Connected, and it can disagree |
**EVSE Panel Link is not EV Connected.** EV Connected is what the charger says about the cable in front of it; EVSE Panel Link is what the panel says about
whether it can reach the charger at all. A charger part-way through a session behind a lost link reports a plugged-in vehicle and a dead link at the same time.
EVSE Panel Link is a diagnostic and appears only where the circuit feeding that charger publishes the link record.
-#### EVSE Controls (per charger, v1.0)
+#### EVSE Controls (per charger)
| Control | Platform | Unit | Notes |
| ------------------------- | -------- | ---- | --------------------------------------------------------------------------------------- |
@@ -397,26 +387,26 @@ charger is still enforcing.
| Serial Number | `serial-number` |
| Software Version | `software-version` |
-### BESS Sub-Device (v2 only, conditional)
+### BESS Sub-Device (conditional)
When a Battery Energy Storage System (BESS) is commissioned, the integration creates a separate BESS sub-device linked to the panel via `via_device`. The BESS
device uses manufacturer, model, serial number, and software version from battery metadata as device info attributes.
#### BESS Sensors
-| Sensor | Device Class | Unit | Notes |
-| ------------------- | -------------- | ---- | ------------------------------------------------------------------------------------------------ |
-| Battery Level | Battery | % | State of energy as percentage |
-| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device |
-| Meter Power | Power | W | The BESS's own meter (**+discharging, −charging**), agreeing with Battery Power. v1.0 data model only |
-| Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default). v1.0 data model only |
-| BESS Vendor | — | — | Battery system vendor (diagnostic) |
-| BESS Model | — | — | Battery system model (diagnostic) |
-| BESS Part Number | — | — | Battery system part number (diagnostic, **off by default**). v1.0 data model only |
-| BESS Serial Number | — | — | Battery system serial number (diagnostic) |
-| BESS Firmware | — | — | Battery system firmware (diagnostic) |
-| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic, **off by default**) |
-| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) |
+| Sensor | Device Class | Unit | Notes |
+| ------------------- | -------------- | ---- | ------------------------------------------------------------------------------- |
+| Battery Level | Battery | % | State of energy as percentage |
+| Battery Power | Power | W | Same entity as Power Flow Battery Power, shown on BESS sub-device |
+| Meter Power | Power | W | The BESS's own meter (**+discharging, −charging**), agreeing with Battery Power |
+| Communication State | — | — | The BESS's report of its own link health (diagnostic, disabled by default) |
+| BESS Vendor | — | — | Battery system vendor (diagnostic) |
+| BESS Model | — | — | Battery system model (diagnostic) |
+| BESS Part Number | — | — | Battery system part number (diagnostic,**off by default**) |
+| BESS Serial Number | — | — | Battery system serial number (diagnostic) |
+| BESS Firmware | — | — | Battery system firmware (diagnostic) |
+| Nameplate Capacity | Energy Storage | kWh | Rated battery capacity (diagnostic,**off by default**) |
+| Stored Energy | Energy Storage | kWh | Current stored energy (diagnostic) |
#### BESS Binary Sensors
@@ -440,22 +430,22 @@ Applies to Main Meter and Feed Through energy sensors.
| Produced Energy | Energy | Wh | Cumulative energy produced |
| Consumed Energy | Energy | Wh | Cumulative energy consumed |
| Net Energy | Energy | Wh | Net energy (sign depends on device type — PV circuits invert) |
-| Current | Current | A | (v2) Measured circuit current. Only when panel reports `current_a` |
-| Breaker Rating | Current | A | (v2) Circuit breaker amperage (diagnostic). Only when reported |
+| Current | Current | A | Measured circuit current. Only when panel reports`current_a` |
+| Breaker Rating | Current | A | Circuit breaker amperage (diagnostic). Only when reported |
### Circuit Power Sensor Attributes
-| Attribute | Type | Notes |
-| ----------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- |
-| `tabs` | string | Breaker slot position(s) |
-| `voltage` | string | 120 or 240 (derived from tab count) |
-| `always_on` | bool | Whether circuit is always-on |
-| `relay_state` | string | OPEN / CLOSED / UNKNOWN |
-| `relay_requester` | string | Who requested relay state |
-| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN |
-| `is_sheddable` | bool | Whether circuit can be shed |
-| `pcs_managed` | bool | (v1.0) Whether the panel's Power Control System manages this circuit. Present only when the circuit reports it |
-| `pcs_priority` | int | (v1.0) This circuit's shed order under an active import limit — distinct from `shed_priority`, which is the backup tier |
+| Attribute | Type | Notes |
+| ----------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
+| `tabs` | string | Breaker slot position(s) |
+| `voltage` | string | 120 or 240 (derived from tab count) |
+| `always_on` | bool | Whether circuit is always-on |
+| `relay_state` | string | OPEN / CLOSED / UNKNOWN |
+| `relay_requester` | string | Who requested relay state |
+| `shed_priority` | string | API value: NEVER / SOC_THRESHOLD / OFF_GRID / UNKNOWN |
+| `is_sheddable` | bool | Whether circuit can be shed |
+| `pcs_managed` | bool | Whether the panel's Power Control System manages this circuit. Present only when the circuit reports it |
+| `pcs_priority` | int | This circuit's shed order under an active import limit — distinct from`shed_priority`, which is the backup tier |
### Circuit Energy Sensor Attributes
@@ -466,34 +456,34 @@ Applies to Main Meter and Feed Through energy sensors.
### Binary Sensors
-| Sensor | Device Class | Notes |
-| --------------- | ------------ | -------------------------------------------------------------------------------------------------- |
-| Door State | Tamper | Panel door open/closed |
-| Ethernet Link | Connectivity | Wired network status |
-| Wi-Fi Link | Connectivity | Wireless network status |
-| Panel Status | Connectivity | Overall panel online/offline |
-| Grid Islandable | — | (v2) Whether the panel can island from the grid. Off on a panel with no MID — see below |
-| PCS Active | Running | (v1.0) Whether the Power Control System is limiting import right now. Only when the panel runs one |
-| PV Panel Link | Connectivity | (v1.0) Whether the panel can reach the solar inverter. Only when the feeding circuit reports it |
+| Sensor | Device Class | Notes |
+| --------------- | ------------ | ------------------------------------------------------------------------------------------- |
+| Door State | Tamper | Panel door open/closed |
+| Ethernet Link | Connectivity | Wired network status |
+| Wi-Fi Link | Connectivity | Wireless network status |
+| Panel Status | Connectivity | Overall panel online/offline |
+| Grid Islandable | — | Whether the panel can island from the grid. Off on a panel with no MID — see below |
+| PCS Active | Running | Whether the Power Control System is limiting import right now. Only when the panel runs one |
+| PV Panel Link | Connectivity | Whether the panel can reach the solar inverter. Only when the feeding circuit reports it |
**Removed from binary sensors:**
-| Sensor | Reason |
-| --------------- | ------------------------------------------------------ |
-| Cellular (wwan) | Replaced by `Vendor Cloud` sensor (cloud connectivity) |
+| Sensor | Reason |
+| --------------- | ----------------------------------------------------- |
+| Cellular (wwan) | Replaced by`Vendor Cloud` sensor (cloud connectivity) |
### Circuit Controls (per user-controllable circuit)
-| Entity | Type | Notes |
-| --------------------- | ------ | -------------------------------------------------------------------------- |
-| Breaker | Switch | On/off relay control |
-| Circuit Priority | Select | (v2) Controls when the circuit is shed during off-grid (translated, see below) |
+| Entity | Type | Notes |
+| ---------------- | ------ | ------------------------------------------------------------------------- |
+| Breaker | Switch | On/off relay control |
+| Circuit Priority | Select | Controls when the circuit is shed during off-grid (translated, see below) |
### Panel Controls
-| Entity | Type | Notes |
-| ---------------------------- | ------ | ---------------------------------------------------------------------- |
-| GFE Override: Grid Connected | Button | (v2) Tell the panel the grid is up when BESS communication interrupted |
+| Entity | Type | Notes |
+| ---------------------------- | ------ | ----------------------------------------------------------------- |
+| GFE Override: Grid Connected | Button | Tell the panel the grid is up when BESS communication interrupted |
### BESS & Grid Management
@@ -582,12 +572,14 @@ Configure via `Settings` > `Devices & Services` > `SPAN Panel` > `Configure` > `
The integration provides flexible entity naming patterns, configured during initial setup:
1. **Friendly Names** (Recommended for new installations)
+
- Entity IDs use descriptive circuit names from your SPAN panel
- Example: `sensor.span_panel_kitchen_outlets_power`
- Automatically updates when you rename circuits in the SPAN panel
- More intuitive for automations and scripts
2. **Circuit Numbers** (Stable entity IDs)
+
- Entity IDs use generic circuit numbers
- Example: `sensor.span_panel_circuit_15_power`
- Entity IDs remain stable even when circuits are renamed
@@ -636,9 +628,9 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo
| Issue | Symptoms | Resolution |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise `TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. |
-| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase **Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. |
-| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In **Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. |
+| **Energy Dashboard spikes after firmware updates** | Huge energy-consumption spikes after panel firmware updates; charts showing untracked values that dwarf normal usage; negative energy values in statistics. Caused by the panel reporting decreased values on otherwise`TOTAL_INCREASING` sensors. | **Prevention:** enable [Energy Dip Compensation](#energy-dip-compensation) in General Options (on by default for new installs). **Fix existing spikes:** in **Developer Tools → Statistics**, search for the affected sensor (e.g. `sensor.span_panel_main_meter_consumed_energy`) and use **Adjust sum** to correct the errant entry. The integration also notifies when a decrease in the main meter consumed sensor is detected. |
+| **High CPU usage** | Elevated CPU on low-power hardware (e.g. Raspberry Pi). The integration rebuilds a full panel snapshot from MQTT messages at a configurable interval (default 1 s). | Increase**Snapshot Update Interval** in **General Options**. 10–15 s is recommended for resource-constrained systems. Setting it to 0 disables debouncing and rebuilds on every MQTT message — not recommended. |
+| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. |
| **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. |
| **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. |
From 2120074050d2f321eb127352580c081f48d10fb1 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 00:28:50 -0700
Subject: [PATCH 097/116] feat(extension): surface vendor properties on devices
we already model
A publisher adding a property to the BESS, a charger, a circuit or the panel
reached the user nowhere: it became a declaration-only discovery row, visible
in a diagnostics attachment and nowhere else. This integration should not be
the gatekeeper on what a publisher can reach a user with, so those properties
now become entities on the device card they belong to -- disabled, diagnostic,
and named in plain wire vocabulary so they read as uncurated, which they are.
**An adopted extension is a terminal identity.** Nothing promotes, re-homes or
migrates one. It changes only on an external trigger: the publisher stops
publishing it, or better metadata arrives. Curation is never blocked by one
existing -- ids are permanent, identity is not.
**Nothing is ever removed.** A row the user deletes returns, disabled, while
the property is still published, so deletion is not suppression and none is
built. Deletion sticks exactly when publishing has stopped, because then
nothing exists to recreate it from.
The unique_id is the one irreversible commitment, so it ships with its tests:
span_{serial}_adopted_{scope}/{node}/{property}
anchored on the serial and the curated scope, addressed by the wire path
verbatim -- upstream's own capability-catalog spelling. Verbatim is what makes
it injective: `battery-2` + `cell-temperature` and `battery` + `2-cell-
temperature` collapse under any normalising scheme and stay distinct here. Not
the eBus proxy composition, whose ids upstream states are local handles rather
than identities and are unstable across the proxy-to-native transition.
Read-only, no controls: these sit beside curated controls that do real work --
the EVSE limit refuses a value above the commissioned ceiling, the islanding
assertion translates GRID into ON_GRID -- and a generic write path would have
neither. The platform is a one-way door enforced in `resolve_platform`: an
entity domain is part of entity_id and the registry refuses a cross-domain
rename, so whatever domain a row is registered under wins however the
declaration later changes. Metadata may reshape everything else freely, which
is safe precisely because no extension entity carries a `state_class` and so
has no statistics for a unit change to corrupt.
A per-device cap bounds a misbehaving publisher, since rows are permanent and
nothing removes them, and the additions notice collapses them one line per
device -- fifteen new vendor readings must not cost the user the curated
additions in the same message.
Requires span-panel-api 3.0.0b12 and span-panel-api-schema-1 0.1.0b9, which
carry `extension_properties`.
---
custom_components/span_panel/additions.py | 30 ++
custom_components/span_panel/adoption.py | 14 +-
custom_components/span_panel/binary_sensor.py | 13 +-
custom_components/span_panel/extension.py | 502 ++++++++++++++++++
custom_components/span_panel/manifest.json | 4 +-
custom_components/span_panel/sensor.py | 14 +-
pyproject.toml | 4 +-
requirements_test.txt | 4 +-
tests/test_extension_entities.py | 343 ++++++++++++
tests/test_extension_ids.py | 178 +++++++
tests/test_new_entity_announcement.py | 36 ++
uv.lock | 4 +-
12 files changed, 1128 insertions(+), 18 deletions(-)
create mode 100644 custom_components/span_panel/extension.py
create mode 100644 tests/test_extension_entities.py
create mode 100644 tests/test_extension_ids.py
diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py
index b591ba40..516b19ec 100644
--- a/custom_components/span_panel/additions.py
+++ b/custom_components/span_panel/additions.py
@@ -34,6 +34,7 @@
from homeassistant.helpers.storage import Store
from .const import DOMAIN
+from .extension import is_extension_unique_id
from .notices import async_raise, read_translations
from .util import ADOPTED_IDENTIFIER_TOKEN
@@ -167,6 +168,11 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str,
for registry_entry in added:
device_name = _adopted_device_name(devices, registry_entry)
+ if device_name is None:
+ # Vendor extensions collapse the same way, and need their own
+ # detector: they live on *curated* devices, so the identifier test
+ # above cannot see them. Their unique_id is what says what they are.
+ device_name = _extension_device_name(devices, registry_entry)
if device_name is not None:
adopted[device_name] = adopted.get(device_name, 0) + 1
continue
@@ -218,6 +224,30 @@ def _adopted_device_name(
return device.name_by_user or device.name or registry_entry.entity_id
+def _extension_device_name(
+ devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry
+) -> str | None:
+ """Return the curated device a vendor extension belongs to, or None for anything else.
+
+ Detected by unique_id rather than by device identifier, because that is the
+ only thing that distinguishes these: an extension entity sits on a *curated*
+ card beside curated entities, so the card says nothing about it.
+
+ Collapsed for the same reason adopted devices are, and the arithmetic is
+ worse here: a firmware update adding fifteen vendor properties to the battery
+ would spend the entire notification on them and teach the user to skip it,
+ costing them the curated additions in the same message.
+ """
+ if registry_entry.device_id is None or not registry_entry.unique_id:
+ return None
+ if not is_extension_unique_id(registry_entry.unique_id):
+ return None
+ device = devices.async_get(registry_entry.device_id)
+ if device is None:
+ return None
+ return device.name_by_user or device.name or registry_entry.entity_id
+
+
def _label(devices: dr.DeviceRegistry, registry_entry: er.RegistryEntry) -> str:
"""Return what to call an entity the user has never seen.
diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py
index 09869d57..ab9b9403 100644
--- a/custom_components/span_panel/adoption.py
+++ b/custom_components/span_panel/adoption.py
@@ -238,9 +238,9 @@ def adopted_device_info(
"""
return DeviceInfo(
identifiers={(DOMAIN, identifier)},
- name=device.name or _humanised(device.device_type.rsplit(".", 1)[-1]),
+ name=device.name or humanised(device.device_type.rsplit(".", 1)[-1]),
manufacturer=device.vendor_name or "Unknown",
- model=device.model or _humanised(device.device_type.rsplit(".", 1)[-1]),
+ model=device.model or humanised(device.device_type.rsplit(".", 1)[-1]),
serial_number=device.serial_number,
sw_version=device.software_version,
hw_version=device.hardware_version,
@@ -248,7 +248,7 @@ def adopted_device_info(
)
-def _humanised(wire_token: str) -> str:
+def humanised(wire_token: str) -> str:
"""`backup-generator` -> `Backup Generator`, for a name with no translation.
Deliberately plain. An adopted entity renders from wire vocabulary until it
@@ -319,7 +319,7 @@ def __init__(
self._device_wire_id = device.device_id
self._declaration_path = declaration.path
self._attr_unique_id = adopted_unique_id(identifier, declaration)
- self._attr_name = _humanised(declaration.property_id)
+ self._attr_name = humanised(declaration.property_id)
self._attr_device_info = adopted_device_info(
identifier, device, panel_device_id=panel_device_id
)
@@ -392,7 +392,7 @@ class AdoptedBinarySensor(AdoptedEntity, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Homie spells a boolean `true`/`false`; anything else is not an answer."""
- return _boolean(self._published())
+ return homie_boolean(self._published())
class AdoptedControl(AdoptedEntity):
@@ -440,7 +440,7 @@ class AdoptedSwitch(AdoptedControl, SwitchEntity):
@property
def is_on(self) -> bool | None:
"""Homie spells a boolean `true`/`false`; anything else is not an answer."""
- return _boolean(self._published())
+ return homie_boolean(self._published())
async def async_turn_on(self, **kwargs: object) -> None:
"""Publish the vocabulary Homie defines for a boolean, not HA's."""
@@ -531,7 +531,7 @@ async def async_set_native_value(self, value: float) -> None:
await self._publish(str(int(value)) if self._integral else str(value))
-def _boolean(published: str | None) -> bool | None:
+def homie_boolean(published: str | None) -> bool | None:
"""Return Homie's `true`/`false`, with anything else meaning no answer."""
if published is None:
return None
diff --git a/custom_components/span_panel/binary_sensor.py b/custom_components/span_panel/binary_sensor.py
index 78c16461..16cb1633 100644
--- a/custom_components/span_panel/binary_sensor.py
+++ b/custom_components/span_panel/binary_sensor.py
@@ -14,7 +14,7 @@
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -34,6 +34,7 @@
)
from .coordinator import SpanPanelCoordinator
from .entity import SpanPanelEntity
+from .extension import create_extension_binary_sensors
from .field_paths import DerivedReason, FieldPathDeclarationMixin
from .helpers import (
build_binary_sensor_unique_id_for_entry,
@@ -682,5 +683,15 @@ async def async_setup_entry(
dr.async_get(hass),
panel_device_id=config_entry.runtime_data.panel_device_id,
),
+ # Vendor extensions on devices this integration *does* model. A
+ # separate inventory from adoption's for the same reason adoption is
+ # separate from the curated descriptions: different question, and
+ # `extension` owns the answer.
+ *create_extension_binary_sensors(
+ coordinator,
+ snapshot,
+ dr.async_get(hass),
+ er.async_get(hass),
+ ),
]
)
diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py
new file mode 100644
index 00000000..6e2aa8fe
--- /dev/null
+++ b/custom_components/span_panel/extension.py
@@ -0,0 +1,502 @@
+"""Identity for vendor extension properties on devices this integration models.
+
+The other half of vendor extensibility from `adoption.py`. That module adopts a
+whole *device* nobody modelled; this one carries a new *property* on a device
+this integration already models -- a battery vendor hanging `battery-2/
+cell-temperature` off the BESS -- which until now reached the user nowhere.
+
+**An adopted extension is a terminal identity.** It is a vendor reading on the
+correct existing device card, disabled and diagnostic, in plain wire vocabulary,
+and it stays that until an external trigger changes it: the publisher stops
+publishing the property, or better metadata arrives. Nothing here promotes,
+re-homes or migrates one on its own schedule, and curation is never blocked by
+one existing -- an entity's `unique_id` and `entity_id` are permanent, its
+*identity* carries no expectation of permanence beyond that.
+
+**Nothing is ever removed by this integration.** A row the user deletes is
+recreated -- disabled, as it arrives -- for as long as the property is still
+published, so deletion is not suppression and no suppression feature is needed.
+Deletion sticks exactly when publishing has stopped, because then nothing exists
+to recreate it from. The delete button therefore already means "hide until next
+setup" for a live reading and "clear this out" for a dead one, decided by the
+wire rather than by a feature.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Final
+
+from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.const import EntityCategory, Platform
+from homeassistant.helpers.device_registry import DeviceInfo, DeviceRegistry
+from homeassistant.helpers.entity_registry import EntityRegistry
+from span_panel_api import ExtensionProperty, ExtensionSubject, SpanPanelSnapshot
+
+from .adoption import BOOLEAN_DATATYPE, DEVICE_CLASS_BY_UNIT, homie_boolean, humanised
+from .const import DOMAIN
+from .coordinator import SpanPanelCoordinator
+from .entity import SpanPanelEntity
+from .util import (
+ ADOPTED_IDENTIFIER_TOKEN,
+ SUB_DEVICE_BESS,
+ SUB_DEVICE_EVSE,
+ SUB_DEVICE_MID,
+ SUB_DEVICE_PV,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+SCOPE_PANEL: Final = "panel"
+
+HINT_READING: Final = "reading"
+HINT_DETAIL: Final = "detail"
+
+IDENTITY_FAMILY: Final = frozenset(
+ {"vendor", "model", "serial", "part-number", "firmware", "hardware", "build", "revision"}
+)
+"""Property-name tokens that mark a declaration as device description.
+
+Matched outside the `info` node, which never reaches adoption at all -- so a hit
+here is catching a vendor that put identity on a capability node rather than
+re-checking what `info` already resolved.
+"""
+
+HOMIE_ID: Final = re.compile(r"^[a-z0-9-]+$")
+"""The Homie id charset: lowercase alphanumerics and hyphens, nothing else.
+
+Load-bearing rather than decorative. The grammar below joins the node and the
+property with `/`, and that separator is only unambiguous because an id cannot
+contain one -- which is the same reason upstream's own capability-catalog paths
+and `discovery_path()` are spelled with slashes. An id outside this charset is
+refused adoption rather than sanitised, because sanitising is what would make
+the split ambiguous again.
+"""
+
+_SCOPE_BY_KIND: Final[dict[str, str]] = {
+ "panel": SCOPE_PANEL,
+ "battery": SUB_DEVICE_BESS,
+ "mid": SUB_DEVICE_MID,
+ "pv": SUB_DEVICE_PV,
+}
+"""Library subject kind → the scope segment this integration's ids already use.
+
+The singletons only. `evse` and `circuit` carry an instance key and are built
+below, because their segment is `{kind}_{key}` rather than a constant.
+"""
+
+
+def extension_scope(subject: ExtensionSubject) -> str | None:
+ """Return the id scope segment for a subject, or `None` if it names no device.
+
+ `None` rather than a fallback string: a subject kind this integration does
+ not place on a device card has no card to hang an entity on, and inventing a
+ scope would mint a permanent id for an entity with nowhere to live.
+ """
+ if subject.kind in _SCOPE_BY_KIND:
+ return _SCOPE_BY_KIND[subject.kind]
+ if subject.instance_key is None:
+ return None
+ if subject.kind == "evse":
+ return f"{SUB_DEVICE_EVSE}_{subject.instance_key}"
+ if subject.kind == "circuit":
+ return f"circuit_{subject.instance_key}"
+ return None
+
+
+def extension_unique_id(
+ serial: str, subject: ExtensionSubject, node_id: str, property_id: str
+) -> str | None:
+ """Return the unique id for one extension property, or `None` if it is unadoptable.
+
+ span_{serial}_adopted_{scope}/{node}/{property}
+
+ Anchored on what is stable and ours -- the panel serial and the curated scope
+ -- and addressed by the wire path **verbatim**, which is upstream's own
+ capability-catalog spelling (`AdoptedProperty.path`, `discovery_path()`).
+
+ **Verbatim, and never through `get_user_friendly_suffix`.** That helper
+ de-*dots* rather than de-hyphens and substitutes a curated suffix on a
+ mapping hit, so routing a wire address through it would both mangle the
+ address and let a vendor string collide with a curated spelling. Carrying the
+ path as published is also what makes this injective: the id *is* the wire
+ address, so two distinct addresses cannot collapse into one. Normalising
+ hyphens to underscores would collapse `battery-2` + `cell-temperature` and
+ `battery` + `2-cell-temperature` into the same id.
+
+ **Not the eBus proxy composition.** `{proxier-id}-{proxied-id}` is upstream's
+ device-handle spelling, and upstream is explicit that those handles are not
+ identities: they differ across enclosures by design and are unstable across
+ the proxy-to-native transition. A permanent id anchored on one would rename
+ itself when a device stopped being proxied, stranding every entity keyed on
+ it -- and nothing migrates, so there would be no recovery.
+
+ The `adopted` token sits immediately after the serial, so the id is
+ namespaced by prefix. Device-level adoption's ids contain no `/`, so the
+ presence of a slash is what tells the two adoption grammars apart -- no
+ reader has to count token positions.
+ """
+ scope = extension_scope(subject)
+ if scope is None:
+ _LOGGER.debug(
+ "Extension property %s/%s names no device card (subject kind %s); not adopted",
+ node_id,
+ property_id,
+ subject.kind,
+ )
+ return None
+ if not HOMIE_ID.match(node_id) or not HOMIE_ID.match(property_id):
+ # Refused rather than sanitised: see `HOMIE_ID`. The property is still
+ # reported in diagnostics, so the case is visible rather than silent.
+ _LOGGER.warning(
+ "Extension property %s/%s is outside the Homie id charset and is not adopted; "
+ "it remains visible in diagnostics",
+ node_id,
+ property_id,
+ )
+ return None
+ return f"span_{serial}_{ADOPTED_IDENTIFIER_TOKEN}_{scope}/{node_id}/{property_id}"
+
+
+def is_extension_unique_id(unique_id: str) -> bool:
+ """Whether an id was minted by the grammar above.
+
+ The slash is the discriminator, for the reason `extension_unique_id`
+ documents: a curated id has no `adopted` token, and a device-level adopted id
+ has the token but no slash.
+ """
+ return f"_{ADOPTED_IDENTIFIER_TOKEN}_" in unique_id and "/" in unique_id
+
+
+def extension_device_identifier(panel_identifier: str, subject: ExtensionSubject) -> str | None:
+ """Return the registry identifier of the curated device this property belongs on.
+
+ The identifier only, never a rebuilt `DeviceInfo`. An extension entity joins
+ a card that already exists; restating that card's name, manufacturer or model
+ here would be a second implementation of `util`'s builders, free to drift and
+ able to rename a user's device by disagreeing with them.
+
+ A circuit's entities live on the panel's own card in this integration, so a
+ circuit subject resolves there too -- the entity's *name* is what says which
+ circuit it came from.
+ """
+ if subject.kind in ("panel", "circuit"):
+ return panel_identifier
+ if subject.kind == "battery":
+ return f"{panel_identifier}_{SUB_DEVICE_BESS}"
+ if subject.kind == "mid":
+ return f"{panel_identifier}_{SUB_DEVICE_MID}"
+ if subject.kind == "pv":
+ return f"{panel_identifier}_{SUB_DEVICE_PV}"
+ if subject.kind == "evse" and subject.instance_key is not None:
+ return f"{panel_identifier}_{SUB_DEVICE_EVSE}_{subject.instance_key}"
+ return None
+
+
+def classify_extension(datatype: str) -> Platform:
+ """Return the platform a declared datatype surfaces on.
+
+ Two platforms, because `adoption.classify`'s three control rows are
+ deliberately absent: an extension property lives on a device whose curated
+ controls do real safety work -- the EVSE limit refuses a value above the
+ commissioned ceiling, the islanding assertion translates `GRID` into
+ `ON_GRID` -- and a generic write path would sit beside them on the same wire
+ with neither. A settable property therefore surfaces as a reading.
+
+ **Derived once, at first sighting, and never re-derived.** See
+ `resolve_platform`: the domain is baked into `entity_id`, so a later
+ datatype change must not move an existing row.
+ """
+ return Platform.BINARY_SENSOR if datatype == BOOLEAN_DATATYPE else Platform.SENSOR
+
+
+def resolve_platform(registry: EntityRegistry, unique_id: str, datatype: str) -> Platform:
+ """Return the platform this id already uses, or the one its datatype implies.
+
+ **The platform is a one-way door**, and this function is the door. An entity
+ domain is part of `entity_id`, and the registry refuses a cross-domain rename
+ outright -- `async_update_entity` raises `ValueError("New entity ID should be
+ same domain")`. So a row born a `sensor` can never become a `binary_sensor`:
+ re-deriving the platform from a changed declaration would not move it, it
+ would strand it and mint a second entity beside it.
+
+ Metadata may reshape everything else about a standing entity -- category,
+ device class, unit, name, icon -- and safely, because these entities carry no
+ `state_class` and so have no statistics for a unit change to corrupt. The
+ platform is the exception, and the exception is enforced here rather than
+ remembered: whatever domain the id is already registered under wins.
+ """
+ for platform in (Platform.SENSOR, Platform.BINARY_SENSOR):
+ if registry.async_get_entity_id(platform.value, DOMAIN, unique_id) is not None:
+ return platform
+ return classify_extension(datatype)
+
+
+def prominence_hint(row: ExtensionProperty) -> str:
+ """Return an advisory ranking for one extension property.
+
+ Advisory, and only advisory: every extension entity arrives DIAGNOSTIC
+ whatever this says. `entity_category` is the one attribute that is free to
+ revise later -- no id change, no statistics consequence -- so the conservative
+ default costs a line in a future release, while the mistakes that are *not*
+ free are simply never made here.
+
+ Ranked by confidence, and each signal's failure mode is why it sits where it
+ does:
+
+ 1. **Identity-family naming → `detail`.** Highest confidence because it is a
+ purely *negative* signal: a property named for a vendor, model, serial,
+ part number or firmware build is device description, not a reading. Fails
+ only on a vendor using an identity word for a live value.
+ 2. **A unit with a device class → `reading`.** Moderate: a physical
+ measurement is more likely a headline than a knob. Fails *systematically*
+ in one direction -- the most headline-worthy number a battery publishes is
+ a `%` state of charge, and `%` is deliberately absent from
+ `DEVICE_CLASS_BY_UNIT` because it is equally a confidence or a duty cycle.
+ So this signal may promote and never demote.
+ 3. **Everything else → `detail`**, with `node_has_curated_siblings` recorded
+ beside it as corroboration rather than as a decision. Homie nodes are
+ organisational rather than editorial: vendors hang configuration knobs off
+ `meter` because that is where the code was.
+
+ The upstream `role` declaration proposed alongside this design would retire
+ all three for compliant publishers, which is why it is worth asking for.
+ """
+ if any(token in row.property_id for token in IDENTITY_FAMILY):
+ return HINT_DETAIL
+ if row.unit and row.unit in DEVICE_CLASS_BY_UNIT:
+ return HINT_READING
+ return HINT_DETAIL
+
+
+MAX_PER_DEVICE: Final = 60
+"""How many extension entities one device may mint before the rest are declined.
+
+A registry row is permanent in a way a reading is not: nothing here removes one,
+and a row a user deletes returns at the next setup while the property is still
+published. So a vendor node declaring hundreds of properties would put hundreds
+of rows in every entity picker on every install that met it, and no later
+release could take them back.
+
+The cap is deliberately generous -- far above any real device, and the sixteen
+`pcs` properties are the largest curated example -- so it is a backstop against a
+misbehaving publisher rather than a policy on normal ones. Declining is reported
+through a notice naming the device, because a silent truncation would read as
+"that is everything the vendor publishes" when it is not.
+"""
+
+
+class ExtensionEntity(SpanPanelEntity):
+ """Base for an entity built from a vendor extension on a curated device.
+
+ Disabled and diagnostic without exception, exactly as `AdoptedEntity` is: the
+ integration's job here is to make the reading reachable, not to put it on
+ somebody's dashboard.
+ """
+
+ _attr_entity_registry_enabled_default = False
+ _attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
+
+ def __init__(
+ self,
+ coordinator: SpanPanelCoordinator,
+ unique_id: str,
+ row: ExtensionProperty,
+ *,
+ device_identifier: str,
+ ) -> None:
+ """Bind this entity to one extension property of one curated device."""
+ super().__init__(coordinator)
+ self._subject_kind = row.subject.kind
+ self._instance_key = row.subject.instance_key
+ self._declaration_path = row.path
+ self._attr_unique_id = unique_id
+ # Node-prefixed on purpose. Curated names on these cards carry no wire
+ # vocabulary, so a collision with a curated "Power" or "Status" is
+ # avoided by construction rather than by a registration-order-dependent
+ # dedup -- and two vendor nodes on one device disambiguate without a
+ # special case. Plain, clunky, and honestly so: it marks the entity as
+ # uncurated, which is what it is.
+ self._attr_name = f"{humanised(row.node_id)} {humanised(row.property_id)}"
+ # Identifiers only: the card already exists and belongs to `util`'s
+ # builders. Restating its name or model here could rename a user's device
+ # by disagreeing with them.
+ self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_identifier)})
+ self._attr_extra_state_attributes = {
+ "prominence_hint": prominence_hint(row),
+ "wire_path": row.path,
+ }
+
+ def _row(self) -> ExtensionProperty | None:
+ """Return this property's current row, or None when it has left the tree.
+
+ Matched out of the snapshot each cycle rather than captured at
+ construction, so a device that leaves and returns keeps reporting through
+ the same entity. A property the publisher stops publishing simply stops
+ being found: the entity reads unknown and is never removed, because
+ absence on the wire is ambiguous between "gone" and "not yet arrived".
+ """
+ snapshot: SpanPanelSnapshot = self.coordinator.data
+ for row in snapshot.extension_properties:
+ if (
+ row.subject.kind == self._subject_kind
+ and row.subject.instance_key == self._instance_key
+ and row.path == self._declaration_path
+ ):
+ return row
+ return None
+
+ def _published(self) -> str | None:
+ """Return the published value, or None when nothing has arrived."""
+ row = self._row()
+ return None if row is None else row.value
+
+
+class ExtensionSensor(ExtensionEntity, SensorEntity):
+ """A vendor reading on a curated device, with no `state_class`.
+
+ No `state_class`, ever, and that is what makes the rest of this safe: an
+ entity writing no long-term statistics has nothing for a later unit or
+ device-class change to corrupt, so metadata may reshape it freely.
+ """
+
+ def __init__(
+ self,
+ coordinator: SpanPanelCoordinator,
+ unique_id: str,
+ row: ExtensionProperty,
+ *,
+ device_identifier: str,
+ ) -> None:
+ """Take the unit and device class from what the publisher declared."""
+ super().__init__(coordinator, unique_id, row, device_identifier=device_identifier)
+ self.entity_description = SensorEntityDescription(
+ key=row.path,
+ device_class=DEVICE_CLASS_BY_UNIT.get(row.unit or ""),
+ native_unit_of_measurement=row.unit,
+ )
+
+ @property
+ def native_value(self) -> str | float | None:
+ """Return the published value, parsed to a number only where one is declared."""
+ raw = self._published()
+ if raw is None:
+ return None
+ if self.entity_description.native_unit_of_measurement is None:
+ return raw
+ try:
+ return float(raw)
+ except ValueError:
+ _LOGGER.debug(
+ "Extension %s published %r, which is not a number", self._declaration_path, raw
+ )
+ return None
+
+
+class ExtensionBinarySensor(ExtensionEntity, BinarySensorEntity):
+ """A declared `boolean` vendor extension on a curated device."""
+
+ @property
+ def is_on(self) -> bool | None:
+ """Homie spells a boolean `true`/`false`; anything else is not an answer."""
+ return homie_boolean(self._published())
+
+
+def create_extension_sensors(
+ coordinator: SpanPanelCoordinator,
+ snapshot: SpanPanelSnapshot,
+ device_registry: DeviceRegistry,
+ entity_registry: EntityRegistry,
+) -> list[ExtensionSensor]:
+ """Every extension property that is not a declared boolean."""
+ return _create(
+ ExtensionSensor, coordinator, snapshot, device_registry, entity_registry, Platform.SENSOR
+ )
+
+
+def create_extension_binary_sensors(
+ coordinator: SpanPanelCoordinator,
+ snapshot: SpanPanelSnapshot,
+ device_registry: DeviceRegistry,
+ entity_registry: EntityRegistry,
+) -> list[ExtensionBinarySensor]:
+ """Every extension property declared `boolean`."""
+ return _create(
+ ExtensionBinarySensor,
+ coordinator,
+ snapshot,
+ device_registry,
+ entity_registry,
+ Platform.BINARY_SENSOR,
+ )
+
+
+def _create[ExtensionT: ExtensionEntity](
+ entity_class: type[ExtensionT],
+ coordinator: SpanPanelCoordinator,
+ snapshot: SpanPanelSnapshot,
+ device_registry: DeviceRegistry,
+ entity_registry: EntityRegistry,
+ platform: Platform,
+) -> list[ExtensionT]:
+ """Build one platform's share of the extension properties.
+
+ One partition function rather than two bodies, so `resolve_platform` stays
+ the only place a property's platform is decided -- two bodies would each
+ restate the predicate, and a property could then reach both platforms or
+ neither.
+ """
+ built: list[ExtensionT] = []
+ for row, unique_id, device_identifier in adoptable(snapshot, device_registry):
+ if resolve_platform(entity_registry, unique_id, row.datatype) is not platform:
+ continue
+ built.append(entity_class(coordinator, unique_id, row, device_identifier=device_identifier))
+ return built
+
+
+def adoptable(
+ snapshot: SpanPanelSnapshot, device_registry: DeviceRegistry
+) -> list[tuple[ExtensionProperty, str, str]]:
+ """Every extension property that can become an entity, with its id and card.
+
+ Three reasons a declared property is declined here, all of them stated rather
+ than silent: its subject resolves to no device card, its card is not in the
+ registry yet, or its address is outside the Homie charset. The first two are
+ ordinary states on a setup that raced a capability -- the entity appears on
+ the next reload, as capability-gated platforms already do.
+ """
+ per_device: dict[str, int] = {}
+ declined: dict[str, int] = {}
+ adoptable_rows: list[tuple[ExtensionProperty, str, str]] = []
+ for row in snapshot.extension_properties:
+ identifier = extension_device_identifier(snapshot.serial_number, row.subject)
+ if identifier is None:
+ continue
+ if device_registry.async_get_device(identifiers={(DOMAIN, identifier)}) is None:
+ _LOGGER.debug(
+ "Extension property %s has no registered device for %s yet; deferred to the next reload",
+ row.path,
+ identifier,
+ )
+ continue
+ unique_id = extension_unique_id(
+ snapshot.serial_number, row.subject, row.node_id, row.property_id
+ )
+ if unique_id is None:
+ continue
+ if per_device.get(identifier, 0) >= MAX_PER_DEVICE:
+ declined[identifier] = declined.get(identifier, 0) + 1
+ continue
+ per_device[identifier] = per_device.get(identifier, 0) + 1
+ adoptable_rows.append((row, unique_id, identifier))
+ for identifier, count in declined.items():
+ _LOGGER.warning(
+ "Device %s declares more than %d vendor properties; %d were not adopted. "
+ "They remain visible in this integration's diagnostics",
+ identifier,
+ MAX_PER_DEVICE,
+ count,
+ )
+ return adoptable_rows
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 25d9a1f0..ece3c4bd 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,9 +22,9 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b11",
+ "span-panel-api==3.0.0b12",
"span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b8"
+ "span-panel-api-schema-1==0.1.0b9"
],
"version": "2.1.0b6",
"zeroconf": [
diff --git a/custom_components/span_panel/sensor.py b/custom_components/span_panel/sensor.py
index 2d1d111e..fefbcb36 100644
--- a/custom_components/span_panel/sensor.py
+++ b/custom_components/span_panel/sensor.py
@@ -6,7 +6,7 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from span_panel_api import SpanPanelSnapshot
@@ -21,6 +21,7 @@
USE_CIRCUIT_NUMBERS,
)
from .coordinator import SpanPanelCoordinator
+from .extension import create_extension_sensors
from .helpers import (
has_bess,
has_bess_telemetry,
@@ -130,8 +131,17 @@ async def async_setup_entry(
panel_device_id=config_entry.runtime_data.panel_device_id,
)
+ # Vendor extensions on devices this integration *does* model, which
+ # adoption deliberately leaves alone -- see `extension`.
+ extensions = create_extension_sensors(
+ coordinator,
+ snapshot,
+ dr.async_get(hass),
+ er.async_get(hass),
+ )
+
# Add all native sensor entities
- async_add_entities([*entities, *adopted])
+ async_add_entities([*entities, *adopted, *extensions])
# Force immediate coordinator refresh to ensure all sensors update right away
await coordinator.async_request_refresh()
diff --git a/pyproject.toml b/pyproject.toml
index 29510978..ef351471 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,13 +8,13 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b11",
+ "span-panel-api==3.0.0b12",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
# and that is the list Home Assistant actually reads.
"span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b8",
+ "span-panel-api-schema-1==0.1.0b9",
]
[dependency-groups]
diff --git a/requirements_test.txt b/requirements_test.txt
index 7ea9a044..5bcb64a4 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0b11
+span-panel-api==3.0.0b12
span-panel-api-schema-0==1.0.0b5
-span-panel-api-schema-1==0.1.0b8
+span-panel-api-schema-1==0.1.0b9
diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py
new file mode 100644
index 00000000..77bfcb1f
--- /dev/null
+++ b/tests/test_extension_entities.py
@@ -0,0 +1,343 @@
+"""Vendor extensions on curated devices become entities, on the right card, forever.
+
+Three properties carry this half of the design and each fails loudly here if it
+stops holding: an extension entity lands on the device it belongs to and never
+mints a card of its own, the platform a row is born under is the platform it
+keeps however the declaration changes, and nothing is created for a card that is
+not there yet.
+"""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from typing import TYPE_CHECKING
+from unittest.mock import MagicMock
+
+from homeassistant.const import EntityCategory, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+import pytest
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+from span_panel_api import ExtensionProperty, ExtensionSubject
+
+from custom_components.span_panel.const import DOMAIN
+from custom_components.span_panel.extension import (
+ HINT_DETAIL,
+ HINT_READING,
+ MAX_PER_DEVICE,
+ ExtensionBinarySensor,
+ ExtensionSensor,
+ adoptable,
+ classify_extension,
+ create_extension_binary_sensors,
+ create_extension_sensors,
+ extension_device_identifier,
+ prominence_hint,
+ resolve_platform,
+)
+from custom_components.span_panel.util import SUB_DEVICE_BESS
+
+from .factories import SpanPanelSnapshotFactory
+
+if TYPE_CHECKING:
+ from span_panel_api import SpanPanelSnapshot
+
+PANEL_SERIAL = "sp3-242424-001"
+BESS_IDENTIFIER = f"{PANEL_SERIAL}_{SUB_DEVICE_BESS}"
+
+
+@pytest.fixture
+def registered_panel(hass: HomeAssistant) -> tuple[str, str]:
+ """Return a config entry with the panel and its BESS card registered, as setup leaves them."""
+ mock = MockConfigEntry(domain=DOMAIN, data={}, unique_id=PANEL_SERIAL)
+ mock.add_to_hass(hass)
+ registry = dr.async_get(hass)
+ panel = registry.async_get_or_create(
+ config_entry_id=mock.entry_id,
+ identifiers={(DOMAIN, PANEL_SERIAL)},
+ name="Span Panel",
+ )
+ registry.async_get_or_create(
+ config_entry_id=mock.entry_id,
+ identifiers={(DOMAIN, BESS_IDENTIFIER)},
+ name="Span Panel Battery",
+ via_device_id=panel.id,
+ )
+ return str(mock.entry_id), panel.id
+
+
+def _row(
+ node_id: str = "battery-2",
+ property_id: str = "cell-temperature",
+ datatype: str = "float",
+ unit: str | None = "°C",
+ value: str | None = "31.4",
+ kind: str = "battery",
+ instance_key: str | None = None,
+ settable: bool = False,
+) -> ExtensionProperty:
+ return ExtensionProperty(
+ subject=ExtensionSubject(kind=kind, instance_key=instance_key),
+ node_id=node_id,
+ property_id=property_id,
+ datatype=datatype,
+ unit=unit,
+ value=value,
+ settable=settable,
+ )
+
+
+def _snapshot(*rows: ExtensionProperty) -> SpanPanelSnapshot:
+ """Return a complete curated snapshot carrying the given extension rows."""
+ return replace(
+ SpanPanelSnapshotFactory.create_complete(serial_number=PANEL_SERIAL),
+ extension_properties=rows,
+ )
+
+
+def _coordinator(snapshot: SpanPanelSnapshot) -> MagicMock:
+ coordinator = MagicMock()
+ coordinator.data = snapshot
+ return coordinator
+
+
+# --- the platform table, and the one-way door -------------------------------
+
+
+@pytest.mark.parametrize(
+ ("datatype", "expected"),
+ [
+ ("boolean", Platform.BINARY_SENSOR),
+ ("float", Platform.SENSOR),
+ ("integer", Platform.SENSOR),
+ ("enum", Platform.SENSOR),
+ ("string", Platform.SENSOR),
+ ],
+)
+def test_two_platforms_only(datatype: str, expected: Platform) -> None:
+ """No controls, whatever the declaration says -- adoption's three rows are absent."""
+ assert classify_extension(datatype) is expected
+
+
+def test_a_settable_property_is_still_a_reading() -> None:
+ """The read-only ruling, at the classifier.
+
+ A control here would sit beside curated controls that do real safety work on
+ the same wire -- the EVSE limit refuses a value above the commissioned
+ ceiling -- with none of their translation or bounds.
+ """
+ assert classify_extension("enum") is Platform.SENSOR
+ assert classify_extension("float") is Platform.SENSOR
+
+
+def test_the_platform_a_row_is_born_under_is_the_one_it_keeps(hass: HomeAssistant) -> None:
+ """Metadata may reshape an entity; it may never move its domain.
+
+ The registry refuses a cross-domain rename outright, so re-deriving the
+ platform from a changed declaration would not move the row -- it would strand
+ it and mint a second entity beside it.
+ """
+ registry = er.async_get(hass)
+ unique_id = "span_sp3-242424-001_adopted_bess/battery-2/cell-temperature"
+ registry.async_get_or_create(
+ Platform.SENSOR.value, DOMAIN, unique_id, suggested_object_id="battery_2_cell_temperature"
+ )
+
+ # The publisher relabels the property as a boolean. The row stays a sensor.
+ assert resolve_platform(registry, unique_id, "boolean") is Platform.SENSOR
+
+
+def test_an_unregistered_id_takes_the_platform_its_datatype_implies(hass: HomeAssistant) -> None:
+ assert (
+ resolve_platform(er.async_get(hass), "span_x_adopted_bess/n/p", "boolean")
+ is Platform.BINARY_SENSOR
+ )
+
+
+# --- placement --------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("kind", "instance_key", "expected"),
+ [
+ ("panel", None, PANEL_SERIAL),
+ ("circuit", "abc123", PANEL_SERIAL),
+ ("battery", None, f"{PANEL_SERIAL}_bess"),
+ ("mid", None, f"{PANEL_SERIAL}_mid"),
+ ("pv", None, f"{PANEL_SERIAL}_pv"),
+ ("evse", "acme-001", f"{PANEL_SERIAL}_evse_acme-001"),
+ ],
+)
+def test_each_subject_resolves_to_an_existing_card(
+ kind: str, instance_key: str | None, expected: str
+) -> None:
+ """A circuit's entities live on the panel's card, as its curated ones do."""
+ subject = ExtensionSubject(kind=kind, instance_key=instance_key)
+ assert extension_device_identifier(PANEL_SERIAL, subject) == expected
+
+
+def test_a_row_whose_card_is_not_registered_yet_is_deferred(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """A capability race defers the entity to the next reload rather than minting a card."""
+ snapshot = _snapshot(_row(kind="pv"))
+ assert adoptable(snapshot, dr.async_get(hass)) == []
+
+
+def test_a_row_on_a_registered_card_is_adoptable(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ snapshot = _snapshot(_row())
+ adoptable_rows = adoptable(snapshot, dr.async_get(hass))
+ assert len(adoptable_rows) == 1
+ row, unique_id, identifier = adoptable_rows[0]
+ assert identifier == BESS_IDENTIFIER
+ assert unique_id == "span_sp3-242424-001_adopted_bess/battery-2/cell-temperature"
+ assert row.path == "battery-2/cell-temperature"
+
+
+def test_an_off_charset_address_is_declined_rather_than_sanitised(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ snapshot = _snapshot(_row(property_id="Cell_Temperature"))
+ assert adoptable(snapshot, dr.async_get(hass)) == []
+
+
+# --- the cap ----------------------------------------------------------------
+
+
+def test_a_vendor_flooding_one_device_is_capped(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """Registry rows are permanent and nothing removes them, so the flood is bounded."""
+ rows = tuple(_row(property_id=f"reading-{index}") for index in range(MAX_PER_DEVICE + 25))
+ adopted = adoptable(_snapshot(*rows), dr.async_get(hass))
+ assert len(adopted) == MAX_PER_DEVICE
+
+
+def test_the_cap_is_per_device_not_per_panel(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """One noisy vendor device must not crowd out a quiet one on another card."""
+ battery_rows = tuple(
+ _row(property_id=f"reading-{index}") for index in range(MAX_PER_DEVICE + 5)
+ )
+ panel_row = _row(kind="panel", node_id="acme", property_id="site-reading")
+ adopted = adoptable(_snapshot(*battery_rows, panel_row), dr.async_get(hass))
+ assert sum(1 for _row_, _uid, identifier in adopted if identifier == PANEL_SERIAL) == 1
+
+
+# --- the entities themselves ------------------------------------------------
+
+
+def test_a_sensor_arrives_disabled_diagnostic_and_without_statistics(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """The arrival state, and the one guarantee that makes reshaping safe.
+
+ No `state_class` means no long-term statistics, so a later unit or
+ device-class change has nothing to corrupt.
+ """
+ snapshot = _snapshot(_row())
+ sensors = create_extension_sensors(
+ _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass)
+ )
+ assert len(sensors) == 1
+ sensor = sensors[0]
+ assert isinstance(sensor, ExtensionSensor)
+ assert sensor._attr_entity_registry_enabled_default is False
+ assert sensor._attr_entity_category is EntityCategory.DIAGNOSTIC
+ assert getattr(sensor.entity_description, "state_class", None) is None
+ assert sensor.native_value == 31.4
+ assert sensor.entity_description.native_unit_of_measurement == "°C"
+
+
+def test_a_name_carries_the_node_so_it_cannot_collide_with_a_curated_one(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """Curated names on these cards carry no wire vocabulary, so prefixing avoids collisions."""
+ snapshot = _snapshot(_row())
+ sensor = create_extension_sensors(
+ _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass)
+ )[0]
+ assert sensor._attr_name == "Battery 2 Cell Temperature"
+
+
+def test_a_declared_boolean_becomes_a_binary_sensor(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ snapshot = _snapshot(
+ _row(property_id="pack-enabled", datatype="boolean", unit=None, value="true")
+ )
+ binary = create_extension_binary_sensors(
+ _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass)
+ )
+ assert len(binary) == 1
+ assert isinstance(binary[0], ExtensionBinarySensor)
+ assert binary[0].is_on is True
+ # And it is not also a sensor: one property, one platform.
+ assert (
+ create_extension_sensors(
+ _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass)
+ )
+ == []
+ )
+
+
+def test_a_property_that_stops_being_published_reads_unknown_rather_than_vanishing(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """Absence on the wire is ambiguous, so the entity stays and reports nothing."""
+ snapshot = _snapshot(_row())
+ sensor = create_extension_sensors(
+ _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass)
+ )[0]
+
+ sensor.coordinator.data = _snapshot()
+ assert sensor.native_value is None
+
+
+def test_an_unparseable_number_is_reported_as_nothing_rather_than_as_text(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """A string behind a unit and a device class is a worse lie than no reading."""
+ snapshot = _snapshot(_row(value="not-a-number"))
+ sensor = create_extension_sensors(
+ _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass)
+ )[0]
+ assert sensor.native_value is None
+
+
+# --- the prominence hint ----------------------------------------------------
+
+
+def test_identity_naming_outranks_a_unit() -> None:
+ """The highest-confidence signal is negative, which is why it is checked first."""
+ assert prominence_hint(_row(property_id="firmware-version", unit=None)) == HINT_DETAIL
+ assert prominence_hint(_row(property_id="pack-serial-number", unit="W")) == HINT_DETAIL
+
+
+def test_a_unit_with_a_device_class_leans_reading() -> None:
+ assert prominence_hint(_row(property_id="cell-temperature", unit="°C")) == HINT_READING
+ assert prominence_hint(_row(property_id="acme-power", unit="W")) == HINT_READING
+
+
+def test_a_percentage_is_never_promoted() -> None:
+ """`%` is a state of charge, a confidence, or a duty cycle, and nothing tells them apart.
+
+ The systematic false negative the design accepts: the most headline-worthy
+ number a battery publishes lands as a detail, and `entity_category` is free
+ to revise later.
+ """
+ assert prominence_hint(_row(property_id="state-of-charge", unit="%")) == HINT_DETAIL
+
+
+def test_the_hint_is_carried_on_the_entity_for_curation_triage(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ snapshot = _snapshot(_row())
+ sensor = create_extension_sensors(
+ _coordinator(snapshot), snapshot, dr.async_get(hass), er.async_get(hass)
+ )[0]
+ assert sensor._attr_extra_state_attributes["prominence_hint"] == HINT_READING
+ assert sensor._attr_extra_state_attributes["wire_path"] == "battery-2/cell-temperature"
diff --git a/tests/test_extension_ids.py b/tests/test_extension_ids.py
new file mode 100644
index 00000000..f50a07bc
--- /dev/null
+++ b/tests/test_extension_ids.py
@@ -0,0 +1,178 @@
+"""The identity grammar for vendor extension properties, and what holds it honest.
+
+The unique id is the one irreversible commitment in extension adoption: nothing
+migrates, so an id minted wrong is minted wrong forever. Three properties are
+asserted here rather than assumed.
+
+**Injectivity.** Two distinct wire addresses must never produce one id. The
+grammar buys this by carrying the wire path verbatim -- the id *is* the address
+-- and the adversarial pairs below are the ones any normalising scheme would
+collapse.
+
+**Namespace closure.** No curated description key contains the `adopted` token,
+so a curated id and an extension id cannot be confused for one another.
+
+**Refusal over sanitisation.** An id outside the Homie charset is refused, not
+cleaned up, because cleaning it is what would make the slash-split ambiguous.
+"""
+
+from __future__ import annotations
+
+import pytest
+from span_panel_api import ExtensionSubject
+
+from custom_components.span_panel.extension import (
+ HOMIE_ID,
+ extension_scope,
+ extension_unique_id,
+ is_extension_unique_id,
+)
+from custom_components.span_panel.field_paths import platform_descriptions
+from custom_components.span_panel.util import ADOPTED_IDENTIFIER_TOKEN
+
+SERIAL = "sp3-000000-001"
+
+
+def _id(kind: str, node: str, prop: str, instance_key: str | None = None) -> str | None:
+ return extension_unique_id(
+ SERIAL, ExtensionSubject(kind=kind, instance_key=instance_key), node, prop
+ )
+
+
+# --- the grammar ------------------------------------------------------------
+
+
+def test_the_grammar_is_serial_token_scope_then_the_wire_path() -> None:
+ assert (
+ _id("battery", "battery-2", "cell-temperature")
+ == "span_sp3-000000-001_adopted_bess/battery-2/cell-temperature"
+ )
+
+
+@pytest.mark.parametrize(
+ ("kind", "instance_key", "scope"),
+ [
+ ("panel", None, "panel"),
+ ("battery", None, "bess"),
+ ("mid", None, "mid"),
+ ("pv", None, "pv"),
+ ("evse", "acme-001", "evse_acme-001"),
+ ("circuit", "0ab966b95f92a6a51ec548485aa85f54", "circuit_0ab966b95f92a6a51ec548485aa85f54"),
+ ],
+)
+def test_each_subject_maps_to_the_scope_its_curated_entities_use(
+ kind: str, instance_key: str | None, scope: str
+) -> None:
+ assert extension_scope(ExtensionSubject(kind=kind, instance_key=instance_key)) == scope
+
+
+def test_a_multi_instance_subject_without_a_key_names_no_device() -> None:
+ """No card to hang it on, so no id: inventing a scope would mint a homeless entity."""
+ assert extension_scope(ExtensionSubject(kind="evse")) is None
+ assert _id("evse", "acme", "charge-limit") is None
+
+
+def test_an_unknown_subject_kind_is_refused_rather_than_guessed() -> None:
+ assert extension_scope(ExtensionSubject(kind="something-new")) is None
+
+
+# --- injectivity ------------------------------------------------------------
+
+
+def test_the_pairs_a_normalising_grammar_would_collapse_stay_distinct() -> None:
+ """The concrete counterexample the verbatim rule exists to defeat.
+
+ Any scheme that turned hyphens into underscores and joined the two segments
+ with an underscore would render both of these as
+ `..._battery_2_cell_temperature`.
+ """
+ first = _id("battery", "battery-2", "cell-temperature")
+ second = _id("battery", "battery", "2-cell-temperature")
+ assert first is not None
+ assert second is not None
+ assert first != second
+
+
+def test_ids_are_injective_over_adversarial_addresses() -> None:
+ """Distinct addresses, distinct ids -- asserted over a set built to collide."""
+ addresses = [
+ ("battery-2", "cell-temperature"),
+ ("battery", "2-cell-temperature"),
+ ("battery-2-cell", "temperature"),
+ ("meter", "acme-cell-balance"),
+ ("meter-acme", "cell-balance"),
+ ("a", "b-c"),
+ ("a-b", "c"),
+ ]
+ minted: dict[str, tuple[str, str]] = {}
+ for node, prop in addresses:
+ unique_id = _id("battery", node, prop)
+ assert unique_id is not None, (node, prop)
+ assert unique_id not in minted, f"{(node, prop)} collides with {minted[unique_id]}"
+ minted[unique_id] = (node, prop)
+
+
+def test_the_same_address_on_two_instances_stays_distinct() -> None:
+ """Two chargers publishing the same vendor node are two entities, not one."""
+ first = _id("evse", "acme", "cell-temperature", instance_key="acme-001")
+ second = _id("evse", "acme", "cell-temperature", instance_key="acme-002")
+ assert first != second
+
+
+# --- namespace closure ------------------------------------------------------
+
+
+def test_no_curated_description_key_contains_the_adopted_token() -> None:
+ """The namespace cannot be invaded silently by a curated key.
+
+ The same closed-mapping idiom the suffix tests use: asserted over every
+ platform description rather than over a hand-listed sample.
+ """
+ invaded = [
+ description.key
+ for description in platform_descriptions()
+ if ADOPTED_IDENTIFIER_TOKEN in description.key.lower()
+ ]
+ assert not invaded, f"curated keys carrying the adoption token: {invaded}"
+
+
+def test_an_extension_id_is_distinguishable_from_a_device_adoption_id() -> None:
+ """The slash is the discriminator; device-level adoption ids carry none."""
+ extension = _id("battery", "battery-2", "cell-temperature")
+ assert extension is not None
+ assert is_extension_unique_id(extension)
+
+ # The device-level grammar, as `adoption.adopted_unique_id` builds it.
+ device_level = f"span_{SERIAL}_{ADOPTED_IDENTIFIER_TOKEN}_acme-001_meter_active_power"
+ assert not is_extension_unique_id(device_level)
+
+ curated = f"span_{SERIAL}_bess_battery_power"
+ assert not is_extension_unique_id(curated)
+
+
+# --- refusal over sanitisation ----------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "node",
+ ["Battery-2", "battery_2", "battery/2", "battery 2", "battery.2", ""],
+)
+def test_an_off_charset_node_is_refused(node: str) -> None:
+ assert _id("battery", node, "cell-temperature") is None
+
+
+@pytest.mark.parametrize(
+ "prop",
+ ["Cell-Temperature", "cell_temperature", "cell/temperature", "cell temperature", ""],
+)
+def test_an_off_charset_property_is_refused(prop: str) -> None:
+ assert _id("battery", "battery-2", prop) is None
+
+
+def test_the_charset_is_exactly_the_homie_one() -> None:
+ """Lowercase alphanumerics and hyphens, and nothing that could break the split."""
+ assert HOMIE_ID.match("battery-2")
+ assert HOMIE_ID.match("a1")
+ assert not HOMIE_ID.match("A")
+ assert not HOMIE_ID.match("a_b")
+ assert not HOMIE_ID.match("a/b")
diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py
index 801ee3b4..8477a4b7 100644
--- a/tests/test_new_entity_announcement.py
+++ b/tests/test_new_entity_announcement.py
@@ -414,3 +414,39 @@ async def test_a_panel_entity_is_not_prefixed(hass: HomeAssistant, entry: MockCo
message = _text(_announcement(hass, entry))
assert "DSM State" in message
assert "SPAN Panel DSM State" not in message
+
+
+async def test_vendor_extensions_on_a_curated_device_collapse_to_one_line(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Fifteen new vendor properties are one line, not fifteen.
+
+ These sit on a *curated* card, so the adopted-device detector cannot see
+ them: the card is the battery's, shared with curated entities that must
+ still be listed individually. Their unique_id is what says what they are.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ battery = dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, "sp3-001_bess")},
+ name="Span Panel Battery",
+ )
+ for index in range(15):
+ _register(
+ hass,
+ entry,
+ f"span_sp3-001_adopted_bess/battery-2/reading-{index}",
+ name=f"Battery 2 Reading {index}",
+ device_id=battery.id,
+ )
+ # A curated entity added to the same device in the same release is still
+ # named: collapsing is for the vendor surface, not for the card.
+ _register(hass, entry, "sp3-001_bess_meter_power", name="Meter Power", device_id=battery.id)
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert "Span Panel Battery (15 entities)" in message
+ assert "Battery 2 Reading 0" not in message
+ assert "Meter Power" in message
diff --git a/uv.lock b/uv.lock
index 0193b512..a0873061 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b11"
+version = "3.0.0b12"
source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
@@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap
[[package]]
name = "span-panel-api-schema-1"
-version = "0.1.0b8"
+version = "0.1.0b9"
source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
From 1a7639d39ce2a1a6cc097dfee3567021cbfecba0 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 00:56:38 -0700
Subject: [PATCH 098/116] chore: 2.1.0b7, and the changelog stops saying the
opposite
The version bump the extension work needed: b6 ships neither the new entities
nor the library pins they require, so HACS would offer nobody the change.
The changelog entry replaces a bullet rather than adding one. It said a new
property on a device this integration models "stays curated in a release" --
true when it was written, and the precise thing this release changes.
---
CHANGELOG.md | 10 ++++++++--
custom_components/span_panel/manifest.json | 2 +-
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b729d30..9b313695 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -87,8 +87,14 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
to what the device declared and nothing invented.
- **Nothing adopted enters long-term statistics** — `state_class` is not declared on the wire and a wrong guess writes corrupt statistics; wrap an adopted
reading in a template sensor or utility meter if you want them.
-- Devices this integration does model are deliberately not adopted: a new property on a circuit, the battery, a charger or the panel stays curated in a release,
- where the judgement about it lives.
+- **A new reading a vendor adds to a device you already have now appears too**, on that device's own card — the battery, a charger, the solar inverter, a
+ circuit or the panel. Previously only whole new _devices_ were picked up, so a battery vendor adding a field reached you nowhere.
+- These arrive switched off and filed as diagnostics, like everything else adopted, and they are readings only — never switches or number boxes, because a
+ control here would sit beside the curated ones without their limits and translations.
+- **They keep the panel's own wording** (`Battery 2 Cell Temperature`), which is deliberately plainer than a curated entity's name so you can tell at a glance
+ which is which.
+- **Deleting one hides it until the next reload while your panel is still publishing it, and removes it for good once your panel stops.** There is no setting to
+ suppress one, because the delete button already does both jobs, decided by what your panel is actually sending.
- **Your panel's own card shows what the panel says it is** — manufacturer, model and hardware revision read from the enclosure rather than assumed, once your
panel publishes them.
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index ece3c4bd..7b28683c 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0b5",
"span-panel-api-schema-1==0.1.0b9"
],
- "version": "2.1.0b6",
+ "version": "2.1.0b7",
"zeroconf": [
{
"type": "_span._tcp.local."
From d15b5f825850324fd9cdd03c82fcbb13a350f04b Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 01:20:08 -0700
Subject: [PATCH 099/116] docs(readme): adopted vendor readings, and what the
delete button does
The Adopted Devices section still said devices this integration models are
never adopted, which this release reverses, so that bullet is replaced rather
than joined by a contradicting one.
The new section covers what a vendor reading is, why it arrives switched off
and read-only, why it keeps the panel's plain wording, and the delete
behaviour, which is the part nobody would guess: deleting one hides it until
the next reload while the property is still published and removes it for good
once publishing stops. It also states plainly that these are permanent in id
but not in identity -- a later curated entity is a new entity, which is the
trade for surfacing a reading the moment it appears.
---
README.md | 37 +++++++++++++++++++++++++++++++++++--
1 file changed, 35 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 9de13b52..aaf32cec 100644
--- a/README.md
+++ b/README.md
@@ -290,8 +290,41 @@ Two things worth knowing before you build on one:
- **Nothing adopted enters long-term statistics.** No adopted entity carries a `state_class`, because the correct one is not published on the wire and guessing
wrong writes corrupt statistics that fixing the panel afterwards does not repair. If you want statistics from an adopted reading, wrap it in a template
sensor, a Riemann-sum integration or a utility meter — a deliberate choice on an entity you enabled.
-- **Devices this integration already models are never adopted.** A new property on a circuit, the battery, a charger or the panel is curated in a release
- instead, because that is where the judgement lives about whether it should be an entity, an attribute or a line on a device card.
+- **A new property on a device this integration already models is adopted too**, but as a reading on that device's existing card rather than as a device of its
+ own. See [Adopted Vendor Readings](#adopted-vendor-readings) below.
+
+### Adopted Vendor Readings
+
+The other half of vendor extensibility. A publisher can add a property to a device this integration already models — the battery, a charger, the solar inverter,
+a circuit or the panel itself — and until 2.1.x that reading went nowhere: it appeared in the diagnostics download and in no entity list. It now becomes an
+entity on that device's own card. The wire already says which device and node it belongs to, so it has a home; what it does not say is how important it is.
+
+They behave like adopted devices in the ways that matter, and differ in two:
+
+- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited, and the new-entity notification names the device with a
+ count rather than listing each one — fifteen new vendor readings would otherwise cost you the curated additions in the same message.
+- **They are readings only — never switches, selects or number boxes**, even where the panel says the property accepts writes. These sit beside curated controls
+ that do real work, such as the charge limit that refuses a value above what your charger was commissioned for, and a generic control would sit there with none
+ of that. If a control is worth having, it arrives curated in a release.
+- **They keep the panel's own wording**, so a vendor property on the battery reads `Battery 2 Cell Temperature` rather than something tidied up. Deliberately
+ plainer than a curated entity's name: it is how you tell at a glance which entities this integration designed and which it is passing through.
+- **Nothing adopted enters long-term statistics**, exactly as for adopted devices, and here it buys something extra: with no statistics behind them, a later
+ release can correct one of these entities' units, device class or category with nothing to repair.
+
+**What the delete button does**, since it is not quite what you would expect:
+
+- Delete one while your panel is still publishing that property and it comes back — switched off — at the next reload. There is no setting to suppress it,
+ because leaving it switched off is already that.
+- Delete one after your panel has stopped publishing it and it stays gone, because nothing exists to recreate it from.
+
+So deletion means "hide it until next time" for a live reading and "clear it out" for a dead one, and your panel decides which. A property your panel stops
+publishing is left in place reading unknown rather than removed: silence on the wire does not distinguish a property that is gone from one that has not arrived
+yet, and deleting your entity on a guess is not something an upgrade should do.
+
+**These entities are permanent in id, not in identity.** If one of these readings is later curated properly, the curated entity is a new entity with its own id
+and its own history — the adopted one is not renamed into it. That is the trade for surfacing a reading the moment it appears rather than waiting for a release
+to model it, and it is why a vendor reading you have come to depend on is worth mentioning in an issue: curation is what turns it into something with a real
+name, a proper category and statistics.
### Power Sensor Attributes
From 9b8513ead81e84bde8b6b9168b81a55e3905c9bf Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 01:34:36 -0700
Subject: [PATCH 100/116] fix(additions): name a handful of vendor readings
instead of counting them
A live b7 install added two vendor readings to the panel -- a postal code and a
time zone, the only two properties that panel declares and this integration
maps nowhere -- and the notification said "Span Panel (2 entities)". That is
strictly less than naming them: the collapse was written for a firmware update
adding fifteen at once, where the flood would cost the reader the curated
additions in the same message, and two is not that.
The distinction the first version missed is which device the line names. An
adopted *device* collapses at any count because its line names a device that
did not exist before, which is itself the news. A vendor reading sits on a card
the user already has, so the card's name says nothing about what appeared on
it. Collapse now applies above five, per notification rather than per device
lifetime: five readings announced last month and one today is a one-line
update, not a flood.
---
custom_components/span_panel/additions.py | 49 ++++++--
tests/test_new_entity_announcement.py | 129 ++++++++++++++++++++--
2 files changed, 158 insertions(+), 20 deletions(-)
diff --git a/custom_components/span_panel/additions.py b/custom_components/span_panel/additions.py
index 516b19ec..dce1ffbf 100644
--- a/custom_components/span_panel/additions.py
+++ b/custom_components/span_panel/additions.py
@@ -47,6 +47,20 @@
_STORE_VERSION: Final = 1
_ANNOUNCED: Final = "announced_unique_ids"
+COLLAPSE_ABOVE: Final = 5
+"""How many vendor readings one curated device may add before they are collapsed.
+
+Above this, the device gets one line with a count; at or below it, each entity is
+named. The flood this guards against is a firmware update adding fifteen
+properties at once, which would spend the whole notification on one device and
+cost the reader the curated additions in the same message. Two or three is not
+that, and naming them is what tells the reader anything at all -- the card is one
+they already have, so its name alone says nothing new.
+
+Adopted *devices* are collapsed regardless of count, because their line names a
+device that did not exist before, which is itself the news.
+"""
+
_SECTION: Final = "new_entities"
"""Names both the translation section and the notice id. They describe the same
thing, and keeping them one symbol means a rename cannot leave a standing notice
@@ -164,17 +178,21 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str,
devices = dr.async_get(hass)
enabled: list[str] = []
disabled: list[str] = []
- adopted: dict[str, int] = {}
+ collapsed: dict[str, int] = {}
+ extensions: dict[str, list[str]] = {}
for registry_entry in added:
device_name = _adopted_device_name(devices, registry_entry)
- if device_name is None:
- # Vendor extensions collapse the same way, and need their own
- # detector: they live on *curated* devices, so the identifier test
- # above cannot see them. Their unique_id is what says what they are.
- device_name = _extension_device_name(devices, registry_entry)
if device_name is not None:
- adopted[device_name] = adopted.get(device_name, 0) + 1
+ collapsed[device_name] = collapsed.get(device_name, 0) + 1
+ continue
+ # Vendor extensions need their own detector: they live on *curated*
+ # devices, so the identifier test above cannot see them. Their unique_id
+ # is what says what they are. Held as labels rather than counted,
+ # because whether they collapse depends on how many there turn out to be.
+ extension_device = _extension_device_name(devices, registry_entry)
+ if extension_device is not None:
+ extensions.setdefault(extension_device, []).append(_label(devices, registry_entry))
continue
target = (
disabled
@@ -183,6 +201,19 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str,
)
target.append(_label(devices, registry_entry))
+ # Collapse a vendor's additions only once there are enough of them to crowd
+ # the message out. Below the threshold the names are the useful part: an
+ # adopted *device*'s line at least names a new device the user can go find,
+ # while a curated card is one they already have, so "Span Panel (2 entities)"
+ # tells them strictly less than naming the two would. Extension entities are
+ # always disabled -- that is their arrival state -- so the uncollapsed ones
+ # join that list rather than being sorted again.
+ for device_name, labels in extensions.items():
+ if len(labels) > COLLAPSE_ABOVE:
+ collapsed[device_name] = collapsed.get(device_name, 0) + len(labels)
+ else:
+ disabled.extend(labels)
+
# Two keys rather than one with a plural placeholder: a plural rule the caller
# picks a *word* for is an English rule, and the five languages here do not
# share it.
@@ -194,10 +225,10 @@ def _message(hass: HomeAssistant, added: list[er.RegistryEntry], text: dict[str,
lines += [f"**{_text(text, 'enabled_heading')}**", ""]
lines += [f"- {label}" for label in sorted(enabled)]
lines.append("")
- if disabled or adopted:
+ if disabled or collapsed:
lines += [f"**{_text(text, 'disabled_heading')}**", ""]
lines += [f"- {label}" for label in sorted(disabled)]
- lines += [f"- {name} ({count} entities)" for name, count in sorted(adopted.items())]
+ lines += [f"- {name} ({count} entities)" for name, count in sorted(collapsed.items())]
lines += ["", _text(text, "how_to_enable"), ""]
lines.append(_text(text, "nothing_broken"))
return "\n".join(lines)
diff --git a/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py
index 8477a4b7..fe14b70d 100644
--- a/tests/test_new_entity_announcement.py
+++ b/tests/test_new_entity_announcement.py
@@ -27,6 +27,7 @@
from custom_components.span_panel.additions import (
_SECTION,
+ COLLAPSE_ABOVE,
async_announce_new_entities,
async_forget_announcements,
)
@@ -82,7 +83,9 @@ def _text(notification: Any) -> str:
# -- Silence where silence is right ------------------------------------------
-async def test_a_first_install_announces_nothing(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+async def test_a_first_install_announces_nothing(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
"""Every entity is new on a first install, so the notice would name them all.
Which would teach the user that this category is noise, and cost them the
@@ -112,7 +115,9 @@ async def test_an_install_that_predates_the_record_announces_nothing_once(
assert _announcement(hass, entry) is None
-async def test_a_restart_that_adds_nothing_announces_nothing(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+async def test_a_restart_that_adds_nothing_announces_nothing(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
"""The failure that would make the whole thing worse than useless."""
_register(hass, entry, _PART_NUMBER, name="Part Number")
await async_announce_new_entities(hass, entry)
@@ -141,7 +146,9 @@ async def test_a_disabled_addition_is_announced_and_says_it_needs_enabling(
assert "enable the ones you want" in message
-async def test_an_enabled_addition_is_announced_too(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+async def test_an_enabled_addition_is_announced_too(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
"""The gap this replaced the Repair to close.
An enabled entity is visible in the entity list and starts recording, which is
@@ -158,7 +165,9 @@ async def test_an_enabled_addition_is_announced_too(hass: HomeAssistant, entry:
assert "Added and ready to use" in message
-async def test_both_kinds_are_split_rather_than_pooled(hass: HomeAssistant, entry: MockConfigEntry) -> None:
+async def test_both_kinds_are_split_rather_than_pooled(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
"""The split is the actionable part: one kind needs an action, the other does not."""
_register(hass, entry, "sp3-001_existing", name="Existing")
await async_announce_new_entities(hass, entry)
@@ -172,8 +181,10 @@ async def test_both_kinds_are_split_rather_than_pooled(hass: HomeAssistant, entr
assert message.index("Added but switched off") < message.index("Switched Off")
-async def test_every_added_entity_is_named_rather_than_sampled(hass: HomeAssistant, entry: MockConfigEntry) -> None:
- """"What exactly was added" means all of it.
+async def test_every_added_entity_is_named_rather_than_sampled(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Naming what was added means naming all of it.
The Repair this replaced showed a count plus three examples, which tells a
user that something happened and not what.
@@ -210,7 +221,9 @@ async def test_an_adopted_device_contributes_one_line_with_a_count(
name="Backup Generator",
)
for index in range(6):
- _register(hass, entry, f"sp3-001_adopted_{index}", name=f"Adopted {index}", device_id=device.id)
+ _register(
+ hass, entry, f"sp3-001_adopted_{index}", name=f"Adopted {index}", device_id=device.id
+ )
_register(hass, entry, "sp3-001_curated", name="Curated Addition")
await async_announce_new_entities(hass, entry)
@@ -322,10 +335,7 @@ def test_every_shipped_locale_carries_the_notification_strings(language: str) ->
def test_an_unknown_language_falls_back_to_english_rather_than_to_nothing() -> None:
- assert (
- read_translations("xx", _SECTION)["title"]
- == read_translations("en", _SECTION)["title"]
- )
+ assert read_translations("xx", _SECTION)["title"] == read_translations("en", _SECTION)["title"]
def test_a_regional_language_resolves_to_its_base(hass: HomeAssistant) -> None:
@@ -450,3 +460,100 @@ async def test_vendor_extensions_on_a_curated_device_collapse_to_one_line(
assert "Span Panel Battery (15 entities)" in message
assert "Battery 2 Reading 0" not in message
assert "Meter Power" in message
+
+
+async def test_a_few_vendor_extensions_are_named_rather_than_collapsed(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Collapsing two tells the reader less than naming them.
+
+ The collapse exists for a firmware update adding fifteen properties at once.
+ An adopted *device*'s line at least names a device that did not exist
+ before; a curated card is one the user already has, so its name alone says
+ nothing about what appeared on it -- which is what a live install showed,
+ reading "Span Panel (2 entities)" for a postal code and a time zone.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ panel = dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, "sp3-001")},
+ name="Span Panel",
+ )
+ for wire_property, label in (
+ ("postal-code", "Status Postal Code"),
+ ("time-zone", "Status Time Zone"),
+ ):
+ _register(
+ hass,
+ entry,
+ f"span_sp3-001_adopted_panel/status/{wire_property}",
+ name=label,
+ device_id=panel.id,
+ )
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert "Status Postal Code" in message
+ assert "Status Time Zone" in message
+ assert "(2 entities)" not in message
+
+
+async def test_at_the_threshold_they_are_still_named(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Exactly the threshold is named, so the boundary is asserted from both sides."""
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ panel = dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, "sp3-001")},
+ name="Span Panel",
+ )
+ for index in range(COLLAPSE_ABOVE):
+ _register(
+ hass,
+ entry,
+ f"span_sp3-001_adopted_panel/acme/reading-{index}",
+ name=f"Acme Reading {index}",
+ device_id=panel.id,
+ )
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert "Acme Reading 0" in message
+ assert "entities)" not in message
+
+
+async def test_one_past_the_threshold_collapses(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """One more than the threshold, in the same update, and the names give way to a count.
+
+ Counted per notification rather than per device lifetime, because the
+ message describes this update: five readings announced last month and one
+ today is a one-line update, not a flood.
+ """
+ _register(hass, entry, "sp3-001_existing", name="Existing")
+ await async_announce_new_entities(hass, entry)
+
+ panel = dr.async_get(hass).async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, "sp3-001")},
+ name="Span Panel",
+ )
+ for index in range(COLLAPSE_ABOVE + 1):
+ _register(
+ hass,
+ entry,
+ f"span_sp3-001_adopted_panel/acme/reading-{index}",
+ name=f"Acme Reading {index}",
+ device_id=panel.id,
+ )
+ await async_announce_new_entities(hass, entry)
+
+ message = _text(_announcement(hass, entry))
+ assert f"Span Panel ({COLLAPSE_ABOVE + 1} entities)" in message
+ assert "Acme Reading 0" not in message
From 13e68ed435fb78bcce4638367becafbd7dece5dc Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 01:37:20 -0700
Subject: [PATCH 101/116] chore: 2.1.0b8, so the naming fix reaches the panel
that showed the problem
Also corrects the README, which described the notification as always giving a
device and a count -- true when written, and the behaviour b8 changes.
---
README.md | 5 +++--
custom_components/span_panel/manifest.json | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index aaf32cec..22b3c32e 100644
--- a/README.md
+++ b/README.md
@@ -301,8 +301,9 @@ entity on that device's own card. The wire already says which device and node it
They behave like adopted devices in the ways that matter, and differ in two:
-- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited, and the new-entity notification names the device with a
- count rather than listing each one — fifteen new vendor readings would otherwise cost you the curated additions in the same message.
+- **They arrive switched off and filed as diagnostics**, so nothing lands on a dashboard uninvited. The new-entity notification names each one, up to five per
+ device; beyond that it gives the device and a count instead, because fifteen new vendor readings at once would otherwise cost you the curated additions in the
+ same message.
- **They are readings only — never switches, selects or number boxes**, even where the panel says the property accepts writes. These sit beside curated controls
that do real work, such as the charge limit that refuses a value above what your charger was commissioned for, and a generic control would sit there with none
of that. If a control is worth having, it arrives curated in a release.
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 7b28683c..319b050c 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0b5",
"span-panel-api-schema-1==0.1.0b9"
],
- "version": "2.1.0b7",
+ "version": "2.1.0b8",
"zeroconf": [
{
"type": "_span._tcp.local."
From 9567cb942c769bdd6d77dc066918cb88966faae4 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 02:28:35 -0700
Subject: [PATCH 102/116] fix(extension): four defects an adversarial review
found in b8
**Two lugs devices shared one identity.** The library paired both with the
`panel` subject, so two lugs declaring the same vendor property minted one
unique_id for two readings -- Home Assistant drops the second, and the survivor
shows whichever sorted first. Identical firmware on both lugs makes that the
expected case, not a coincidence. They are `kind="lugs"` keyed by direction
now, and `_SCOPE_PREFIX_BY_KIND` gives them their own scope while
`extension_device_identifier` still renders them on the panel's card: identity
must distinguish what placement merges.
**The cap could strand a user's enabled entity.** It admitted rows in adapter
emission order, which tracks the wire, so a firmware update declaring a
property earlier could evict a standing entity -- whose registry row is
permanent, and for which nothing would build an entity again. Unavailable
forever, stranger in its slot, no migration path by design. `adoptable` now
admits everything the registry already holds before capping the rest.
**The cap counted cards, not devices.** Panel, every circuit and both lugs
render on the panel's card, so thirty-five wire devices shared one allowance:
two vendor properties per circuit truncates a 32-circuit panel with no
misbehaving publisher anywhere. It counts `subject_key` now. The test that
claimed this behaviour only exercised battery-vs-panel and passed while the
code was wrong; it exercises the shared card now.
**Overflow was silent.** `MAX_PER_DEVICE`'s own docstring and the design both
promised a notice; the code logged, twice per setup, once per platform.
`async_notice_declined_extensions` raises a durable notice once at setup,
translated into all five locales.
Also clamps a vendor string to 255 characters rather than letting Home
Assistant raise on the state write.
The baseline reasons for `status/postal-code` and `status/time-zone` said
surfacing them would copy location into recorder history and duplicate what
Home Assistant owns. Both were written when surfacing meant a default-on
curated entity. They now surface as disabled vendor readings, where the
recorder cost is opt-in, so the reasons say what is now true: no curated
entity, which is a different decision from invisibility.
Requires span-panel-api 3.0.0b13 and span-panel-api-schema-1 0.1.0b10.
---
custom_components/span_panel/__init__.py | 13 +-
custom_components/span_panel/extension.py | 196 +++++++++++++++---
custom_components/span_panel/manifest.json | 6 +-
custom_components/span_panel/strings.json | 4 +
.../span_panel/translations/en.json | 4 +
.../span_panel/translations/es.json | 4 +
.../span_panel/translations/fr.json | 4 +
.../span_panel/translations/ja.json | 4 +
.../span_panel/translations/pt.json | 4 +
developer.md | 163 +++++++++++++--
pyproject.toml | 4 +-
requirements_test.txt | 4 +-
.../unread_declarations_baseline.json | 4 +-
tests/test_extension_entities.py | 87 +++++++-
uv.lock | 4 +-
15 files changed, 441 insertions(+), 64 deletions(-)
diff --git a/custom_components/span_panel/__init__.py b/custom_components/span_panel/__init__.py
index dedf71f2..09fd5d51 100644
--- a/custom_components/span_panel/__init__.py
+++ b/custom_components/span_panel/__init__.py
@@ -16,7 +16,11 @@
ConfigEntryError,
ConfigEntryNotReady,
)
-from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers import (
+ config_validation as cv,
+ device_registry as dr,
+ entity_registry as er,
+)
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType
from span_panel_api import SpanMqttClient, SpanPanelSnapshot
@@ -45,6 +49,7 @@
)
from .coordinator import SpanPanelCoordinator
from .current_monitor import CurrentMonitor
+from .extension import async_notice_declined_extensions
from .frontend import (
PANEL_FRONTEND_DIR as PANEL_FRONTEND_DIR,
PANEL_URL as PANEL_URL,
@@ -306,6 +311,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpanPanelConfigEntry) ->
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ # After the forward, because it reports on what the platforms just built
+ # -- and once, rather than from each platform's own call to `adoptable`.
+ await async_notice_declined_extensions(
+ hass, entry, snapshot, dr.async_get(hass), er.async_get(hass)
+ )
+
# After the platforms, not before: schema validation runs on the first
# refresh, which is awaited above, but the Repairs it raises name the
# entities an unresolved field took down — and those entities only
diff --git a/custom_components/span_panel/extension.py b/custom_components/span_panel/extension.py
index 6e2aa8fe..d25cfb48 100644
--- a/custom_components/span_panel/extension.py
+++ b/custom_components/span_panel/extension.py
@@ -30,7 +30,9 @@
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo, DeviceRegistry
from homeassistant.helpers.entity_registry import EntityRegistry
from span_panel_api import ExtensionProperty, ExtensionSubject, SpanPanelSnapshot
@@ -39,6 +41,7 @@
from .const import DOMAIN
from .coordinator import SpanPanelCoordinator
from .entity import SpanPanelEntity
+from .notices import async_raise, read_translations
from .util import (
ADOPTED_IDENTIFIER_TOKEN,
SUB_DEVICE_BESS,
@@ -51,6 +54,9 @@
SCOPE_PANEL: Final = "panel"
+MAX_STATE_LENGTH: Final = 255
+"""Home Assistant's hard limit on a state string; a longer one raises on write."""
+
HINT_READING: Final = "reading"
HINT_DETAIL: Final = "detail"
@@ -83,8 +89,23 @@
}
"""Library subject kind → the scope segment this integration's ids already use.
-The singletons only. `evse` and `circuit` carry an instance key and are built
-below, because their segment is `{kind}_{key}` rather than a constant.
+The singletons only. The kinds below carry an instance key, so their segment is
+`{prefix}_{key}` rather than a constant.
+"""
+
+_SCOPE_PREFIX_BY_KIND: Final[dict[str, str]] = {
+ "evse": SUB_DEVICE_EVSE,
+ "circuit": "circuit",
+ "lugs": "lugs",
+}
+"""Multi-instance subject kinds, and the prefix their scope segment takes.
+
+`lugs` is here rather than folded into `panel` even though a lugs device's
+curated fields land on the panel: the two lugs devices run identical firmware,
+so a vendor extension on one is the expected case of the same extension on
+both, and one scope for both would mint one id for two readings. They still
+*render* on the panel's card -- see `extension_device_identifier`, where the
+divergence between identity and placement is deliberate.
"""
@@ -99,10 +120,8 @@ def extension_scope(subject: ExtensionSubject) -> str | None:
return _SCOPE_BY_KIND[subject.kind]
if subject.instance_key is None:
return None
- if subject.kind == "evse":
- return f"{SUB_DEVICE_EVSE}_{subject.instance_key}"
- if subject.kind == "circuit":
- return f"circuit_{subject.instance_key}"
+ if subject.kind in _SCOPE_PREFIX_BY_KIND:
+ return f"{_SCOPE_PREFIX_BY_KIND[subject.kind]}_{subject.instance_key}"
return None
@@ -180,9 +199,11 @@ def extension_device_identifier(panel_identifier: str, subject: ExtensionSubject
A circuit's entities live on the panel's own card in this integration, so a
circuit subject resolves there too -- the entity's *name* is what says which
- circuit it came from.
+ circuit it came from. Lugs are the same: two devices for identity, one card
+ for rendering, which is why this mapping and `extension_scope` are separate
+ functions rather than one. Identity must distinguish what placement merges.
"""
- if subject.kind in ("panel", "circuit"):
+ if subject.kind in ("panel", "circuit", "lugs"):
return panel_identifier
if subject.kind == "battery":
return f"{panel_identifier}_{SUB_DEVICE_BESS}"
@@ -385,6 +406,18 @@ def native_value(self) -> str | float | None:
if raw is None:
return None
if self.entity_description.native_unit_of_measurement is None:
+ # Truncated rather than passed through: Home Assistant refuses a
+ # state over 255 characters, and a vendor string is unbounded on the
+ # wire. Raising once per update for a value nobody chose is worse
+ # than showing the first 255 characters of it.
+ if len(raw) > MAX_STATE_LENGTH:
+ _LOGGER.debug(
+ "Extension %s published %d characters; truncated to %d",
+ self._declaration_path,
+ len(raw),
+ MAX_STATE_LENGTH,
+ )
+ return raw[:MAX_STATE_LENGTH]
return raw
try:
return float(raw)
@@ -449,15 +482,30 @@ def _create[ExtensionT: ExtensionEntity](
neither.
"""
built: list[ExtensionT] = []
- for row, unique_id, device_identifier in adoptable(snapshot, device_registry):
+ for row, unique_id, device_identifier in adoptable(snapshot, device_registry, entity_registry):
if resolve_platform(entity_registry, unique_id, row.datatype) is not platform:
continue
built.append(entity_class(coordinator, unique_id, row, device_identifier=device_identifier))
return built
+def subject_key(subject: ExtensionSubject) -> str:
+ """Return the wire device a subject names, as one string.
+
+ What the cap counts. Not the device *card*: `panel`, every circuit and both
+ lugs render on the panel's card, so counting per card would pool thirty-five
+ wire devices against one allowance -- two vendor properties on each circuit
+ of a 32-circuit panel would truncate with no misbehaving publisher anywhere.
+ """
+ return (
+ subject.kind if subject.instance_key is None else f"{subject.kind}:{subject.instance_key}"
+ )
+
+
def adoptable(
- snapshot: SpanPanelSnapshot, device_registry: DeviceRegistry
+ snapshot: SpanPanelSnapshot,
+ device_registry: DeviceRegistry,
+ entity_registry: EntityRegistry,
) -> list[tuple[ExtensionProperty, str, str]]:
"""Every extension property that can become an entity, with its id and card.
@@ -466,10 +514,44 @@ def adoptable(
registry yet, or its address is outside the Homie charset. The first two are
ordinary states on a setup that raced a capability -- the entity appears on
the next reload, as capability-gated platforms already do.
+
+ **An id the registry already holds is never displaced by the cap.** The cap
+ admits rows in the order the adapter emitted them, and that order tracks the
+ wire: a firmware update declaring a new property earlier in a description
+ shifts everything after it. Capping on arrival order alone would therefore
+ let a *new* property evict a standing entity -- whose registry row is
+ permanent, and for which nothing here would ever build an entity again, so
+ it would read unavailable forever while a stranger took its slot. Nothing
+ migrates in this design, so there would be no recovery. Two passes instead:
+ everything already registered is admitted first, and the cap applies only to
+ what is new.
"""
- per_device: dict[str, int] = {}
- declined: dict[str, int] = {}
- adoptable_rows: list[tuple[ExtensionProperty, str, str]] = []
+ return _partition(snapshot, device_registry, entity_registry)[0]
+
+
+def declined_extensions(
+ snapshot: SpanPanelSnapshot,
+ device_registry: DeviceRegistry,
+ entity_registry: EntityRegistry,
+) -> dict[str, int]:
+ """How many properties each wire device declared beyond the cap.
+
+ Separate from `adoptable` so the overflow is reported once at setup rather
+ than once per platform: both platforms build their share from the same
+ partition, and a warning per platform would double-count in the log while
+ saying nothing new.
+ """
+ return _partition(snapshot, device_registry, entity_registry)[1]
+
+
+def _partition(
+ snapshot: SpanPanelSnapshot,
+ device_registry: DeviceRegistry,
+ entity_registry: EntityRegistry,
+) -> tuple[list[tuple[ExtensionProperty, str, str]], dict[str, int]]:
+ """Split the declared properties into what is adopted and what the cap declined."""
+ known: list[tuple[ExtensionProperty, str, str]] = []
+ fresh: list[tuple[ExtensionProperty, str, str]] = []
for row in snapshot.extension_properties:
identifier = extension_device_identifier(snapshot.serial_number, row.subject)
if identifier is None:
@@ -486,17 +568,81 @@ def adoptable(
)
if unique_id is None:
continue
- if per_device.get(identifier, 0) >= MAX_PER_DEVICE:
- declined[identifier] = declined.get(identifier, 0) + 1
+ registered = any(
+ entity_registry.async_get_entity_id(platform.value, DOMAIN, unique_id) is not None
+ for platform in (Platform.SENSOR, Platform.BINARY_SENSOR)
+ )
+ (known if registered else fresh).append((row, unique_id, identifier))
+
+ per_subject: dict[str, int] = {}
+ for row, _unique_id, _identifier in known:
+ key = subject_key(row.subject)
+ per_subject[key] = per_subject.get(key, 0) + 1
+
+ adoptable_rows = list(known)
+ declined: dict[str, int] = {}
+ for row, unique_id, identifier in fresh:
+ key = subject_key(row.subject)
+ if per_subject.get(key, 0) >= MAX_PER_DEVICE:
+ declined[key] = declined.get(key, 0) + 1
continue
- per_device[identifier] = per_device.get(identifier, 0) + 1
+ per_subject[key] = per_subject.get(key, 0) + 1
adoptable_rows.append((row, unique_id, identifier))
- for identifier, count in declined.items():
- _LOGGER.warning(
- "Device %s declares more than %d vendor properties; %d were not adopted. "
- "They remain visible in this integration's diagnostics",
- identifier,
- MAX_PER_DEVICE,
- count,
- )
- return adoptable_rows
+ return adoptable_rows, declined
+
+
+_OVERFLOW_NOTICE: Final = "extension_overflow"
+
+_OVERFLOW_FALLBACK: Final[dict[str, str]] = {
+ "title": "SPAN Panel: some vendor readings were not added",
+ "body": (
+ "A device on your panel declares more vendor readings than this integration will add "
+ "for one device ({limit}). The rest were left out: {devices}.\n\n"
+ "Nothing you already have is affected, and nothing is broken. The readings that were "
+ "left out are still listed in this integration's diagnostics download, which is what "
+ "to attach if you want them surfaced."
+ ),
+}
+"""English text for the overflow notice, used when a translation cannot be read.
+
+Carried here rather than only in `strings.json` for the reason `additions`
+documents: an unreadable file should cost the translation, never the notice --
+and a silently truncated surface is exactly the thing that must not be silent.
+"""
+
+
+async def async_notice_declined_extensions(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ snapshot: SpanPanelSnapshot,
+ device_registry: DeviceRegistry,
+ entity_registry: EntityRegistry,
+) -> None:
+ """Tell the user when the cap left vendor readings out, or say nothing.
+
+ A durable notice rather than a log line, because the alternative is a
+ truncation the user cannot see: an entity list showing sixty of a device's
+ eighty readings looks exactly like a device with sixty readings. Raised once
+ at setup rather than from `adoptable`, which both platforms call.
+ """
+ declined = declined_extensions(snapshot, device_registry, entity_registry)
+ if not declined:
+ return
+ rendered = ", ".join(f"{key} ({count})" for key, count in sorted(declined.items()))
+ _LOGGER.warning(
+ "Vendor readings beyond the per-device limit of %d were not adopted: %s",
+ MAX_PER_DEVICE,
+ rendered,
+ )
+ text = await hass.async_add_executor_job(
+ read_translations, hass.config.language, _OVERFLOW_NOTICE
+ )
+ async_raise(
+ hass,
+ entry,
+ _OVERFLOW_NOTICE,
+ title=text.get("title") or _OVERFLOW_FALLBACK["title"],
+ message=(text.get("body") or _OVERFLOW_FALLBACK["body"]).format(
+ limit=MAX_PER_DEVICE, devices=rendered
+ ),
+ )
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 319b050c..03ab55ca 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,11 +22,11 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b12",
+ "span-panel-api==3.0.0b13",
"span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b9"
+ "span-panel-api-schema-1==0.1.0b10"
],
- "version": "2.1.0b8",
+ "version": "2.1.0b9",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index 71a4b769..a27c0cde 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -787,6 +787,10 @@
"disabled_heading": "Added but switched off",
"how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.",
"nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online."
+ },
+ "extension_overflow": {
+ "title": "SPAN Panel: some vendor readings were not added",
+ "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced."
}
}
}
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 71a4b769..a27c0cde 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -787,6 +787,10 @@
"disabled_heading": "Added but switched off",
"how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.",
"nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online."
+ },
+ "extension_overflow": {
+ "title": "SPAN Panel: some vendor readings were not added",
+ "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced."
}
}
}
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 8d2ed679..d1846ebb 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -787,6 +787,10 @@
"disabled_heading": "Añadidas pero desactivadas",
"how_to_enable": "Las entidades desactivadas no registran nada hasta que las actives. Abre la página del dispositivo Panel SPAN, muestra sus entidades desactivadas y activa las que quieras.",
"nothing_broken": "No hay ningún problema y no se requiere ninguna acción. Las entidades aparecen por varios motivos: una actualización que expone más de lo que informa tu panel, un circuito añadido al panel, o hardware como una batería, paneles solares o un cargador que se pone en línea."
+ },
+ "extension_overflow": {
+ "title": "Panel SPAN: algunas lecturas del fabricante no se añadieron",
+ "body": "Un dispositivo de tu panel declara más lecturas del fabricante de las que esta integración añade para un solo dispositivo ({limit}). El resto quedó fuera: {devices}.\n\nNada de lo que ya tienes se ve afectado y nada está roto. Las lecturas que quedaron fuera siguen apareciendo en la descarga de diagnósticos de esta integración, que es lo que conviene adjuntar si quieres que se muestren."
}
}
}
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index ab154286..0b42bdf0 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -787,6 +787,10 @@
"disabled_heading": "Ajoutées mais désactivées",
"how_to_enable": "Les entités désactivées n'enregistrent rien tant que vous ne les activez pas. Ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez.",
"nothing_broken": "Rien n'est cassé et aucune action n'est requise. Des entités apparaissent pour plusieurs raisons : une mise à jour qui expose davantage de ce que votre panneau rapporte, un circuit ajouté au panneau, ou du matériel tel qu'une batterie, du solaire ou un chargeur qui se met en ligne."
+ },
+ "extension_overflow": {
+ "title": "Panneau SPAN : certaines mesures du fabricant n'ont pas été ajoutées",
+ "body": "Un appareil de votre panneau déclare plus de mesures du fabricant que cette intégration n'en ajoute pour un seul appareil ({limit}). Les autres ont été laissées de côté : {devices}.\n\nRien de ce que vous avez déjà n'est affecté et rien n'est cassé. Les mesures laissées de côté figurent toujours dans le téléchargement des diagnostics de cette intégration, qu'il convient de joindre si vous souhaitez les voir apparaître."
}
}
}
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 2e174645..6795877e 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -787,6 +787,10 @@
"disabled_heading": "追加されましたが無効です",
"how_to_enable": "無効なエンティティは、有効にするまで何も記録しません。SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。",
"nothing_broken": "問題はなく、対応は不要です。エンティティが追加される理由はいくつかあります: パネルが報告する内容をより多く公開するアップデート、パネルに追加された回路、またはバッテリー、太陽光、充電器などのハードウェアがオンラインになった場合です。"
+ },
+ "extension_overflow": {
+ "title": "SPAN パネル: 一部のベンダー測定値は追加されませんでした",
+ "body": "パネル上のあるデバイスが、この統合が 1 台あたりに追加する上限({limit})を超える数のベンダー測定値を宣言しています。残りは追加されませんでした: {devices}。\n\n既存のものには影響はなく、故障でもありません。追加されなかった測定値は、この統合の診断ダウンロードには引き続き記載されています。表示を希望する場合は、それを添付してください。"
}
}
}
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index 501a80ef..bf140185 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -787,6 +787,10 @@
"disabled_heading": "Adicionadas mas desativadas",
"how_to_enable": "As entidades desativadas não registam nada até as ativar. Abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser.",
"nothing_broken": "Nada está avariado e não é necessária qualquer ação. As entidades aparecem por vários motivos: uma atualização que expõe mais do que o seu painel reporta, um circuito adicionado ao painel, ou hardware como uma bateria, solar ou um carregador a ficar online."
+ },
+ "extension_overflow": {
+ "title": "Painel SPAN: algumas leituras do fabricante não foram adicionadas",
+ "body": "Um dispositivo do seu painel declara mais leituras do fabricante do que esta integração adiciona para um único dispositivo ({limit}). As restantes ficaram de fora: {devices}.\n\nNada do que já tem é afetado e nada está avariado. As leituras que ficaram de fora continuam listadas na transferência de diagnósticos desta integração, que é o que deve anexar se quiser que sejam apresentadas."
}
}
}
diff --git a/developer.md b/developer.md
index e76bddf9..57a134d0 100644
--- a/developer.md
+++ b/developer.md
@@ -187,10 +187,17 @@ When you surface a property, delete its baseline line in the same change. The te
line you left.
When you decide a property should _stay_ unread, add a line with an honest reason. The reasons are load-bearing — they are what stops the file becoming a list
-of things nobody remembers deciding. The current entries are all permanent: deliberate skips (`status/postal-code` copies location into recorder history; Home
-Assistant owns `status/time-zone`), values held for identity reasons (`pv/info/serial-number`), redundant echoes (`connection/*-device-type` dereferences to a
+of things nobody remembers deciding. The current entries are all permanent: deliberate skips (`status/postal-code` and `status/time-zone`, which Home Assistant
+already owns or has no use for), values held for identity reasons (`pv/info/serial-number`), redundant echoes (`connection/*-device-type` dereferences to a
declared `$type`), and properties no producer publishes (`connection/count`).
+**"Unread" here means "no curated entity", not "invisible".** Since 2.1.0b7 an unread property on a _modelled_ device also surfaces through
+[extension adoption](#extension-properties) as a disabled diagnostic entity, so a baseline line records a decision not to **curate** something — to give it a
+designed name, a category and a place — rather than a decision to withhold it. The two skips above were written before that distinction existed and read as
+though a baseline line hid a property; it does not, and the entries were reworded rather than left to mislead. A reason that turns on the cost of a _default-on_
+entity is worth re-reading in that light: `postal-code`'s original reason was that surfacing it copies the user's location into recorder history, which an
+opt-in disabled entity does only if the user asks for it.
+
### What it does not cover
The gate reads the **vendored fixture**, so it answers "what does our capture declare that we do not read". It cannot see a property a real panel starts
@@ -221,8 +228,12 @@ the panel has published a value for the property, which is the declared-but-neve
key-based over the config entry: it knows nothing about wire property names and could not protect a value added here. `test_schema_discovery` asserts that
against the capture's own published values rather than leaving it to review.
-This is **maintainer-facing only**. Nothing creates an entity, a Repair or a notification from it — including for a device that _is_ adopted, whose properties
-are reported here as declarations exactly like any other. Adoption itself is the next section.
+This block is **maintainer-facing only**: nothing creates an entity, a Repair or a notification from a `schema_discovery` row — including for a device that _is_
+adopted, whose properties are reported here as declarations exactly like any other.
+
+That is a statement about this block, not about the properties in it. The same properties on a _modelled_ device also arrive as `snapshot.extension_properties`,
+which does carry values and does become entities — see [Extension properties](#extension-properties). Two artefacts describing one property, joined by its
+`{node}/{property}` path, with opposite audiences and opposite rules about values. Adoption itself is the next section.
### Why discovered rows cannot reach the curated inventories
@@ -270,16 +281,19 @@ second inverter, which the eBus schema explicitly permits. Such a device used to
### The rule
-**The unit of adoption is a device, never a property.**
+Both halves of vendor extensibility are adopted, and **the half decides the shape**: a device nobody modelled becomes a device, a property on a device we do
+model becomes a reading on that device's existing card.
-| What arrives | What happens |
-| ------------------------------------------------ | -------------------------------------------------------------------------------- |
-| A device type `MODELLED_TYPES` does not name | **Adopt.** One sub-device, its properties surfaced beneath it. |
-| A new node or property on a device we _do_ model | **Do not adopt.** It lands in `schema_discovery`; curate it in the next release. |
-| A new property on a device already adopted | Adopt, with its siblings. |
+| What arrives | What happens |
+| ------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
+| A device type `MODELLED_TYPES` does not name | **Adopt as a device.** One sub-device, its properties surfaced beneath it. This section. |
+| A new node or property on a device we _do_ model | **Adopt as a reading** on that device's card — never a new device. [Below](#extension-properties). |
+| A new property on a device already adopted | Adopt, with its siblings. |
+| A second instance of a modelled type | **Not adopted.** See below. |
-The asymmetry is where the cost calculus actually points. An adopted entity's id is machine-derived and permanent once it registers, which is only a loss where
-curation is coming. On a type nobody modelled, no better id is coming and the alternative is silence.
+The two differ in what they can promise. An adopted _device_ is a card nothing else was ever going to describe. An adopted _property_ sits beside curated
+entities on a card this integration designed, so it is deliberately the plainer thing: read-only, wire-named, and carrying no expectation that curation will one
+day rename it into something better.
Extra instances of a modelled type are **not** adopted. A second BESS is a multiplicity limitation of the snapshot model, not an unmodelled device, and adopting
it would stand a machine-named card beside the curated Battery describing the same hardware. The gap stays visible as a gap.
@@ -426,9 +440,14 @@ Three things it does that the Repair did not:
It stays silent on a first install, and silent once more on the first run after this mechanism ships: an install predating the record has entities that were
never announced but are not new either, so the first pass adopts them as known.
-Adopted devices are collapsed to one line with a count — `Backup Generator (6 entities)` — for the reason the whole design is device-scoped: a vendor device
-declaring a dozen properties would otherwise spend the entire notification on itself and teach the user to skip it, costing them the curated additions in the
-same message.
+Adopted devices are collapsed to one line with a count — `Backup Generator (6 entities)` — because a vendor device declaring a dozen properties would otherwise
+spend the entire notification on itself and teach the user to skip it, costing them the curated additions in the same message.
+
+**Extension properties collapse only above `additions.COLLAPSE_ABOVE` (five), and the asymmetry is the point.** An adopted device's line names a device that did
+not exist before, which is itself the news at any count. An extension property sits on a card the user already has, so `Span Panel (2 entities)` tells them
+strictly less than the two names would — which is exactly what a live b7 install produced for a postal code and a time zone. Counted per notification rather
+than per device lifetime: five readings announced last month and one today is a one-line update, not a flood. The detector is `_extension_device_name`, which
+tests the **unique_id** rather than the device identifier, because these live on curated cards and the card says nothing about them.
**Translations are read from this component's own `translations/` directory**, not through `homeassistant.helpers.translation`. That helper filters to the
categories Home Assistant defines, and a persistent notification is not one of them — a custom category loads as nothing at all, which was verified rather than
@@ -444,7 +463,119 @@ cannot protect a wire value put there.
### Adopted entities declare no field paths
`snapshot.adopted_devices` is outside the curated field-path vocabulary by construction — it carries no metadata row, so the producible gate has nothing to
-check it against. `adoption.py` is therefore absent from `residual_field_paths()`'s import list, and its entity classes declare no `_residual_field_paths`.
+check it against. `adoption.py` is therefore absent from `residual_field_paths()`'s import list, and its entity classes declare no `_residual_field_paths`. The
+same holds for `extension.py` and `snapshot.extension_properties`, for the same reason.
+
+## Extension properties
+
+A property on a device this integration **does** model — `battery-2/cell-temperature` hung off the BESS by a battery vendor. Until 2.1.0b7 it reached the user
+nowhere: it became a `DiscoveredMetadata` row and stopped at the diagnostics download. `extension.py` turns it into an entity on that device's existing card.
+
+### Where the value comes from
+
+The library carries it, in a type built for the purpose:
+
+| Type | Carries | Audience |
+| -------------------------------------------- | ----------------------------- | --------------------------- |
+| `DiscoveredMetadata` (`discovered.*` paths) | Declaration only, never value | Maintainer, via diagnostics |
+| `ExtensionProperty` (`extension_properties`) | Declaration **and** value | User, via entities |
+
+The same wire property appears in both, joined by its `{node}/{property}` path. **`ExtensionProperty` is deliberately not a `FieldMetadata`**: `partition()`
+walks `build_field_metadata()`, so a type that cannot enter that map has no path into a payload that leaves the machine. That is the diagnostics guarantee as a
+shape rather than as a rule somebody has to remember, and `test_extension_property_is_not_field_metadata` asserts it.
+
+`schema-1`'s `addressed_rows()` is shared by `build_discovery` and `build_extension_properties` so the two cannot disagree about what "unaddressed" means. A
+property counted as addressed by one and not the other would appear as an entity the diagnostics claim is ignored, or the reverse.
+
+### The identity, which is the irreversible part
+
+```text
+span_{serial}_adopted_{scope}/{node}/{property}
+```
+
+- **Anchored on what is stable and ours** — the panel serial and the curated scope (`bess`, `mid`, `pv`, `panel`, `evse_{node}`, `circuit_{id}`).
+- **Addressed by the wire path verbatim**, which is upstream's own capability-catalog spelling (`AdoptedProperty.path`, `discovery_path()`). Verbatim is what
+ makes it injective: the id _is_ the address. Normalising hyphens would collapse `battery-2` + `cell-temperature` and `battery` + `2-cell-temperature` into one
+ id, which `test_the_pairs_a_normalising_grammar_would_collapse_stay_distinct` pins.
+- **Never through `get_user_friendly_suffix`**, which de-_dots_ rather than de-hyphens and substitutes a curated suffix on a mapping hit.
+- **Not the eBus proxy composition.** `{proxier-id}-{proxied-id}` is upstream's device-handle spelling, and upstream states those handles are not identities:
+ they differ across enclosures and are unstable across the proxy-to-native transition. An id anchored on one would rename itself when a device stopped being
+ proxied, and nothing here migrates, so there would be no recovery.
+- An address outside the Homie charset (`[a-z0-9-]`) is **refused, not sanitised** — sanitising is what would make the slash-split ambiguous. It stays visible
+ in diagnostics.
+
+The slash distinguishes the two adoption grammars: device-level ids contain none.
+
+### Terminal identity
+
+An adopted extension is never promoted, re-sourced, re-homed or migrated. It changes only on an **external** trigger — the publisher stops publishing it, or
+better metadata arrives. Three consequences worth knowing before changing any of this:
+
+- **Curation is never blocked by one existing.** If a property is later curated, the curated entity is a _new_ entity with its own id and history; the adopted
+ one is not renamed into it. Ids are permanent, identity is not, and an earlier draft of this design built a registry take-over path to avoid that — it was
+ cut, because it rested on the library ceasing to emit the row in lockstep with curation, a two-repo promise whose conformance test cannot distinguish
+ "curation mapped it" from "the capture was regenerated without it".
+- **Nothing is ever removed by this integration**, and there is no engagement test anywhere. A row the user deletes is recreated — disabled — at the next setup
+ while the property is still published, so deletion is not suppression. It sticks exactly when publishing has stopped, because then nothing exists to recreate
+ it from. That is why no suppression feature exists: the delete button already means "hide until next reload" for a live reading and "clear it out" for a dead
+ one, decided by the wire.
+- **A property that stops being published reads unknown rather than disappearing.** Silence does not distinguish gone from not-yet-arrived.
+
+### What metadata may reshape, and what it may not
+
+| Attribute | Revisable later? |
+| ------------------------------------------- | --------------------------------------------- |
+| `entity_category`, device class, unit, name | **Yes**, freely — no id change, no statistics |
+| Platform (`sensor` vs `binary_sensor`) | **No.** The domain is baked into `entity_id` |
+| `state_class` | Never set at all |
+
+The free half is free _because_ of the never half: these entities carry no `state_class`, so they write no long-term statistics, so a later unit or device-class
+change has nothing to reinterpret. Contrast a curated entity, where changing a unit under a `state_class` is the unrepairable case.
+
+The platform is enforced in `resolve_platform`, not remembered: whatever domain the id is already registered under wins, however the declaration later changes.
+`async_update_entity` raises `ValueError("New entity ID should be same domain")`, so re-deriving the platform from better metadata would not move a row — it
+would strand it and mint a second entity beside it. `test_the_platform_a_row_is_born_under_is_the_one_it_keeps` is the guard.
+
+### Read-only, and why disabled-by-default does not gate it
+
+No switches, selects or number boxes, even where the panel declares the property settable — `classify_extension` is `adoption.classify` with its three control
+rows deleted. The worked bypass: a vendor publishes `acme/charge-limit` beside the curated EVSE limit. The curated number goes through
+`evse_charge_limit_payload()`, which **refuses** a value above the commissioned ceiling. An auto-generated number on the same device, fed by a generic set
+topic, publishes whatever the user types on the same wire. The islanding assertion is the same shape — schema_1 translates `GRID` into `ON_GRID`.
+
+"Disabled-by-default gates the control", which `classify` argues for unmodelled devices, does not transfer: there the hazard is user intent, here it is semantic
+interaction with curated logic the user cannot see. The library enforces it structurally — `ExtensionProperty` has no set-topic member to populate, and
+`set_adopted_property` still resolves only against `adopted_devices`.
+
+### The cap
+
+`MAX_PER_DEVICE` (60) bounds what one **wire device** may mint — counted on `subject_key`, not on the card. The panel, every circuit and both lugs render on the
+panel's card, so counting per card would pool thirty-five devices against one allowance and truncate a 32-circuit panel at two vendor properties each, with no
+misbehaving publisher anywhere. Overflow raises a durable notice (`async_notice_declined_extensions`, once at setup rather than once per platform), because a
+truncation the user cannot see is the one thing worse than the truncation: sixty of a device's eighty readings looks exactly like a device with sixty.
+
+**An id the registry already holds is never displaced.** The cap admits in adapter emission order, which tracks the wire, so a firmware update declaring a new
+property earlier shifts everything after it. Capping on arrival order alone would let a new property evict a standing entity — whose registry row is permanent,
+and for which nothing would ever build an entity again, leaving it unavailable forever with a stranger in its slot and no migration path by design. `adoptable`
+therefore partitions registered from new, admits every registered row, and applies the cap only to the rest. Registry rows are permanent here and nothing
+removes them, so a vendor node declaring hundreds of properties would otherwise put hundreds of rows in every entity picker on every install that met it, with
+no later release able to take them back. Deliberately far above any real device — the sixteen `pcs` properties are the largest curated example — so it is a
+backstop against a misbehaving publisher rather than a policy on normal ones.
+
+### The prominence hint
+
+`prominence_hint()` is advisory only: everything arrives `DIAGNOSTIC` regardless, and the hint rides along as an entity attribute for curation triage. Ranked by
+confidence, and the ranking is the argument:
+
+1. **Identity-family naming → detail.** Highest confidence because it is purely _negative_ — a property named for a vendor, model, serial, part number or
+ firmware build is device description.
+2. **A unit in `DEVICE_CLASS_BY_UNIT` → reading.** Moderate, and it may promote but never demote, because it fails systematically in one direction: the most
+ headline-worthy number a battery publishes is a `%` state of charge, and `%` is absent from that map on purpose, being equally a confidence or a duty cycle.
+3. **Everything else → detail**, with `node_has_curated_siblings` recorded as corroboration rather than as a decision. Homie nodes are organisational, not
+ editorial.
+
+The real fix is upstream: a declared `role` on the property, proposed in `SpanPanel_Docs/span/docs/dev/ebus-property-role-proposal.md`. Until then the ranking
+is the shipping plan, and `entity_category` being free to revise is what makes a conservative default cheap.
## Linting and Type Checking
diff --git a/pyproject.toml b/pyproject.toml
index ef351471..a386548e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,13 +8,13 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b12",
+ "span-panel-api==3.0.0b13",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
# and that is the list Home Assistant actually reads.
"span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b9",
+ "span-panel-api-schema-1==0.1.0b10",
]
[dependency-groups]
diff --git a/requirements_test.txt b/requirements_test.txt
index 5bcb64a4..b7ca2546 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0b12
+span-panel-api==3.0.0b13
span-panel-api-schema-0==1.0.0b5
-span-panel-api-schema-1==0.1.0b9
+span-panel-api-schema-1==0.1.0b10
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
index 8cfe362e..07f3e8ae 100644
--- a/tests/fixtures/unread_declarations_baseline.json
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -2,8 +2,8 @@
"circuit/breaker/poles": "reaches circuit.is_240v and stops there; the voltage attribute is derived from the tab count instead, so no entity or attribute reads the field",
"circuit/connection/count": "microinverter/pack count; no producer publishes it, so there is no fixture value an entity could be tested against",
"circuit/connection/feeds-device-type": "redundant: the library resolves a DER's class by dereferencing feeds-device-id against the tree's declared $type",
- "distribution-enclosure/status/postal-code": "deliberate skip: location data with no Home Assistant value, and surfacing it would copy the user's location into recorder history",
- "distribution-enclosure/status/time-zone": "deliberate skip: Home Assistant owns the time zone, so a mismatch is a Repair rather than an entity",
+ "distribution-enclosure/status/postal-code": "deliberate skip: no curated entity, because location has no Home Assistant value. It does surface as a disabled vendor reading through extension adoption, where the recorder cost is opt-in rather than imposed",
+ "distribution-enclosure/status/time-zone": "deliberate skip: no curated entity, because Home Assistant owns the time zone and a mismatch is a Repair rather than an entity. It does surface as a disabled vendor reading through extension adoption",
"lugs/connection/count": "the lugs counterpart of circuit/connection/count, with no fed-device story upstream and no producer publishing it",
"lugs/connection/fed-by-device-type": "redundant: the id dereferences to the device's declared $type, which is how the library already resolves classes",
"lugs/connection/feeds-device-status": "feedthrough link-health; the feeds-* half of the downstream lugs record is topology to consume, and no producer publishes it",
diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py
index 77bfcb1f..44ad55a6 100644
--- a/tests/test_extension_entities.py
+++ b/tests/test_extension_entities.py
@@ -32,8 +32,10 @@
create_extension_binary_sensors,
create_extension_sensors,
extension_device_identifier,
+ extension_unique_id,
prominence_hint,
resolve_platform,
+ subject_key,
)
from custom_components.span_panel.util import SUB_DEVICE_BESS
@@ -181,14 +183,14 @@ def test_a_row_whose_card_is_not_registered_yet_is_deferred(
) -> None:
"""A capability race defers the entity to the next reload rather than minting a card."""
snapshot = _snapshot(_row(kind="pv"))
- assert adoptable(snapshot, dr.async_get(hass)) == []
+ assert adoptable(snapshot, dr.async_get(hass), er.async_get(hass)) == []
def test_a_row_on_a_registered_card_is_adoptable(
hass: HomeAssistant, registered_panel: tuple[str, str]
) -> None:
snapshot = _snapshot(_row())
- adoptable_rows = adoptable(snapshot, dr.async_get(hass))
+ adoptable_rows = adoptable(snapshot, dr.async_get(hass), er.async_get(hass))
assert len(adoptable_rows) == 1
row, unique_id, identifier = adoptable_rows[0]
assert identifier == BESS_IDENTIFIER
@@ -200,7 +202,7 @@ def test_an_off_charset_address_is_declined_rather_than_sanitised(
hass: HomeAssistant, registered_panel: tuple[str, str]
) -> None:
snapshot = _snapshot(_row(property_id="Cell_Temperature"))
- assert adoptable(snapshot, dr.async_get(hass)) == []
+ assert adoptable(snapshot, dr.async_get(hass), er.async_get(hass)) == []
# --- the cap ----------------------------------------------------------------
@@ -211,20 +213,83 @@ def test_a_vendor_flooding_one_device_is_capped(
) -> None:
"""Registry rows are permanent and nothing removes them, so the flood is bounded."""
rows = tuple(_row(property_id=f"reading-{index}") for index in range(MAX_PER_DEVICE + 25))
- adopted = adoptable(_snapshot(*rows), dr.async_get(hass))
+ adopted = adoptable(_snapshot(*rows), dr.async_get(hass), er.async_get(hass))
assert len(adopted) == MAX_PER_DEVICE
-def test_the_cap_is_per_device_not_per_panel(
+def test_the_cap_is_per_wire_device_not_per_card(
hass: HomeAssistant, registered_panel: tuple[str, str]
) -> None:
- """One noisy vendor device must not crowd out a quiet one on another card."""
- battery_rows = tuple(
- _row(property_id=f"reading-{index}") for index in range(MAX_PER_DEVICE + 5)
+ """One noisy device must not crowd out a quiet one that shares its card.
+
+ The panel, every circuit and both lugs render on the panel's card. Counting
+ per card would pool thirty-five wire devices against one allowance, so two
+ vendor properties on each circuit of a 32-circuit panel would truncate with
+ no misbehaving publisher anywhere. The cap counts the wire device.
+ """
+ noisy = tuple(
+ _row(kind="circuit", instance_key="circuit-a", node_id="acme", property_id=f"reading-{index}")
+ for index in range(MAX_PER_DEVICE + 5)
+ )
+ quiet = (
+ _row(kind="circuit", instance_key="circuit-b", node_id="acme", property_id="reading-0"),
+ _row(kind="panel", node_id="acme", property_id="site-reading"),
+ _row(kind="lugs", instance_key="upstream", node_id="acme", property_id="phase-balance"),
)
- panel_row = _row(kind="panel", node_id="acme", property_id="site-reading")
- adopted = adoptable(_snapshot(*battery_rows, panel_row), dr.async_get(hass))
- assert sum(1 for _row_, _uid, identifier in adopted if identifier == PANEL_SERIAL) == 1
+ adopted = adoptable(_snapshot(*noisy, *quiet), dr.async_get(hass), er.async_get(hass))
+
+ # Every quiet device keeps its readings, though all four share the panel card.
+ adopted_keys = [subject_key(row.subject) for row, _uid, _identifier in adopted]
+ assert adopted_keys.count("circuit:circuit-a") == MAX_PER_DEVICE
+ assert adopted_keys.count("circuit:circuit-b") == 1
+ assert adopted_keys.count("panel") == 1
+ assert adopted_keys.count("lugs:upstream") == 1
+ assert {identifier for _row_, _uid, identifier in adopted} == {PANEL_SERIAL}
+
+
+def test_two_lugs_publishing_the_same_property_get_two_identities(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """The collision that folding lugs into `panel` produced.
+
+ Both lugs devices run the same firmware, so a vendor extension on one is the
+ expected case of the same extension on both. One subject for the pair minted
+ one unique_id for two readings: Home Assistant drops the second, and the
+ survivor shows whichever device sorted first.
+ """
+ upstream = _row(kind="lugs", instance_key="upstream", node_id="acme", property_id="phase-balance", value="1.5")
+ downstream = _row(kind="lugs", instance_key="downstream", node_id="acme", property_id="phase-balance", value="99.9")
+ adopted = adoptable(_snapshot(upstream, downstream), dr.async_get(hass), er.async_get(hass))
+
+ ids = [unique_id for _row_, unique_id, _identifier in adopted]
+ assert len(ids) == len(set(ids)) == 2
+ # Both still render on the panel's card: identity distinguishes, placement merges.
+ assert {identifier for _row_, _uid, identifier in adopted} == {PANEL_SERIAL}
+
+
+def test_a_registered_entity_is_never_displaced_by_the_cap(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """A standing entity outranks a new arrival, whatever order the wire sends them in.
+
+ The row order tracks the wire, so a firmware update declaring a property
+ earlier shifts everything after it. Capping on arrival order alone would let
+ a new property evict a standing entity whose registry row is permanent and
+ for which nothing would ever build an entity again -- unavailable forever,
+ with no migration path by design.
+ """
+ registry = er.async_get(hass)
+ standing = _row(property_id="long-standing")
+ standing_id = extension_unique_id(PANEL_SERIAL, standing.subject, standing.node_id, standing.property_id)
+ assert standing_id is not None
+ registry.async_get_or_create(Platform.SENSOR.value, DOMAIN, standing_id)
+
+ # The standing property now arrives *last*, behind a full cap of new ones.
+ newcomers = tuple(_row(property_id=f"new-{index}") for index in range(MAX_PER_DEVICE))
+ adopted = adoptable(_snapshot(*newcomers, standing), dr.async_get(hass), registry)
+
+ assert standing_id in {unique_id for _row_, unique_id, _identifier in adopted}
+ assert len(adopted) == MAX_PER_DEVICE
# --- the entities themselves ------------------------------------------------
diff --git a/uv.lock b/uv.lock
index a0873061..a52d9a7e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b12"
+version = "3.0.0b13"
source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
@@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap
[[package]]
name = "span-panel-api-schema-1"
-version = "0.1.0b9"
+version = "0.1.0b10"
source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
From ce5a79b4573d1b9e32d3bffd9effdbee22d31b01 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 16:46:51 -0700
Subject: [PATCH 103/116] chore(deps): follow span-panel-api to its release
versions
span-panel-api 3.0.0, span-panel-api-schema-0 1.0.0 and span-panel-api-schema-1
1.0.0 replace the beta pins. Nothing about this integration's behaviour changes:
the libraries are the same code their last betas carried, renumbered for the
first public release.
The pins live in three files and all three move together. pyproject.toml is the
development environment, manifest.json is the list Home Assistant actually reads
at install time, and requirements_test.txt is derived from the manifest -- which
is not a fact worth remembering, because test_the_real_repository_is_in_sync runs
sync-dependencies and fails if any of them disagree. It caught the third file
here after the first two were edited by hand.
manifest.json now names versions that are not on PyPI yet, so the release order
is the libraries first -- v3.0.0, then schema-0-v1.0.0 and schema-1-v1.0.0 --
and this integration afterwards. Until then the editable [tool.uv.sources] paths
supply them, which is what the local lock resolves.
Home Assistant pins are deliberately untouched. homeassistant 2026.8.0,
homeassistant-stubs 2026.8.0 and pytest-homeassistant-custom-component 0.13.354
already agree with each other and with the 2026.8.0 floor hacs.json declares, and
the floor is the version worth testing against: it is the oldest one a user can
be running, while the newest is only where developers drift.
---
custom_components/span_panel/manifest.json | 6 +++---
pyproject.toml | 6 +++---
requirements_test.txt | 6 +++---
uv.lock | 6 +++---
4 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 03ab55ca..1e14cb9f 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,9 +22,9 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0b13",
- "span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b10"
+ "span-panel-api==3.0.0",
+ "span-panel-api-schema-0==1.0.0",
+ "span-panel-api-schema-1==1.0.0"
],
"version": "2.1.0b9",
"zeroconf": [
diff --git a/pyproject.toml b/pyproject.toml
index a386548e..a83199ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,13 +8,13 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0b13",
+ "span-panel-api==3.0.0",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
# and that is the list Home Assistant actually reads.
- "span-panel-api-schema-0==1.0.0b5",
- "span-panel-api-schema-1==0.1.0b10",
+ "span-panel-api-schema-0==1.0.0",
+ "span-panel-api-schema-1==1.0.0",
]
[dependency-groups]
diff --git a/requirements_test.txt b/requirements_test.txt
index b7ca2546..dffd818b 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0b13
-span-panel-api-schema-0==1.0.0b5
-span-panel-api-schema-1==0.1.0b10
+span-panel-api==3.0.0
+span-panel-api-schema-0==1.0.0
+span-panel-api-schema-1==1.0.0
diff --git a/uv.lock b/uv.lock
index a52d9a7e..841f401b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2550,7 +2550,7 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0b13"
+version = "3.0.0"
source = { editable = "../../span/span-panel-api-p3" }
dependencies = [
{ name = "httpx" },
@@ -2590,7 +2590,7 @@ dev = [
[[package]]
name = "span-panel-api-schema-0"
-version = "1.0.0b5"
+version = "1.0.0"
source = { editable = "../../span/span-panel-api-p3/packages/schema-0" }
dependencies = [
{ name = "span-panel-api" },
@@ -2601,7 +2601,7 @@ requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-ap
[[package]]
name = "span-panel-api-schema-1"
-version = "0.1.0b10"
+version = "1.0.0"
source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
From ac786711f54aafa0045fa191d70efa3f767778e9 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 19:27:36 -0700
Subject: [PATCH 104/116] fix(naming): circuit energy descriptors match the
suffixes they ship with
The display names and the entity-id suffixes for circuit energy sensors
disagreed on word order: name="Produced Energy" against a suffix of
energy_produced, and the same for consumed and net. Nothing surfaced it while
this integration assembles entity ids itself, because the two never meet -- the
id comes from the suffix mapping and the name is only a label.
They meet the moment Home Assistant composes the id from the name, which is what
core 2026.8's entity_id_parts does. Composition would slugify "Kitchen Produced
Energy" to kitchen_produced_energy while the entity has shipped as
kitchen_energy_produced since before 2.0.8, and "Recreate entity IDs" would then
offer to reorder the suffix of every circuit energy sensor on the panel -- three
per circuit, 96 on a fully mapped 40-space install. Those offers say nothing
about the circuit names a user set in the SPAN app, which are identical on both
sides; they are purely our own two spellings disagreeing.
So the names move to the ids rather than the other way round. Verified: every
descriptor in CIRCUIT_SENSORS and UNMAPPED_SENSORS now slugifies to exactly the
suffix get_user_friendly_suffix returns for it, through the api_key_mapping in
SpanCircuitEnergySensor that resolves circuit_energy_produced to
producedEnergyWh.
Nothing structural moves. unique_id is built from description_key and never
reads a name, so it is byte-identical and stays single-form and predictable,
which is what a future migration depends on. Entity ids are untouched. What does
change is user-visible: cards read "Kitchen Energy Produced" rather than
"Kitchen Produced Energy". Panel-level descriptors are left alone -- they
already match their own suffixes (main_meter_produced_energy), which are
noun-last, so the two levels necessarily read differently. That asymmetry is
inherited from the ids, not introduced here.
This is a precondition for adopting core's composition rather than a change that
stands on its own; taken alone it is a relabel.
---
custom_components/span_panel/sensor_definitions.py | 10 +++++-----
tests/test_sensor_entities.py | 8 ++++----
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 4de27762..15081511 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -200,7 +200,7 @@ class SpanPanelBatterySensorEntityDescription(
SpanPanelCircuitsSensorEntityDescription(
key="producedEnergyWh",
field_path="circuit.produced_energy_wh",
- name="Produced Energy",
+ name="Energy Produced",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
@@ -212,7 +212,7 @@ class SpanPanelBatterySensorEntityDescription(
SpanPanelCircuitsSensorEntityDescription(
key="consumedEnergyWh",
field_path="circuit.consumed_energy_wh",
- name="Consumed Energy",
+ name="Energy Consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
@@ -1168,7 +1168,7 @@ class SpanPVMetadataSensorEntityDescription(
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_produced",
field_path="circuit.produced_energy_wh",
- name="Produced Energy",
+ name="Energy Produced",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
@@ -1180,7 +1180,7 @@ class SpanPVMetadataSensorEntityDescription(
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_consumed",
field_path="circuit.consumed_energy_wh",
- name="Consumed Energy",
+ name="Energy Consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
@@ -1192,7 +1192,7 @@ class SpanPVMetadataSensorEntityDescription(
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_net",
derived=DerivedReason.MULTIPLE_FIELDS,
- name="Net Energy",
+ name="Energy Net",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=2,
diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py
index e3aa3090..cdb824ef 100644
--- a/tests/test_sensor_entities.py
+++ b/tests/test_sensor_entities.py
@@ -460,11 +460,11 @@ def test_circuit_energy_sensor_missing_circuit_uses_fallback_names() -> None:
assert (
sensor._generate_friendly_name(snapshot, sensor.entity_description)
- == "Circuit c9 Consumed Energy"
+ == "Circuit c9 Energy Consumed"
)
assert (
sensor._generate_panel_name(snapshot, sensor.entity_description)
- == "Circuit c9 Consumed Energy"
+ == "Circuit c9 Energy Consumed"
)
@@ -483,8 +483,8 @@ def test_circuit_energy_sensor_subdevice_uses_description_only() -> None:
device_info_override={"identifiers": {("span_panel", "evse")}},
)
- assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Consumed Energy"
- assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Consumed Energy"
+ assert sensor._generate_friendly_name(snapshot, sensor.entity_description) == "Energy Consumed"
+ assert sensor._generate_panel_name(snapshot, sensor.entity_description) == "Energy Consumed"
def test_circuit_energy_sensor_extra_attributes_only_include_base_when_circuit_missing() -> None:
From 541121293260aeef87d0644386c00a9936c7bf17 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:12:31 -0700
Subject: [PATCH 105/116] chore(deps): pin span-panel-api 3.0.1
The manifest is the pin that reaches a user; `sync-dependencies` propagated it
to pyproject.toml and requirements_test.txt. The schema adapters stay at 1.0.0 --
only the bootstrap moved.
`uv.lock` is unchanged, and that is not an oversight: `[tool.uv.sources]` points
span-panel-api at the local worktree, and uv lets a path source override the
version specifier rather than checking against it. Local development therefore
resolves to whatever that worktree holds, pin or no pin. CI strips the sources
block and re-locks from PyPI, which is the only place this specifier is enforced.
---
custom_components/span_panel/manifest.json | 2 +-
pyproject.toml | 2 +-
requirements_test.txt | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 1e14cb9f..775c5489 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,7 +22,7 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==3.0.0",
+ "span-panel-api==3.0.1",
"span-panel-api-schema-0==1.0.0",
"span-panel-api-schema-1==1.0.0"
],
diff --git a/pyproject.toml b/pyproject.toml
index a83199ec..c0512e87 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,7 @@ readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
"homeassistant==2026.8.0",
- "span-panel-api==3.0.0",
+ "span-panel-api==3.0.1",
# Separate distributions, not dependencies of the bootstrap: adapters are found
# through the `span_panel_api.schema_adapters` entry-point group, so each one has
# to be installed for discovery to see it. manifest.json carries the same three,
diff --git a/requirements_test.txt b/requirements_test.txt
index dffd818b..5c371cd4 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -3,6 +3,6 @@ pytest-asyncio>=1.3.0
setuptools>=65.7.0
pytest-homeassistant-custom-component==0.13.354
homeassistant==2026.8.0
-span-panel-api==3.0.0
+span-panel-api==3.0.1
span-panel-api-schema-0==1.0.0
span-panel-api-schema-1==1.0.0
From 83bf98b518e120f111d1c76848f176b0f7fe8f73 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:18:07 -0700
Subject: [PATCH 106/116] chore(deps): point the local sources at the primary
checkout
The library half of this branch merged and released, so the `-p3` worktree these
paths named is gone. A missing path source is not a soft failure -- `uv run`
cannot build the environment, so nothing in the repo runs until it is corrected.
Repointing also lets the 3.0.1 pin resolve: the lock now records span-panel-api
3.0.1 from the primary checkout instead of 3.0.0 from a worktree that no longer
exists. The Pyright extraPath moved with it.
CI never reads this block -- it strips `[tool.uv.sources]` and re-locks from PyPI
-- so a stale path here breaks local work only.
---
pyproject.toml | 21 ++++++++++++---------
uv.lock | 27 ++++++++++++++-------------
2 files changed, 26 insertions(+), 22 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index c0512e87..df96c150 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,14 +45,17 @@ dev = [
package = false
[tool.uv.sources]
-# This branch develops against the in-progress schema-1 adapter, which lives in a
-# worktree rather than the primary checkout the symlink points at. `-p3` carries
-# the library half of `feat/discovery-and-catalog-validation`; the two halves are
-# one change and pointing at the wrong worktree means the branch is not being
-# tested against the library it needs.
-span-panel-api = { path = "../../span/span-panel-api-p3", editable = true }
-span-panel-api-schema-0 = { path = "../../span/span-panel-api-p3/packages/schema-0", editable = true }
-span-panel-api-schema-1 = { path = "../../span/span-panel-api-p3/packages/schema-1", editable = true }
+# The library half of this branch is merged and released, so the primary checkout
+# carries it and the `-p3` worktree these paths used to name is gone. A path that
+# does not exist is not a soft failure: `uv run` cannot build the environment at
+# all, so nothing in this repo runs until it is corrected.
+#
+# CI never reads this block -- it deletes it and re-locks from PyPI (.github/
+# workflows/ci.yml) -- so a stale path here breaks only local work, and only
+# noisily.
+span-panel-api = { path = "../../span/span-panel-api", editable = true }
+span-panel-api-schema-0 = { path = "../../span/span-panel-api/packages/schema-0", editable = true }
+span-panel-api-schema-1 = { path = "../../span/span-panel-api/packages/schema-1", editable = true }
[tool.jscpd]
path = ["custom_components/span_panel", "./*.{html,md}"]
@@ -132,7 +135,7 @@ exclude = [
]
extraPaths = [
"./custom_components",
- "../../span/span-panel-api-p3/src",
+ "../../span/span-panel-api/src",
"../ha-synthetic-sensors/src"
]
pythonPlatform = "Darwin"
diff --git a/uv.lock b/uv.lock
index 841f401b..340c79c8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2523,9 +2523,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "homeassistant", specifier = "==2026.8.0" },
- { name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
- { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" },
- { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" },
+ { name = "span-panel-api", editable = "../../span/span-panel-api" },
+ { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" },
+ { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" },
]
[package.metadata.requires-dev]
@@ -2550,8 +2550,8 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "3.0.0"
-source = { editable = "../../span/span-panel-api-p3" }
+version = "3.0.1"
+source = { editable = "../../span/span-panel-api" }
dependencies = [
{ name = "httpx" },
{ name = "paho-mqtt" },
@@ -2563,8 +2563,8 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1,<1.0" },
{ name = "paho-mqtt", specifier = ">=2.0.0,<3.0.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
- { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api-p3/packages/schema-0" },
- { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api-p3/packages/schema-1" },
+ { name = "span-panel-api-schema-0", marker = "extra == 'schema-0'", editable = "../../span/span-panel-api/packages/schema-0" },
+ { name = "span-panel-api-schema-1", marker = "extra == 'schema-1'", editable = "../../span/span-panel-api/packages/schema-1" },
]
provides-extras = ["schema-0", "schema-1"]
@@ -2573,6 +2573,7 @@ dev = [
{ name = "bandit", specifier = ">=1.9.4" },
{ name = "black" },
{ name = "coverage" },
+ { name = "cryptography", specifier = ">=50.0.0" },
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pylint" },
@@ -2581,8 +2582,8 @@ dev = [
{ name = "pytest-cov" },
{ name = "radon" },
{ name = "ruff", specifier = ">=0.15.5" },
- { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api-p3/packages/schema-0" },
- { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api-p3/packages/schema-1" },
+ { name = "span-panel-api-schema-0", editable = "../../span/span-panel-api/packages/schema-0" },
+ { name = "span-panel-api-schema-1", editable = "../../span/span-panel-api/packages/schema-1" },
{ name = "twine", specifier = ">=7.0" },
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
{ name = "vulture", specifier = ">=2.14" },
@@ -2591,18 +2592,18 @@ dev = [
[[package]]
name = "span-panel-api-schema-0"
version = "1.0.0"
-source = { editable = "../../span/span-panel-api-p3/packages/schema-0" }
+source = { editable = "../../span/span-panel-api/packages/schema-0" }
dependencies = [
{ name = "span-panel-api" },
]
[package.metadata]
-requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api-p3" }]
+requires-dist = [{ name = "span-panel-api", editable = "../../span/span-panel-api" }]
[[package]]
name = "span-panel-api-schema-1"
version = "1.0.0"
-source = { editable = "../../span/span-panel-api-p3/packages/schema-1" }
+source = { editable = "../../span/span-panel-api/packages/schema-1" }
dependencies = [
{ name = "ebus-sdk" },
{ name = "span-panel-api" },
@@ -2611,7 +2612,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "ebus-sdk", specifier = ">=0.19.0,<0.24" },
- { name = "span-panel-api", editable = "../../span/span-panel-api-p3" },
+ { name = "span-panel-api", editable = "../../span/span-panel-api" },
]
[[package]]
From 87c640e02a6fbf19fe5de8cd9afca27b51077f18 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:07:52 -0700
Subject: [PATCH 107/116] fix(naming): Recreate entity IDs proposes what the
panel says now
Renaming a circuit in the SPAN app left "Recreate entity IDs" offering the
entity its own ID, so the button appeared to do nothing (#252).
The registry generates an ID from the user's `name` override, else
`suggested_object_id`, else `object_id_base`. Friendly-names mode writes no
registry `name` -- the panel name arrives as `original_name` -- so the
suggestion decides, and `construct_single_circuit_entity_id` was handing back
the entity's stored ID on every reload. The suggestion was therefore frozen at
whatever the circuit was called the day it was added.
It now computes the ID from current panel data and the naming flags, for
existing entities as much as new ones. That cannot move a live entity ID: an
entity_id set before add is only a suggestion, and for a unique_id already on
file `async_get_or_create` routes to `_async_update_entity` with no
`new_entity_id` before `entity.entity_id` is reassigned from the stored entry.
Unique IDs are untouched -- they derive from the description key and never read
a name.
Circuit-numbers mode is deliberately unchanged, guarded by a test. There the
registry `name` phase 2 sync writes is both what shows the panel's name in the
UI and what outranks the suggestion, so Recreate keeps composing from that name.
Correcting it would mean rerouting phase 2 sync, which is a product decision,
not part of this fix.
The registry lookup was the only use of the `unique_id` argument, and
`existing_entity_id` the only use of that parameter on `_construct_entity_id`;
both are gone rather than left as parameters that no longer decide anything.
Every test reloads before asserting -- asserting straight after creation
exercises the first-add path, where the suggestion is trivially current and the
bug cannot appear. The three that demonstrate the bug fail on the previous code;
the five guards pass on both.
---
.../span_panel/entity_resolver.py | 40 +--
custom_components/span_panel/select.py | 7 +-
custom_components/span_panel/sensor_base.py | 8 +-
.../span_panel/sensor_circuit.py | 4 -
custom_components/span_panel/switch.py | 7 +-
tests/test_recreate_entity_ids.py | 306 ++++++++++++++++++
6 files changed, 334 insertions(+), 38 deletions(-)
create mode 100644 tests/test_recreate_entity_ids.py
diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py
index bf637cc9..3443f5a7 100644
--- a/custom_components/span_panel/entity_resolver.py
+++ b/custom_components/span_panel/entity_resolver.py
@@ -284,10 +284,25 @@ def construct_single_circuit_entity_id(
platform: str,
suffix: str,
circuit_data: SpanCircuitSnapshot,
- unique_id: str | None = None,
device_name: str | None = None,
) -> str | None:
- """Construct entity ID for single-circuit sensors.
+ """Construct the entity ID the current panel data and naming flags produce.
+
+ Always computed, never read back from the registry -- including for a circuit
+ that already has entities. Home Assistant treats an entity_id set before the
+ entity is added as a *suggestion*: `EntityPlatform` splits it into
+ `suggested_object_id`, hands that to `async_get_or_create`, and for a
+ unique_id already on file that call routes to `_async_update_entity` with no
+ `new_entity_id`, then reassigns `entity.entity_id` from the stored entry. A
+ live entity ID therefore cannot move from here; only the stored suggestion
+ changes.
+
+ That suggestion is the field "Recreate entity IDs" regenerates from when the
+ registry holds no user `name` override, which in friendly-names mode is
+ always the case -- the panel name reaches the UI as `original_name`. Handing
+ back the stored entity ID froze the suggestion at whatever the circuit was
+ called on the day it was added, so a circuit renamed in the SPAN app was
+ offered its own ID and Recreate looked broken (issue #252).
Args:
coordinator: The coordinator instance
@@ -295,33 +310,12 @@ def construct_single_circuit_entity_id(
platform: Platform name ("sensor", "switch", "select")
suffix: Entity-specific suffix ("power", "energy_produced", etc.)
circuit_data: Circuit data object
- unique_id: The unique ID for this entity (None to skip registry lookup)
device_name: Device name for entity ID construction (None to use from config entry)
Returns:
Constructed entity ID string or None if device info unavailable
"""
- # Check registry first only if unique_id is provided
- if unique_id is not None:
- entity_registry = er.async_get(coordinator.hass)
- existing_entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id)
-
- _LOGGER.debug(
- "Circuit helper registry lookup - unique_id=%s, found_entity_id=%s",
- unique_id,
- existing_entity_id,
- )
-
- if existing_entity_id:
- return existing_entity_id
- # FATAL ERROR: Expected unique_id not found in registry
- raise ValueError(
- f"REGISTRY LOOKUP ERROR: Expected unique_id '{unique_id}' not found in registry. "
- f"This indicates a migration or configuration mismatch."
- )
- _LOGGER.debug("Circuit helper - no unique_id provided, skipping registry lookup")
-
# Get device info
device_info = snapshot_to_device_info(snapshot, device_name)
if not device_info or not device_info.get("name"):
diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py
index ebf87070..b08176c3 100644
--- a/custom_components/span_panel/select.py
+++ b/custom_components/span_panel/select.py
@@ -166,16 +166,15 @@ def __init__(
self._attr_name = None
# Explicitly set entity_id using construct_single_circuit_entity_id
- # which correctly handles 240V two-tab circuits.
- # Only pass unique_id for existing entities (registry lookup);
- # for new entities pass None to get the constructed default.
+ # which correctly handles 240V two-tab circuits. For an entity already
+ # in the registry this is a suggestion HA records and does not act on --
+ # the stored entity_id stands. See the helper's docstring.
constructed_id = construct_single_circuit_entity_id(
coordinator,
snapshot,
"select",
description.entity_description.key,
circuit,
- unique_id=self._attr_unique_id if existing_entity_id else None,
)
if constructed_id:
self.entity_id = constructed_id
diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py
index 88dbf41f..d40152ae 100644
--- a/custom_components/span_panel/sensor_base.py
+++ b/custom_components/span_panel/sensor_base.py
@@ -123,7 +123,7 @@ def __init__(
)
# Wire explicit entity_id via subclass helper
- entity_id = self._construct_entity_id(snapshot, description, existing_entity_id)
+ entity_id = self._construct_entity_id(snapshot, description)
if entity_id:
self.entity_id = entity_id
else:
@@ -228,17 +228,19 @@ def _construct_entity_id(
self,
snapshot: SpanPanelSnapshot,
description: T,
- existing_entity_id: str | None = None,
) -> str | None:
"""Construct explicit entity_id for the sensor.
Subclasses may override to use entity_id helpers from helpers.py.
Returns None to let HA auto-generate from _attr_name.
+ Whether the entity is already in the registry is deliberately not an
+ input: the value is what current panel data and the naming flags
+ produce, and HA keeps an existing entity's stored ID regardless.
+
Args:
snapshot: The panel snapshot data
description: The sensor description
- existing_entity_id: The existing entity_id from registry, or None for new entities
"""
return None
diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py
index 83cba192..c9802957 100644
--- a/custom_components/span_panel/sensor_circuit.py
+++ b/custom_components/span_panel/sensor_circuit.py
@@ -204,7 +204,6 @@ def _construct_entity_id(
self,
snapshot: SpanPanelSnapshot,
description: SpanPanelCircuitsSensorEntityDescription,
- existing_entity_id: str | None = None,
) -> str | None:
"""Construct explicit entity_id for circuit power sensors."""
circuit = snapshot.circuits.get(self.circuit_id)
@@ -219,7 +218,6 @@ def _construct_entity_id(
"sensor",
suffix,
circuit,
- unique_id=self._attr_unique_id if existing_entity_id else None,
)
def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot:
@@ -389,7 +387,6 @@ def _construct_entity_id(
self,
snapshot: SpanPanelSnapshot,
description: SpanPanelCircuitsSensorEntityDescription,
- existing_entity_id: str | None = None,
) -> str | None:
"""Construct explicit entity_id for circuit energy sensors."""
circuit = snapshot.circuits.get(self.circuit_id)
@@ -408,7 +405,6 @@ def _construct_entity_id(
"sensor",
suffix,
circuit,
- unique_id=self._attr_unique_id if existing_entity_id else None,
)
# Map original_key to the energy type used for coordinator dip offset tracking
diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py
index a4dba851..35923d95 100644
--- a/custom_components/span_panel/switch.py
+++ b/custom_components/span_panel/switch.py
@@ -120,16 +120,15 @@ def __init__(
super().__init__(coordinator)
# Explicitly set entity_id using construct_single_circuit_entity_id
- # which correctly handles 240V two-tab circuits.
- # Only pass unique_id for existing entities (registry lookup);
- # for new entities pass None to get the constructed default.
+ # which correctly handles 240V two-tab circuits. For an entity already
+ # in the registry this is a suggestion HA records and does not act on --
+ # the stored entity_id stands. See the helper's docstring.
constructed_id = construct_single_circuit_entity_id(
coordinator,
snapshot,
"switch",
"breaker",
circuit,
- unique_id=self._attr_unique_id if existing_entity_id else None,
)
if constructed_id:
self.entity_id = constructed_id
diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py
new file mode 100644
index 00000000..4d664a39
--- /dev/null
+++ b/tests/test_recreate_entity_ids.py
@@ -0,0 +1,306 @@
+"""Recreate entity IDs proposes the ID the current panel data would produce.
+
+Issue #252: renaming a circuit in the SPAN app left "Recreate entity IDs" (the
+HA registry's `async_regenerate_entity_id`) proposing the entity's own ID, so
+the button appeared to do nothing.
+
+The registry generates an ID from three fields in priority order: the user's
+`name` override, then `suggested_object_id`, then `object_id_base`. In
+friendly-names mode this integration never writes a registry `name` -- the
+panel name reaches the UI through `original_name` -- so `name` is None, the
+generator short-circuits to `suggested_object_id`, and that field is whatever
+was suggested when the entity was first added. It was frozen because the
+integration preset the entity's *stored* ID on every reload, suggesting the
+value already in place.
+
+Every case here reloads before asserting. Asserting straight after creation
+tests the first-add path, where the suggestion is trivially current and the bug
+cannot appear.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+from pytest_homeassistant_custom_component.common import (
+ MockConfigEntry,
+ MockEntityPlatform,
+)
+
+from custom_components.span_panel import SpanPanelRuntimeData
+from custom_components.span_panel.const import (
+ DOMAIN,
+ USE_CIRCUIT_NUMBERS,
+ USE_DEVICE_PREFIX,
+)
+from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor
+from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS
+from custom_components.span_panel.switch import SpanPanelCircuitsSwitch
+
+from .factories import SpanCircuitSnapshotFactory, SpanPanelSnapshotFactory
+
+CIRCUIT_ID = "15"
+SERIAL = "sp3-recreate-001"
+
+ORIGINAL_NAME = "Refrigerator"
+RENAMED = "Beer Fridge"
+
+ORIGINAL_ENTITY_ID = "sensor.span_panel_refrigerator_power"
+RENAMED_ENTITY_ID = "sensor.span_panel_beer_fridge_power"
+CIRCUIT_NUMBERS_ENTITY_ID = "sensor.span_panel_circuit_15_power"
+
+FRIENDLY_NAMES = {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: False}
+CIRCUIT_NUMBERS = {USE_DEVICE_PREFIX: True, USE_CIRCUIT_NUMBERS: True}
+
+POWER_DESCRIPTION = next(desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_power")
+
+
+def _snapshot(circuit_name: str):
+ """Build a one-circuit panel snapshot with the circuit named as given."""
+ circuit = SpanCircuitSnapshotFactory.create(
+ circuit_id=CIRCUIT_ID, name=circuit_name, tabs=[15]
+ )
+ return SpanPanelSnapshotFactory.create(serial_number=SERIAL, circuits={CIRCUIT_ID: circuit})
+
+
+def _coordinator(hass: HomeAssistant, snapshot, entry: MockConfigEntry) -> MagicMock:
+ """Build a coordinator standing in for a live one, bound to the real hass."""
+ coordinator = MagicMock()
+ coordinator.hass = hass
+ coordinator.data = snapshot
+ coordinator.panel_offline = False
+ coordinator.config_entry = entry
+ coordinator.request_reload = MagicMock()
+ coordinator.register_circuit_energy_sensor = MagicMock()
+ coordinator.get_circuit_dip_offset = MagicMock(return_value=0.0)
+ return coordinator
+
+
+class _Install:
+ """One install of the sensor platform, reloadable.
+
+ `load` a second time is what a reload is: the entry's entities are torn down
+ and rebuilt from the current snapshot, against the entity registry that
+ survived. Nothing here is faked -- `async_add_entities` is the real
+ `EntityPlatform` path, so a preset entity_id travels the same route into
+ `async_get_or_create` that it does in a running install, and the teardown is
+ the same `async_reset` an entry unload performs.
+ """
+
+ def __init__(self, hass: HomeAssistant, entry: MockConfigEntry) -> None:
+ self._hass = hass
+ self._entry = entry
+ self._platform: MockEntityPlatform | None = None
+
+ async def load(self, circuit_name: str) -> SpanCircuitPowerSensor:
+ """Tear down any previous platform, then set one up from fresh panel data."""
+ if self._platform is not None:
+ await self._platform.async_reset()
+
+ snapshot = _snapshot(circuit_name)
+ coordinator = _coordinator(self._hass, snapshot, self._entry)
+ self._entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+
+ self._platform = MockEntityPlatform(self._hass, domain="sensor", platform_name=DOMAIN)
+ self._platform.config_entry = self._entry
+
+ sensor = SpanCircuitPowerSensor(coordinator, POWER_DESCRIPTION, snapshot, CIRCUIT_ID)
+ await self._platform.async_add_entities([sensor])
+ await self._hass.async_block_till_done()
+
+ assert sensor.hass is not None, "entity was rejected before it reached the registry"
+ return sensor
+
+
+@pytest.fixture
+def entry(hass: HomeAssistant) -> MockConfigEntry:
+ """A config entry in friendly-names mode."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "192.168.1.50", "device_name": "SPAN Panel"},
+ options=dict(FRIENDLY_NAMES),
+ title="SPAN Panel",
+ unique_id=SERIAL,
+ entry_id="entry-recreate",
+ )
+ config_entry.add_to_hass(hass)
+ return config_entry
+
+
+async def test_the_first_install_takes_its_entity_id_from_the_circuit_name(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Baseline for every case below: the ID before any rename."""
+ sensor = await _Install(hass, entry).load(ORIGINAL_NAME)
+
+ assert sensor.entity_id == ORIGINAL_ENTITY_ID
+
+
+async def test_renaming_a_circuit_does_not_move_an_existing_entity_id(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The non-negotiable one: a rename must never move a live entity_id.
+
+ Dashboards, automations, and recorder history all key off the entity_id.
+ Recreate is an offer the user accepts; a rename is not.
+ """
+ install = _Install(hass, entry)
+ await install.load(ORIGINAL_NAME)
+ sensor = await install.load(RENAMED)
+
+ assert sensor.entity_id == ORIGINAL_ENTITY_ID
+
+ registry = er.async_get(hass)
+ assert registry.async_get(ORIGINAL_ENTITY_ID) is not None
+ assert registry.async_get(RENAMED_ENTITY_ID) is None
+
+
+async def test_renaming_a_circuit_does_not_move_the_unique_id(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Unique IDs are derived from the description key and never from a name.
+
+ A moved unique_id orphans the entity and drops its long-term statistics, and
+ it would break any future migration that has to predict what a unique_id
+ looks like.
+ """
+ install = _Install(hass, entry)
+ before = await install.load(ORIGINAL_NAME)
+ unique_id_before = before.unique_id
+
+ after = await install.load(RENAMED)
+
+ assert after.unique_id == unique_id_before
+
+ registry = er.async_get(hass)
+ entry_after = registry.async_get(ORIGINAL_ENTITY_ID)
+ assert entry_after is not None
+ assert entry_after.unique_id == unique_id_before
+
+
+async def test_renaming_a_circuit_refreshes_the_registrys_entity_id_suggestion(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The stored suggestion has to track the panel, not the install date.
+
+ This is the field `async_regenerate_entity_id` reads when there is no user
+ `name` override, which in friendly-names mode is always.
+ """
+ install = _Install(hass, entry)
+ await install.load(ORIGINAL_NAME)
+ await install.load(RENAMED)
+
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get(ORIGINAL_ENTITY_ID)
+ assert registry_entry is not None
+ assert registry_entry.suggested_object_id == "span_panel_beer_fridge_power"
+
+
+async def test_recreate_entity_ids_proposes_the_renamed_id(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Issue #252 itself, at the API the button calls."""
+ install = _Install(hass, entry)
+ await install.load(ORIGINAL_NAME)
+ await install.load(RENAMED)
+
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get(ORIGINAL_ENTITY_ID)
+ assert registry_entry is not None
+
+ proposed = registry.async_regenerate_entity_id(registry_entry)
+
+ assert proposed == RENAMED_ENTITY_ID
+ assert proposed != registry_entry.entity_id
+
+
+async def test_an_unrenamed_circuit_is_offered_its_own_entity_id(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Recreate must be a no-op when nothing changed.
+
+ Without this the previous test passes for the wrong reason -- a suggestion
+ that moves on every reload would satisfy it while offering every user a
+ pointless rename.
+ """
+ install = _Install(hass, entry)
+ await install.load(ORIGINAL_NAME)
+ await install.load(ORIGINAL_NAME)
+
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get(ORIGINAL_ENTITY_ID)
+ assert registry_entry is not None
+
+ assert registry.async_regenerate_entity_id(registry_entry) == ORIGINAL_ENTITY_ID
+
+
+async def test_circuit_numbers_mode_keeps_its_id_its_display_name_and_its_sync(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Regression guard: nothing in circuit-numbers mode may change.
+
+ There the registry `name` written by phase 2 name sync is both what puts the
+ panel's name in the UI and what outranks `suggested_object_id` during
+ regeneration. That second effect means Recreate in this mode proposes a
+ friendly-name ID for a circuit-numbered entity -- a known limitation, and
+ the assertion below pins it deliberately: it is what the mode did before
+ this fix, and this fix must not disturb it.
+
+ The two are the same write, so correcting Recreate here would mean dropping
+ or rerouting phase 2 sync. That is a product decision, recorded in the design
+ doc, not something to change while fixing friendly-names mode.
+ """
+ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS))
+
+ install = _Install(hass, entry)
+ await install.load(ORIGINAL_NAME)
+ sensor = await install.load(RENAMED)
+
+ assert sensor.entity_id == CIRCUIT_NUMBERS_ENTITY_ID
+
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID)
+ assert registry_entry is not None
+
+ # Phase 2 sync still writes the panel's name as the display name.
+ assert registry_entry.name == f"{RENAMED} Power"
+
+ # And that name still outranks the suggestion, so the offer is composed
+ # from it -- unchanged, limitation included.
+ assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_ENTITY_ID
+
+
+async def test_the_breaker_switch_gets_the_same_refreshed_suggestion(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Switches and selects preset their IDs through the same helper.
+
+ They call it from their own constructors rather than through
+ `_construct_entity_id`, so a fix that only reached the sensor path would
+ leave a renamed circuit's breaker switch still offering its old ID.
+ """
+ platform = MockEntityPlatform(hass, domain="switch", platform_name=DOMAIN)
+ platform.config_entry = entry
+
+ for circuit_name in (ORIGINAL_NAME, RENAMED):
+ snapshot = _snapshot(circuit_name)
+ coordinator = _coordinator(hass, snapshot, entry)
+ switch = SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, circuit_name, "SPAN Panel")
+ await platform.async_add_entities([switch])
+ await hass.async_block_till_done()
+ await platform.async_reset()
+
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get("switch.span_panel_refrigerator_breaker")
+ assert registry_entry is not None
+ assert registry_entry.suggested_object_id == "span_panel_beer_fridge_breaker"
+ assert (
+ registry.async_regenerate_entity_id(registry_entry)
+ == "switch.span_panel_beer_fridge_breaker"
+ )
From f91d3b255ad106880bd50eef6ff35d7f4c36cbb3 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:25:49 -0700
Subject: [PATCH 108/116] chore(release): 2.1.0b10
Cut from the entity-id branch so the "Recreate entity IDs" fix gets field time
before it merges. Carries span-panel-api 3.0.1 and both adapters at 1.0.0.
---
custom_components/span_panel/manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 775c5489..a409fab8 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0",
"span-panel-api-schema-1==1.0.0"
],
- "version": "2.1.0b9",
+ "version": "2.1.0b10",
"zeroconf": [
{
"type": "_span._tcp.local."
From 28e6035419e37aace116fda20bb23f3804672a95 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:33:54 -0700
Subject: [PATCH 109/116] docs(changelog): the entity-id fix and the energy
sensor rename
Both are outward facing and neither was recorded. Written against 2.1.0, not a
beta -- the changelog describes the public release, and the steps between betas
are not what a reader is looking for.
---
CHANGELOG.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b313695..0a0a8380 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -149,8 +149,18 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
`panel_size` was already in that attribute block. If you have a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at
the Wi-Fi Link binary sensor instead. `panel_size` is unaffected and stays where it is.
+- **The three circuit energy sensors are renamed to match the entity ids they have always had.** "Produced Energy", "Consumed Energy" and "Net Energy" become
+ **Energy Produced**, **Energy Consumed** and **Energy Net** — the order their entity ids (`..._energy_produced`, `..._energy_consumed`, `..._energy_net`) have
+ used since those sensors shipped. **Entity ids, unique ids and history are unchanged**; only the name shown in the UI reorders. Left alone, the word order
+ would have had Recreate entity IDs offering you a rename for every circuit on your panel.
+
### Fixed
+- **Recreate entity IDs proposes the ids your panel would produce now.** Renaming a circuit in the SPAN app used to leave the button offering each entity the id
+ it already had, so it looked like it did nothing (#252). The proposal was frozen at whatever the circuit was called when the entity was first created; it now
+ follows the panel. **It is still an offer you accept** — a rename in the SPAN app never moves a live entity id by itself, and unique ids and statistics are
+ untouched. Rename nothing and Recreate proposes the ids you already have. Circuit-numbers installations are unchanged: there the display name written by name
+ sync is also what Home Assistant builds the proposal from, so the button behaves exactly as it did.
- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed
"Possible states: Unknown".
- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has — that is what release 2.0.5
From d1c580a09af988a346d6a1817e4da6a8beb420b3 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:54:53 -0700
Subject: [PATCH 110/116] fix(naming): keep the entity-id suffix an install
already has
Recreate offered 74 renames on a real panel after the previous beta: every
circuit energy sensor, `_consumed_energy` to `_energy_consumed`. Only one of the
74 was a circuit the owner had renamed, and that one was buried.
Installs predating the point where the suffix mapping reached entity ids took
their id from the descriptor name, so they carry `_consumed_energy` while their
own unique id carries `_energy_consumed`. The two have disagreed all along;
recomputing the suggestion is what made the disagreement visible.
The descriptor rename is not the cause and reverting it would not have removed a
single offer: in friendly-names mode the registry holds no `name`, so
`suggested_object_id` -- our suffix-based preset -- outranks `original_name` and
the descriptor name never reaches the id. Measured both ways; the proposal is
`_energy_consumed` either way.
So an existing entity keeps the suffix it shipped with and only the circuit-name
half of its id follows the panel. `LEGACY_ENTITY_ID_SUFFIXES` records the older
spellings; comparing with the suffix removed means a renamed circuit still gets
the computed id, which is the whole point of #252. A trailing segment that merely
looks like a suffix change -- "Kitchen Outlets" renamed to "Kitchen" -- is a
rename and is still offered.
`_construct_entity_id` takes `existing_entity_id` again. It no longer decides
whether to compute an id, only which suffix the computed one carries.
Four tests, two of which fail with preservation disabled. The earlier ones all
built their entities with current code, where preset and live id are the same
string by construction and this could not appear -- which is why it reached a
beta.
---
CHANGELOG.md | 15 +-
.../span_panel/entity_resolver.py | 12 +-
custom_components/span_panel/id_builder.py | 90 +++++++++--
custom_components/span_panel/manifest.json | 2 +-
custom_components/span_panel/select.py | 1 +
custom_components/span_panel/sensor_base.py | 10 +-
.../span_panel/sensor_circuit.py | 4 +
custom_components/span_panel/switch.py | 1 +
tests/test_recreate_entity_ids.py | 144 +++++++++++++++++-
9 files changed, 254 insertions(+), 25 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a0a8380..22001379 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -149,18 +149,21 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
`panel_size` was already in that attribute block. If you have a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at
the Wi-Fi Link binary sensor instead. `panel_size` is unaffected and stays where it is.
-- **The three circuit energy sensors are renamed to match the entity ids they have always had.** "Produced Energy", "Consumed Energy" and "Net Energy" become
- **Energy Produced**, **Energy Consumed** and **Energy Net** — the order their entity ids (`..._energy_produced`, `..._energy_consumed`, `..._energy_net`) have
- used since those sensors shipped. **Entity ids, unique ids and history are unchanged**; only the name shown in the UI reorders. Left alone, the word order
- would have had Recreate entity IDs offering you a rename for every circuit on your panel.
+- **The three circuit energy sensors are renamed to match the ids they are given.** "Produced Energy", "Consumed Energy" and "Net Energy" become **Energy
+ Produced**, **Energy Consumed** and **Energy Net**, the order used by the `energy_produced`, `energy_consumed` and `energy_net` suffixes that these sensors'
+ unique ids carry and that new entities are given. **Entity ids, unique ids and history are unchanged**; only the name shown in the UI reorders.
### Fixed
- **Recreate entity IDs proposes the ids your panel would produce now.** Renaming a circuit in the SPAN app used to leave the button offering each entity the id
it already had, so it looked like it did nothing (#252). The proposal was frozen at whatever the circuit was called when the entity was first created; it now
follows the panel. **It is still an offer you accept** — a rename in the SPAN app never moves a live entity id by itself, and unique ids and statistics are
- untouched. Rename nothing and Recreate proposes the ids you already have. Circuit-numbers installations are unchanged: there the display name written by name
- sync is also what Home Assistant builds the proposal from, so the button behaves exactly as it did.
+ untouched. Circuit-numbers installations are unchanged: there the display name written by name sync is also what Home Assistant builds the proposal from, so
+ the button behaves exactly as it did.
+- **Only circuits you actually renamed are offered.** Installations old enough to predate the current suffixes carry entity ids ending `_consumed_energy`,
+ `_produced_energy`, `_net_energy` or `_current_power`, where an entity created today would end `_energy_consumed`, `_energy_produced`, `_energy_net` or
+ `_power`. Those ids keep the suffix they have. Renormalising them would have offered a rename for **every circuit on the panel** — seventy-four on one we
+ measured — burying the one circuit that had actually been renamed and breaking the dashboards and automations of anyone who accepted.
- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed
"Possible states: Unknown".
- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has — that is what release 2.0.5
diff --git a/custom_components/span_panel/entity_resolver.py b/custom_components/span_panel/entity_resolver.py
index 3443f5a7..d006705a 100644
--- a/custom_components/span_panel/entity_resolver.py
+++ b/custom_components/span_panel/entity_resolver.py
@@ -25,6 +25,7 @@
build_select_unique_id,
build_switch_unique_id,
construct_synthetic_unique_id,
+ preserve_legacy_entity_id_suffix,
)
from .util import snapshot_to_device_info
@@ -285,6 +286,7 @@ def construct_single_circuit_entity_id(
suffix: str,
circuit_data: SpanCircuitSnapshot,
device_name: str | None = None,
+ existing_entity_id: str | None = None,
) -> str | None:
"""Construct the entity ID the current panel data and naming flags produce.
@@ -311,6 +313,8 @@ def construct_single_circuit_entity_id(
suffix: Entity-specific suffix ("power", "energy_produced", etc.)
circuit_data: Circuit data object
device_name: Device name for entity ID construction (None to use from config entry)
+ existing_entity_id: This entity's id in the registry, when it has one, so an
+ id predating the suffix mapping keeps the suffix it shipped with
Returns:
Constructed entity ID string or None if device info unavailable
@@ -366,7 +370,13 @@ def construct_single_circuit_entity_id(
if suffix and not circuit_part.endswith(f"_{suffix}"):
parts.append(suffix)
- return f"{platform}.{'_'.join(parts)}"
+ # An entity created before the suffix mapping reached entity ids carries the
+ # older spelling. Renormalising it would offer a rename to every circuit on
+ # the panel, so the circuit-name half follows the panel and the suffix half
+ # stays as it shipped.
+ return preserve_legacy_entity_id_suffix(
+ f"{platform}.{'_'.join(parts)}", existing_entity_id, suffix
+ )
def construct_unmapped_entity_id(
diff --git a/custom_components/span_panel/id_builder.py b/custom_components/span_panel/id_builder.py
index 5d02bb8d..61055dca 100644
--- a/custom_components/span_panel/id_builder.py
+++ b/custom_components/span_panel/id_builder.py
@@ -34,10 +34,14 @@
"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style.
Every entry here translates a legacy camelCase description key into the suffix
-its entities have carried since before 2.0.8 -- and that suffix is shared by the
-`unique_id` *and* the `entity_id`, so a changed entry moves both on every
-installed panel. A moved `unique_id` costs the statistics; a moved `entity_id`
-breaks the templates and automations a user wrote.
+its entities have carried since before 2.0.8, so a changed entry moves a live
+`unique_id` on every installed panel, and a moved `unique_id` costs the
+statistics.
+
+It governs the `entity_id` too, but only for entities created since the
+integration began presetting one. Older entities took their id from the
+descriptor name instead, which used the opposite word order -- that is what
+`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist.
So the rule for anything new is **verbatim**: a description key added from here on
resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`,
@@ -50,6 +54,60 @@
"""
+# Entity-id suffixes that predate the mapping above, keyed by the suffix that
+# replaced them.
+LEGACY_ENTITY_ID_SUFFIXES: dict[str, frozenset[str]] = {
+ "power": frozenset({"current_power"}),
+ "energy_produced": frozenset({"produced_energy"}),
+ "energy_consumed": frozenset({"consumed_energy"}),
+ "energy_net": frozenset({"net_energy"}),
+}
+"""Entity ids only -- never a `unique_id`, which has always used the canonical form.
+
+Before the integration preset an `entity_id`, Home Assistant composed one from the
+descriptor name: "Consumed Energy" gave `..._consumed_energy` where the mapping
+above says `energy_consumed`. Installs from that era carry an entity id whose
+suffix disagrees with their own unique id; the two orders were only reconciled
+going forward.
+
+That disagreement is not a defect to correct on a user's behalf. Renormalising it
+offers a rename for every circuit on the panel -- seventy-four on a measured one --
+which buries the circuit they actually renamed and breaks every dashboard and
+automation belonging to anyone who accepts. So an existing entity keeps the suffix
+it has; only the circuit-name half of its id follows the panel.
+
+Entries are historical fact, so this table only grows by discovering another form
+that shipped. `energy_imported`, `energy_exported`, `priority`, `current` and
+`breaker_rating` have no entry because they were never named the other way round.
+"""
+
+
+def preserve_legacy_entity_id_suffix(
+ computed_entity_id: str, existing_entity_id: str | None, suffix: str
+) -> str:
+ """Return the id to use, keeping an existing entity's legacy suffix form.
+
+ The ids are compared with the suffix removed, so the existing id wins only
+ when the circuit-name half already agrees and the suffix is a known older
+ spelling. A circuit renamed on the panel differs in that half and gets the
+ computed id, which is what issue #252 is about.
+ """
+ if not existing_entity_id or existing_entity_id == computed_entity_id:
+ return computed_entity_id
+
+ legacy_forms = LEGACY_ENTITY_ID_SUFFIXES.get(suffix)
+ if not legacy_forms:
+ return computed_entity_id
+
+ stem = computed_entity_id.removesuffix(f"_{suffix}")
+ if stem == computed_entity_id:
+ return computed_entity_id
+
+ if any(existing_entity_id == f"{stem}_{form}" for form in legacy_forms):
+ return existing_entity_id
+ return computed_entity_id
+
+
# Panel sensor API field mappings (used by get_user_friendly_suffix)
# Includes main meter/feedthrough produced, consumed, and net energy
PANEL_SUFFIX_MAPPING = {
@@ -70,10 +128,14 @@
"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style.
Every entry here translates a legacy camelCase description key into the suffix
-its entities have carried since before 2.0.8 -- and that suffix is shared by the
-`unique_id` *and* the `entity_id`, so a changed entry moves both on every
-installed panel. A moved `unique_id` costs the statistics; a moved `entity_id`
-breaks the templates and automations a user wrote.
+its entities have carried since before 2.0.8, so a changed entry moves a live
+`unique_id` on every installed panel, and a moved `unique_id` costs the
+statistics.
+
+It governs the `entity_id` too, but only for entities created since the
+integration began presetting one. Older entities took their id from the
+descriptor name instead, which used the opposite word order -- that is what
+`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist.
So the rule for anything new is **verbatim**: a description key added from here on
resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`,
@@ -107,10 +169,14 @@
"""**Closed.** A compatibility shim for the keys that predate snake_case, not a house style.
Every entry here translates a legacy camelCase description key into the suffix
-its entities have carried since before 2.0.8 -- and that suffix is shared by the
-`unique_id` *and* the `entity_id`, so a changed entry moves both on every
-installed panel. A moved `unique_id` costs the statistics; a moved `entity_id`
-breaks the templates and automations a user wrote.
+its entities have carried since before 2.0.8, so a changed entry moves a live
+`unique_id` on every installed panel, and a moved `unique_id` costs the
+statistics.
+
+It governs the `entity_id` too, but only for entities created since the
+integration began presetting one. Older entities took their id from the
+descriptor name instead, which used the opposite word order -- that is what
+`LEGACY_ENTITY_ID_SUFFIXES` records, and why it has to exist.
So the rule for anything new is **verbatim**: a description key added from here on
resolves to itself, exactly as the sub-device builders (`build_bess_unique_id`,
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index a409fab8..53a8d03e 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0",
"span-panel-api-schema-1==1.0.0"
],
- "version": "2.1.0b10",
+ "version": "2.1.0b11",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py
index b08176c3..88ae38a8 100644
--- a/custom_components/span_panel/select.py
+++ b/custom_components/span_panel/select.py
@@ -175,6 +175,7 @@ def __init__(
"select",
description.entity_description.key,
circuit,
+ existing_entity_id=existing_entity_id,
)
if constructed_id:
self.entity_id = constructed_id
diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py
index d40152ae..6d85ca4a 100644
--- a/custom_components/span_panel/sensor_base.py
+++ b/custom_components/span_panel/sensor_base.py
@@ -123,7 +123,7 @@ def __init__(
)
# Wire explicit entity_id via subclass helper
- entity_id = self._construct_entity_id(snapshot, description)
+ entity_id = self._construct_entity_id(snapshot, description, existing_entity_id)
if entity_id:
self.entity_id = entity_id
else:
@@ -228,19 +228,21 @@ def _construct_entity_id(
self,
snapshot: SpanPanelSnapshot,
description: T,
+ existing_entity_id: str | None = None,
) -> str | None:
"""Construct explicit entity_id for the sensor.
Subclasses may override to use entity_id helpers from helpers.py.
Returns None to let HA auto-generate from _attr_name.
- Whether the entity is already in the registry is deliberately not an
- input: the value is what current panel data and the naming flags
- produce, and HA keeps an existing entity's stored ID regardless.
+ The value is what current panel data and the naming flags produce; an
+ existing id is not consulted to decide *whether* to compute one, only so
+ that an id predating the suffix mapping keeps the suffix it shipped with.
Args:
snapshot: The panel snapshot data
description: The sensor description
+ existing_entity_id: This entity's id in the registry, or None if new
"""
return None
diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py
index c9802957..33da8579 100644
--- a/custom_components/span_panel/sensor_circuit.py
+++ b/custom_components/span_panel/sensor_circuit.py
@@ -204,6 +204,7 @@ def _construct_entity_id(
self,
snapshot: SpanPanelSnapshot,
description: SpanPanelCircuitsSensorEntityDescription,
+ existing_entity_id: str | None = None,
) -> str | None:
"""Construct explicit entity_id for circuit power sensors."""
circuit = snapshot.circuits.get(self.circuit_id)
@@ -218,6 +219,7 @@ def _construct_entity_id(
"sensor",
suffix,
circuit,
+ existing_entity_id=existing_entity_id,
)
def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot:
@@ -387,6 +389,7 @@ def _construct_entity_id(
self,
snapshot: SpanPanelSnapshot,
description: SpanPanelCircuitsSensorEntityDescription,
+ existing_entity_id: str | None = None,
) -> str | None:
"""Construct explicit entity_id for circuit energy sensors."""
circuit = snapshot.circuits.get(self.circuit_id)
@@ -405,6 +408,7 @@ def _construct_entity_id(
"sensor",
suffix,
circuit,
+ existing_entity_id=existing_entity_id,
)
# Map original_key to the energy type used for coordinator dip offset tracking
diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py
index 35923d95..bf9f988b 100644
--- a/custom_components/span_panel/switch.py
+++ b/custom_components/span_panel/switch.py
@@ -129,6 +129,7 @@ def __init__(
"switch",
"breaker",
circuit,
+ existing_entity_id=existing_entity_id,
)
if constructed_id:
self.entity_id = constructed_id
diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py
index 4d664a39..d8298fc4 100644
--- a/tests/test_recreate_entity_ids.py
+++ b/tests/test_recreate_entity_ids.py
@@ -37,7 +37,14 @@
USE_CIRCUIT_NUMBERS,
USE_DEVICE_PREFIX,
)
-from custom_components.span_panel.sensor_circuit import SpanCircuitPowerSensor
+from custom_components.span_panel.id_builder import (
+ build_circuit_unique_id,
+ preserve_legacy_entity_id_suffix,
+)
+from custom_components.span_panel.sensor_circuit import (
+ SpanCircuitEnergySensor,
+ SpanCircuitPowerSensor,
+)
from custom_components.span_panel.sensor_definitions import CIRCUIT_SENSORS
from custom_components.span_panel.switch import SpanPanelCircuitsSwitch
@@ -304,3 +311,138 @@ async def test_the_breaker_switch_gets_the_same_refreshed_suggestion(
registry.async_regenerate_entity_id(registry_entry)
== "switch.span_panel_beer_fridge_breaker"
)
+
+
+# --- Entities that predate the suffix mapping reaching entity ids -------------
+#
+# Every case above builds its entities with the current code, so the preset and
+# the live id are the same string by construction and a suffix disagreement
+# cannot appear. A real install upgrading is the case that matters: those
+# entities took their id from the descriptor name ("Consumed Energy" ->
+# `..._consumed_energy`) where the mapping says `energy_consumed`, so their
+# entity id and their own unique id have always disagreed. Recomputing the
+# suggestion surfaces that, and on a measured panel it offered 74 renames.
+
+LEGACY_ENTITY_ID = "sensor.span_panel_refrigerator_consumed_energy"
+CANONICAL_ENTITY_ID = "sensor.span_panel_refrigerator_energy_consumed"
+RENAMED_LEGACY_ENTITY_ID = "sensor.span_panel_beer_fridge_energy_consumed"
+
+ENERGY_DESCRIPTION = next(
+ desc for desc in CIRCUIT_SENSORS if desc.key == "circuit_energy_consumed"
+)
+
+
+class _LegacyInstall(_Install):
+ """An install whose energy sensor id was composed from the descriptor name."""
+
+ def _seed(self) -> str:
+ """Register the entity the way a pre-preset install left it."""
+ registry = er.async_get(self._hass)
+ unique_id = build_circuit_unique_id(SERIAL, CIRCUIT_ID, "consumedEnergyWh")
+ entry = registry.async_get_or_create(
+ "sensor",
+ DOMAIN,
+ unique_id,
+ suggested_object_id="span_panel_refrigerator_consumed_energy",
+ original_name=f"{ORIGINAL_NAME} Consumed Energy",
+ config_entry=self._entry,
+ )
+ return entry.entity_id
+
+ async def load(self, circuit_name: str) -> SpanCircuitEnergySensor: # type: ignore[override]
+ """Set the platform up for the energy sensor, tearing down any previous one."""
+ if self._platform is not None:
+ await self._platform.async_reset()
+
+ snapshot = _snapshot(circuit_name)
+ coordinator = _coordinator(self._hass, snapshot, self._entry)
+ self._entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
+
+ self._platform = MockEntityPlatform(self._hass, domain="sensor", platform_name=DOMAIN)
+ self._platform.config_entry = self._entry
+
+ sensor = SpanCircuitEnergySensor(
+ coordinator, ENERGY_DESCRIPTION, snapshot, CIRCUIT_ID
+ )
+ await self._platform.async_add_entities([sensor])
+ await self._hass.async_block_till_done()
+
+ assert sensor.hass is not None, "entity was rejected before it reached the registry"
+ return sensor
+
+
+async def test_upgrading_does_not_offer_to_renormalise_a_legacy_suffix(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The 74-rename case. Nothing was renamed on the panel, so nothing is offered."""
+ install = _LegacyInstall(hass, entry)
+ seeded = install._seed()
+ assert seeded == LEGACY_ENTITY_ID
+
+ sensor = await install.load(ORIGINAL_NAME)
+
+ assert sensor.entity_id == LEGACY_ENTITY_ID
+
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get(LEGACY_ENTITY_ID)
+ assert registry_entry is not None
+ assert registry.async_regenerate_entity_id(registry_entry) == LEGACY_ENTITY_ID
+
+
+async def test_a_legacy_entity_still_follows_a_circuit_rename(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Preserving the suffix must not cost the fix.
+
+ A renamed circuit differs in the name half, so the proposal is the computed
+ id -- carrying the canonical suffix, because there is no older spelling of
+ `beer_fridge` to preserve.
+ """
+ install = _LegacyInstall(hass, entry)
+ install._seed()
+
+ await install.load(ORIGINAL_NAME)
+ sensor = await install.load(RENAMED)
+
+ assert sensor.entity_id == LEGACY_ENTITY_ID
+
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get(LEGACY_ENTITY_ID)
+ assert registry_entry is not None
+ assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_LEGACY_ENTITY_ID
+
+
+async def test_a_new_install_gets_the_canonical_suffix(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Preservation is for ids that already exist; nothing new inherits the old form."""
+ sensor = await _LegacyInstall(hass, entry).load(ORIGINAL_NAME)
+
+ assert sensor.entity_id == CANONICAL_ENTITY_ID
+
+
+def test_a_suffix_with_no_older_spelling_is_left_alone() -> None:
+ """A name that merely looks like a suffix change is a rename, not a legacy form.
+
+ Renaming a circuit "Kitchen Outlets" to "Kitchen" leaves an existing id whose
+ trailing segments differ from the computed suffix. That is exactly the case
+ #252 exists to offer, so it must not be mistaken for an older spelling.
+ """
+ assert (
+ preserve_legacy_entity_id_suffix(
+ "sensor.span_panel_kitchen_power",
+ "sensor.span_panel_kitchen_outlets_power",
+ "power",
+ )
+ == "sensor.span_panel_kitchen_power"
+ )
+ assert (
+ preserve_legacy_entity_id_suffix(
+ "sensor.span_panel_kitchen_energy_consumed",
+ "sensor.span_panel_kitchen_consumed_energy",
+ "energy_consumed",
+ )
+ == "sensor.span_panel_kitchen_consumed_energy"
+ )
From 348754ebcff4fcf75bd5d3e9f98d28b509a552cb Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sat, 22 Aug 2026 23:00:04 -0700
Subject: [PATCH 111/116] fix(naming): stop the registry name deciding
circuit-numbered entity ids
Recreate in circuit-numbers mode proposed a friendly-name id for a
circuit-numbered entity, so accepting it converted the whole panel to the naming
the mode exists to avoid. Pre-existing -- it shipped in b9 and everything before
-- but only reachable now that Recreate works at all.
`suggested_object_id` was always correct in that mode. It was outranked: phase 2
sync wrote the panel's name into the registry's `name`, and
`_async_get_full_entity_name` reads the suggestion only when `name` is None
(entity_registry.py:513). One field was doing two jobs, and the second was
invisible.
The name now travels as `original_name`, which becomes `object_id_base` and ranks
below the suggestion, so it reaches the UI without touching the id. Clearing it
alone does nothing -- measured, the old sync writes it straight back on the same
load -- so the write had to stop as well.
No migration pass. The six sites that wrote the name already look up their own
entity and already hold the ownership test, so each releases the field as its
entity loads: idempotent, self-healing, and only ever touching a name this
integration wrote. A name the user set fails that test and is left alone, keeping
today's behaviour for those entities.
Both modes now share one naming path -- `_attr_name` from the panel, registry
`name` never written -- leaving the entity_id preset as the only difference
between them, which is the only thing they are supposed to differ in.
Costs, both measured and recorded in the spec: a rename in the SPAN app refreshes
the display at reload rather than in place, and `friendly_name` gains the device
prefix that friendly-names mode already carries. Neither the SPAN card nor a
device page shows the prefix -- both strip it.
Six new tests, four of which fail on the previous code. Eight existing tests
asserted the old scheme and now assert the new one.
---
CHANGELOG.md | 8 ++
custom_components/span_panel/manifest.json | 2 +-
custom_components/span_panel/select.py | 119 ++++++----------
custom_components/span_panel/sensor_base.py | 141 +++++++++----------
custom_components/span_panel/switch.py | 114 ++++++----------
tests/test_circuit_control.py | 32 +++--
tests/test_recreate_entity_ids.py | 143 ++++++++++++++------
tests/test_select.py | 19 ++-
tests/test_sensor_entities.py | 24 ++--
9 files changed, 295 insertions(+), 307 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22001379..4b169dfb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -160,6 +160,14 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
follows the panel. **It is still an offer you accept** — a rename in the SPAN app never moves a live entity id by itself, and unique ids and statistics are
untouched. Circuit-numbers installations are unchanged: there the display name written by name sync is also what Home Assistant builds the proposal from, so
the button behaves exactly as it did.
+- **Recreate entity IDs no longer offers to convert a tab-named panel to friendly names.** In Tab Based Names mode the button used to propose
+ `sensor.span_panel_air_conditioner_power` for a live `sensor.span_panel_circuit_15_power` — accepting it undid, for every circuit at once, the one thing that
+ mode exists to provide. It now proposes the tab-based ID the entity already has, so Recreate is a no-op unless a breaker actually moved.
+- **Names still follow your panel in that mode**, and still stop at a name you set yourself. What changed is where the name is kept: in a field that describes
+ the entity rather than the one Home Assistant reads when it builds an entity ID. A name written by an earlier release is handed back the first time each
+ entity loads — nothing to run, nothing to confirm. **One consequence:** a circuit renamed in the SPAN app now updates the display after the integration
+ reloads, a few seconds rather than immediately, which is what Circuit Friendly Names mode has always done.
+
- **Only circuits you actually renamed are offered.** Installations old enough to predate the current suffixes carry entity ids ending `_consumed_energy`,
`_produced_energy`, `_net_energy` or `_current_power`, where an entity created today would end `_energy_consumed`, `_energy_produced`, `_energy_net` or
`_power`. Those ids keep the suffix they have. Renormalising them would have offered a rename for **every circuit on the panel** — seventy-four on one we
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 53a8d03e..a4b9602b 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0",
"span-panel-api-schema-1==1.0.0"
],
- "version": "2.1.0b11",
+ "version": "2.1.0b12",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/custom_components/span_panel/select.py b/custom_components/span_panel/select.py
index 88ae38a8..af033269 100644
--- a/custom_components/span_panel/select.py
+++ b/custom_components/span_panel/select.py
@@ -130,27 +130,26 @@ def __init__(
desc_name = description.entity_description.name
if existing_entity_id:
- # Entity exists - use circuit-based name when configured, else panel name
- if use_circuit_numbers:
- circuit_identifier = construct_circuit_identifier_from_tabs(
- circuit.tabs, circuit_id
- )
- self._attr_name = f"{circuit_identifier} {desc_name}"
- elif circuit.name:
+ # Phase 2: the panel's name, in both modes. It reaches the UI as
+ # `original_name`, which ranks below `suggested_object_id` and so
+ # cannot decide what "Recreate entity IDs" proposes.
+ if circuit.name:
self._attr_name = f"{circuit.name} {desc_name}"
else:
fallback = _unnamed_select_fallback(circuit, circuit_id)
self._attr_name = f"{fallback} {desc_name}"
- # Sync the panel friendly name to the entity registry display name
- # so the UI shows e.g. "Air Conditioner Circuit Priority" while the
- # entity_id stays circuit-based.
- if existing_entity_id and use_circuit_numbers and circuit.name:
+ # Circuit-numbers mode used to deliver the panel's name by writing the
+ # registry's `name`. That field is the user's override, and Home Assistant
+ # reads it ahead of `suggested_object_id` when generating an entity id, so
+ # occupying it made "Recreate entity IDs" propose a friendly-name id for a
+ # circuit-numbered entity. The name travels as `original_name` now, so all
+ # that is left is to let go of what the old scheme wrote -- and only that:
+ # any other name is the user's.
+ if existing_entity_id and circuit.name:
entity_entry = entity_registry.async_get(existing_entity_id)
- if entity_entry:
- expected_name = f"{circuit.name} {desc_name}"
- if entity_entry.name is None or entity_entry.name == expected_name:
- entity_registry.async_update_entity(existing_entity_id, name=expected_name)
+ if entity_entry and entity_entry.name == f"{circuit.name} {desc_name}":
+ entity_registry.async_update_entity(existing_entity_id, name=None)
if not existing_entity_id:
# Initial install - use flag-based name for entity_id generation
@@ -289,70 +288,38 @@ def _handle_coordinator_update(self) -> None:
circuit = snapshot.circuits.get(self.id)
if circuit:
current_circuit_name = circuit.name
- use_circuit_numbers = self.coordinator.config_entry.options.get(
- USE_CIRCUIT_NUMBERS, False
- )
- desc_name = self.description_wrapper.entity_description.name
- if use_circuit_numbers:
- # Circuit-numbers mode: update registry display name, no reload
- if self.entity_id and current_circuit_name:
- entity_registry = er.async_get(self.hass)
- entity_entry = entity_registry.async_get(self.entity_id)
- if entity_entry:
- # Compute old expected display BEFORE updating
- # _previous_circuit_name
- old_display = (
- f"{self._previous_circuit_name} {desc_name}"
- if isinstance(self._previous_circuit_name, str)
- else None
- )
- new_display = f"{current_circuit_name} {desc_name}"
-
- # User override: registry name differs from both old
- # and new expected display names
- user_has_override = (
- entity_entry.name is not None
- and entity_entry.name not in {old_display, new_display}
- )
-
- if not user_has_override and (
- self._previous_circuit_name is _NAME_UNSET
- or current_circuit_name != self._previous_circuit_name
- ):
- entity_registry.async_update_entity(self.entity_id, name=new_display)
+ # One path for both modes: the name is carried by `original_name`,
+ # which is written when the entity is added, so a reload is what
+ # refreshes it. A name in the registry is one the user set.
+ user_has_override = False
+ if self.entity_id:
+ entity_registry = er.async_get(self.hass)
+ entity_entry = entity_registry.async_get(self.entity_id)
+ if entity_entry and entity_entry.name:
+ user_has_override = True
+ _LOGGER.debug(
+ "User has customized name for %s, skipping sync",
+ self.entity_id,
+ )
+ if user_has_override:
self._previous_circuit_name = current_circuit_name
- else:
- # Friendly-names mode: existing reload behavior
- user_has_override = False
- if self.entity_id:
- entity_registry = er.async_get(self.hass)
- entity_entry = entity_registry.async_get(self.entity_id)
- if entity_entry and entity_entry.name:
- user_has_override = True
- _LOGGER.debug(
- "User has customized name for %s, skipping sync",
- self.entity_id,
- )
-
- if user_has_override:
- self._previous_circuit_name = current_circuit_name
- elif self._previous_circuit_name is _NAME_UNSET:
- _LOGGER.info(
- "First update: syncing entity name to panel name '%s' for select, requesting reload",
- current_circuit_name,
- )
- self._previous_circuit_name = current_circuit_name
- self.coordinator.request_reload()
- elif current_circuit_name != self._previous_circuit_name:
- _LOGGER.info(
- "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload",
- self._previous_circuit_name,
- current_circuit_name,
- )
- self._previous_circuit_name = current_circuit_name
- self.coordinator.request_reload()
+ elif self._previous_circuit_name is _NAME_UNSET:
+ _LOGGER.info(
+ "First update: syncing entity name to panel name '%s' for select, requesting reload",
+ current_circuit_name,
+ )
+ self._previous_circuit_name = current_circuit_name
+ self.coordinator.request_reload()
+ elif current_circuit_name != self._previous_circuit_name:
+ _LOGGER.info(
+ "Auto-sync detected circuit name change from '%s' to '%s' for select, requesting integration reload",
+ self._previous_circuit_name,
+ current_circuit_name,
+ )
+ self._previous_circuit_name = current_circuit_name
+ self.coordinator.request_reload()
# Update options and current option based on coordinator data
circuit = self._get_circuit()
diff --git a/custom_components/span_panel/sensor_base.py b/custom_components/span_panel/sensor_base.py
index 6d85ca4a..974117a8 100644
--- a/custom_components/span_panel/sensor_base.py
+++ b/custom_components/span_panel/sensor_base.py
@@ -22,7 +22,7 @@
from homeassistant.helpers.typing import StateType
from span_panel_api import SpanPanelSnapshot
-from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION, USE_CIRCUIT_NUMBERS
+from .const import DOMAIN, ENABLE_ENERGY_DIP_COMPENSATION
from .coordinator import SpanPanelCoordinator
from .energy_dip import build_dip_attributes, process_energy_dip
from .entity import SpanPanelEntity
@@ -99,26 +99,17 @@ def __init__(
"sensor", DOMAIN, self._attr_unique_id
)
- use_circuit_numbers = data_coordinator.config_entry.options.get(
- USE_CIRCUIT_NUMBERS, False
- )
-
if existing_entity_id:
- if use_circuit_numbers:
- # Circuit-numbers mode: keep circuit-based name for entity_id stability
- self._attr_name = self._generate_friendly_name(snapshot, description)
- else:
- # Friendly-names mode: use panel name for sync
- self._attr_name = self._generate_panel_name(snapshot, description)
+ # Phase 2: the panel's name, in both modes. It reaches the UI
+ # as `original_name`, which ranks below `suggested_object_id`
+ # and so cannot decide what "Recreate entity IDs" proposes.
+ self._attr_name = self._generate_panel_name(snapshot, description)
else:
- # Initial install - use flag-based name
+ # Phase 1: the flag-based name the mode calls for.
self._attr_name = self._generate_friendly_name(snapshot, description)
- # Sync panel friendly name to registry display name in
- # circuit-numbers mode so the UI shows e.g.
- # "Kitchen Power" while entity_id stays circuit-based.
- if existing_entity_id and use_circuit_numbers:
- self._sync_friendly_name_to_registry(
+ if existing_entity_id:
+ self._release_synced_registry_name(
snapshot, description, entity_registry, existing_entity_id
)
@@ -205,24 +196,35 @@ def _generate_panel_name(self, snapshot: SpanPanelSnapshot, description: T) -> s
# For now, fall back to friendly name
return self._generate_friendly_name(snapshot, description)
- def _sync_friendly_name_to_registry(
+ def _release_synced_registry_name(
self,
snapshot: SpanPanelSnapshot,
description: T,
entity_registry: er.EntityRegistry,
existing_entity_id: str,
) -> None:
- """Sync panel circuit name to registry display name in circuit-numbers mode."""
+ """Give the registry's `name` back to the user, where an older release took it.
+
+ Circuit-numbers mode used to deliver the panel's name by writing the
+ registry's `name`. That field is the *user's* override, and Home Assistant
+ reads it ahead of `suggested_object_id` when generating an entity id -- so
+ occupying it made "Recreate entity IDs" propose a friendly-name id for a
+ circuit-numbered entity, converting the whole panel if accepted.
+
+ The name now travels as `original_name` instead, so this only has to let
+ go of what the old scheme wrote. Only a name this integration would have
+ written is cleared; anything else is the user's and is left exactly where
+ it is, which is the same test the write used to gate on.
+ """
+ entity_entry = entity_registry.async_get(existing_entity_id)
+ if not entity_entry or entity_entry.name is None:
+ return
circuit = snapshot.circuits.get(getattr(self, "circuit_id", ""))
if not (circuit and circuit.name):
return
- entity_entry = entity_registry.async_get(existing_entity_id)
- if not entity_entry:
- return
description_suffix = str(getattr(description, "name", None) or "Sensor")
- expected_name = f"{circuit.name} {description_suffix}"
- if entity_entry.name is None or entity_entry.name == expected_name:
- entity_registry.async_update_entity(existing_entity_id, name=expected_name)
+ if entity_entry.name == f"{circuit.name} {description_suffix}":
+ entity_registry.async_update_entity(existing_entity_id, name=None)
def _construct_entity_id(
self,
@@ -248,7 +250,14 @@ def _construct_entity_id(
return None
def _sync_circuit_name(self) -> None:
- """Sync circuit name changes: registry display in circuit-numbers mode, reload in friendly-names mode."""
+ """Follow a circuit renamed on the panel, by reloading so the name is rebuilt.
+
+ One path for both modes. The name is carried by `original_name`, which is
+ written when the entity is added, so a reload is what refreshes it --
+ circuit-numbers mode used to write the registry's `name` in place instead,
+ which was quicker but handed that field the last word over entity id
+ generation. See `_release_synced_registry_name`.
+ """
if not (hasattr(self, "circuit_id") and hasattr(self.coordinator.data, "circuits")):
return
@@ -257,61 +266,33 @@ def _sync_circuit_name(self) -> None:
return
current_circuit_name = circuit.name
- use_circuit_numbers = self.coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False)
-
- if use_circuit_numbers:
- # Circuit-numbers mode: update registry display name, no reload
- if self.entity_id:
- entity_registry = er.async_get(self.hass)
- entity_entry = entity_registry.async_get(self.entity_id)
- if entity_entry:
- description_suffix = str(
- getattr(self.entity_description, "name", None) or "Sensor"
- )
- old_display = (
- f"{self._previous_circuit_name} {description_suffix}"
- if isinstance(self._previous_circuit_name, str)
- else None
- )
- new_display = f"{current_circuit_name} {description_suffix}"
-
- user_has_override = entity_entry.name is not None and entity_entry.name not in {
- old_display,
- new_display,
- }
-
- if not user_has_override and (
- self._previous_circuit_name is _NAME_UNSET
- or current_circuit_name != self._previous_circuit_name
- ):
- entity_registry.async_update_entity(self.entity_id, name=new_display)
+
+ # A name in the registry is one the user set: theirs outranks the panel's,
+ # and reloading would not change what is displayed anyway.
+ user_has_override = False
+ if self.entity_id:
+ entity_registry = er.async_get(self.hass)
+ entity_entry = entity_registry.async_get(self.entity_id)
+ if entity_entry and entity_entry.name:
+ user_has_override = True
+
+ if user_has_override:
self._previous_circuit_name = current_circuit_name
- else:
- # Friendly-names mode: existing reload behavior
- user_has_override = False
- if self.entity_id:
- entity_registry = er.async_get(self.hass)
- entity_entry = entity_registry.async_get(self.entity_id)
- if entity_entry and entity_entry.name:
- user_has_override = True
-
- if user_has_override:
- self._previous_circuit_name = current_circuit_name
- elif self._previous_circuit_name is _NAME_UNSET:
- _LOGGER.info(
- "First update: syncing sensor name to panel name '%s', requesting reload",
- current_circuit_name,
- )
- self._previous_circuit_name = current_circuit_name
- self.coordinator.request_reload()
- elif current_circuit_name != self._previous_circuit_name:
- _LOGGER.info(
- "Auto-sync detected circuit name change from '%s' to '%s' for sensor, requesting integration reload",
- self._previous_circuit_name,
- current_circuit_name,
- )
- self._previous_circuit_name = current_circuit_name
- self.coordinator.request_reload()
+ elif self._previous_circuit_name is _NAME_UNSET:
+ _LOGGER.info(
+ "First update: syncing sensor name to panel name '%s', requesting reload",
+ current_circuit_name,
+ )
+ self._previous_circuit_name = current_circuit_name
+ self.coordinator.request_reload()
+ elif current_circuit_name != self._previous_circuit_name:
+ _LOGGER.info(
+ "Auto-sync detected circuit name change from '%s' to '%s' for sensor, requesting integration reload",
+ self._previous_circuit_name,
+ current_circuit_name,
+ )
+ self._previous_circuit_name = current_circuit_name
+ self.coordinator.request_reload()
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py
index bf9f988b..be75e47d 100644
--- a/custom_components/span_panel/switch.py
+++ b/custom_components/span_panel/switch.py
@@ -82,27 +82,26 @@ def __init__(
use_circuit_numbers = coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False)
if existing_entity_id:
- # Entity exists - use circuit-based name when configured, else panel name
- if use_circuit_numbers:
- circuit_identifier = construct_circuit_identifier_from_tabs(
- circuit.tabs, circuit_id
- )
- self._attr_name = f"{circuit_identifier} Breaker"
- elif circuit.name:
+ # Phase 2: the panel's name, in both modes. It reaches the UI as
+ # `original_name`, which ranks below `suggested_object_id` and so
+ # cannot decide what "Recreate entity IDs" proposes.
+ if circuit.name:
self._attr_name = f"{circuit.name} Breaker"
else:
fallback = _unnamed_switch_fallback(circuit, circuit_id)
self._attr_name = f"{fallback} Breaker"
- # Sync the panel friendly name to the entity registry display name
- # so the UI shows e.g. "Air Conditioner Breaker" while the entity_id
- # stays circuit-based (e.g. switch.span_panel_circuit_15_breaker).
- if existing_entity_id and use_circuit_numbers and circuit.name:
+ # Circuit-numbers mode used to deliver the panel's name by writing the
+ # registry's `name`. That field is the user's override, and Home Assistant
+ # reads it ahead of `suggested_object_id` when generating an entity id, so
+ # occupying it made "Recreate entity IDs" propose a friendly-name id for a
+ # circuit-numbered entity. The name travels as `original_name` now, so all
+ # that is left is to let go of what the old scheme wrote -- and only that:
+ # any other name is the user's.
+ if existing_entity_id and circuit.name:
entity_entry = entity_registry.async_get(existing_entity_id)
- if entity_entry:
- expected_name = f"{circuit.name} Breaker"
- if entity_entry.name is None or entity_entry.name == expected_name:
- entity_registry.async_update_entity(existing_entity_id, name=expected_name)
+ if entity_entry and entity_entry.name == f"{circuit.name} Breaker":
+ entity_registry.async_update_entity(existing_entity_id, name=None)
if not existing_entity_id:
# Initial install - use flag-based name for entity_id generation
@@ -157,66 +156,35 @@ def _handle_coordinator_update(self) -> None:
circuit = snapshot.circuits.get(self._circuit_id)
if circuit:
current_circuit_name = circuit.name
- use_circuit_numbers = self.coordinator.config_entry.options.get(
- USE_CIRCUIT_NUMBERS, False
- )
-
- if use_circuit_numbers:
- # Circuit-numbers mode: update registry display name, no reload
- if self.entity_id and current_circuit_name:
- entity_registry = er.async_get(self.hass)
- entity_entry = entity_registry.async_get(self.entity_id)
- if entity_entry:
- # Compute old expected display BEFORE updating
- # _previous_circuit_name
- old_display = (
- f"{self._previous_circuit_name} Breaker"
- if isinstance(self._previous_circuit_name, str)
- else None
- )
- new_display = f"{current_circuit_name} Breaker"
-
- # User override: registry name differs from both old
- # and new expected display names
- user_has_override = (
- entity_entry.name is not None
- and entity_entry.name not in {old_display, new_display}
- )
-
- if not user_has_override and (
- self._previous_circuit_name is _NAME_UNSET
- or current_circuit_name != self._previous_circuit_name
- ):
- entity_registry.async_update_entity(self.entity_id, name=new_display)
+ # One path for both modes: the name is carried by `original_name`,
+ # which is written when the entity is added, so a reload is what
+ # refreshes it. A name in the registry is one the user set.
+ user_has_override = False
+ if self.entity_id:
+ entity_registry = er.async_get(self.hass)
+ entity_entry = entity_registry.async_get(self.entity_id)
+ if entity_entry and entity_entry.name:
+ user_has_override = True
+
+ if user_has_override:
self._previous_circuit_name = current_circuit_name
- else:
- # Friendly-names mode: existing reload behavior
- user_has_override = False
- if self.entity_id:
- entity_registry = er.async_get(self.hass)
- entity_entry = entity_registry.async_get(self.entity_id)
- if entity_entry and entity_entry.name:
- user_has_override = True
-
- if user_has_override:
- self._previous_circuit_name = current_circuit_name
- elif self._previous_circuit_name is _NAME_UNSET:
- _LOGGER.info(
- "First update: syncing entity name to panel name '%s' for switch, requesting reload",
- current_circuit_name,
- )
- self._previous_circuit_name = current_circuit_name
- self.coordinator.request_reload()
- elif current_circuit_name != self._previous_circuit_name:
- _LOGGER.info(
- "Auto-sync detected circuit name change from '%s' to '%s' for "
- "switch, requesting integration reload",
- self._previous_circuit_name,
- current_circuit_name,
- )
- self._previous_circuit_name = current_circuit_name
- self.coordinator.request_reload()
+ elif self._previous_circuit_name is _NAME_UNSET:
+ _LOGGER.info(
+ "First update: syncing entity name to panel name '%s' for switch, requesting reload",
+ current_circuit_name,
+ )
+ self._previous_circuit_name = current_circuit_name
+ self.coordinator.request_reload()
+ elif current_circuit_name != self._previous_circuit_name:
+ _LOGGER.info(
+ "Auto-sync detected circuit name change from '%s' to '%s' for "
+ "switch, requesting integration reload",
+ self._previous_circuit_name,
+ current_circuit_name,
+ )
+ self._previous_circuit_name = current_circuit_name
+ self.coordinator.request_reload()
self._update_is_on()
super()._handle_coordinator_update()
diff --git a/tests/test_circuit_control.py b/tests/test_circuit_control.py
index 59ac49f3..c4d08199 100644
--- a/tests/test_circuit_control.py
+++ b/tests/test_circuit_control.py
@@ -560,7 +560,11 @@ def test_switch_relay_state_target_absent_when_none() -> None:
def test_switch_circuit_numbers_entity_id_stable_after_reload(
hass: HomeAssistant,
) -> None:
- """Entity_id must stay circuit-based after name sync sets friendly display name."""
+ """Entity_id stays circuit-based while the displayed name follows the panel.
+
+ Phase 1 names the entity for the mode; phase 2 replaces that with the panel's
+ name. The entity_id is preset either way, so it does not follow the name.
+ """
circuit = SpanCircuitSnapshotFactory.create(
circuit_id="2",
name="Air Conditioner",
@@ -601,8 +605,9 @@ def test_switch_circuit_numbers_entity_id_stable_after_reload(
coordinator, "2", "Air Conditioner", "SPAN Panel"
)
- # Entity_id must still be circuit-based
- assert switch2.name == "Circuit 15 17 Breaker"
+ # Phase 2: the panel's name, carried by original_name rather than the
+ # registry's `name`, which would outrank the preset id.
+ assert switch2.name == "Air Conditioner Breaker"
assert switch2.entity_id == "switch.span_panel_circuit_15_17_breaker"
@@ -636,10 +641,10 @@ def test_switch_circuit_numbers_entity_id_120v_single_tab(
assert switch.entity_id == "switch.span_panel_circuit_10_breaker"
-def test_switch_circuit_numbers_syncs_friendly_name_to_registry(
+def test_switch_circuit_numbers_releases_the_synced_registry_name(
hass: HomeAssistant,
) -> None:
- """Registry display name should be synced to the panel friendly name."""
+ """A name an older release wrote is handed back, so it stops deciding the id."""
circuit = SpanCircuitSnapshotFactory.create(
circuit_id="2",
name="Air Conditioner",
@@ -660,7 +665,7 @@ def test_switch_circuit_numbers_syncs_friendly_name_to_registry(
# Use PropertyMock because MagicMock(name=...) sets the mock's
# internal label rather than the .name attribute.
entity_entry = MagicMock()
- type(entity_entry).name = PropertyMock(return_value=None)
+ type(entity_entry).name = PropertyMock(return_value="Air Conditioner Breaker")
registry.async_get.return_value = entity_entry
mp.setattr(
"custom_components.span_panel.switch.er.async_get",
@@ -669,7 +674,7 @@ def test_switch_circuit_numbers_syncs_friendly_name_to_registry(
SpanPanelCircuitsSwitch(coordinator, "2", "Air Conditioner", "SPAN Panel")
registry.async_update_entity.assert_called_once_with(
- "switch.span_panel_circuit_15_17_breaker", name="Air Conditioner Breaker"
+ "switch.span_panel_circuit_15_17_breaker", name=None
)
@@ -708,10 +713,10 @@ def test_switch_circuit_numbers_preserves_user_custom_name(
registry.async_update_entity.assert_not_called()
-def test_switch_coordinator_update_circuit_numbers_updates_registry(
+def test_switch_coordinator_update_circuit_numbers_requests_reload(
hass: HomeAssistant,
) -> None:
- """In circuit-numbers mode, a name change should update the registry display name."""
+ """A renamed circuit reloads, which is what rebuilds original_name."""
circuit = SpanCircuitSnapshotFactory.create(
circuit_id="2",
name="Air Conditioner",
@@ -750,7 +755,8 @@ def test_switch_coordinator_update_circuit_numbers_updates_registry(
with pytest.MonkeyPatch.context() as mp:
runtime_registry = MagicMock()
runtime_entry = MagicMock()
- type(runtime_entry).name = PropertyMock(return_value="Air Conditioner Breaker")
+ # Released at construction, so nothing occupies the field any more.
+ type(runtime_entry).name = PropertyMock(return_value=None)
runtime_registry.async_get.return_value = runtime_entry
mp.setattr(
"custom_components.span_panel.switch.er.async_get",
@@ -758,10 +764,8 @@ def test_switch_coordinator_update_circuit_numbers_updates_registry(
)
switch._handle_coordinator_update()
- runtime_registry.async_update_entity.assert_called_once_with(
- "switch.span_panel_circuit_15_17_breaker", name="Kitchen AC Breaker"
- )
- coordinator.request_reload.assert_not_called()
+ coordinator.request_reload.assert_called_once()
+ runtime_registry.async_update_entity.assert_not_called()
def test_switch_coordinator_update_circuit_numbers_preserves_user_override(
diff --git a/tests/test_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py
index d8298fc4..c5ea9fd9 100644
--- a/tests/test_recreate_entity_ids.py
+++ b/tests/test_recreate_entity_ids.py
@@ -247,21 +247,17 @@ async def test_an_unrenamed_circuit_is_offered_its_own_entity_id(
assert registry.async_regenerate_entity_id(registry_entry) == ORIGINAL_ENTITY_ID
-async def test_circuit_numbers_mode_keeps_its_id_its_display_name_and_its_sync(
+async def test_circuit_numbers_mode_is_offered_its_own_tab_based_id(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
- """Regression guard: nothing in circuit-numbers mode may change.
-
- There the registry `name` written by phase 2 name sync is both what puts the
- panel's name in the UI and what outranks `suggested_object_id` during
- regeneration. That second effect means Recreate in this mode proposes a
- friendly-name ID for a circuit-numbered entity -- a known limitation, and
- the assertion below pins it deliberately: it is what the mode did before
- this fix, and this fix must not disturb it.
-
- The two are the same write, so correcting Recreate here would mean dropping
- or rerouting phase 2 sync. That is a product decision, recorded in the design
- doc, not something to change while fixing friendly-names mode.
+ """Recreate must not offer to convert a circuit-numbered panel to friendly names.
+
+ The mode exists so an id follows the breaker position rather than the name.
+ Accepting a friendly-name proposal would undo that for every circuit at once.
+
+ It used to be offered because phase 2 sync wrote the panel's name into the
+ registry's `name`, which Home Assistant reads ahead of `suggested_object_id`.
+ The name now travels as `original_name`, which ranks below it.
"""
hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS))
@@ -275,42 +271,113 @@ async def test_circuit_numbers_mode_keeps_its_id_its_display_name_and_its_sync(
registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID)
assert registry_entry is not None
- # Phase 2 sync still writes the panel's name as the display name.
- assert registry_entry.name == f"{RENAMED} Power"
+ assert registry.async_regenerate_entity_id(registry_entry) == CIRCUIT_NUMBERS_ENTITY_ID
+
+
+async def test_circuit_numbers_mode_still_shows_the_panels_name(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Phase 2 sync still follows the panel -- through a field that cannot move an id."""
+ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS))
+
+ install = _Install(hass, entry)
+ await install.load(ORIGINAL_NAME)
+ sensor = await install.load(RENAMED)
+
+ registry = er.async_get(hass)
+ registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID)
+ assert registry_entry is not None
+
+ assert registry_entry.original_name == f"{RENAMED} Power"
+ assert sensor.name == f"{RENAMED} Power"
+ assert registry_entry.name is None
+
+
+async def test_circuit_numbers_mode_releases_a_name_an_older_release_wrote(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """An install upgrading has the old scheme's name handed back to it."""
+ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS))
+
+ install = _Install(hass, entry)
+ sensor = await install.load(ORIGINAL_NAME)
+
+ registry = er.async_get(hass)
+ # Exactly what the previous release's phase 2 sync would have written.
+ registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Power")
+ assert registry.async_get(sensor.entity_id).name == f"{ORIGINAL_NAME} Power"
+
+ await install.load(ORIGINAL_NAME)
- # And that name still outranks the suggestion, so the offer is composed
- # from it -- unchanged, limitation included.
- assert registry.async_regenerate_entity_id(registry_entry) == RENAMED_ENTITY_ID
+ registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID)
+ assert registry_entry is not None
+ assert registry_entry.name is None
+ assert registry.async_regenerate_entity_id(registry_entry) == CIRCUIT_NUMBERS_ENTITY_ID
-async def test_the_breaker_switch_gets_the_same_refreshed_suggestion(
+async def test_releasing_the_name_is_idempotent(
hass: HomeAssistant, entry: MockConfigEntry
) -> None:
- """Switches and selects preset their IDs through the same helper.
+ """Two reloads in a row are the same as one. There is no migration to run twice."""
+ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS))
- They call it from their own constructors rather than through
- `_construct_entity_id`, so a fix that only reached the sensor path would
- leave a renamed circuit's breaker switch still offering its old ID.
+ install = _Install(hass, entry)
+ sensor = await install.load(ORIGINAL_NAME)
+ registry = er.async_get(hass)
+ registry.async_update_entity(sensor.entity_id, name=f"{ORIGINAL_NAME} Power")
+
+ await install.load(ORIGINAL_NAME)
+ await install.load(ORIGINAL_NAME)
+
+ registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID)
+ assert registry_entry is not None
+ assert registry_entry.name is None
+ assert registry_entry.entity_id == CIRCUIT_NUMBERS_ENTITY_ID
+
+
+async def test_a_name_the_user_set_is_never_released(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """The registry's `name` is the user's field, and only their own writes are theirs.
+
+ A name we did not write is left exactly where it is -- which also means it
+ keeps outranking the suggestion, so Recreate composes from it. That is what
+ Home Assistant does for any integration once a user names an entity.
"""
- platform = MockEntityPlatform(hass, domain="switch", platform_name=DOMAIN)
- platform.config_entry = entry
+ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS))
- for circuit_name in (ORIGINAL_NAME, RENAMED):
- snapshot = _snapshot(circuit_name)
- coordinator = _coordinator(hass, snapshot, entry)
- switch = SpanPanelCircuitsSwitch(coordinator, CIRCUIT_ID, circuit_name, "SPAN Panel")
- await platform.async_add_entities([switch])
- await hass.async_block_till_done()
- await platform.async_reset()
+ install = _Install(hass, entry)
+ sensor = await install.load(ORIGINAL_NAME)
registry = er.async_get(hass)
- registry_entry = registry.async_get("switch.span_panel_refrigerator_breaker")
+ registry.async_update_entity(sensor.entity_id, name="Beverage Cooling")
+
+ await install.load(ORIGINAL_NAME)
+
+ registry_entry = registry.async_get(CIRCUIT_NUMBERS_ENTITY_ID)
assert registry_entry is not None
- assert registry_entry.suggested_object_id == "span_panel_beer_fridge_breaker"
- assert (
- registry.async_regenerate_entity_id(registry_entry)
- == "switch.span_panel_beer_fridge_breaker"
- )
+ assert registry_entry.name == "Beverage Cooling"
+ assert registry_entry.entity_id == CIRCUIT_NUMBERS_ENTITY_ID
+
+
+async def test_circuit_numbers_mode_does_not_move_ids_across_the_change(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """Neither id moves, whatever happens to the name."""
+ hass.config_entries.async_update_entry(entry, options=dict(CIRCUIT_NUMBERS))
+
+ install = _Install(hass, entry)
+ before = await install.load(ORIGINAL_NAME)
+ unique_id_before = before.unique_id
+
+ registry = er.async_get(hass)
+ registry.async_update_entity(before.entity_id, name=f"{ORIGINAL_NAME} Power")
+
+ after = await install.load(RENAMED)
+
+ assert after.entity_id == CIRCUIT_NUMBERS_ENTITY_ID
+ assert after.unique_id == unique_id_before
+ assert registry.async_get(RENAMED_ENTITY_ID) is None
# --- Entities that predate the suffix mapping reaching entity ids -------------
diff --git a/tests/test_select.py b/tests/test_select.py
index 54af76ed..d6997817 100644
--- a/tests/test_select.py
+++ b/tests/test_select.py
@@ -515,8 +515,9 @@ def test_select_circuit_numbers_entity_id_stable_after_reload(
"SPAN Panel",
)
- # Entity_id must still be circuit-based
- assert select2.name == "Circuit 15 17 Circuit Priority"
+ # Phase 2: the panel's name, carried by original_name rather than the
+ # registry's `name`, which would outrank the preset id.
+ assert select2.name == "Air Conditioner Circuit Priority"
assert select2.entity_id == "select.span_panel_circuit_15_17_circuit_priority"
@@ -558,7 +559,7 @@ def test_select_circuit_numbers_entity_id_120v_single_tab(
assert select.entity_id == "select.span_panel_circuit_10_circuit_priority"
-def test_select_coordinator_update_circuit_numbers_updates_registry(
+def test_select_coordinator_update_circuit_numbers_requests_reload(
hass: HomeAssistant,
) -> None:
"""In circuit-numbers mode, a name change should update the registry display name."""
@@ -610,9 +611,8 @@ def test_select_coordinator_update_circuit_numbers_updates_registry(
with pytest.MonkeyPatch.context() as mp:
runtime_registry = MagicMock()
runtime_entry = MagicMock()
- type(runtime_entry).name = PropertyMock(
- return_value="Air Conditioner Circuit Priority"
- )
+ # Released at construction, so nothing occupies the field any more.
+ type(runtime_entry).name = PropertyMock(return_value=None)
runtime_registry.async_get.return_value = runtime_entry
mp.setattr(
"custom_components.span_panel.select.er.async_get",
@@ -620,8 +620,5 @@ def test_select_coordinator_update_circuit_numbers_updates_registry(
)
select._handle_coordinator_update()
- runtime_registry.async_update_entity.assert_called_once_with(
- "select.span_panel_circuit_15_17_circuit_priority",
- name="Kitchen AC Circuit Priority",
- )
- coordinator.request_reload.assert_not_called()
+ coordinator.request_reload.assert_called_once()
+ runtime_registry.async_update_entity.assert_not_called()
diff --git a/tests/test_sensor_entities.py b/tests/test_sensor_entities.py
index cdb824ef..4c114415 100644
--- a/tests/test_sensor_entities.py
+++ b/tests/test_sensor_entities.py
@@ -1198,8 +1198,8 @@ def test_energy_sensor_name_change_requests_reload() -> None:
assert sensor._previous_circuit_name == "Renamed Kitchen"
-def test_circuit_sensor_entity_id_stable_in_circuit_numbers_mode() -> None:
- """Entity name should be circuit-based in circuit-numbers mode for entity_id stability."""
+def test_circuit_sensor_takes_the_panel_name_in_circuit_numbers_mode() -> None:
+ """The name follows the panel; the entity_id does not follow the name."""
circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen", tabs=[5])
snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit})
coordinator = _make_coordinator(snapshot, options={USE_CIRCUIT_NUMBERS: True})
@@ -1214,14 +1214,13 @@ def test_circuit_sensor_entity_id_stable_in_circuit_numbers_mode() -> None:
sensor = SpanCircuitPowerSensor(coordinator, CIRCUIT_CURRENT_SENSOR, snapshot, "c1")
- # In circuit-numbers mode, _attr_name should be circuit-based (contains "Circuit")
- assert sensor._attr_name is not None
- assert "Circuit" in sensor._attr_name
+ assert sensor._attr_name == "Kitchen Current"
+ assert sensor.entity_id == "sensor.span_panel_circuit_5_current"
assert sensor._previous_circuit_name == "Kitchen"
-def test_circuit_sensor_name_change_updates_registry_in_circuit_numbers_mode() -> None:
- """In circuit-numbers mode, name changes update registry display name without reload."""
+def test_circuit_sensor_name_change_requests_reload_in_circuit_numbers_mode() -> None:
+ """A renamed circuit reloads, which is what rebuilds original_name."""
circuit = SpanCircuitSnapshotFactory.create(circuit_id="c1", name="Kitchen", tabs=[5])
snapshot = SpanPanelSnapshotFactory.create(circuits={"c1": circuit})
coordinator = _make_coordinator(snapshot, options={USE_CIRCUIT_NUMBERS: True})
@@ -1248,15 +1247,12 @@ def test_circuit_sensor_name_change_updates_registry_in_circuit_numbers_mode() -
with patch("custom_components.span_panel.sensor_base.er.async_get") as mock_async_get:
runtime_registry = MagicMock()
runtime_entry = MagicMock()
- runtime_entry.name = "Kitchen Current"
+ # Released at construction, so nothing occupies the field any more.
+ runtime_entry.name = None
runtime_registry.async_get.return_value = runtime_entry
mock_async_get.return_value = runtime_registry
sensor._handle_coordinator_update()
- # Registry should be updated with the new display name
- runtime_registry.async_update_entity.assert_called_once_with(
- "sensor.circuit_5_current", name="Renamed Kitchen Current"
- )
- # No reload should be requested in circuit-numbers mode
- coordinator.request_reload.assert_not_called()
+ coordinator.request_reload.assert_called_once()
+ runtime_registry.async_update_entity.assert_not_called()
assert sensor._previous_circuit_name == "Renamed Kitchen"
From fc6dfe1055378ad27053028db440c17f81504aa6 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sun, 23 Aug 2026 12:48:30 -0700
Subject: [PATCH 112/116] docs: correct the 2.1.0 notes and the adoption
docstring
adoption.py claimed "the unit of adoption is a device, never a property"
and argued against ever minting an entity for a property on a modelled
device -- which extension.py has done since it shipped. It now states the
split between the two modules and why a settable property becomes a
control on an adopted device and a reading on a curated one.
The five sensors disabled for SPAN API defects repeated the same
eight-line note at each site. One paragraph in the module docstring names
all five; each site keeps a one-line pointer to #234, which is now the
pinned tracking issue for them.
CHANGELOG, 2.1.0 only:
- Every entry trimmed to at most two sentences, and the four entries
covering "Recreate entity IDs" collapsed into one line stating the
fixed behaviour rather than the sequence of beta fixes.
- Three entries corrected against the code: circuit and lugs vendor
readings land on the panel's card rather than a card of their own; the
new-entity notification collapses an adopted device, and any card
gaining more than five vendor readings, into a line with a count; and
the controls-vs-readings bullets now name their subject, since adopted
devices do get switches and selects while extensions never do.
- Dropped the Grid Forming Entity entry: resolve_dominant_power_source
preserves the value space deliberately, so nothing observable changed.
- Spelling and grammar.
---
CHANGELOG.md | 139 ++++++------------
custom_components/span_panel/adoption.py | 14 +-
.../span_panel/sensor_definitions.py | 91 +++++-------
3 files changed, 91 insertions(+), 153 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b169dfb..8b659a79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,39 +4,27 @@ All notable changes to this project will be documented in this file.
## [2.1.0] - 8/2026
-### You will need this release when SPAN updates your panel
+### You will need this release before SPAN updates your panel
SPAN firmware `r202633` replaces the way the panel publishes its data — the wire model every release up to 2.0.8 is retired in the same update that introduces
the new one. There is no wire overlap and no setting to keep the old behaviour. **2.0.8 cannot read a panel on `r202633`**: it stays connected, reports every
circuit as missing, and shows nothing useful.
-We do not control when that update reaches you, and the schedule belongs exclusively to SPAN. Panels update on SPAN's timing, not on yours or ours. A mandatory
-upgrade that keeps working therefore demands a release that can adapt on connection, and adapt again as necessary. If uninterrupted integration matters to you,
-be on this release **before** your panel changes — afterwards you are looking at a blank integration while you work out why.
+We do not control when that update reaches you, and the schedule belongs exclusively to SPAN. Panels update on SPAN's timing, not on yours or ours. If
+uninterrupted integration matters to you, install this release **before** your panel changes — afterwards you are looking at a blank integration while you work
+out why.
-**The transition itself is seamless.** Install 2.1.0 early and your only outage is the firmware update itself. When your panel changes over, the integration
-notices on the wire, reloads itself, notifies you, and carries on — no reconfiguration, no re-pairing, no lost history. Your entities keep their entity ids,
-their unique ids and their statistics across the change. New things appear because the new firmware publishes a bit more and because devices appropriate to your
-install are added; nothing you already had goes away. A log line and a one-time notification tell you exactly what happened, so take a screenshot. If you
-experience an extended delay, reload the integration.
+**The transition itself is seamless.** Install 2.1.0 early and your only outage is the firmware update itself. A log line and a one-time notification tell you
+exactly what happened, so take a screenshot.
**A panel that is still rebooting is waited out, for as long as your panel takes.** Taking the firmware upgrade drops the panel's connection for several minutes
-— four or more is not uncommon once the panel receives its upgrade file. The integration keeps checking until the panel answers properly.
-
-**Your energy history is unaffected by that wait.** Energy sensors hold their last reading through an outage for the grace period you configure (fifteen minutes
-by default), which is what stops a gap becoming an `unknown` and a spike in your statistics.
-
-**A firmware upgrade that adds a capability reloads as well**, so hardware your panel starts reporting — the Microgrid Interconnect Device, the shed forecast,
-the power control system, battery telemetry, DER link health — turns into entities when it appears rather than at your next restart.
-
-**If following the upgrade ever fails for some other reason, you are told what to do about it**: plainly, that a reload is needed once the panel is back up,
-rather than a bare error in the log while the integration carries on reading the panel with the wrong reader.
+— four or more is not uncommon once the panel receives its upgrade file. If you experience an extended delay (over 5 minutes), reload the integration (reload is
+a menu item, not a reinstallation).
### Requires Home Assistant 2026.8.0 or newer
This release raises the minimum from 2026.5.4. Home Assistant 2026.8 replaced the two device-registry calls this integration relies on — the old forms stop
-working entirely in 2027.8 — and their replacements do not exist in 2026.5 through 2026.7, so there is no version of this release that runs on both. If you are
-on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not offer you this release.
+working entirely in 2027.8.
### Added
@@ -74,23 +62,20 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
- Each forecast carries its full-charge equivalent and the panel's own `forecast_confidence` as attributes, rather than as two more near-constant entities.
- **Import Limit, Binding Constraint and PCS Active** — the current limit your panel enforces, which rule set it, and whether anything is being throttled right
- now. Filed under Diagnostics with the panel's other electrical characteristics.
-- **Import Limit carries the whole arbitration as attributes**: the four constraint limits the panel reconciled, each one's `_enablement` and `_active` flag,
- and `pcs_enabled`.
+ now. Filed under Diagnostics with the panel's other electrical characteristics. Import Limit carries the whole arbitration as attributes.
- **Every circuit's power sensor gains `pcs_managed` and `pcs_priority`** where the circuit reports them — the shed order when an import limit binds, which is
not the backup tier the existing `shed_priority` names.
-- All three entities appear even when the PCS is switched off, because that is a state, and the state most panels are in.
-
- **New kinds of device your panel gains no longer wait for a release.** SPAN's data model is vendor-extensible, so a device type nobody has modelled can turn
up at any time; one now gets a card of its own hanging off the panel, with whatever it publishes as entities beneath it, all disabled and diagnostic.
-- **A property such a device accepts writes to becomes a control**: a boolean becomes a switch, an enum a select, a bounded number a number entity, constrained
- to what the device declared and nothing invented.
+- **A property an adopted device accepts writes to becomes a control**: a boolean becomes a switch, an enum a select, a bounded number a number entity,
+ constrained to what the device declared and nothing invented.
- **Nothing adopted enters long-term statistics** — `state_class` is not declared on the wire and a wrong guess writes corrupt statistics; wrap an adopted
reading in a template sensor or utility meter if you want them.
-- **A new reading a vendor adds to a device you already have now appears too**, on that device's own card — the battery, a charger, the solar inverter, a
- circuit or the panel. Previously only whole new _devices_ were picked up, so a battery vendor adding a field reached you nowhere.
-- These arrive switched off and filed as diagnostics, like everything else adopted, and they are readings only — never switches or number boxes, because a
- control here would sit beside the curated ones without their limits and translations.
+- **A new reading a vendor adds to a device you already have now appears too**, on the card that device already has — the battery, the Microgrid Interconnect
+ Device, a SPAN Drive or the solar inverter, with circuit and lugs readings landing on the panel's own card. Previously only whole new _devices_ were picked
+ up, so a battery vendor adding a field reached you nowhere.
+- These new readings arrive switched off and filed as diagnostics, like everything else adopted, and they are readings only — never switches or number boxes,
+ because a control here would sit beside the curated ones without their limits and translations.
- **They keep the panel's own wording** (`Battery 2 Cell Temperature`), which is deliberately plainer than a curated entity's name so you can tell at a glance
which is which.
- **Deleting one hides it until the next reload while your panel is still publishing it, and removes it for good once your panel stops.** There is no setting to
@@ -107,75 +92,45 @@ on an older Home Assistant, stay on 2.0.8 until you can update; HACS will not of
### Changed
-- **`DSM Grid State` is now more trustworthy.** It keeps its entity id and all of its history. Previously it was _inferred_ — from the battery if one was
- fitted, otherwise from the dominant power source and whether power was crossing the grid connection. It now reads the islanding state the Microgrid
- Interconnect Device actually senses.
-- **`Grid Islandable` keeps working** across the upgrade. The new firmware publishes no panel-level islandable property — it makes the question structural
- instead, where the presence of a Microgrid Interconnect Device is what says a panel can island — so the entity now reflects whether that device is present. A
- panel without one reads `Off`, which is an answer, rather than going unavailable.
-- **`Grid Forming Entity` keeps working** across the upgrade too. Your panel used to publish a source class outright; the new firmware names the forming
- _device_, and it names it on the Microgrid Interconnect Device, which a panel without a battery does not have. On such a panel the answer is settled by what
- cannot be there — a battery needs that device, a solar inverter cannot form a grid on its own, and a panel supplying nothing is not publishing — so the entity
- reads `Grid`, which is exactly what your panel reported before.
+- **`DSM Grid State` is now more trustworthy**, reading the islanding state the Microgrid Interconnect Device actually senses rather than inferring it from the
+ battery, the dominant power source and which way power was crossing the grid connection. It keeps its entity id and all of its history.
+- **`Grid Islandable` keeps working** across the upgrade: the new firmware publishes no panel-level islandable property and makes the question structural
+ instead, so the entity now reflects whether a Microgrid Interconnect Device is present. A panel without one reads `Off`, which is an answer, rather than going
+ unavailable.
- **Battery model** may read differently after upgrading: the new firmware separates the human-readable designation from the SKU, and this entity now shows the
designation. The normalisation happens in the library on both sides of the upgrade, so it lands once, at this release, rather than unpredictably when your
panel changes over.
-- **Five panel sensors are switched off for new installations, because the eBus specification's own maintainer has documented that their values cannot be relied
- on.** A conformance note for SPAN firmware r202633 identifies three defects in what the panel publishes, all of which predate that release: the feedthrough
- (downstream lugs) energy registers are computed from two unrelated counters and can decrease or go negative — on a panel with no feedthrough load they report
- roughly whole-panel figures where the truth is zero — the feedthrough power reading is inverted relative to every other terminal, and the feedthrough currents
- report the _upstream_ service conductors rather than a downstream measurement. The affected entities are **Feedthrough Produced Energy**, **Feedthrough
- Consumed Energy**, **Feedthrough Power**, and the two **Downstream** current sensors.
-- **If you already have those five, nothing changes and they stay exactly where they are.** Home Assistant consults the enabled-by-default setting only when an
- entity is first created, so an existing installation keeps them, keeps its history and keeps its entity IDs. This stops new installations picking them up; it
- cannot reach back. If you use any of the five on a dashboard or in an automation, they are worth removing — but that is your decision to make, not something
- an upgrade should do to you.
-- **Your other panel readings are unaffected, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel arrived
- alongside the conformance note: the panel's four power-flow values sum to zero exactly, and the battery power sensor's definition is byte-for-byte what 2.0.8
- shipped. The upstream lugs, the main panel meter and every circuit are in the correct frame, as is the power-flow group, which the specification has now been
- corrected to describe the way the panel has always published it.
-
-- **New entities are now announced in a notification that names them — whether or not they arrived switched on.** Previously only entities added _disabled_ were
- mentioned, on the reasoning that an enabled one is already visible in your entity list and its history. That is only true if you are watching your entity
- list, which nobody is: an addition that breaks nothing was indistinguishable from no addition at all. The notification names every entity that was added,
- splits them by whether they are ready to use or still switched off, and tells you where to turn the switched-off ones on.
-- **It is a notification rather than a Repair, because an addition is not a repair.** Nothing is broken and nothing needs fixing. Any new-entity item still
- sitting in your Repairs list from a previous version is removed on upgrade.
-
-- **The Wi-Fi network name moved to the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now also tells
- you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID.
-- **It is no longer an attribute of the Software Version sensor.** A network name on a firmware-version sensor never made sense — it sat there because
- `panel_size` was already in that attribute block. If you have a template reading `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at
- the Wi-Fi Link binary sensor instead. `panel_size` is unaffected and stays where it is.
-
-- **The three circuit energy sensors are renamed to match the ids they are given.** "Produced Energy", "Consumed Energy" and "Net Energy" become **Energy
- Produced**, **Energy Consumed** and **Energy Net**, the order used by the `energy_produced`, `energy_consumed` and `energy_net` suffixes that these sensors'
- unique ids carry and that new entities are given. **Entity ids, unique ids and history are unchanged**; only the name shown in the UI reorders.
+- **Five panel sensors are switched off for new installations** (#234), because the SPAN API documented three defects that predate SPAN firmware r202633: the
+ feedthrough energy registers can decrease or go negative, the feedthrough power reading is inverted relative to every other terminal, and the feedthrough
+ currents report the _upstream_ service conductors. The affected entities are **Feedthrough Produced Energy**, **Feedthrough Consumed Energy**, **Feedthrough
+ Power**, and the two **Downstream** current sensors.
+- **If you already have those five, nothing changes and they stay exactly where they are**, because Home Assistant consults the enabled-by-default setting only
+ when an entity is first created — so an existing installation keeps them, keeps its history and keeps its entity IDs. If you use any of the five on a
+ dashboard or in an automation they are worth removing, but that is your decision to make, not something an upgrade should do to you.
+- **Your other panel readings are unaffected, and that is now checked against a real panel rather than assumed.** A capture from a live upgraded panel shows the
+ four power-flow values summing to zero exactly and the battery power definition byte-for-byte what 2.0.8 shipped, with the upstream lugs, the main panel meter
+ and every circuit in the correct frame.
+
+- **New entities are now announced in a notification, whether or not they arrived switched on.** The notification now splits what arrived into ready to use and
+ switched off and names each entity.
+
+- **The Wi-Fi network name attribute moved to the Wi-Fi Link sensor**, which is where you would look for it: the entity that tells you whether Wi-Fi is up now
+ also tells you which network it is up on, as a `wifi_ssid` attribute. It is absent rather than blank on a panel that publishes no SSID.
+- **It is no longer an attribute of the Software Version sensor**, where a network name never made sense attribute block. If you have a template reading
+ `state_attr('sensor.span_panel_software_version', 'wifi_ssid')`, point it at the Wi-Fi Link binary sensor instead.
+
+- **The three circuit energy sensors are renamed to match the ids they are given**: "Produced Energy", "Consumed Energy" and "Net Energy" become **Energy
+ Produced**, **Energy Consumed** and **Energy Net**, the order the `energy_produced`, `energy_consumed` and `energy_net` suffixes already use. **Entity ids,
+ unique ids and history are unchanged**; only the name shown in the UI reorders.
### Fixed
-- **Recreate entity IDs proposes the ids your panel would produce now.** Renaming a circuit in the SPAN app used to leave the button offering each entity the id
- it already had, so it looked like it did nothing (#252). The proposal was frozen at whatever the circuit was called when the entity was first created; it now
- follows the panel. **It is still an offer you accept** — a rename in the SPAN app never moves a live entity id by itself, and unique ids and statistics are
- untouched. Circuit-numbers installations are unchanged: there the display name written by name sync is also what Home Assistant builds the proposal from, so
- the button behaves exactly as it did.
-- **Recreate entity IDs no longer offers to convert a tab-named panel to friendly names.** In Tab Based Names mode the button used to propose
- `sensor.span_panel_air_conditioner_power` for a live `sensor.span_panel_circuit_15_power` — accepting it undid, for every circuit at once, the one thing that
- mode exists to provide. It now proposes the tab-based ID the entity already has, so Recreate is a no-op unless a breaker actually moved.
-- **Names still follow your panel in that mode**, and still stop at a name you set yourself. What changed is where the name is kept: in a field that describes
- the entity rather than the one Home Assistant reads when it builds an entity ID. A name written by an earlier release is handed back the first time each
- entity loads — nothing to run, nothing to confirm. **One consequence:** a circuit renamed in the SPAN app now updates the display after the integration
- reloads, a few seconds rather than immediately, which is what Circuit Friendly Names mode has always done.
-
-- **Only circuits you actually renamed are offered.** Installations old enough to predate the current suffixes carry entity ids ending `_consumed_energy`,
- `_produced_energy`, `_net_energy` or `_current_power`, where an entity created today would end `_energy_consumed`, `_energy_produced`, `_energy_net` or
- `_power`. Those ids keep the suffix they have. Renormalising them would have offered a rename for **every circuit on the panel** — seventy-four on one we
- measured — burying the one circuit that had actually been renamed and breaking the dashboards and automations of anyone who accepted.
+- **Recreate entity IDs proposes the ids your panel would produce now** (#252), in your installation's naming style; unique ids and statistics are untouched.
- **Enum sensors advertise the states they can actually report.** Nine sensors declared only `unknown`, so `DSM Grid State` sitting at `On Grid` showed
"Possible states: Unknown".
-- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has — that is what release 2.0.5
- established (#184) and what a measured panel confirms. **No entity changed and no reading moved**; only the documentation was ever wrong.
+- **The README described Battery Power's sign backwards.** The sensor reports **discharging** as positive and always has, as release 2.0.5 established (#184)
+ and a measured panel confirms — **no entity changed and no reading moved**, only the documentation was ever wrong.
## [2.0.8] - 5/2026
diff --git a/custom_components/span_panel/adoption.py b/custom_components/span_panel/adoption.py
index ab9b9403..74bebfe5 100644
--- a/custom_components/span_panel/adoption.py
+++ b/custom_components/span_panel/adoption.py
@@ -5,12 +5,14 @@
arrives as an expected event rather than a hypothetical one -- and until now it
arrived as nothing at all: no device, no entity, no sign it was there.
-**The unit of adoption is a device, never a property.** A new property on a device
-this integration already models is a curation task with a short turnaround, and
-minting an entity for it automatically would spend an `entity_id` permanently on
-a shape a human would likely have chosen differently. That cost only bites where
-curation is coming. On a device type nobody has modelled, no better identity is
-coming, so a disabled diagnostic entity is strictly better than the silence.
+**This module's unit is a device. A property on a device already modelled is
+`extension.py`'s**, and the split is where the two costs differ. On a device type
+nobody has modelled, no better identity is coming, so a disabled diagnostic
+entity is strictly better than the silence -- and a settable property becomes a
+real control, because there is no curated control beside it for a generic write
+path to undercut. On a curated device, curation is plausibly coming and its
+controls do real work, so `extension.py` mints a terminal identity in plain wire
+vocabulary and surfaces even a settable property as a reading.
**Nothing adopted enters long-term statistics.** No adopted entity carries a
`state_class`, ever. Three reasons, and the third is the one that shapes the
diff --git a/custom_components/span_panel/sensor_definitions.py b/custom_components/span_panel/sensor_definitions.py
index 15081511..0ac8957e 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -7,6 +7,11 @@
- Circuit power and energy sensors
- Unmapped circuit sensors (invisible backing data)
- Battery sensor
+
+Disabled by default from 2.1.0, because the SPAN API's own values are unreliable
+(#234): Feedthrough Power, Feedthrough Produced Energy, Feedthrough Consumed
+Energy, Downstream L1 Current, Downstream L2 Current. The default applies at
+first registration only, so existing installs keep them.
"""
from __future__ import annotations
@@ -297,42 +302,36 @@ class SpanPanelBatterySensorEntityDescription(
)
)
-DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
- key="downstream_l1_current",
- field_path="panel.downstream_l1_current_a",
- # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
- # note says this cannot be relied on -- reports the UPSTREAM service conductors, not a downstream measurement.
- # Disabled rather than removed because it shipped in 2.0.8, so an existing
- # install has it with history and entities must not vanish. This changes
- # nothing for those installs -- the default applies at first registration
- # only -- it stops NEW installs adopting it. See the delta document.
- entity_registry_enabled_default=False,
- translation_key="downstream_l1_current",
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- entity_category=EntityCategory.DIAGNOSTIC,
- suggested_display_precision=2,
- value_fn=lambda s: s.downstream_l1_current_a,
+DOWNSTREAM_L1_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = (
+ SpanPanelDataSensorEntityDescription(
+ key="downstream_l1_current",
+ field_path="panel.downstream_l1_current_a",
+ # Unreliable in the SPAN API; see the module docstring (#234).
+ entity_registry_enabled_default=False,
+ translation_key="downstream_l1_current",
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ suggested_display_precision=2,
+ value_fn=lambda s: s.downstream_l1_current_a,
+ )
)
-DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = SpanPanelDataSensorEntityDescription(
- key="downstream_l2_current",
- field_path="panel.downstream_l2_current_a",
- # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
- # note says this cannot be relied on -- reports the UPSTREAM service conductors, not a downstream measurement.
- # Disabled rather than removed because it shipped in 2.0.8, so an existing
- # install has it with history and entities must not vanish. This changes
- # nothing for those installs -- the default applies at first registration
- # only -- it stops NEW installs adopting it. See the delta document.
- entity_registry_enabled_default=False,
- translation_key="downstream_l2_current",
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- entity_category=EntityCategory.DIAGNOSTIC,
- suggested_display_precision=2,
- value_fn=lambda s: s.downstream_l2_current_a,
+DOWNSTREAM_L2_CURRENT_SENSOR: SpanPanelDataSensorEntityDescription = (
+ SpanPanelDataSensorEntityDescription(
+ key="downstream_l2_current",
+ field_path="panel.downstream_l2_current_a",
+ # Unreliable in the SPAN API; see the module docstring (#234).
+ entity_registry_enabled_default=False,
+ translation_key="downstream_l2_current",
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ suggested_display_precision=2,
+ value_fn=lambda s: s.downstream_l2_current_a,
+ )
)
# Main breaker rating sensor (v2 only, conditionally created)
@@ -986,13 +985,7 @@ class SpanPVMetadataSensorEntityDescription(
SpanPanelDataSensorEntityDescription(
key="feedthroughPowerW",
field_path="panel.feedthrough_power_w",
- # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
- # note says this cannot be relied on -- inverted: positive when power leaves the enclosure, where every other
- # enclosure terminal is positive inbound. Negate to compensate.
- # Disabled rather than removed because it shipped in 2.0.8, so an existing
- # install has it with history and entities must not vanish. This changes
- # nothing for those installs -- the default applies at first registration
- # only -- it stops NEW installs adopting it. See the delta document.
+ # Unreliable in the SPAN API; see the module docstring (#234).
entity_registry_enabled_default=False,
translation_key="feedthrough_power",
native_unit_of_measurement=UnitOfPower.WATT,
@@ -1083,13 +1076,7 @@ class SpanPVMetadataSensorEntityDescription(
SpanPanelDataSensorEntityDescription(
key="feedthroughEnergyProducedWh",
field_path="panel.feedthrough_energy_produced_wh",
- # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
- # note says this cannot be relied on -- the difference of two unrelated counters, non-monotonic, and ~whole-panel
- # figures where the truth is zero. No transformation recovers a real value.
- # Disabled rather than removed because it shipped in 2.0.8, so an existing
- # install has it with history and entities must not vanish. This changes
- # nothing for those installs -- the default applies at first registration
- # only -- it stops NEW installs adopting it. See the delta document.
+ # Unreliable in the SPAN API; see the module docstring (#234).
entity_registry_enabled_default=False,
translation_key="feedthrough_produced_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
@@ -1101,13 +1088,7 @@ class SpanPVMetadataSensorEntityDescription(
SpanPanelDataSensorEntityDescription(
key="feedthroughEnergyConsumedWh",
field_path="panel.feedthrough_energy_consumed_wh",
- # Disabled by default from 2.1.0: the eBus maintainer's r202633 conformance
- # note says this cannot be relied on -- the difference of two unrelated counters, non-monotonic, and ~whole-panel
- # figures where the truth is zero. No transformation recovers a real value.
- # Disabled rather than removed because it shipped in 2.0.8, so an existing
- # install has it with history and entities must not vanish. This changes
- # nothing for those installs -- the default applies at first registration
- # only -- it stops NEW installs adopting it. See the delta document.
+ # Unreliable in the SPAN API; see the module docstring (#234).
entity_registry_enabled_default=False,
translation_key="feedthrough_consumed_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
From f3eb9295d14374fb28acdc11a168c84ebfc6dbb1 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sun, 23 Aug 2026 12:49:54 -0700
Subject: [PATCH 113/116] chore: 2.1.0b13
---
custom_components/span_panel/manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index a4b9602b..e13979f9 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0",
"span-panel-api-schema-1==1.0.0"
],
- "version": "2.1.0b12",
+ "version": "2.1.0b13",
"zeroconf": [
{
"type": "_span._tcp.local."
From b5074a98d0f3584c32db310079f18c86e799d3f6 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sun, 23 Aug 2026 16:02:12 -0700
Subject: [PATCH 114/116] chore: 2.1.0b14
---
custom_components/span_panel/manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index e13979f9..4d3294d7 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -26,7 +26,7 @@
"span-panel-api-schema-0==1.0.0",
"span-panel-api-schema-1==1.0.0"
],
- "version": "2.1.0b13",
+ "version": "2.1.0b14",
"zeroconf": [
{
"type": "_span._tcp.local."
From f76f32c643cb0fa7cebebeaca567db6aca5f1be4 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sun, 23 Aug 2026 18:08:22 -0700
Subject: [PATCH 115/116] docs: troubleshooting entry for entity IDs surviving
a reinstall
---
README.md | 21 ++-------------------
1 file changed, 2 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 22b3c32e..e239c50c 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ This integration communicates with the SPAN Panel over your local network using
infrastructure. eBus uses the [Homie Convention](https://homieiot.github.io/) for MQTT topics and messages, with the panel's built-in MQTT broker delivering
real-time state updates without polling.
-## ⚠️ Backup and Upgrade to 2.1.x before your panel's firmware updates, or the integration will stop working (upgrade only from v2.0.8!)
+## ⚠️ Backup and Upgrade to v2.1.x before your panel's firmware updates, or the integration will stop working (upgrade only from v2.0.8!)
**SPAN firmware `r202633` changes the API in a non-compatible way after the firmware hits** When your panel takes that update, 2.0.8 stops being able to read
it. The integration still connects, still shows as loaded, and reports every circuit as missing — sensors go unavailable, automations stop firing, dashboards go
@@ -76,24 +76,6 @@ The old format is retired in the same update that introduces the new one — the
-## 2.0.x Breaking Changes (v2)
-
-**Do NOT upgrade unless your panel is running firmware `spanos2/r202603/05` or later and you are on 2.0.8 of the integration.**
-
-**What you need:**
-
-- SPAN Panel firmware `spanos2/r202603/05` or later
-- Panel passphrase (found in the SPAN mobile app, On-premise settings) **or** physical access to the panel door for proof-of-proximity authentication
-
-**Breaking:**
-
-- Requires firmware `spanos2/r202603/05` or later — panels on older firmware will not work
-- `Cellular` binary sensor removed — replaced by `Vendor Cloud` sensor
-
-> Running older firmware? See [v1 Legacy Documentation](v1-legacy.md).
-
-See [CHANGELOG.md](CHANGELOG.md) for all additions or value changes.
-
## Prerequisites
- [Home Assistant](https://www.home-assistant.io/) installed
@@ -667,6 +649,7 @@ See [WebSocket API Reference](websocket-api.md) for the full schema, response fo
| **Replaced sub-device shows the old serial number** | After replacing a SPAN sub-device (Drive / EVSE, BESS, PV inverter), the device entry in Home Assistant keeps showing the previous hardware's serial number. The integration keys entities off the panel-assigned node identity, which is intentionally stable across hardware swaps so long-term history (e.g. lifetime charging kWh for a Drive) is preserved. The device-registry serial number, however, does not auto-refresh. | In**Settings → Devices & Services → Span Panel**, open the affected sub-device and delete it, then reload the integration (or restart Home Assistant). The device re-registers with the new serial number. Entity IDs and their recorded history are preserved. |
| **Door sensor unavailable** | The SPAN API returns UNKNOWN if the cabinet door has not been operated recently. This is a defect in the SPAN API. | The integration reports the sensor as unavailable until a proper value arrives. Opening or closing the door publishes the correct state. The door is classified as a tamper sensor (`Detected` / `Clear`) to differentiate it from a normal entry door. |
| **No switch on a circuit** | A circuit has no switch entity exposed in Home Assistant. | The circuit is configured in the SPAN App as one of the "Always on Circuits". The API does not permit user control of those circuits, so no switch is created. |
+| **Reinstalling to change the entity ID style gives back the old entity IDs** | The naming style is chosen at install and cannot be changed from the options, so reinstalling looks like the way to switch. It is not: every entity returns with the entity ID it had before. | Home Assistant remembers a removed entity for **30 days**, keyed on its unique ID, and restores that record's entity ID — along with its name, area, labels and icon — as soon as an entity with the same unique ID appears again. This integration's unique IDs do not change with the naming style, so the remembered ID wins over the one the new style asks for. Either clear the leftover registry entries between removing and reinstalling, or wait out the 30 days and let Home Assistant discard them. A tool such as [ha-registry-clean](https://github.com/LegoTypes/ha-registry-clean) can do the clearing; it is a separate project, not part of this integration. Clearing also discards the names, areas and labels you had assigned. |
## Development
From a7776a9c82d528f12c7b48c57503b3d844f39661 Mon Sep 17 00:00:00 2001
From: cayossarian <23534755+cayossarian@users.noreply.github.com>
Date: Sun, 23 Aug 2026 18:17:01 -0700
Subject: [PATCH 116/116] fix(translations): move notification strings out of
the hassfest-validated files
hassfest validates strings.json and translations/en.json against Home
Assistant's own schema and rejects any key it does not define, so the
`notifications` section those files carried failed the check outright --
"extra keys not allowed @ data['notifications']". That is what an
unsupported custom category looks like from the outside, and it is the
same fact notices.py already documents as its reason for reading these
strings itself rather than through homeassistant.helpers.translation.
The strings move to a notifications/ directory beside translations/, one
file per language, so hassfest sees only the categories it defines and
the notices keep their per-language files. read_translations reads the
new directory; its language-chain fallback is unchanged.
sync_translations.py validates the new directory the way it validates
translations/, against notifications/en.json instead of strings.json --
without that these five files would have silently lost the key-parity
guarantee the move was supposed to preserve. Verified by removing a key
from the French file and watching the hook fail.
1269 tests passing; full prek set green.
---
custom_components/span_panel/notices.py | 14 ++++++--
.../span_panel/notifications/en.json | 19 +++++++++++
.../span_panel/notifications/es.json | 19 +++++++++++
.../span_panel/notifications/fr.json | 19 +++++++++++
.../span_panel/notifications/ja.json | 19 +++++++++++
.../span_panel/notifications/pt.json | 19 +++++++++++
custom_components/span_panel/strings.json | 19 -----------
.../span_panel/translations/en.json | 19 -----------
.../span_panel/translations/es.json | 19 -----------
.../span_panel/translations/fr.json | 19 -----------
.../span_panel/translations/ja.json | 19 -----------
.../span_panel/translations/pt.json | 19 -----------
scripts/sync_translations.py | 34 ++++++++++++++-----
13 files changed, 132 insertions(+), 125 deletions(-)
create mode 100644 custom_components/span_panel/notifications/en.json
create mode 100644 custom_components/span_panel/notifications/es.json
create mode 100644 custom_components/span_panel/notifications/fr.json
create mode 100644 custom_components/span_panel/notifications/ja.json
create mode 100644 custom_components/span_panel/notifications/pt.json
diff --git a/custom_components/span_panel/notices.py b/custom_components/span_panel/notices.py
index 9acb6e83..15b3545d 100644
--- a/custom_components/span_panel/notices.py
+++ b/custom_components/span_panel/notices.py
@@ -306,20 +306,28 @@ async def async_forget(hass: HomeAssistant, entry: ConfigEntry) -> None:
def read_translations(language: str, section: str) -> dict[str, str]:
"""One notification's strings for one language, or an empty mapping.
- Read from this component's `translations/` directory rather than through
+ Read from this component's `notifications/` directory rather than through
`homeassistant.helpers.translation`, because that helper filters to the
categories Home Assistant defines and a persistent notification is not one of
them -- a custom category loads as nothing at all. These are this
integration's own package files, so reading them is not reaching into
somebody else's layout.
+ **A directory of its own, not `translations/`.** hassfest validates
+ `strings.json` and `translations/en.json` against Home Assistant's schema and
+ rejects any key it does not define, so a `notifications` section there fails
+ the check outright -- which is what a custom category being unsupported looks
+ like from the outside. Keeping these strings beside those files rather than
+ inside them is what makes both true at once: hassfest sees only what it
+ defines, and the notices keep per-language files.
+
Falls back along the language chain -- `pt-BR`, then `pt`, then `en` -- so a
regional variant with no file of its own still gets its language rather than
English.
Blocking file I/O. Callers run it in an executor.
"""
- directory = Path(__file__).parent / "translations"
+ directory = Path(__file__).parent / "notifications"
for candidate in (f"{language}.json", f"{language.split('-')[0]}.json", "en.json"):
path = directory / candidate
if not path.is_file():
@@ -331,7 +339,7 @@ def read_translations(language: str, section: str) -> dict[str, str]:
continue
if not isinstance(loaded, dict):
continue
- strings = loaded.get("notifications", {}).get(section, {})
+ strings = loaded.get(section, {})
if isinstance(strings, dict) and strings:
return {str(key): str(value) for key, value in strings.items()}
return {}
diff --git a/custom_components/span_panel/notifications/en.json b/custom_components/span_panel/notifications/en.json
new file mode 100644
index 00000000..e04a307c
--- /dev/null
+++ b/custom_components/span_panel/notifications/en.json
@@ -0,0 +1,19 @@
+{
+ "panel_upgraded": {
+ "title": "SPAN Panel firmware upgraded",
+ "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look."
+ },
+ "new_entities": {
+ "title": "SPAN Panel added new entities",
+ "intro_one": "This update added 1 new entity to your SPAN Panel.",
+ "intro_many": "This update added {count} new entities to your SPAN Panel.",
+ "enabled_heading": "Added and ready to use",
+ "disabled_heading": "Added but switched off",
+ "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.",
+ "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online."
+ },
+ "extension_overflow": {
+ "title": "SPAN Panel: some vendor readings were not added",
+ "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced."
+ }
+}
diff --git a/custom_components/span_panel/notifications/es.json b/custom_components/span_panel/notifications/es.json
new file mode 100644
index 00000000..c5e9d6d9
--- /dev/null
+++ b/custom_components/span_panel/notifications/es.json
@@ -0,0 +1,19 @@
+{
+ "panel_upgraded": {
+ "title": "Firmware del Panel SPAN actualizado",
+ "body": "Su Panel SPAN ha comunicado un nuevo modelo de datos eBus (**{previous} → {current}**), lo que ocurre tras una actualización de firmware. La integración se ha recargado para que sus dispositivos y entidades coincidan con lo que el panel publica ahora.\n\nNada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha cambiado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. El nuevo modelo de datos no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como se determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba. Las entidades que la actualización haya añadido se enumeran en su propio aviso.\n\nLas entidades renombradas o sustituidas por la actualización pueden tener que eliminarse manualmente si siguen sin estar disponibles.\n\nPuede descartar este aviso cuando lo haya revisado."
+ },
+ "new_entities": {
+ "title": "El Panel SPAN ha añadido nuevas entidades",
+ "intro_one": "Esta actualización añadió 1 entidad nueva a tu Panel SPAN.",
+ "intro_many": "Esta actualización añadió {count} entidades nuevas a tu Panel SPAN.",
+ "enabled_heading": "Añadidas y listas para usar",
+ "disabled_heading": "Añadidas pero desactivadas",
+ "how_to_enable": "Las entidades desactivadas no registran nada hasta que las actives. Abre la página del dispositivo Panel SPAN, muestra sus entidades desactivadas y activa las que quieras.",
+ "nothing_broken": "No hay ningún problema y no se requiere ninguna acción. Las entidades aparecen por varios motivos: una actualización que expone más de lo que informa tu panel, un circuito añadido al panel, o hardware como una batería, paneles solares o un cargador que se pone en línea."
+ },
+ "extension_overflow": {
+ "title": "Panel SPAN: algunas lecturas del fabricante no se añadieron",
+ "body": "Un dispositivo de tu panel declara más lecturas del fabricante de las que esta integración añade para un solo dispositivo ({limit}). El resto quedó fuera: {devices}.\n\nNada de lo que ya tienes se ve afectado y nada está roto. Las lecturas que quedaron fuera siguen apareciendo en la descarga de diagnósticos de esta integración, que es lo que conviene adjuntar si quieres que se muestren."
+ }
+}
diff --git a/custom_components/span_panel/notifications/fr.json b/custom_components/span_panel/notifications/fr.json
new file mode 100644
index 00000000..fb937c90
--- /dev/null
+++ b/custom_components/span_panel/notifications/fr.json
@@ -0,0 +1,19 @@
+{
+ "panel_upgraded": {
+ "title": "Micrologiciel du Panneau SPAN mis à jour",
+ "body": "Votre Panneau SPAN a signalé un nouveau modèle de données eBus (**{previous} → {current}**), ce qui se produit après une mise à jour du micrologiciel. L'intégration a été rechargée afin que ses appareils et ses entités correspondent à ce que le panneau publie désormais.\n\nRien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui a changé.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. Le nouveau modèle de données ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont la capacité de secours est déterminée.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas. Les entités ajoutées par la mise à jour sont répertoriées dans un avis distinct.\n\nLes entités renommées ou remplacées par la mise à jour peuvent devoir être supprimées manuellement si elles restent indisponibles.\n\nVous pouvez ignorer cet avis après consultation."
+ },
+ "new_entities": {
+ "title": "Le Panneau SPAN a ajouté de nouvelles entités",
+ "intro_one": "Cette mise à jour a ajouté 1 nouvelle entité à votre Panneau SPAN.",
+ "intro_many": "Cette mise à jour a ajouté {count} nouvelles entités à votre Panneau SPAN.",
+ "enabled_heading": "Ajoutées et prêtes à l'emploi",
+ "disabled_heading": "Ajoutées mais désactivées",
+ "how_to_enable": "Les entités désactivées n'enregistrent rien tant que vous ne les activez pas. Ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez.",
+ "nothing_broken": "Rien n'est cassé et aucune action n'est requise. Des entités apparaissent pour plusieurs raisons : une mise à jour qui expose davantage de ce que votre panneau rapporte, un circuit ajouté au panneau, ou du matériel tel qu'une batterie, du solaire ou un chargeur qui se met en ligne."
+ },
+ "extension_overflow": {
+ "title": "Panneau SPAN : certaines mesures du fabricant n'ont pas été ajoutées",
+ "body": "Un appareil de votre panneau déclare plus de mesures du fabricant que cette intégration n'en ajoute pour un seul appareil ({limit}). Les autres ont été laissées de côté : {devices}.\n\nRien de ce que vous avez déjà n'est affecté et rien n'est cassé. Les mesures laissées de côté figurent toujours dans le téléchargement des diagnostics de cette intégration, qu'il convient de joindre si vous souhaitez les voir apparaître."
+ }
+}
diff --git a/custom_components/span_panel/notifications/ja.json b/custom_components/span_panel/notifications/ja.json
new file mode 100644
index 00000000..e5c79d7f
--- /dev/null
+++ b/custom_components/span_panel/notifications/ja.json
@@ -0,0 +1,19 @@
+{
+ "panel_upgraded": {
+ "title": "SPAN パネルのファームウェアが更新されました",
+ "body": "SPAN パネルが新しい eBus データモデル (**{previous} → {current}**) を報告しました。これはファームウェアの更新後に発生します。パネルが現在公開している内容にデバイスとエンティティを合わせるため、統合を再読み込みしました。\n\nご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は変更点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。新しいデータモデルではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これがバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。今回の更新で追加されたエンティティは、別の通知に一覧表示されます。\n\n更新によって名称が変更された、または置き換えられたエンティティが利用不可のままの場合は、手動で削除する必要があることがあります。\n\n内容を確認したら、この通知は閉じて構いません。"
+ },
+ "new_entities": {
+ "title": "SPAN パネルに新しいエンティティが追加されました",
+ "intro_one": "このアップデートで SPAN パネルに新しいエンティティが 1 個追加されました。",
+ "intro_many": "このアップデートで SPAN パネルに新しいエンティティが {count} 個追加されました。",
+ "enabled_heading": "追加され、すぐに使用できます",
+ "disabled_heading": "追加されましたが無効です",
+ "how_to_enable": "無効なエンティティは、有効にするまで何も記録しません。SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。",
+ "nothing_broken": "問題はなく、対応は不要です。エンティティが追加される理由はいくつかあります: パネルが報告する内容をより多く公開するアップデート、パネルに追加された回路、またはバッテリー、太陽光、充電器などのハードウェアがオンラインになった場合です。"
+ },
+ "extension_overflow": {
+ "title": "SPAN パネル: 一部のベンダー測定値は追加されませんでした",
+ "body": "パネル上のあるデバイスが、この統合が 1 台あたりに追加する上限({limit})を超える数のベンダー測定値を宣言しています。残りは追加されませんでした: {devices}。\n\n既存のものには影響はなく、故障でもありません。追加されなかった測定値は、この統合の診断ダウンロードには引き続き記載されています。表示を希望する場合は、それを添付してください。"
+ }
+}
diff --git a/custom_components/span_panel/notifications/pt.json b/custom_components/span_panel/notifications/pt.json
new file mode 100644
index 00000000..752171ba
--- /dev/null
+++ b/custom_components/span_panel/notifications/pt.json
@@ -0,0 +1,19 @@
+{
+ "panel_upgraded": {
+ "title": "Firmware do Painel SPAN atualizado",
+ "body": "O seu Painel SPAN comunicou um novo modelo de dados eBus (**{previous} → {current}**), o que acontece após uma atualização de firmware. A integração foi recarregada para que os seus dispositivos e entidades correspondam ao que o painel publica agora.\n\nNada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que mudou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. O novo modelo de dados não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a capacidade de reserva é determinada.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava. As entidades que a atualização tenha adicionado são listadas num aviso próprio.\n\nAs entidades renomeadas ou substituídas pela atualização podem ter de ser removidas manualmente se continuarem indisponíveis.\n\nPode dispensar este aviso depois de o consultar."
+ },
+ "new_entities": {
+ "title": "O Painel SPAN adicionou novas entidades",
+ "intro_one": "Esta atualização adicionou 1 nova entidade ao seu Painel SPAN.",
+ "intro_many": "Esta atualização adicionou {count} novas entidades ao seu Painel SPAN.",
+ "enabled_heading": "Adicionadas e prontas a usar",
+ "disabled_heading": "Adicionadas mas desativadas",
+ "how_to_enable": "As entidades desativadas não registam nada até as ativar. Abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser.",
+ "nothing_broken": "Nada está avariado e não é necessária qualquer ação. As entidades aparecem por vários motivos: uma atualização que expõe mais do que o seu painel reporta, um circuito adicionado ao painel, ou hardware como uma bateria, solar ou um carregador a ficar online."
+ },
+ "extension_overflow": {
+ "title": "Painel SPAN: algumas leituras do fabricante não foram adicionadas",
+ "body": "Um dispositivo do seu painel declara mais leituras do fabricante do que esta integração adiciona para um único dispositivo ({limit}). As restantes ficaram de fora: {devices}.\n\nNada do que já tem é afetado e nada está avariado. As leituras que ficaram de fora continuam listadas na transferência de diagnósticos desta integração, que é o que deve anexar se quiser que sejam apresentadas."
+ }
+}
diff --git a/custom_components/span_panel/strings.json b/custom_components/span_panel/strings.json
index a27c0cde..34629521 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -773,24 +773,5 @@
"title": "SPAN Panel unit mismatch",
"description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
}
- },
- "notifications": {
- "panel_upgraded": {
- "title": "SPAN Panel firmware upgraded",
- "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look."
- },
- "new_entities": {
- "title": "SPAN Panel added new entities",
- "intro_one": "This update added 1 new entity to your SPAN Panel.",
- "intro_many": "This update added {count} new entities to your SPAN Panel.",
- "enabled_heading": "Added and ready to use",
- "disabled_heading": "Added but switched off",
- "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.",
- "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online."
- },
- "extension_overflow": {
- "title": "SPAN Panel: some vendor readings were not added",
- "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced."
- }
}
}
diff --git a/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index a27c0cde..34629521 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -773,24 +773,5 @@
"title": "SPAN Panel unit mismatch",
"description": "Your panel reports `{field_path}` in {schema_unit}, but this integration expects {ha_unit}. Entities affected: {count} (for example: {examples}). Their readings and long-term statistics may be wrong. They have not been changed automatically."
}
- },
- "notifications": {
- "panel_upgraded": {
- "title": "SPAN Panel firmware upgraded",
- "body": "Your SPAN Panel reported a new eBus data model (**{previous} → {current}**), which happens after a firmware upgrade. The integration reloaded so its devices and entities match what the panel now publishes.\n\nNothing you rely on has gone away, and no automation changes are required — this is a note about what changed.\n\n**DSM Grid State** keeps its entity ID and its history, and is now more trustworthy. On the previous firmware it was *inferred* — from the battery when one was fitted, otherwise from the dominant power source and whether any power was crossing the grid connection. It now reads the islanding state the Microgrid Interconnect Device (MID) actually senses.\n\n**Grid Islandable** also keeps working. The new data model publishes no panel-level `grid-islandable` property, so it now reflects whether a MID is present, which is how backup capability is detected.\n\n**New:** a Microgrid Interconnect Device appears among your devices, carrying **Grid State** — the health of the utility supply itself, which the previous firmware did not report. Any entities the upgrade added are listed in their own notification.\n\nEntities that were renamed or replaced by the upgrade may need to be removed manually if they remain unavailable.\n\nDismiss this once you have had a look."
- },
- "new_entities": {
- "title": "SPAN Panel added new entities",
- "intro_one": "This update added 1 new entity to your SPAN Panel.",
- "intro_many": "This update added {count} new entities to your SPAN Panel.",
- "enabled_heading": "Added and ready to use",
- "disabled_heading": "Added but switched off",
- "how_to_enable": "Switched-off entities record nothing until you turn them on. Open the SPAN Panel device page, show its disabled entities, and enable the ones you want.",
- "nothing_broken": "Nothing is broken and no action is required. Entities appear for several reasons: an update that surfaces more of what your panel reports, a circuit added to the panel, or hardware such as a battery, solar or a charger coming online."
- },
- "extension_overflow": {
- "title": "SPAN Panel: some vendor readings were not added",
- "body": "A device on your panel declares more vendor readings than this integration will add for one device ({limit}). The rest were left out: {devices}.\n\nNothing you already have is affected, and nothing is broken. The readings that were left out are still listed in this integration's diagnostics download, which is what to attach if you want them surfaced."
- }
}
}
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index d1846ebb..dba3f3ef 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -773,24 +773,5 @@
"title": "Discrepancia de unidades del Panel SPAN",
"description": "Su panel informa de `{field_path}` en {schema_unit}, pero esta integración espera {ha_unit}. Entidades afectadas: {count} (por ejemplo: {examples}). Sus lecturas y estadísticas a largo plazo pueden ser incorrectas. No se han modificado automáticamente."
}
- },
- "notifications": {
- "panel_upgraded": {
- "title": "Firmware del Panel SPAN actualizado",
- "body": "Su Panel SPAN ha comunicado un nuevo modelo de datos eBus (**{previous} → {current}**), lo que ocurre tras una actualización de firmware. La integración se ha recargado para que sus dispositivos y entidades coincidan con lo que el panel publica ahora.\n\nNada de lo que usted utiliza ha desaparecido y no es necesario modificar ninguna automatización: este aviso solo explica lo que ha cambiado.\n\n**Estado de Red DSM** conserva su ID de entidad y su historial, y ahora es más fiable. Con el firmware anterior se *deducía*: a partir de la batería cuando había una instalada y, en su defecto, de la fuente de energía dominante y de si circulaba potencia por la conexión a la red. Ahora lee el estado de aislamiento que el Dispositivo de Interconexión de Microrred (MID) detecta realmente.\n\n**Aislable de la Red** también sigue funcionando. El nuevo modelo de datos no publica ninguna propiedad `grid-islandable` a nivel de panel, por lo que ahora refleja si hay un MID presente, que es como se determina la capacidad de respaldo.\n\n**Novedad:** aparece un Dispositivo de Interconexión de Microrred entre sus dispositivos, con **Estado de la Red**, es decir, el estado del suministro eléctrico, que el firmware anterior no informaba. Las entidades que la actualización haya añadido se enumeran en su propio aviso.\n\nLas entidades renombradas o sustituidas por la actualización pueden tener que eliminarse manualmente si siguen sin estar disponibles.\n\nPuede descartar este aviso cuando lo haya revisado."
- },
- "new_entities": {
- "title": "El Panel SPAN ha añadido nuevas entidades",
- "intro_one": "Esta actualización añadió 1 entidad nueva a tu Panel SPAN.",
- "intro_many": "Esta actualización añadió {count} entidades nuevas a tu Panel SPAN.",
- "enabled_heading": "Añadidas y listas para usar",
- "disabled_heading": "Añadidas pero desactivadas",
- "how_to_enable": "Las entidades desactivadas no registran nada hasta que las actives. Abre la página del dispositivo Panel SPAN, muestra sus entidades desactivadas y activa las que quieras.",
- "nothing_broken": "No hay ningún problema y no se requiere ninguna acción. Las entidades aparecen por varios motivos: una actualización que expone más de lo que informa tu panel, un circuito añadido al panel, o hardware como una batería, paneles solares o un cargador que se pone en línea."
- },
- "extension_overflow": {
- "title": "Panel SPAN: algunas lecturas del fabricante no se añadieron",
- "body": "Un dispositivo de tu panel declara más lecturas del fabricante de las que esta integración añade para un solo dispositivo ({limit}). El resto quedó fuera: {devices}.\n\nNada de lo que ya tienes se ve afectado y nada está roto. Las lecturas que quedaron fuera siguen apareciendo en la descarga de diagnósticos de esta integración, que es lo que conviene adjuntar si quieres que se muestren."
- }
}
}
diff --git a/custom_components/span_panel/translations/fr.json b/custom_components/span_panel/translations/fr.json
index 0b42bdf0..afb0e5e8 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -773,24 +773,5 @@
"title": "Incohérence d'unité du Panneau SPAN",
"description": "Votre panneau signale `{field_path}` en {schema_unit}, alors que cette intégration attend {ha_unit}. Entités concernées : {count} (par exemple : {examples}). Leurs relevés et leurs statistiques à long terme peuvent être erronés. Elles n'ont pas été modifiées automatiquement."
}
- },
- "notifications": {
- "panel_upgraded": {
- "title": "Micrologiciel du Panneau SPAN mis à jour",
- "body": "Votre Panneau SPAN a signalé un nouveau modèle de données eBus (**{previous} → {current}**), ce qui se produit après une mise à jour du micrologiciel. L'intégration a été rechargée afin que ses appareils et ses entités correspondent à ce que le panneau publie désormais.\n\nRien de ce que vous utilisez n'a disparu et aucune modification de vos automatisations n'est nécessaire : cet avis explique simplement ce qui a changé.\n\n**État Réseau DSM** conserve son identifiant d'entité et son historique, et gagne en fiabilité. Avec le micrologiciel précédent, il était *déduit* : à partir de la batterie lorsqu'il y en avait une, sinon à partir de la source d'énergie dominante et de la présence de puissance transitant par le raccordement réseau. Il lit désormais l'état d'îlotage réellement mesuré par le Dispositif d'Interconnexion de Micro-réseau (MID).\n\n**Îlotage Réseau Possible** continue également de fonctionner. Le nouveau modèle de données ne publie aucune propriété `grid-islandable` au niveau du panneau ; ce capteur indique donc maintenant la présence d'un MID, ce qui est la façon dont la capacité de secours est déterminée.\n\n**Nouveau :** un Dispositif d'Interconnexion de Micro-réseau apparaît parmi vos appareils, avec **État du Réseau** — l'état de l'alimentation du réseau public, que le micrologiciel précédent ne signalait pas. Les entités ajoutées par la mise à jour sont répertoriées dans un avis distinct.\n\nLes entités renommées ou remplacées par la mise à jour peuvent devoir être supprimées manuellement si elles restent indisponibles.\n\nVous pouvez ignorer cet avis après consultation."
- },
- "new_entities": {
- "title": "Le Panneau SPAN a ajouté de nouvelles entités",
- "intro_one": "Cette mise à jour a ajouté 1 nouvelle entité à votre Panneau SPAN.",
- "intro_many": "Cette mise à jour a ajouté {count} nouvelles entités à votre Panneau SPAN.",
- "enabled_heading": "Ajoutées et prêtes à l'emploi",
- "disabled_heading": "Ajoutées mais désactivées",
- "how_to_enable": "Les entités désactivées n'enregistrent rien tant que vous ne les activez pas. Ouvrez la page de l'appareil Panneau SPAN, affichez ses entités désactivées et activez celles que vous souhaitez.",
- "nothing_broken": "Rien n'est cassé et aucune action n'est requise. Des entités apparaissent pour plusieurs raisons : une mise à jour qui expose davantage de ce que votre panneau rapporte, un circuit ajouté au panneau, ou du matériel tel qu'une batterie, du solaire ou un chargeur qui se met en ligne."
- },
- "extension_overflow": {
- "title": "Panneau SPAN : certaines mesures du fabricant n'ont pas été ajoutées",
- "body": "Un appareil de votre panneau déclare plus de mesures du fabricant que cette intégration n'en ajoute pour un seul appareil ({limit}). Les autres ont été laissées de côté : {devices}.\n\nRien de ce que vous avez déjà n'est affecté et rien n'est cassé. Les mesures laissées de côté figurent toujours dans le téléchargement des diagnostics de cette intégration, qu'il convient de joindre si vous souhaitez les voir apparaître."
- }
}
}
diff --git a/custom_components/span_panel/translations/ja.json b/custom_components/span_panel/translations/ja.json
index 6795877e..95fe751e 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -773,24 +773,5 @@
"title": "SPAN パネルの単位の不一致",
"description": "パネルは `{field_path}` を {schema_unit} で報告していますが、この統合は {ha_unit} を想定しています。{count} 個のエンティティが影響を受けます (例: {examples})。これらの測定値と長期統計が誤っている可能性があります。エンティティは自動的には変更されていません。"
}
- },
- "notifications": {
- "panel_upgraded": {
- "title": "SPAN パネルのファームウェアが更新されました",
- "body": "SPAN パネルが新しい eBus データモデル (**{previous} → {current}**) を報告しました。これはファームウェアの更新後に発生します。パネルが現在公開している内容にデバイスとエンティティを合わせるため、統合を再読み込みしました。\n\nご利用中のものが失われることはなく、オートメーションの変更も必要ありません。この通知は変更点をお知らせするものです。\n\n**DSM 系統状態** はエンティティ ID と履歴をそのまま維持し、さらに信頼性が向上しました。以前のファームウェアでは *推定* されていました。バッテリーがある場合はバッテリーから、ない場合は主電源と系統接続を流れる電力の有無から判断していました。現在はマイクログリッド相互接続装置 (MID) が実際に検知した自立運転状態を読み取ります。\n\n**系統自立運転可能** も引き続き動作します。新しいデータモデルではパネル単位の `grid-islandable` プロパティは公開されないため、現在は MID の有無を示します。これがバックアップ能力の判定方法です。\n\n**新規:** デバイス一覧にマイクログリッド相互接続装置が追加され、**系統状態** (電力会社からの供給状態) を報告します。これは以前のファームウェアにはなかった情報です。今回の更新で追加されたエンティティは、別の通知に一覧表示されます。\n\n更新によって名称が変更された、または置き換えられたエンティティが利用不可のままの場合は、手動で削除する必要があることがあります。\n\n内容を確認したら、この通知は閉じて構いません。"
- },
- "new_entities": {
- "title": "SPAN パネルに新しいエンティティが追加されました",
- "intro_one": "このアップデートで SPAN パネルに新しいエンティティが 1 個追加されました。",
- "intro_many": "このアップデートで SPAN パネルに新しいエンティティが {count} 個追加されました。",
- "enabled_heading": "追加され、すぐに使用できます",
- "disabled_heading": "追加されましたが無効です",
- "how_to_enable": "無効なエンティティは、有効にするまで何も記録しません。SPAN パネルのデバイスページを開き、無効なエンティティを表示して、必要なものを有効にしてください。",
- "nothing_broken": "問題はなく、対応は不要です。エンティティが追加される理由はいくつかあります: パネルが報告する内容をより多く公開するアップデート、パネルに追加された回路、またはバッテリー、太陽光、充電器などのハードウェアがオンラインになった場合です。"
- },
- "extension_overflow": {
- "title": "SPAN パネル: 一部のベンダー測定値は追加されませんでした",
- "body": "パネル上のあるデバイスが、この統合が 1 台あたりに追加する上限({limit})を超える数のベンダー測定値を宣言しています。残りは追加されませんでした: {devices}。\n\n既存のものには影響はなく、故障でもありません。追加されなかった測定値は、この統合の診断ダウンロードには引き続き記載されています。表示を希望する場合は、それを添付してください。"
- }
}
}
diff --git a/custom_components/span_panel/translations/pt.json b/custom_components/span_panel/translations/pt.json
index bf140185..af2cc18a 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -773,24 +773,5 @@
"title": "Discrepância de unidades do Painel SPAN",
"description": "O seu painel comunica `{field_path}` em {schema_unit}, mas esta integração espera {ha_unit}. Entidades afetadas: {count} (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente."
}
- },
- "notifications": {
- "panel_upgraded": {
- "title": "Firmware do Painel SPAN atualizado",
- "body": "O seu Painel SPAN comunicou um novo modelo de dados eBus (**{previous} → {current}**), o que acontece após uma atualização de firmware. A integração foi recarregada para que os seus dispositivos e entidades correspondam ao que o painel publica agora.\n\nNada do que utiliza desapareceu e não é necessário alterar automações — este aviso apenas explica o que mudou.\n\n**Estado da Rede DSM** mantém o seu ID de entidade e o seu histórico, e passa a ser mais fiável. No firmware anterior era *inferido*: a partir da bateria quando existia uma e, caso contrário, a partir da fonte de energia dominante e da existência de potência a atravessar a ligação à rede. Agora lê o estado de ilhamento que o Dispositivo de Interligação de Microrrede (MID) realmente deteta.\n\n**Isolável da Rede** também continua a funcionar. O novo modelo de dados não publica qualquer propriedade `grid-islandable` ao nível do painel, pelo que agora reflete a presença de um MID, que é como a capacidade de reserva é determinada.\n\n**Novidade:** aparece um Dispositivo de Interligação de Microrrede entre os seus dispositivos, com **Estado da Rede** — o estado do fornecimento da rede elétrica, que o firmware anterior não comunicava. As entidades que a atualização tenha adicionado são listadas num aviso próprio.\n\nAs entidades renomeadas ou substituídas pela atualização podem ter de ser removidas manualmente se continuarem indisponíveis.\n\nPode dispensar este aviso depois de o consultar."
- },
- "new_entities": {
- "title": "O Painel SPAN adicionou novas entidades",
- "intro_one": "Esta atualização adicionou 1 nova entidade ao seu Painel SPAN.",
- "intro_many": "Esta atualização adicionou {count} novas entidades ao seu Painel SPAN.",
- "enabled_heading": "Adicionadas e prontas a usar",
- "disabled_heading": "Adicionadas mas desativadas",
- "how_to_enable": "As entidades desativadas não registam nada até as ativar. Abra a página do dispositivo Painel SPAN, mostre as suas entidades desativadas e ative as que quiser.",
- "nothing_broken": "Nada está avariado e não é necessária qualquer ação. As entidades aparecem por vários motivos: uma atualização que expõe mais do que o seu painel reporta, um circuito adicionado ao painel, ou hardware como uma bateria, solar ou um carregador a ficar online."
- },
- "extension_overflow": {
- "title": "Painel SPAN: algumas leituras do fabricante não foram adicionadas",
- "body": "Um dispositivo do seu painel declara mais leituras do fabricante do que esta integração adiciona para um único dispositivo ({limit}). As restantes ficaram de fora: {devices}.\n\nNada do que já tem é afetado e nada está avariado. As leituras que ficaram de fora continuam listadas na transferência de diagnósticos desta integração, que é o que deve anexar se quiser que sejam apresentadas."
- }
}
}
diff --git a/scripts/sync_translations.py b/scripts/sync_translations.py
index 0423cb1f..47f5e0da 100644
--- a/scripts/sync_translations.py
+++ b/scripts/sync_translations.py
@@ -15,6 +15,15 @@
STRINGS_PATH = COMPONENT_DIR / "strings.json"
TRANSLATIONS_DIR = COMPONENT_DIR / "translations"
EN_PATH = TRANSLATIONS_DIR / "en.json"
+NOTIFICATIONS_DIR = COMPONENT_DIR / "notifications"
+NOTIFICATIONS_EN = NOTIFICATIONS_DIR / "en.json"
+"""Persistent-notification strings, deliberately outside `translations/`.
+
+hassfest validates `strings.json` and `translations/en.json` against Home
+Assistant's own schema and rejects any key it does not define, so these cannot
+live there. They still need the same key-parity guarantee across languages,
+which is why this script checks them too rather than leaving them unchecked.
+"""
def collect_leaf_keys(obj: dict | str, prefix: str = "") -> set[str]:
@@ -67,17 +76,17 @@ def sync_en(source: dict) -> bool:
return True
-def validate_translations(
- source_all_keys: set[str], source_leaf_keys: set[str]
+def validate_dir(
+ directory: Path, source_all_keys: set[str], source_leaf_keys: set[str], source_name: str
) -> list[str]:
- """Validate all non-en translation files. Return list of error messages."""
+ """Validate every non-en file in `directory` against the English source."""
errors: list[str] = []
- for lang_file in sorted(TRANSLATIONS_DIR.glob("*.json")):
+ for lang_file in sorted(directory.glob("*.json")):
if lang_file.name == "en.json":
continue
- lang = lang_file.stem
+ lang = f"{directory.name}/{lang_file.stem}"
try:
translation = json.loads(lang_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
@@ -87,14 +96,14 @@ def validate_translations(
orphaned = find_orphaned_keys(source_all_keys, translation)
if orphaned:
errors.append(
- f"{lang}: {len(orphaned)} orphaned key(s) not in strings.json:\n"
+ f"{lang}: {len(orphaned)} orphaned key(s) not in {source_name}:\n"
+ "\n".join(f" - {k}" for k in orphaned)
)
missing = find_missing_keys(source_leaf_keys, translation)
if missing:
errors.append(
- f"{lang}: {len(missing)} missing key(s) from strings.json:\n"
+ f"{lang}: {len(missing)} missing key(s) from {source_name}:\n"
+ "\n".join(f" - {k}" for k in missing)
)
@@ -118,7 +127,16 @@ def main() -> int:
display_path = EN_PATH
print(f"Updated {display_path}")
- errors = validate_translations(source_all_keys, source_leaf_keys)
+ errors = validate_dir(TRANSLATIONS_DIR, source_all_keys, source_leaf_keys, "strings.json")
+
+ if NOTIFICATIONS_EN.exists():
+ notifications = json.loads(NOTIFICATIONS_EN.read_text(encoding="utf-8"))
+ errors += validate_dir(
+ NOTIFICATIONS_DIR,
+ collect_all_keys(notifications),
+ collect_leaf_keys(notifications),
+ "notifications/en.json",
+ )
if errors:
print("Translation validation failed:", file=sys.stderr)
for error in errors: