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:
"""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:
@@ -221,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
@@ -251,16 +268,153 @@ def has_power_flows(snapshot: SpanPanelSnapshot) -> bool:
return snapshot.power_flow_site is not None
+def has_mid(snapshot: SpanPanelSnapshot) -> bool:
+ """Detect whether a Microgrid Interconnect Device is published.
+
+ Unambiguous, unlike `has_bess`, which has to infer presence from
+ `soe_percentage is not None` because the battery field is always there. The library
+ makes `mid` optional precisely so presence needs no sentinel.
+
+ Always false on flat firmware, which publishes no MID at all.
+
+ DUAL-SCHEMA: this integration must serve flat and parent/child panels side by side until
+ every panel has hot-loaded v1.0. Grep this token to find every place that branches on
+ which schema a panel is publishing; when the flat path is finally retired, these are
+ the conditionals that become unconditional and the flat branches that get deleted.
+ Nothing here may *assume* parent/child before then.
+ """
+ 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_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_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
+ """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:
+ """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.
- Used by the coordinator to detect when new hardware (BESS, PV, EVSE) 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):
@@ -271,4 +425,14 @@ def detect_capabilities(snapshot: SpanPanelSnapshot) -> frozenset[str]:
caps.add("power_flows")
if has_evse(snapshot):
caps.add("evse")
+ if has_mid(snapshot):
+ caps.add("mid")
+ if has_shed_forecast(snapshot):
+ caps.add("shed_forecast")
+ if has_bess_telemetry(snapshot):
+ 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 4a7e9445..1272e4a6 100644
--- a/custom_components/span_panel/icons.json
+++ b/custom_components/span_panel/icons.json
@@ -32,6 +32,15 @@
},
"wifi_link": {
"default": "mdi:wifi"
+ },
+ "pcs_active": {
+ "default": "mdi:speedometer"
+ },
+ "pv_panel_link": {
+ "default": "mdi:solar-power-variant"
+ },
+ "evse_panel_link": {
+ "default": "mdi:lan-connect"
}
},
"button": {
@@ -39,6 +48,11 @@
"default": "mdi:transmission-tower"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "default": "mdi:ev-station"
+ }
+ },
"select": {
"circuit_priority": {
"default": "mdi:priority-high"
@@ -57,6 +71,9 @@
"bess_model": {
"default": "mdi:battery-outline"
},
+ "bess_communication_state": {
+ "default": "mdi:lan-connect"
+ },
"bess_nameplate_capacity": {
"default": "mdi:battery-high"
},
@@ -180,12 +197,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"
},
@@ -198,6 +221,15 @@
"connected": "mdi:cloud-check",
"unconnected": "mdi:cloud-off-outline"
}
+ },
+ "pcs_import_limit": {
+ "default": "mdi:transmission-tower-import"
+ },
+ "pcs_binding_constraint": {
+ "default": "mdi:scale-balance"
+ },
+ "bess_meter_power": {
+ "default": "mdi:battery-charging-outline"
}
}
},
diff --git a/custom_components/span_panel/id_builder.py b/custom_components/span_panel/id_builder.py
index b790883c..61055dca 100644
--- a/custom_components/span_panel/id_builder.py
+++ b/custom_components/span_panel/id_builder.py
@@ -31,6 +31,82 @@
"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, 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`,
+`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.
+"""
+
+
+# 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
@@ -49,6 +125,28 @@
"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, 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`,
+`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 +166,28 @@
"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, 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`,
+`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}
@@ -314,6 +434,18 @@ def build_bess_unique_id(serial: str, description_key: str) -> str:
return f"span_{serial}_bess_{description_key}"
+def build_mid_unique_id(serial: str, description_key: str) -> str:
+ """Build unique ID for Microgrid Interconnect Device sensors (pure function).
+
+ Returns: "span_{serial}_mid_{description_key}"
+
+ Keyed on the panel serial rather than the MID's own, matching `build_bess_unique_id`.
+ The MID is one per enclosure, so the panel serial already makes it unique, and a
+ device-derived key would move if the BESS the MID ships with were ever replaced.
+ """
+ return f"span_{serial}_mid_{description_key}"
+
+
def build_evse_unique_id(serial: str, evse_id: str, description_key: str) -> str:
"""Build unique ID for EVSE sensor/binary_sensor entities (pure function).
diff --git a/custom_components/span_panel/manifest.json b/custom_components/span_panel/manifest.json
index 022832c9..4d3294d7 100644
--- a/custom_components/span_panel/manifest.json
+++ b/custom_components/span_panel/manifest.json
@@ -22,9 +22,11 @@
],
"quality_scale": "gold",
"requirements": [
- "span-panel-api==2.6.4"
+ "span-panel-api==3.0.1",
+ "span-panel-api-schema-0==1.0.0",
+ "span-panel-api-schema-1==1.0.0"
],
- "version": "2.0.8",
+ "version": "2.1.0b14",
"zeroconf": [
{
"type": "_span._tcp.local."
diff --git a/custom_components/span_panel/notices.py b/custom_components/span_panel/notices.py
new file mode 100644
index 00000000..15b3545d
--- /dev/null
+++ b/custom_components/span_panel/notices.py
@@ -0,0 +1,345 @@
+"""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}"
+ )
+ notices = _Notices(store=store, standing=_load(await store.async_load(), entry))
+ 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)))
+
+
+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
+) -> 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 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)
+
+
+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 `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 / "notifications"
+ 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
+ if not isinstance(loaded, dict):
+ continue
+ 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/number.py b/custom_components/span_panel/number.py
new file mode 100644
index 00000000..1bd7f9bb
--- /dev/null
+++ b/custom_components/span_panel/number.py
@@ -0,0 +1,309 @@
+"""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 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
+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
+
+ 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/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_expectations.py b/custom_components/span_panel/schema_expectations.py
deleted file mode 100644
index ed4d2050..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.product_name",
- "serial_number": "battery.serial_number",
- "firmware_version": "battery.software_version",
- # --- PV metadata sensors -------------------------------------------------
- "pv_vendor": "pv.vendor_name",
- "pv_product": "pv.product_name",
- "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_repairs.py b/custom_components/span_panel/schema_repairs.py
new file mode 100644
index 00000000..dade2f0c
--- /dev/null
+++ b/custom_components/span_panel/schema_repairs.py
@@ -0,0 +1,271 @@
+"""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.
+
+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
+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
+from homeassistant.helpers import issue_registry as ir
+
+from .const import DOMAIN, EVENT_SCHEMA_ISSUE
+from .schema_validation import SchemaFindings
+
+_LOGGER = logging.getLogger(__name__)
+
+_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_"
+"""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.
+"""
+
+
+_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}"
+
+
+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,
+ 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.
+
+ 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
+ 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()
+ 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)
+ raised_unresolved.append(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, **_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,
+ 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,
+ **_placeholders(affected),
+ },
+ )
+
+ # 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:
+ # 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": raised_unresolved,
+ "unit_mismatches": raised_mismatches,
+ },
+ )
+
+
+@callback
+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.
+
+ 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.
+ """
+ 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(scoped)
+ }
+
+
+@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_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.
+
+ 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)
+ 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/schema_validation.py b/custom_components/span_panel/schema_validation.py
index 34febe52..a4c77573 100644
--- a/custom_components/span_panel/schema_validation.py
+++ b/custom_components/span_panel/schema_validation.py
@@ -1,176 +1,234 @@
-"""Schema validation — cross-check field metadata against sensor definitions.
+"""Compare adapter field metadata against what this integration declares it reads.
+
+Consumes the library's three-way signal:
+
+- 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.
+
+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
+
+from dataclasses import dataclass
+import logging
-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.
+from homeassistant.components.sensor import SensorEntityDescription
+from span_panel_api.models import DiscoveredMetadata, FieldMetadata, is_discovery_path
-Schema drift detection (diffing schema versions between firmware updates) is
-the library's responsibility. The integration only consumes the result.
+from .field_paths import RESIDUAL_EXEMPT_PATHS, conditional_field_paths, declared_field_paths
-All output is log-only. No entity creation or sensor behavior changes.
+_LOGGER = logging.getLogger(__name__)
-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.
+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.
"""
-from __future__ import annotations
-import logging
+@dataclass(frozen=True, slots=True)
+class UnitMismatch:
+ """A declared unit that disagrees with the schema's."""
-from homeassistant.components.sensor import SensorEntityDescription
+ field_path: str
+ ha_unit: str
+ schema_unit: str
-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,
-)
-_LOGGER = logging.getLogger(__name__)
+@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.
-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.
+ 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.
- 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.
+ **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.
"""
- for sensor_key, field_path in SENSOR_FIELD_MAP.items():
- sensor_def = sensor_defs.get(sensor_key)
- if sensor_def is None:
- continue
- ha_unit = sensor_def.native_unit_of_measurement
- if ha_unit is None:
- # Sensor has no unit (enum, string) — nothing to cross-check
- continue
+ path: str
+ """The library's namespaced path, ``discovered.{device type}/{node}/{property}``.
- 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
-
- 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,
- )
+ 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.
-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,
- )
+ 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.
+ """
-def validate_field_metadata(
- field_metadata: dict[str, dict[str, object]] | None,
- sensor_defs: dict[str, SensorEntityDescription] | None = None,
-) -> None:
- """Run integration-side schema validation checks.
+@dataclass(frozen=True, slots=True)
+class SchemaFindings:
+ """Outcome of one validation pass."""
- 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.
+ 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.
"""
- 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 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 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).
+def evaluate_field_metadata(
+ field_metadata: dict[str, FieldMetadata],
+ sensor_defs: dict[str, SensorEntityDescription] | None = None,
+) -> SchemaFindings:
+ """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`.
"""
- 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}
+ # 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
+ # 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 resolvable:
+ entry = curated.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
+ 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))
+
+ # `RESIDUAL_EXEMPT_PATHS` are read by the integration; they are exempt from
+ # 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.
+ #
+ # `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)
+
+ 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/custom_components/span_panel/select.py b/custom_components/span_panel/select.py
index 30f54042..af033269 100644
--- a/custom_components/span_panel/select.py
+++ b/custom_components/span_panel/select.py
@@ -2,18 +2,19 @@
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
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
@@ -84,6 +85,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,
@@ -119,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
@@ -155,16 +165,16 @@ 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,
+ existing_entity_id=existing_entity_id,
)
if constructed_id:
self.entity_id = constructed_id
@@ -263,8 +273,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
@@ -277,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()
@@ -379,7 +358,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:
@@ -401,4 +380,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/sensor.py b/custom_components/span_panel/sensor.py
index 928c6b88..fefbcb36 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, entity_registry as er
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,
@@ -19,11 +21,16 @@
USE_CIRCUIT_NUMBERS,
)
from .coordinator import SpanPanelCoordinator
+from .extension import create_extension_sensors
from .helpers import (
has_bess,
+ has_bess_telemetry,
has_evse,
+ has_mid,
+ has_pcs,
has_power_flows,
has_pv,
+ has_shed_forecast,
resolve_evse_display_suffix,
)
from .sensor_base import SpanEnergySensorBase, SpanSensorBase
@@ -36,6 +43,7 @@
BATTERY_POWER_SENSOR,
BATTERY_SENSOR,
BESS_METADATA_SENSORS,
+ BESS_TELEMETRY_SENSORS,
CIRCUIT_BREAKER_RATING_SENSOR,
CIRCUIT_CURRENT_SENSOR,
CIRCUIT_SENSORS,
@@ -46,11 +54,14 @@
L1_VOLTAGE_SENSOR,
L2_VOLTAGE_SENSOR,
MAIN_BREAKER_RATING_SENSOR,
+ MID_SENSORS,
PANEL_DATA_STATUS_SENSORS,
PANEL_ENERGY_SENSORS,
PANEL_POWER_SENSORS,
+ PCS_SENSORS,
PV_METADATA_SENSORS,
PV_POWER_SENSOR,
+ SHED_FORECAST_SENSORS,
SITE_POWER_SENSOR,
STATUS_SENSORS,
UNMAPPED_SENSORS,
@@ -60,18 +71,22 @@
from .sensor_evse import SpanEvseSensor
from .sensor_panel import (
SpanBessMetadataSensor,
+ SpanMidSensor,
SpanPanelBattery,
SpanPanelEnergySensor,
SpanPanelPanelStatus,
SpanPanelPowerSensor,
SpanPanelStatus,
+ SpanPcsSensor,
SpanPVMetadataSensor,
+ SpanShedForecastSensor,
)
-from .util import bess_device_info, evse_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__ = [
"SpanBessMetadataSensor",
+ "SpanMidSensor",
"SpanCircuitEnergySensor",
"SpanCircuitPowerSensor",
"SpanEnergySensorBase",
@@ -81,7 +96,9 @@
"SpanPanelPanelStatus",
"SpanPanelPowerSensor",
"SpanPanelStatus",
+ "SpanPcsSensor",
"SpanSensorBase",
+ "SpanShedForecastSensor",
"SpanUnmappedCircuitSensor",
]
@@ -103,8 +120,28 @@ 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,
+ )
+
+ # 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)
+ async_add_entities([*entities, *adopted, *extensions])
# Force immediate coordinator refresh to ensure all sensors update right away
await coordinator.async_request_refresh()
@@ -192,7 +229,13 @@ def _build_evse_device_info_map(
mapping: dict[str, DeviceInfo] = {}
for evse in snapshot.evse.values():
display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers)
- info = evse_device_info(panel_identifier, evse, panel_name, display_suffix)
+ info = evse_device_info(
+ panel_identifier,
+ evse,
+ panel_name,
+ display_suffix,
+ panel_device_id=coordinator.config_entry.runtime_data.panel_device_id,
+ )
mapping[evse.feed_circuit_id] = info
return mapping
@@ -301,7 +344,115 @@ def _build_bess_device_info(
or "Span Panel"
)
- return bess_device_info(snapshot.serial_number, snapshot.battery, panel_name)
+ return bess_device_info(
+ snapshot.serial_number,
+ snapshot.battery,
+ panel_name,
+ panel_device_id=coordinator.config_entry.runtime_data.panel_device_id,
+ )
+
+
+def _build_mid_device_info(
+ coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
+) -> DeviceInfo:
+ """DeviceInfo for the Microgrid Interconnect sub-device."""
+ panel_name = (
+ coordinator.config_entry.data.get(CONF_DEVICE_NAME, coordinator.config_entry.title)
+ or "Span Panel"
+ )
+ mid = snapshot.mid
+ if mid is None:
+ raise ValueError("cannot build MID device info for a snapshot with no MID")
+ return mid_device_info(
+ snapshot.serial_number,
+ mid,
+ panel_name,
+ panel_device_id=coordinator.config_entry.runtime_data.panel_device_id,
+ )
+
+
+def create_mid_sensors(
+ coordinator: SpanPanelCoordinator, snapshot: SpanPanelSnapshot
+) -> list[SpanMidSensor]:
+ """Create the Microgrid Interconnect sub-device and its sensors.
+
+ v1.0 only, and purely additive: no flat panel publishes a MID, so `has_mid` is false
+ everywhere today and nothing a user has changes. Presence is the library's optional
+ `mid` field rather than a sentinel value, so there is nothing to infer.
+
+ DUAL-SCHEMA: gated on what the snapshot carries, never on a version or a config flag. A
+ panel that hot-loads parent/child mid-life gains the MID as a capability change, the
+ coordinator reloads, and the device appears; one that never does simply never sees
+ it. When the flat path retires, this gate can go and the sensors become
+ unconditional.
+ """
+ if not has_mid(snapshot):
+ return []
+
+ mid_info = _build_mid_device_info(coordinator, snapshot)
+ 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_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(
@@ -330,9 +481,42 @@ 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
+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]:
@@ -341,15 +525,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):
@@ -389,6 +593,9 @@ def create_native_sensors(
| SpanBessMetadataSensor
| SpanPVMetadataSensor
| SpanEvseSensor
+ | SpanMidSensor
+ | SpanShedForecastSensor
+ | SpanPcsSensor
]:
"""Create all native sensors for the platform."""
entities: list[
@@ -403,6 +610,9 @@ def create_native_sensors(
| SpanBessMetadataSensor
| SpanPVMetadataSensor
| SpanEvseSensor
+ | SpanMidSensor
+ | SpanShedForecastSensor
+ | SpanPcsSensor
] = []
# Create different sensor types
@@ -411,6 +621,9 @@ def create_native_sensors(
if config_entry.options.get(ENABLE_UNMAPPED_CIRCUIT_SENSORS, False):
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_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_base.py b/custom_components/span_panel/sensor_base.py
index fddec4d7..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,
@@ -235,16 +237,27 @@ def _construct_entity_id(
Subclasses may override to use entity_id helpers from helpers.py.
Returns None to let HA auto-generate from _attr_name.
+ 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: The existing entity_id from registry, or None for new entities
+ existing_entity_id: This entity's id in the registry, or None if new
"""
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
@@ -253,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."""
@@ -321,7 +306,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
@@ -441,20 +432,36 @@ def _process_raw_value(self, raw_value: float | str | None) -> None:
str_value = str(raw_value)
# For enum sensors, ensure the value is in the options list before
# setting it — HA raises ValueError if the state is not in options.
- # Options are built dynamically from observed MQTT values.
- # Values are normalized to lowercase to satisfy HA's translation
- # key requirement ([a-z0-9-_]+). HA uses the state value directly
- # as the translation key lookup.
+ # Values are normalized to lowercase to satisfy HA's translation key
+ # requirement ([a-z0-9-_]+); HA uses the state value directly as the
+ # translation key lookup.
+ #
+ # Options are declared statically on each description, from the states
+ # `en.json` renders. They used to be discovered here instead, which could
+ # not work: options would only ever list states the panel had already
+ # reached, so a state it had not yet visited was absent from its own
+ # "Possible states" — and every panel advertised a different set
+ # depending on what it had lived through.
+ #
+ # The append survives as a last resort so an undeclared value degrades to
+ # a shown state rather than a ValueError. It is a warning rather than a
+ # debug line because reaching it means the panel published outside the
+ # enum its own catalog declares, which is a producer defect and not
+ # something a consumer should absorb quietly. It also renders untranslated,
+ # as a raw key.
if self._attr_device_class is SensorDeviceClass.ENUM:
str_value = str_value.lower()
if not hasattr(self, "_attr_options") or self._attr_options is None:
self._attr_options = []
if str_value not in self._attr_options:
self._attr_options.append(str_value)
- _LOGGER.debug(
- "Added enum option '%s' for %s",
- str_value,
+ _LOGGER.warning(
+ "%s reported '%s', which is not one of its declared states %s. "
+ "Showing it untranslated; the panel is publishing outside the "
+ "enum its catalog declares.",
self.entity_id or self._attr_unique_id,
+ str_value,
+ sorted(o for o in self._attr_options if o != str_value),
)
self._attr_native_value = str_value
diff --git a/custom_components/span_panel/sensor_circuit.py b/custom_components/span_panel/sensor_circuit.py
index 8e12c30c..33da8579 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,
@@ -113,6 +124,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,
@@ -206,7 +219,7 @@ def _construct_entity_id(
"sensor",
suffix,
circuit,
- unique_id=self._attr_unique_id if existing_entity_id else None,
+ existing_entity_id=existing_entity_id,
)
def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanCircuitSnapshot:
@@ -231,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
@@ -240,6 +254,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
@@ -248,6 +283,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,
@@ -270,6 +308,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,
)
@@ -368,7 +408,7 @@ def _construct_entity_id(
"sensor",
suffix,
circuit,
- unique_id=self._attr_unique_id if existing_entity_id else None,
+ existing_entity_id=existing_entity_id,
)
# Map original_key to the energy type used for coordinator dip offset tracking
@@ -413,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
@@ -445,6 +486,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 e94d3ac6..0ac8957e 100644
--- a/custom_components/span_panel/sensor_definitions.py
+++ b/custom_components/span_panel/sensor_definitions.py
@@ -7,12 +7,18 @@
- 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
from collections.abc import Callable
from dataclasses import dataclass
+from typing import NamedTuple
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -25,24 +31,33 @@
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
+ UnitOfTime,
)
from homeassistant.helpers.entity import EntityCategory
from span_panel_api import (
SpanBatterySnapshot,
SpanCircuitSnapshot,
SpanEvseSnapshot,
+ SpanMidSnapshot,
SpanPanelSnapshot,
+ SpanPcsSnapshot,
+)
+
+from .field_paths import (
+ DerivedReason,
+ FieldPathDeclarationMixin,
+ iter_source_field_declarations,
)
@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
):
@@ -50,25 +65,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
):
@@ -76,13 +91,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
):
@@ -100,52 +115,59 @@ class SpanPanelBatterySensorEntityDescription(
] = (
SpanPanelDataSensorEntityDescription(
key="dsm_state",
+ derived=DerivedReason.NO_SOURCE_FIELD,
translation_key="dsm_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- options=["unknown"],
+ options=["dsm_off_grid", "dsm_on_grid", "unknown"],
value_fn=lambda s: s.dsm_state,
),
SpanPanelDataSensorEntityDescription(
key="dsm_grid_state",
+ derived=DerivedReason.NO_SOURCE_FIELD,
translation_key="dsm_grid_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
- options=["unknown"],
+ options=["dsm_off_grid", "dsm_on_grid", "unknown"],
value_fn=lambda s: s.dsm_state, # deprecated alias — reads dsm_state
),
SpanPanelDataSensorEntityDescription(
key="current_run_config",
+ derived=DerivedReason.NO_SOURCE_FIELD,
translation_key="current_run_config",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
- options=["unknown"],
+ options=["panel_backup", "panel_off_grid", "panel_on_grid", "unknown"],
value_fn=lambda s: s.current_run_config,
),
SpanPanelDataSensorEntityDescription(
key="main_relay_state",
+ field_path="panel.main_relay_state",
translation_key="main_relay_state",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
- options=["unknown"],
+ options=["closed", "open", "unknown"],
value_fn=lambda s: s.main_relay_state,
),
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,
entity_category=EntityCategory.DIAGNOSTIC,
- options=["unknown"],
+ options=["battery", "generator", "grid", "none", "pv", "unknown"],
value_fn=lambda s: s.dominant_power_source or "unknown",
),
SpanPanelDataSensorEntityDescription(
key="vendor_cloud",
+ field_path="panel.vendor_cloud",
translation_key="vendor_cloud",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- options=["unknown"],
+ options=["connected", "unconnected", "unknown"],
value_fn=lambda s: s.vendor_cloud or "unknown",
),
)
@@ -154,6 +176,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,
@@ -169,6 +192,7 @@ class SpanPanelBatterySensorEntityDescription(
] = (
SpanPanelCircuitsSensorEntityDescription(
key="instantPowerW",
+ field_path="circuit.instant_power_w",
name="Power",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
@@ -180,7 +204,8 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="producedEnergyWh",
- name="Produced Energy",
+ field_path="circuit.produced_energy_wh",
+ name="Energy Produced",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
@@ -191,7 +216,8 @@ class SpanPanelBatterySensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="consumedEnergyWh",
- name="Consumed Energy",
+ field_path="circuit.consumed_energy_wh",
+ name="Energy Consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
@@ -205,6 +231,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,
@@ -220,6 +247,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,
@@ -232,6 +260,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,
@@ -246,6 +275,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,
@@ -260,6 +290,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,
@@ -274,6 +305,9 @@ class SpanPanelBatterySensorEntityDescription(
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,
@@ -287,6 +321,9 @@ class SpanPanelBatterySensorEntityDescription(
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,
@@ -301,6 +338,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,
@@ -309,6 +347,332 @@ 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",
+ field_path="panel.shed_time_to_priority_shed_min",
+ 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",
+ field_path="panel.shed_total_time_remaining_min",
+ 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.
+"""
+
+
+# ---------------------------------------------------------------------------
+# 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,
+ # 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,
+ ),
+ 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)
# ---------------------------------------------------------------------------
@@ -317,6 +681,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,
@@ -330,6 +695,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,
@@ -344,19 +710,55 @@ 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
):
"""Describes a BESS metadata sensor entity."""
+@dataclass(frozen=True)
+class SpanMidRequiredKeysMixin(FieldPathDeclarationMixin):
+ """Required keys mixin for MID sensors."""
+
+ value_fn: Callable[[SpanMidSnapshot], str | None]
+
+
+@dataclass(frozen=True, kw_only=True)
+class SpanMidSensorEntityDescription(SensorEntityDescription, SpanMidRequiredKeysMixin):
+ """Describes a sensor on the Microgrid Interconnect Device."""
+
+
+MID_SENSORS: tuple[SpanMidSensorEntityDescription, ...] = (
+ SpanMidSensorEntityDescription(
+ key="mid_grid_state",
+ derived=DerivedReason.NO_SOURCE_FIELD,
+ translation_key="mid_grid_state",
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options=["up", "down", "degraded", "unknown"],
+ value_fn=lambda m: (m.grid_state or "unknown").lower(),
+ ),
+)
+"""Sensors on the MID device.
+
+Only `grid-state` — utility-supply health, genuinely new in v1.0 with no flat
+equivalent, and the one non-metadata addition the MID brings.
+
+Islanding state and the grid-forming entity are deliberately *not* duplicated here.
+Both already reach a user through entities that predate v1.0 — `dsm_state` and
+`grid_forming_entity` on the panel — and those must keep their ids and their history.
+Showing the same fact twice is not the benign cell of the absorb-or-surface policy;
+adding a fact nobody had is.
+"""
+
+
BESS_METADATA_SENSORS: tuple[
SpanBessMetadataSensorEntityDescription,
SpanBessMetadataSensorEntityDescription,
@@ -364,33 +766,47 @@ class SpanBessMetadataSensorEntityDescription(
SpanBessMetadataSensorEntityDescription,
SpanBessMetadataSensorEntityDescription,
SpanBessMetadataSensorEntityDescription,
+ SpanBessMetadataSensorEntityDescription,
] = (
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.product_name,
+ 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",
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,
@@ -399,6 +815,7 @@ class SpanBessMetadataSensorEntityDescription(
),
SpanBessMetadataSensorEntityDescription(
key="soe_kwh",
+ field_path="battery.soe_kwh",
translation_key="bess_soe_kwh",
device_class=SensorDeviceClass.ENERGY_STORAGE,
state_class=SensorStateClass.MEASUREMENT,
@@ -409,19 +826,110 @@ class 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",
+ 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. 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, 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.
+
+**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
+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
+`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)
# ---------------------------------------------------------------------------
@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
):
@@ -435,18 +943,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.product_name,
+ 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,
@@ -463,6 +974,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,
@@ -472,6 +984,9 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="feedthroughPowerW",
+ field_path="panel.feedthrough_power_w",
+ # 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,
state_class=SensorStateClass.MEASUREMENT,
@@ -484,6 +999,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,
@@ -495,6 +1011,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,
@@ -506,6 +1023,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,
@@ -517,6 +1035,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,
@@ -536,6 +1055,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,
@@ -545,6 +1065,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,
@@ -554,6 +1075,9 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="feedthroughEnergyProducedWh",
+ field_path="panel.feedthrough_energy_produced_wh",
+ # 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,
state_class=SensorStateClass.TOTAL,
@@ -563,6 +1087,9 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="feedthroughEnergyConsumedWh",
+ field_path="panel.feedthrough_energy_consumed_wh",
+ # 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,
state_class=SensorStateClass.TOTAL,
@@ -572,6 +1099,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="mainMeterNetEnergyWh",
+ derived=DerivedReason.MULTIPLE_FIELDS,
translation_key="main_meter_net_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -584,6 +1112,7 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelDataSensorEntityDescription(
key="feedthroughNetEnergyWh",
+ derived=DerivedReason.MULTIPLE_FIELDS,
translation_key="feedthrough_net_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
@@ -605,6 +1134,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,
@@ -618,7 +1148,8 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_produced",
- name="Produced Energy",
+ field_path="circuit.produced_energy_wh",
+ name="Energy Produced",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
@@ -629,7 +1160,8 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_consumed",
- name="Consumed Energy",
+ field_path="circuit.consumed_energy_wh",
+ name="Energy Consumed",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=2,
@@ -640,7 +1172,8 @@ class SpanPVMetadataSensorEntityDescription(
),
SpanPanelCircuitsSensorEntityDescription(
key="circuit_energy_net",
- name="Net Energy",
+ derived=DerivedReason.MULTIPLE_FIELDS,
+ name="Energy Net",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=2,
@@ -662,13 +1195,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."""
@@ -677,16 +1210,30 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK
SpanEvseSensorEntityDescription,
SpanEvseSensorEntityDescription,
SpanEvseSensorEntityDescription,
+ SpanEvseSensorEntityDescription,
] = (
SpanEvseSensorEntityDescription(
key="evse_status",
+ field_path="evse.status",
translation_key="evse_status",
device_class=SensorDeviceClass.ENUM,
- options=["unknown"],
+ options=[
+ "available",
+ "charging",
+ "faulted",
+ "finishing",
+ "preparing",
+ "reserved",
+ "suspended_ev",
+ "suspended_evse",
+ "unavailable",
+ "unknown",
+ ],
value_fn=lambda e: e.status or "unknown",
),
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,
@@ -696,9 +1243,93 @@ class SpanEvseSensorEntityDescription(SensorEntityDescription, SpanEvseRequiredK
),
SpanEvseSensorEntityDescription(
key="evse_lock_state",
+ field_path="evse.lock_state",
translation_key="evse_lock_state",
device_class=SensorDeviceClass.ENUM,
- options=["unknown"],
+ 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,
+ ),
)
+
+
+def all_sensor_descriptions() -> tuple[SensorEntityDescription, ...]:
+ """Every sensor description, without deduplication.
+
+ 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,
+ *STATUS_SENSORS,
+ *UNMAPPED_SENSORS,
+ *MID_SENSORS,
+ *BESS_METADATA_SENSORS,
+ *BESS_TELEMETRY_SENSORS,
+ *PCS_SENSORS,
+ *PV_METADATA_SENSORS,
+ *PANEL_POWER_SENSORS,
+ *PANEL_ENERGY_SENSORS,
+ *CIRCUIT_SENSORS,
+ *EVSE_SENSORS,
+ *SHED_FORECAST_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,
+ )
+
+
+def sensor_descriptions_by_field_path() -> dict[str, SensorEntityDescription]:
+ """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.
+ 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
+ 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_source_field_declarations`
+ holds that rule.
+ """
+ by_field_path: dict[str, SensorEntityDescription] = {}
+ 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/custom_components/span_panel/sensor_evse.py b/custom_components/span_panel/sensor_evse.py
index f45d6a66..4d9bdf15 100644
--- a/custom_components/span_panel/sensor_evse.py
+++ b/custom_components/span_panel/sensor_evse.py
@@ -46,7 +46,11 @@ def __init__(
use_circuit_numbers = data_coordinator.config_entry.options.get(USE_CIRCUIT_NUMBERS, False)
display_suffix = resolve_evse_display_suffix(evse, snapshot, use_circuit_numbers)
self._attr_device_info = evse_device_info(
- panel_identifier, evse, panel_name, display_suffix
+ panel_identifier,
+ evse,
+ panel_name,
+ display_suffix,
+ panel_device_id=data_coordinator.config_entry.runtime_data.panel_device_id,
)
def _generate_unique_id(
diff --git a/custom_components/span_panel/sensor_panel.py b/custom_components/span_panel/sensor_panel.py
index da07c49a..79d5bc90 100644
--- a/custom_components/span_panel/sensor_panel.py
+++ b/custom_components/span_panel/sensor_panel.py
@@ -7,11 +7,17 @@
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import UNDEFINED
-from span_panel_api import SpanBatterySnapshot, SpanPanelSnapshot
+from span_panel_api import (
+ SpanBatterySnapshot,
+ SpanMidSnapshot,
+ SpanPanelSnapshot,
+ SpanPcsSnapshot,
+)
from .coordinator import SpanPanelCoordinator
from .helpers import (
build_bess_unique_id_for_entry,
+ build_mid_unique_id_for_entry,
construct_panel_unique_id_for_entry,
construct_synthetic_unique_id_for_entry,
get_panel_entity_suffix,
@@ -19,18 +25,89 @@
from .sensor_base import SpanEnergySensorBase, SpanSensorBase
from .sensor_definitions import (
SpanBessMetadataSensorEntityDescription,
+ SpanMidSensorEntityDescription,
SpanPanelBatterySensorEntityDescription,
SpanPanelDataSensorEntityDescription,
SpanPanelStatusSensorEntityDescription,
+ SpanPcsSensorEntityDescription,
SpanPVMetadataSensorEntityDescription,
+ SpanShedForecastSensorEntityDescription,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
+def _grid_forming_device_name(snapshot: SpanPanelSnapshot) -> str | None:
+ """Return the forming device's readable name, when the library knows it.
+
+ The state stays the source *class* — `GRID`, `BATTERY`, `PV` — because that is the
+ closed enum automations compare against and it must not change. v1.0 additionally
+ knows *which* device, which flat never did, and that belongs here: an attribute
+ refines a value already on screen without adding entity-list noise, and cannot break
+ an automation that never referenced it.
+
+ Deliberately the display name and not the wire id. `sim-40t-001-SIM-BESS-40T-001` is
+ a Homie device id, not a Home Assistant one, and an opaque string on a dashboard is
+ worse than none. The id stays in the snapshot for correlation and diagnostics.
+
+ DUAL-SCHEMA: `None` on any flat panel, which publishes no MID, so the attribute simply
+ does not appear there. The library field is always present now that the pin is
+ 3.0.0b3 — the conditional is about what the *panel* publishes, not about which
+ library is installed, and the earlier `getattr` guarding the latter has gone.
+ """
+ mid = snapshot.mid
+ if mid is None:
+ return 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,
@@ -64,10 +141,212 @@ 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]
+):
+ """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 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."""
+ # `_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,
data_coordinator: SpanPanelCoordinator,
@@ -103,7 +382,25 @@ 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.
+
+ **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
@@ -111,8 +408,11 @@ 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)
+ if forming is not None:
+ attributes["grid_forming_device"] = forming
return attributes or None
@@ -160,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,
@@ -223,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
@@ -319,19 +652,84 @@ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanBatterySnapshot:
return snapshot.battery
+class SpanMidSensor(SpanSensorBase[SpanMidSensorEntityDescription, SpanMidSnapshot]):
+ """A sensor on the Microgrid Interconnect Device sub-device."""
+
+ def __init__(
+ self,
+ data_coordinator: SpanPanelCoordinator,
+ description: SpanMidSensorEntityDescription,
+ snapshot: SpanPanelSnapshot,
+ device_info_override: DeviceInfo,
+ ) -> None:
+ """Initialize the MID sensor."""
+ super().__init__(data_coordinator, description, snapshot)
+ self._attr_device_info = device_info_override
+
+ def _generate_unique_id(
+ self,
+ snapshot: SpanPanelSnapshot,
+ description: SpanMidSensorEntityDescription,
+ ) -> str:
+ """Generate unique ID for MID sensors."""
+ return build_mid_unique_id_for_entry(
+ self.coordinator, snapshot, description.key, self._device_name
+ )
+
+ def _generate_friendly_name(
+ self,
+ snapshot: SpanPanelSnapshot,
+ description: SpanMidSensorEntityDescription,
+ ) -> str:
+ """Generate friendly name for MID sensors."""
+ if description.name is not None and description.name is not UNDEFINED:
+ return str(description.name)
+ return "MID Sensor"
+
+ def get_data_source(self, snapshot: SpanPanelSnapshot) -> SpanMidSnapshot:
+ """Get the data source for the MID sensor.
+
+ The MID is optional, so a snapshot without one has no data source. Entities are
+ only created when `has_mid` is true, and a panel that stops publishing its MID
+ makes them unavailable rather than reaching this.
+ """
+ mid = snapshot.mid
+ if mid is None:
+ raise ValueError("MID sensor asked for a data source on a snapshot with no MID")
+ return mid
+
+
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/strings.json b/custom_components/span_panel/strings.json
index e374babf..34629521 100644
--- a/custom_components/span_panel/strings.json
+++ b/custom_components/span_panel/strings.json
@@ -147,6 +147,15 @@
},
"wifi_link": {
"name": "Wi-Fi Link"
+ },
+ "pcs_active": {
+ "name": "PCS Active"
+ },
+ "pv_panel_link": {
+ "name": "PV Panel Link"
+ },
+ "evse_panel_link": {
+ "name": "EVSE Panel Link"
}
},
"button": {
@@ -154,6 +163,11 @@
"name": "GFE Override: Grid Connected"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "name": "EVSE Charge Current Limit"
+ }
+ },
"select": {
"circuit_priority": {
"name": "Circuit Priority",
@@ -177,9 +191,21 @@
"bess_model": {
"name": "Model"
},
+ "bess_communication_state": {
+ "name": "Communication State",
+ "state": {
+ "ok": "OK",
+ "degraded": "Degraded",
+ "lost": "Lost",
+ "unknown": "Unknown"
+ }
+ },
"bess_nameplate_capacity": {
"name": "Nameplate Capacity"
},
+ "bess_part_number": {
+ "name": "Part Number"
+ },
"bess_serial_number": {
"name": "Serial Number"
},
@@ -231,6 +257,9 @@
"unlocked": "Unlocked"
}
},
+ "evse_part_number": {
+ "name": "Part Number"
+ },
"evse_status": {
"name": "Charger Status",
"state": {
@@ -301,6 +330,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"
},
@@ -313,12 +351,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"
},
@@ -332,6 +376,25 @@
"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"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Meter Power"
}
}
},
@@ -342,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."
},
@@ -399,8 +468,8 @@
"enable_energy_dip_compensation": "Compensate when panel reports lower energy readings. Disabling clears offsets.",
"enable_panel_net_energy_sensors": "Net energy for main meter and feed-through, accounting for bidirectional flows.",
"enable_unmapped_circuit_sensors": "Backing sensors for tabs not assigned to named breakers.",
- "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0\u201360). Default: 15.",
- "snapshot_update_interval": "Seconds between snapshot rebuilds (0\u201315). Lower = faster, more CPU. 0 = no debounce."
+ "energy_reporting_grace_period": "Minutes sensors hold last value when panel is offline (0–60). Default: 15.",
+ "snapshot_update_interval": "Seconds between snapshot rebuilds (0–15). Lower = faster, more CPU. 0 = no debounce."
},
"description": "Configure SPAN Panel integration settings.",
"title": "SPAN Panel Options"
@@ -694,5 +763,15 @@
}
}
}
+ },
+ "issues": {
+ "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."
+ },
+ "schema_unit_mismatch": {
+ "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."
+ }
}
}
diff --git a/custom_components/span_panel/switch.py b/custom_components/span_panel/switch.py
index 88427934..be75e47d 100644
--- a/custom_components/span_panel/switch.py
+++ b/custom_components/span_panel/switch.py
@@ -2,15 +2,16 @@
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
-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
@@ -43,6 +44,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,
@@ -72,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
@@ -110,16 +119,16 @@ 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,
+ existing_entity_id=existing_entity_id,
)
if constructed_id:
self.entity_id = constructed_id
@@ -147,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()
@@ -237,8 +215,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
@@ -326,7 +305,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:
@@ -342,4 +321,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/custom_components/span_panel/translations/en.json b/custom_components/span_panel/translations/en.json
index 040332a9..34629521 100644
--- a/custom_components/span_panel/translations/en.json
+++ b/custom_components/span_panel/translations/en.json
@@ -147,6 +147,15 @@
},
"wifi_link": {
"name": "Wi-Fi Link"
+ },
+ "pcs_active": {
+ "name": "PCS Active"
+ },
+ "pv_panel_link": {
+ "name": "PV Panel Link"
+ },
+ "evse_panel_link": {
+ "name": "EVSE Panel Link"
}
},
"button": {
@@ -154,6 +163,11 @@
"name": "GFE Override: Grid Connected"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "name": "EVSE Charge Current Limit"
+ }
+ },
"select": {
"circuit_priority": {
"name": "Circuit Priority",
@@ -177,9 +191,21 @@
"bess_model": {
"name": "Model"
},
+ "bess_communication_state": {
+ "name": "Communication State",
+ "state": {
+ "ok": "OK",
+ "degraded": "Degraded",
+ "lost": "Lost",
+ "unknown": "Unknown"
+ }
+ },
"bess_nameplate_capacity": {
"name": "Nameplate Capacity"
},
+ "bess_part_number": {
+ "name": "Part Number"
+ },
"bess_serial_number": {
"name": "Serial Number"
},
@@ -231,6 +257,9 @@
"unlocked": "Unlocked"
}
},
+ "evse_part_number": {
+ "name": "Part Number"
+ },
"evse_status": {
"name": "Charger Status",
"state": {
@@ -301,6 +330,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"
},
@@ -313,12 +351,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"
},
@@ -332,6 +376,25 @@
"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"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Meter Power"
}
}
},
@@ -342,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."
},
@@ -694,5 +763,15 @@
}
}
}
+ },
+ "issues": {
+ "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."
+ },
+ "schema_unit_mismatch": {
+ "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."
+ }
}
}
diff --git a/custom_components/span_panel/translations/es.json b/custom_components/span_panel/translations/es.json
index 3bb65e2e..dba3f3ef 100644
--- a/custom_components/span_panel/translations/es.json
+++ b/custom_components/span_panel/translations/es.json
@@ -147,6 +147,15 @@
},
"wifi_link": {
"name": "Enlace Wi-Fi"
+ },
+ "pcs_active": {
+ "name": "PCS Activo"
+ },
+ "pv_panel_link": {
+ "name": "Enlace del Panel FV"
+ },
+ "evse_panel_link": {
+ "name": "Enlace del Panel EVSE"
}
},
"button": {
@@ -154,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",
@@ -177,9 +191,21 @@
"bess_model": {
"name": "Modelo"
},
+ "bess_communication_state": {
+ "name": "Estado de Comunicación",
+ "state": {
+ "ok": "Correcto",
+ "degraded": "Degradado",
+ "lost": "Perdido",
+ "unknown": "Desconocido"
+ }
+ },
"bess_nameplate_capacity": {
"name": "Capacidad Nominal"
},
+ "bess_part_number": {
+ "name": "Número de Pieza"
+ },
"bess_serial_number": {
"name": "Número de Serie"
},
@@ -231,6 +257,9 @@
"unlocked": "Desbloqueado"
}
},
+ "evse_part_number": {
+ "name": "Número de Pieza"
+ },
"evse_status": {
"name": "Estado del Cargador",
"state": {
@@ -301,6 +330,15 @@
"unknown": "Desconocido"
}
},
+ "mid_grid_state": {
+ "name": "Estado de la red",
+ "state": {
+ "up": "Activa",
+ "down": "Caída",
+ "degraded": "Degradada",
+ "unknown": "Desconocido"
+ }
+ },
"pv_nameplate_capacity": {
"name": "Capacidad Nominal PV"
},
@@ -313,12 +351,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"
},
@@ -332,6 +376,25 @@
"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"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Potencia del Medidor"
}
}
},
@@ -342,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."
},
@@ -694,5 +763,15 @@
}
}
}
+ },
+ "issues": {
+ "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."
+ },
+ "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}. 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 712bef06..afb0e5e8 100644
--- a/custom_components/span_panel/translations/fr.json
+++ b/custom_components/span_panel/translations/fr.json
@@ -147,6 +147,15 @@
},
"wifi_link": {
"name": "Liaison Wi-Fi"
+ },
+ "pcs_active": {
+ "name": "PCS Actif"
+ },
+ "pv_panel_link": {
+ "name": "Liaison du panneau PV"
+ },
+ "evse_panel_link": {
+ "name": "Liaison du panneau EVSE"
}
},
"button": {
@@ -154,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",
@@ -177,9 +191,21 @@
"bess_model": {
"name": "Modèle"
},
+ "bess_communication_state": {
+ "name": "État de Communication",
+ "state": {
+ "ok": "Correct",
+ "degraded": "Dégradé",
+ "lost": "Perdu",
+ "unknown": "Inconnu"
+ }
+ },
"bess_nameplate_capacity": {
"name": "Capacité Nominale"
},
+ "bess_part_number": {
+ "name": "Référence"
+ },
"bess_serial_number": {
"name": "Numéro de Série"
},
@@ -231,6 +257,9 @@
"unlocked": "Déverrouillé"
}
},
+ "evse_part_number": {
+ "name": "Référence"
+ },
"evse_status": {
"name": "État du Chargeur",
"state": {
@@ -301,6 +330,15 @@
"unknown": "Inconnu"
}
},
+ "mid_grid_state": {
+ "name": "État du réseau",
+ "state": {
+ "up": "Actif",
+ "down": "Coupé",
+ "degraded": "Dégradé",
+ "unknown": "Inconnu"
+ }
+ },
"pv_nameplate_capacity": {
"name": "Capacité Nominale PV"
},
@@ -313,12 +351,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"
},
@@ -332,6 +376,25 @@
"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"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Puissance du Compteur"
}
}
},
@@ -342,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."
},
@@ -694,5 +763,15 @@
}
}
}
+ },
+ "issues": {
+ "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."
+ },
+ "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}. 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 8879fc0e..95fe751e 100644
--- a/custom_components/span_panel/translations/ja.json
+++ b/custom_components/span_panel/translations/ja.json
@@ -147,6 +147,15 @@
},
"wifi_link": {
"name": "Wi-Fiリンク"
+ },
+ "pcs_active": {
+ "name": "PCS 動作中"
+ },
+ "pv_panel_link": {
+ "name": "PVパネルリンク"
+ },
+ "evse_panel_link": {
+ "name": "EVSEパネルリンク"
}
},
"button": {
@@ -154,6 +163,11 @@
"name": "GFEオーバーライド:系統接続"
}
},
+ "number": {
+ "evse_charge_current_limit": {
+ "name": "EVSE充電電流上限"
+ }
+ },
"select": {
"circuit_priority": {
"name": "回路優先度",
@@ -177,9 +191,21 @@
"bess_model": {
"name": "モデル"
},
+ "bess_communication_state": {
+ "name": "通信状態",
+ "state": {
+ "ok": "正常",
+ "degraded": "劣化",
+ "lost": "喪失",
+ "unknown": "不明"
+ }
+ },
"bess_nameplate_capacity": {
"name": "定格容量"
},
+ "bess_part_number": {
+ "name": "型番"
+ },
"bess_serial_number": {
"name": "シリアル番号"
},
@@ -231,6 +257,9 @@
"unlocked": "アンロック"
}
},
+ "evse_part_number": {
+ "name": "型番"
+ },
"evse_status": {
"name": "充電器ステータス",
"state": {
@@ -301,6 +330,15 @@
"unknown": "不明"
}
},
+ "mid_grid_state": {
+ "name": "系統状態",
+ "state": {
+ "up": "正常",
+ "down": "停電",
+ "degraded": "劣化",
+ "unknown": "不明"
+ }
+ },
"pv_nameplate_capacity": {
"name": "PV定格容量"
},
@@ -313,12 +351,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電流"
},
@@ -332,6 +376,25 @@
"unconnected": "未接続",
"unknown": "不明"
}
+ },
+ "pcs_import_limit": {
+ "name": "インポート制限"
+ },
+ "pcs_binding_constraint": {
+ "name": "拘束制約",
+ "state": {
+ "fsr": "確定サービス容量",
+ "doe": "系統エンベロープ",
+ "voltage": "電圧サポート",
+ "off_grid": "オフグリッド",
+ "requested": "要求",
+ "operator": "オペレーター",
+ "none": "なし",
+ "unknown": "不明"
+ }
+ },
+ "bess_meter_power": {
+ "name": "メーター電力"
}
}
},
@@ -342,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パネルを追加して設定してください。"
},
@@ -694,5 +763,15 @@
}
}
}
+ },
+ "issues": {
+ "schema_field_unresolved": {
+ "title": "SPAN パネルのデータフィールドが利用できません",
+ "description": "パネルのファームウェアが `{field_path}` を提供しなくなりました。{count} 個のエンティティが影響を受けます (例: {examples})。新しいファームウェアに対応する統合の更新が入るまで、これらは利用不可になるか、不完全なデータを報告する可能性があります。"
+ },
+ "schema_unit_mismatch": {
+ "title": "SPAN パネルの単位の不一致",
+ "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 3898d7f0..af2cc18a 100644
--- a/custom_components/span_panel/translations/pt.json
+++ b/custom_components/span_panel/translations/pt.json
@@ -147,6 +147,15 @@
},
"wifi_link": {
"name": "Ligação Wi-Fi"
+ },
+ "pcs_active": {
+ "name": "PCS Ativo"
+ },
+ "pv_panel_link": {
+ "name": "Ligação do Painel FV"
+ },
+ "evse_panel_link": {
+ "name": "Ligação do Painel EVSE"
}
},
"button": {
@@ -154,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",
@@ -177,9 +191,21 @@
"bess_model": {
"name": "Modelo"
},
+ "bess_communication_state": {
+ "name": "Estado de Comunicação",
+ "state": {
+ "ok": "Correto",
+ "degraded": "Degradado",
+ "lost": "Perdido",
+ "unknown": "Desconhecido"
+ }
+ },
"bess_nameplate_capacity": {
"name": "Capacidade Nominal"
},
+ "bess_part_number": {
+ "name": "Número de Peça"
+ },
"bess_serial_number": {
"name": "Número de Série"
},
@@ -231,6 +257,9 @@
"unlocked": "Desbloqueado"
}
},
+ "evse_part_number": {
+ "name": "Número de Peça"
+ },
"evse_status": {
"name": "Estado do Carregador",
"state": {
@@ -301,6 +330,15 @@
"unknown": "Desconhecido"
}
},
+ "mid_grid_state": {
+ "name": "Estado da rede",
+ "state": {
+ "up": "Ativa",
+ "down": "Em falha",
+ "degraded": "Degradada",
+ "unknown": "Desconhecido"
+ }
+ },
"pv_nameplate_capacity": {
"name": "Capacidade Nominal PV"
},
@@ -313,12 +351,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"
},
@@ -332,6 +376,25 @@
"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"
+ }
+ },
+ "bess_meter_power": {
+ "name": "Potência do Medidor"
}
}
},
@@ -342,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."
},
@@ -694,5 +763,15 @@
}
}
}
+ },
+ "issues": {
+ "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."
+ },
+ "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}. Entidades afetadas: {count} (por exemplo: {examples}). As suas leituras e estatísticas de longo prazo podem estar erradas. Não foram alteradas automaticamente."
+ }
}
}
diff --git a/custom_components/span_panel/util.py b/custom_components/span_panel/util.py
index a42ce0de..1e0230ad 100644
--- a/custom_components/span_panel/util.py
+++ b/custom_components/span_panel/util.py
@@ -1,29 +1,119 @@
"""Utility functions for the Span integration."""
import logging
+from typing import Final
from homeassistant.helpers.device_registry import DeviceInfo
-from span_panel_api import SpanBatterySnapshot, SpanEvseSnapshot, SpanPanelSnapshot
+from span_panel_api import (
+ SpanBatterySnapshot,
+ SpanEvseSnapshot,
+ SpanMidSnapshot,
+ SpanPanelSnapshot,
+ SpanPVSnapshot,
+)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+# Every SPAN sub-device hangs off the panel and says so with `via_device_id` --
+# the panel's *registry* id, not its identifiers. Identifiers are unique only
+# within a config entry, so linking by them is ambiguous by construction; Home
+# Assistant deprecated `via_device` for that reason and stops honouring it in
+# 2027.8. The id is resolved once during setup and carried on the entry's
+# runtime data, because a sub-device is only ever built after the panel device
+# exists -- which is what lets the builders below take a plain `str` and leaves
+# no caller with an absence to handle.
+
+# A sub-device's registry identifier is `{panel serial}_{kind}`, with EVSE
+# carrying its node id after the kind because a panel can have several.
+#
+# Named here, beside the builders that construct them, because the topology
+# WebSocket command has to read the grammar back. It used to restate it, and the
+# MID shipped classifying as "unknown" for exactly that reason: a third kind was
+# added to the writing end and not to the reading one. `classify_sub_device_identifier`
+# is the reading end, so the two cannot drift again.
+SUB_DEVICE_BESS: Final = "bess"
+SUB_DEVICE_MID: Final = "mid"
+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.
+
+ 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.
+ """
+ # 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:
+ 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
+
def snapshot_to_device_info(
snapshot: SpanPanelSnapshot,
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,
)
@@ -32,17 +122,69 @@ def bess_device_info(
panel_identifier: str,
battery: SpanBatterySnapshot,
panel_name: str,
+ *,
+ panel_device_id: str,
) -> DeviceInfo:
- """Create DeviceInfo for a BESS sub-device linked to the parent panel."""
+ """Create DeviceInfo for a BESS sub-device linked to the parent panel.
+
+ Two panel-shaped arguments doing different jobs: `panel_identifier` is the
+ serial this sub-device namespaces its own identity under, `panel_device_id`
+ is the registry id it links to.
+ """
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=battery.product_name or "Battery Storage",
+ # `model` is the human designation on both schemas now: v1.0 publishes it as
+ # `info/model` and schema_0 translates flat's `bess/product-name` into it.
+ model=battery.model or "Battery Storage",
serial_number=battery.serial_number,
sw_version=battery.software_version,
- via_device=(DOMAIN, panel_identifier),
+ via_device_id=panel_device_id,
+ )
+
+
+def mid_device_info(
+ panel_identifier: str,
+ mid: SpanMidSnapshot,
+ panel_name: str,
+ *,
+ panel_device_id: str,
+) -> DeviceInfo:
+ """Create DeviceInfo for the Microgrid Interconnect Device.
+
+ v1.0 publishes the MID as a device of its own and puts the `grid` capability on it
+ rather than on the enclosure — "the enclosure device itself does not publish them" —
+ so islanding decisions belong to hardware with its own identity. Registering it here
+ keeps the integration's model in step with the library's rather than folding a
+ device's properties onto the panel.
+
+ Purely additive: no flat panel publishes a MID, so nothing a user has today changes.
+
+ Linked to the panel, matching BESS and EVSE, even though the wire tree makes the
+ MID a child of the BESS. Home Assistant's device graph is about what a user navigates,
+ and every SPAN sub-device hangs off the panel there; mirroring the Homie parentage
+ would put the MID one level deeper than its siblings for no reader's benefit.
+ """
+ return DeviceInfo(
+ 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",
+ 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,
)
@@ -51,17 +193,72 @@ def evse_device_info(
evse: SpanEvseSnapshot,
panel_name: str,
display_suffix: str | None = None,
+ *,
+ panel_device_id: str,
) -> DeviceInfo:
"""Create DeviceInfo for an EVSE sub-device linked to the parent panel."""
- base_name = evse.product_name or "EV Charger"
+ base_name = evse.model or "EV Charger"
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.product_name or "SPAN Drive",
+ model=evse.model or "SPAN Drive",
serial_number=evse.serial_number,
sw_version=evse.software_version,
- via_device=(DOMAIN, panel_identifier),
+ 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 aae65dbb..e06611e6 100644
--- a/custom_components/span_panel/websocket.py
+++ b/custom_components/span_panel/websocket.py
@@ -11,8 +11,9 @@
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
if TYPE_CHECKING:
from . import SpanPanelRuntimeData
@@ -74,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"]
@@ -91,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
@@ -155,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,
@@ -224,12 +230,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, 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/developer.md b/developer.md
index 46b96dcc..57a134d0 100644
--- a/developer.md
+++ b/developer.md
@@ -162,6 +162,421 @@ 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` 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
+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 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
+
+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".
+
+## 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
+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
+
+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 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 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.
+
+### 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.
+
+### 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.
+
+### 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,
+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
+
+`classify` routes a declaration to a platform:
+
+| 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. A settable `boolean` needs none — its datatype states the domain in full.
+
+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.
+
+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.
+
+### 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)` — 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
+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
+
+`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`. 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
```bash
diff --git a/hacs.json b/hacs.json
index 1d29ef7d..77764d3c 100644
--- a/hacs.json
+++ b/hacs.json
@@ -1,7 +1,7 @@
{
"name": "Span Panel",
"content_in_root": false,
- "homeassistant": "2023.3.0",
+ "homeassistant": "2026.8.0",
"render_readme": true,
"zip_release": false,
"filename": "custom_components/span_panel"
diff --git a/pyproject.toml b/pyproject.toml
index b3e559bc..df96c150 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,19 +1,25 @@
[project]
name = "span"
-version = "2.0.7"
+version = "2.1.0b6"
description = "Span Panel Custom Integration for Home Assistant"
authors = [{name = "SpanPanel"}]
license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.14.3,<3.15"
dependencies = [
- "homeassistant==2026.5.4",
- "span-panel-api==2.6.4",
+ "homeassistant==2026.8.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,
+ # and that is the list Home Assistant actually reads.
+ "span-panel-api-schema-0==1.0.0",
+ "span-panel-api-schema-1==1.0.0",
]
[dependency-groups]
dev = [
- "homeassistant-stubs==2026.5.4",
+ "homeassistant-stubs==2026.8.0",
"types-requests",
"types-PyYAML",
"mypy==1.20.2",
@@ -26,7 +32,11 @@ dev = [
"prettier",
"radon==6.0.1",
"pylint==4.0.5",
- "pytest-homeassistant-custom-component>=0.13.333",
+ # Pinned, not floored: each release pins one exact Home Assistant, so this
+ # and the `homeassistant` pin above are a single choice rather than two.
+ # 0.13.354 is the release that pins 2026.8.0, the version this integration
+ # declares as its floor and therefore the one worth testing against.
+ "pytest-homeassistant-custom-component==0.13.354",
"isort",
"vulture>=2.14",
]
@@ -35,7 +45,17 @@ dev = [
package = false
[tool.uv.sources]
-span-panel-api = { path = "../span-panel-api", 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}"]
@@ -115,7 +135,7 @@ exclude = [
]
extraPaths = [
"./custom_components",
- "../span-panel-api/src",
+ "../../span/span-panel-api/src",
"../ha-synthetic-sensors/src"
]
pythonPlatform = "Darwin"
diff --git a/requirements_test.txt b/requirements_test.txt
index 0733eb9d..5c371cd4 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -1,5 +1,8 @@
pytest>=9.0.3
pytest-asyncio>=1.3.0
setuptools>=65.7.0
-pytest-homeassistant-custom-component>=0.13.333
-homeassistant>=2026.5.4
+pytest-homeassistant-custom-component==0.13.354
+homeassistant==2026.8.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/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/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:
diff --git a/tests/adapter_fixtures.py b/tests/adapter_fixtures.py
new file mode 100644
index 00000000..d2fd0b20
--- /dev/null
+++ b/tests/adapter_fixtures.py
@@ -0,0 +1,185 @@
+"""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, 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 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
+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_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(without: str | None = None) -> dict[str, dict[str, str]]:
+ """A mutable copy of the parent/child capture, ready to be rewritten.
+
+ **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 = {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:
+ """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]:
+ """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 _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]:
+ """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_from(schema_one_tree())))
+
+
+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
+ 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_from(schema_one_tree()))
+
+
+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_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_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_from(schema_one_tree(without="pv"))))
diff --git a/tests/factories.py b/tests/factories.py
index ad469691..2420529a 100644
--- a/tests/factories.py
+++ b/tests/factories.py
@@ -10,6 +10,7 @@
SpanBatterySnapshot,
SpanCircuitSnapshot,
SpanEvseSnapshot,
+ SpanMidSnapshot,
SpanPanelSnapshot,
SpanPVSnapshot,
)
@@ -140,7 +141,7 @@ def create(
lock_state: str = "LOCKED",
advertised_current_a: float | None = 32.0,
vendor_name: str | None = "SPAN",
- product_name: str | None = "SPAN Drive",
+ model: str | None = "SPAN Drive",
part_number: str | None = None,
serial_number: str | None = "SN-EVSE-001",
software_version: str | None = "2.1.0",
@@ -153,7 +154,7 @@ def create(
lock_state=lock_state,
advertised_current_a=advertised_current_a,
vendor_name=vendor_name,
- product_name=product_name,
+ model=model,
part_number=part_number,
serial_number=serial_number,
software_version=software_version,
@@ -176,7 +177,8 @@ def create(
soe_percentage: float | None = 85.0,
soe_kwh: float | None = None,
vendor_name: str | None = None,
- product_name: str | None = None,
+ model: str | None = None,
+ part_number: str | None = None,
serial_number: str | None = None,
software_version: str | None = None,
nameplate_capacity_kwh: float | None = None,
@@ -187,7 +189,8 @@ def create(
soe_percentage=soe_percentage,
soe_kwh=soe_kwh,
vendor_name=vendor_name,
- product_name=product_name,
+ model=model,
+ part_number=part_number,
serial_number=serial_number,
software_version=software_version,
nameplate_capacity_kwh=nameplate_capacity_kwh,
@@ -219,6 +222,8 @@ def create(
wwan_link: bool = False,
circuits: dict[str, SpanCircuitSnapshot] | None = None,
battery: SpanBatterySnapshot | None = None,
+ # v1.0 only. Defaults to None so every existing fixture stays a flat panel.
+ mid: SpanMidSnapshot | None = None,
dominant_power_source: str | None = None,
grid_state: str | None = None,
grid_islandable: bool | None = None,
@@ -238,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:
@@ -268,6 +278,7 @@ def create(
wwan_link=wwan_link,
circuits=circuits,
battery=battery,
+ mid=mid,
dominant_power_source=dominant_power_source,
grid_state=grid_state,
grid_islandable=grid_islandable,
@@ -281,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/fixtures/README.md b/tests/fixtures/README.md
new file mode 100644
index 00000000..9c458c60
--- /dev/null
+++ b/tests/fixtures/README.md
@@ -0,0 +1,52 @@
+# 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` | `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.
+
+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) + "\n")
+ 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.
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"
+}
diff --git a/tests/fixtures/unread_declarations_baseline.json b/tests/fixtures/unread_declarations_baseline.json
new file mode 100644
index 00000000..07f3e8ae
--- /dev/null
+++ b/tests/fixtures/unread_declarations_baseline.json
@@ -0,0 +1,12 @@
+{
+ "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: 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",
+ "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"
+}
diff --git a/tests/test_adoption.py b/tests/test_adoption.py
new file mode 100644
index 00000000..b5be5d00
--- /dev/null
+++ b/tests/test_adoption.py
@@ -0,0 +1,637 @@
+"""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
+import json
+from pathlib import Path
+from typing import TYPE_CHECKING
+from unittest.mock import AsyncMock, 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 pytest_homeassistant_custom_component.common import MockConfigEntry
+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,
+ adopted_unique_id,
+ async_register_adopted_devices,
+ 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
+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,
+ classify_sub_device_identifier,
+)
+
+from .factories import SpanPanelSnapshotFactory
+
+if TYPE_CHECKING:
+ from span_panel_api import SpanPanelSnapshot
+
+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",
+ 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_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),
+ _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_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)
+
+
+# -- 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 == []
+
+
+# -- 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
+
+
+# -- 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)
diff --git a/tests/test_bess_telemetry.py b/tests/test_bess_telemetry.py
new file mode 100644
index 00000000..72e917c9
--- /dev/null
+++ b/tests/test_bess_telemetry.py
@@ -0,0 +1,583 @@
+"""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.
+
+**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
+
+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_binary_sensor_platform.py b/tests/test_binary_sensor_platform.py
index 5c8ac2b8..60d57e97 100644
--- a/tests/test_binary_sensor_platform.py
+++ b/tests/test_binary_sensor_platform.py
@@ -4,6 +4,7 @@
from unittest.mock import AsyncMock, MagicMock
+from custom_components.span_panel import SpanPanelRuntimeData
from custom_components.span_panel.binary_sensor import (
BESS_CONNECTED_SENSOR,
BINARY_SENSORS,
@@ -42,6 +43,9 @@ def _make_coordinator(snapshot) -> MagicMock:
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
@@ -98,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/tests/test_button.py b/tests/test_button.py
index 521ad6cb..02de4eb4 100644
--- a/tests/test_button.py
+++ b/tests/test_button.py
@@ -101,10 +101,15 @@ async def test_gfe_override_button_server_error_creates_notification() -> None:
def test_gfe_override_button_available_only_when_override_is_relevant() -> None:
- """Availability should reflect panel state and whether firmware already has control."""
+ """Availability should reflect panel state and whether firmware already has control.
+
+ Keyed on `dsm_state` rather than `dominant_power_source`: the guard asks "are we
+ already on the grid", and that is populated on both wire schemas where the GFE is
+ not — under v1.0 it is `None`, so the old comparison never fired.
+ """
snapshot = SpanPanelSnapshotFactory.create(
battery=SpanBatterySnapshotFactory.create(connected=False),
- dominant_power_source="BATTERY",
+ dsm_state="DSM_OFF_GRID",
)
coordinator = _make_button_coordinator(snapshot)
button = SpanPanelGFEOverrideButton(coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID")
@@ -114,16 +119,18 @@ def test_gfe_override_button_available_only_when_override_is_relevant() -> None:
coordinator.panel_offline = True
assert button.available is False
+ # BESS reachable: firmware sets the grid state itself, so there is nothing to assert.
coordinator.panel_offline = False
coordinator.data = SpanPanelSnapshotFactory.create(
battery=SpanBatterySnapshotFactory.create(connected=True),
- dominant_power_source="BATTERY",
+ dsm_state="DSM_OFF_GRID",
)
assert button.available is False
+ # Already on grid: the assertion would be a no-op, and firmware would reject it.
coordinator.data = SpanPanelSnapshotFactory.create(
battery=SpanBatterySnapshotFactory.create(connected=False),
- dominant_power_source="GRID",
+ dsm_state="DSM_ON_GRID",
)
assert button.available is False
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_circuit_manifest_service.py b/tests/test_circuit_manifest_service.py
index 922ef680..b79da009 100644
--- a/tests/test_circuit_manifest_service.py
+++ b/tests/test_circuit_manifest_service.py
@@ -90,7 +90,8 @@ async def test_basic_manifest(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
_register_power_entity(
@@ -166,10 +167,12 @@ async def test_multiple_panels(self, hass: HomeAssistant):
entry_a.mock_state(hass, ConfigEntryState.LOADED)
entry_b.mock_state(hass, ConfigEntryState.LOADED)
entry_a.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot_a)
+ coordinator=_make_coordinator(snapshot_a),
+ panel_device_id="panel-device-id",
)
entry_b.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot_b)
+ coordinator=_make_coordinator(snapshot_b),
+ panel_device_id="panel-device-id",
)
_register_power_entity(
@@ -211,7 +214,8 @@ async def test_unmapped_tabs_excluded(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
_register_power_entity(
@@ -254,7 +258,8 @@ async def test_circuit_without_entity_excluded(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
# Only register entity for one circuit
@@ -315,7 +320,8 @@ async def test_all_device_types_included(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
_register_power_entity(
@@ -361,7 +367,8 @@ async def test_bess_device_type_mapped_to_battery(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
_register_power_entity(
@@ -395,7 +402,8 @@ async def test_panel_with_no_resolvable_circuits_omitted(self, hass: HomeAssista
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
# No entities registered
@@ -426,7 +434,8 @@ async def test_template_uses_min_tab(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
_register_power_entity(
@@ -458,7 +467,8 @@ async def test_host_included_from_config_entry(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
_register_power_entity(
diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py
index 48427766..b449bb95 100644
--- a/tests/test_coordinator.py
+++ b/tests/test_coordinator.py
@@ -18,12 +18,15 @@
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,
HomeAssistantError,
)
+from .adapter_fixtures import schema_one_snapshot
from .factories import (
SpanBatterySnapshotFactory,
SpanEvseSnapshotFactory,
@@ -117,8 +120,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,
@@ -230,61 +242,62 @@ 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.
+
+ 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 = {"panel.l1_voltage": FieldMetadata("kV", "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] == ["panel.l1_voltage"]
+ assert findings.unit_mismatches[0].schema_unit == "kV"
+ assert findings.unit_mismatches[0].ha_unit == "V"
@pytest.mark.parametrize(
@@ -507,3 +520,203 @@ 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_entities_register_themselves_against_the_field_they_read(
+ hass: HomeAssistant,
+) -> None:
+ """The affected-entity map is recorded by entities, never derived from them.
+
+ 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.
+ """
+ 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)
+ 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"
+ )
+ assert issue is not None
+ assert issue.translation_placeholders["count"] == "1"
+ assert issue.translation_placeholders["examples"] == "sensor.span_panel_kitchen_power"
+
+
+async def test_sync_schema_repairs_is_a_no_op_while_findings_are_unknown(
+ hass: HomeAssistant,
+) -> None:
+ """"Unknown" must not reconcile at all.
+
+ `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
+
+ 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()
+ 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"
+ )
+
+
+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"})
+
+
+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)
+ )
diff --git a/tests/test_declared_but_unread.py b/tests/test_declared_but_unread.py
new file mode 100644
index 00000000..891bd213
--- /dev/null
+++ b/tests/test_declared_but_unread.py
@@ -0,0 +1,388 @@
+"""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,
+ SpanPcsSnapshot,
+ 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
+ | SpanPcsSnapshot
+)
+
+_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
+`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)
+ if snapshot.pcs is not None:
+ _record(fields, "pcs", snapshot.pcs)
+ 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_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}"
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_device_links.py b/tests/test_device_links.py
new file mode 100644
index 00000000..6ea08af2
--- /dev/null
+++ b/tests/test_device_links.py
@@ -0,0 +1,273 @@
+"""Sub-devices hang off the panel by registry id, not by identifiers.
+
+Home Assistant deprecated `via_device=(DOMAIN, serial)` and the unscoped
+`async_get_device(identifiers=...)` together in 2026.8, for one reason: device
+identifiers are unique only *within* a config entry, so anything treating them as
+globally unique is ambiguous by construction. Both stop working in 2027.8.
+
+The replacement is a registry id, which only exists once the panel device does.
+That is the whole design constraint here — a sub-device cannot name its parent
+until its parent is registered — and it is why setup resolves the id once and
+carries it on the entry's runtime data instead of every platform looking it up.
+
+The end-to-end test below is the one that matters. `via_device_id` naming a
+device that does not exist is dropped by the registry rather than rejected, so a
+wrong id threaded through these builders would not raise anywhere: the device
+would simply appear unparented in the UI, which is exactly the kind of failure
+a unit test asserting on a dict cannot see.
+"""
+
+from __future__ import annotations
+
+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
+from custom_components.span_panel.util import (
+ bess_device_info,
+ classify_sub_device_identifier,
+ evse_device_info,
+ mid_device_info,
+ pv_device_info,
+)
+
+from .factories import (
+ SpanBatterySnapshotFactory,
+ SpanEvseSnapshotFactory,
+ SpanPanelSnapshotFactory,
+)
+
+_PANEL_ID = "a-registry-id"
+
+
+def _mid() -> SpanMidSnapshot:
+ return SpanMidSnapshot(
+ node_id="sp3-link-001-mid",
+ serial_number="sp3-link-001-mid",
+ vendor_name="Span",
+ model=None,
+ islanding_state="ON_GRID",
+ grid_state="UP",
+ grid_forming_entity="GRID",
+ )
+
+
+def _builders() -> list[tuple[str, Any]]:
+ """Every sub-device builder, so a new one is covered the day it lands."""
+ return [
+ (
+ "bess",
+ lambda: bess_device_info(
+ "sp3-link-001",
+ SpanBatterySnapshotFactory.create(),
+ "Panel",
+ panel_device_id=_PANEL_ID,
+ ),
+ ),
+ (
+ "mid",
+ lambda: mid_device_info(
+ "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(
+ "sp3-link-001",
+ SpanEvseSnapshotFactory.create(),
+ "Panel",
+ panel_device_id=_PANEL_ID,
+ ),
+ ),
+ ]
+
+
+@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
+
+
+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.
+
+ Checking only that `via_device_id` is present would pass a builder that
+ passes both, which Home Assistant accepts today and stops accepting in
+ 2027.8 -- a regression that would sit unnoticed until the deadline.
+ """
+ info = build()
+
+ assert info.get("via_device_id") == _PANEL_ID, label
+ assert "via_device" not in info, f"{label} still links by identifiers"
+
+
+async def test_registering_the_panel_answers_with_the_id_sub_devices_need(
+ hass: HomeAssistant,
+) -> None:
+ """The returned id is the created device's, on the branch that creates it."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "192.168.1.30"},
+ entry_id="entry-link-new",
+ unique_id="sp3-link-new",
+ )
+ entry.add_to_hass(hass)
+ snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-new")
+
+ panel_device_id = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel")
+
+ created = dr.async_get(hass).async_get_device_by_identifier(
+ (DOMAIN, "sp3-link-new"), entry.entry_id
+ )
+ assert created is not None
+ assert panel_device_id == created.id
+
+
+async def test_an_already_registered_panel_answers_with_the_same_id(
+ hass: HomeAssistant,
+) -> None:
+ """And on the branch that finds one, which is every reload after the first.
+
+ A second registration must not mint a new id: sub-devices registered against
+ the old one would be orphaned, and a user would watch their battery and
+ chargers detach from the panel on a restart.
+ """
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "192.168.1.31"},
+ entry_id="entry-link-existing",
+ unique_id="sp3-link-existing",
+ )
+ entry.add_to_hass(hass)
+ snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-existing")
+
+ first = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel")
+ second = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel")
+
+ assert first == second
+
+
+async def test_a_foreign_device_sharing_the_identifier_is_not_adopted(
+ hass: HomeAssistant,
+) -> None:
+ """The ambiguity the deprecation exists to remove, made concrete.
+
+ Identifiers are unique within a config entry and nowhere else, so a lookup
+ that searches every entry can answer with a device this entry does not own --
+ and then every sub-device built from that answer hangs off somebody else's
+ panel. Scoping the lookup is what makes the answer unambiguous.
+
+ A second SPAN entry cannot reach this state today, because the serial is the
+ entry's unique_id. That is a property of our config flow rather than of the
+ registry, though, and it is not the assumption the sub-device links should
+ rest on.
+ """
+ other = MockConfigEntry(
+ domain=DOMAIN, data={}, entry_id="entry-other", unique_id="other"
+ )
+ other.add_to_hass(hass)
+ registry = dr.async_get(hass)
+ foreign = registry.async_get_or_create(
+ config_entry_id=other.entry_id,
+ identifiers={(DOMAIN, "sp3-link-shared")},
+ name="Somebody else's panel",
+ )
+
+ mine = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "192.168.1.33"},
+ entry_id="entry-mine",
+ unique_id="sp3-link-shared",
+ )
+ mine.add_to_hass(hass)
+ snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-shared")
+
+ panel_device_id = await ensure_device_registered(hass, mine, snapshot, "SPAN Panel")
+
+ assert panel_device_id != foreign.id
+ assert registry.async_get(panel_device_id) is not None
+
+
+async def test_a_sub_device_really_lands_under_the_panel(hass: HomeAssistant) -> None:
+ """End to end through the registry, because a bad id fails silently.
+
+ The registry drops a `via_device_id` it cannot resolve instead of raising, so
+ the only way to know the id threaded from setup through runtime data into a
+ builder is the right one is to register a device with it and read the link
+ back.
+ """
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_HOST: "192.168.1.32"},
+ entry_id="entry-link-e2e",
+ unique_id="sp3-link-e2e",
+ )
+ entry.add_to_hass(hass)
+ snapshot = SpanPanelSnapshotFactory.create(serial_number="sp3-link-e2e")
+ registry = dr.async_get(hass)
+
+ panel_device_id = await ensure_device_registered(hass, entry, snapshot, "SPAN Panel")
+ bess = registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ **bess_device_info(
+ "sp3-link-e2e",
+ SpanBatterySnapshotFactory.create(),
+ "SPAN Panel",
+ panel_device_id=panel_device_id,
+ ),
+ )
+
+ assert bess.via_device_id == panel_device_id
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
index c5af3331..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,
@@ -64,6 +65,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,
@@ -76,7 +81,7 @@ async def test_config_entry_diagnostics_includes_redacted_runtime_data(
title="SPAN Panel",
unique_id="sp3-diag-001",
)
- entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator)
+ entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id")
result = await async_get_config_entry_diagnostics(hass, entry)
@@ -89,6 +94,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,
@@ -150,14 +158,19 @@ 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
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)
+ entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id")
result = await async_get_config_entry_diagnostics(hass, entry)
@@ -165,6 +178,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,
@@ -183,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"
diff --git a/tests/test_dps_and_bess.py b/tests/test_dps_and_bess.py
index 8985afa2..d8a64bfa 100644
--- a/tests/test_dps_and_bess.py
+++ b/tests/test_dps_and_bess.py
@@ -1,5 +1,6 @@
"""Tests for GFE override buttons and BESS connected binary sensor."""
+from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -12,6 +13,8 @@
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
@@ -67,10 +70,16 @@ def test_bess_sensor_created_with_bess(self) -> None:
def _make_gfe_coordinator(
dominant_power_source: str | None = "GRID",
battery: SpanBatterySnapshot | None = None,
+ dsm_state: str = "DSM_ON_GRID",
) -> MagicMock:
- """Build a mock coordinator for GFE button tests."""
+ """Build a mock coordinator for GFE button tests.
+
+ `dsm_state` is what the button's availability guard reads; `dominant_power_source`
+ stays because other tests here assert on the GFE sensor itself.
+ """
snapshot = SpanPanelSnapshotFactory.create(
dominant_power_source=dominant_power_source,
+ dsm_state=dsm_state,
battery=battery if battery is not None else SpanBatterySnapshot(),
)
@@ -154,9 +163,9 @@ async def test_button_server_error(self) -> None:
mock_notification.assert_called_once()
def test_available_when_bess_offline_and_not_grid(self) -> None:
- """Button is available when BESS is offline and GFE is not GRID."""
+ """Button is available when BESS is offline and the panel is not on grid."""
coordinator = _make_gfe_coordinator(
- dominant_power_source="BATTERY",
+ dsm_state="DSM_OFF_GRID",
battery=SpanBatterySnapshot(soe_percentage=50.0, connected=False),
)
coordinator.panel_offline = False
@@ -190,9 +199,9 @@ def test_unavailable_when_gfe_is_grid(self) -> None:
assert button.available is False
def test_available_when_no_bess(self) -> None:
- """Button is available when no BESS is commissioned and GFE is not GRID."""
+ """Button is available when no BESS is commissioned and the panel is not on grid."""
coordinator = _make_gfe_coordinator(
- dominant_power_source="BATTERY",
+ dsm_state="DSM_OFF_GRID",
battery=SpanBatterySnapshot(),
)
coordinator.panel_offline = False
@@ -200,3 +209,50 @@ def test_available_when_no_bess(self) -> None:
coordinator, GFE_OVERRIDE_DESCRIPTION, "GRID"
)
assert button.available is True
+
+
+class TestGridFormingDeviceAttribute:
+ """The GFE sensor's `grid_forming_device` attribute."""
+
+ def test_the_attribute_is_absent_when_the_library_has_no_mid(self) -> None:
+ """Degrades to nothing against span-panel-api 2.6.4, which has no `mid` field.
+
+ Also the correct answer on any flat panel: no flat firmware publishes a MID, so
+ there is no forming device to name.
+ """
+ snapshot = SpanPanelSnapshotFactory.create(dominant_power_source="BATTERY")
+
+ assert _grid_forming_device_name(snapshot) is None
+
+ def test_the_attribute_is_the_display_name_not_the_wire_id(self) -> None:
+ """A Homie device id is not a Home Assistant device id.
+
+ The state keeps the closed enum automations compare against; this refines it with
+ the part a person recognises.
+ """
+ mid = SimpleNamespace(
+ grid_forming_entity="sim-40t-001-SIM-BESS-40T-001",
+ grid_forming_device_name="Battery",
+ )
+ snapshot = SimpleNamespace(mid=mid)
+
+ assert _grid_forming_device_name(snapshot) == "Battery"
+
+ def test_no_name_when_the_grid_itself_is_forming(self) -> None:
+ """There is no device to name, so the attribute stays off the sensor."""
+ 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 None
diff --git a/tests/test_enum_sensor_options.py b/tests/test_enum_sensor_options.py
new file mode 100644
index 00000000..b9c80338
--- /dev/null
+++ b/tests/test_enum_sensor_options.py
@@ -0,0 +1,115 @@
+"""Every enum sensor must declare the states it can actually report.
+
+Home Assistant renders `options` as the entity's "Possible states" and validates the
+state against it. Eight sensors declared `["unknown"]` and nothing else, so a panel
+reporting `dsm_on_grid` showed a live state that its own entity said was impossible.
+
+`sensor_base` tried to fix this at runtime, appending each value as it was first
+observed. That does not work, and could not: options would only ever list states the
+panel had already reached, so `dsm_off_grid` stays absent until the day of an actual
+outage, and the advertised set differs between two identical panels depending on what
+each has lived through. A value domain that is known when the code is written should
+be declared there.
+
+The translations are the authority rather than a second hand-maintained list: a state
+with no translation renders to a user as a raw key like `dsm_on_grid`, and an option
+with no state to match is dead weight. Both are the same defect seen from either end,
+so one list is derived from the other.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+from homeassistant.components.sensor import SensorDeviceClass
+
+from custom_components.span_panel.sensor_definitions import (
+ BESS_METADATA_SENSORS,
+ BESS_TELEMETRY_SENSORS,
+ CIRCUIT_SENSORS,
+ EVSE_SENSORS,
+ MID_SENSORS,
+ PANEL_DATA_STATUS_SENSORS,
+ PCS_SENSORS,
+ PV_METADATA_SENSORS,
+ STATUS_SENSORS,
+ UNMAPPED_SENSORS,
+)
+
+_COMPONENT = Path(__file__).resolve().parent.parent / "custom_components" / "span_panel"
+
+
+def _translated_states() -> dict[str, set[str]]:
+ """The state keys `en.json` renders, per sensor translation key."""
+ entities = json.loads((_COMPONENT / "translations" / "en.json").read_text())
+ sensors = entities["entity"]["sensor"]
+ return {key: set(body.get("state", {})) for key, body in sensors.items()}
+
+
+def _enum_descriptions() -> list[Any]:
+ # Every published group, so a new enum in any of them is covered the day it
+ # lands rather than the day someone remembers this file.
+ groups = (
+ PANEL_DATA_STATUS_SENSORS,
+ STATUS_SENSORS,
+ UNMAPPED_SENSORS,
+ MID_SENSORS,
+ BESS_METADATA_SENSORS,
+ BESS_TELEMETRY_SENSORS,
+ PCS_SENSORS,
+ PV_METADATA_SENSORS,
+ CIRCUIT_SENSORS,
+ EVSE_SENSORS,
+ )
+ return [
+ description
+ for group in groups
+ for description in group
+ if description.device_class is SensorDeviceClass.ENUM
+ ]
+
+
+def test_there_are_enum_sensors_to_check() -> None:
+ """Guard the guard.
+
+ Every assertion below is a loop over discovered descriptions, so an import that
+ silently stopped finding any would leave this file passing while checking nothing.
+ """
+ assert len(_enum_descriptions()) >= 8
+
+
+@pytest.mark.parametrize("description", _enum_descriptions(), ids=lambda d: str(d.key))
+def test_declared_options_match_the_states_the_ui_can_render(description: Any) -> None:
+ """Declared options and translated states are the same set.
+
+ Not a subset in either direction. An option with no translation reaches a user as
+ a raw key; a translated state absent from options is one Home Assistant will
+ reject when the panel reports it -- which is how `dsm_grid_state` came to show
+ "Possible states: Unknown" while sitting at `dsm_on_grid`.
+ """
+ translated = _translated_states().get(description.translation_key)
+ assert translated, f"{description.key} has no translated states to compare against"
+
+ declared = set(description.options or [])
+
+ assert declared == translated, (
+ f"{description.key}: options {sorted(declared)} do not match the states "
+ f"en.json renders {sorted(translated)}. Add the missing ones to whichever "
+ "side is short; the two lists describe the same thing."
+ )
+
+
+@pytest.mark.parametrize("description", _enum_descriptions(), ids=lambda d: str(d.key))
+def test_unknown_is_offered_as_a_fallback(description: Any) -> None:
+ """Every enum can be unknown, so every enum must say so.
+
+ The value functions fall back to `"unknown"` when the panel publishes nothing, and
+ Home Assistant rejects a state outside `options`. A set that omits it is one
+ missing reading away from an error.
+ """
+ assert "unknown" in (description.options or []), (
+ f"{description.key} can report 'unknown' but does not declare 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_evse_entities.py b/tests/test_evse_entities.py
index de88028a..06c9e36a 100644
--- a/tests/test_evse_entities.py
+++ b/tests/test_evse_entities.py
@@ -77,19 +77,19 @@ 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")
assert status_desc.device_class is not None
assert status_desc.device_class.value == "enum"
- assert status_desc.options == ["unknown"]
+ assert "charging" in (status_desc.options or [])
def test_evse_lock_state_sensor_is_enum(self):
lock_desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state")
assert lock_desc.device_class is not None
assert lock_desc.device_class.value == "enum"
- assert lock_desc.options == ["unknown"]
+ assert "locked" in (lock_desc.options or [])
def test_evse_advertised_current_is_measurement(self):
current_desc = next(
@@ -172,12 +172,16 @@ def test_evse_device_info_full_metadata(self):
evse = SpanEvseSnapshotFactory.create(
node_id="evse-0",
vendor_name="SPAN",
- product_name="SPAN Drive",
+ model="SPAN Drive",
serial_number="SN123",
software_version="2.0.0",
)
info = evse_device_info(
- "panel-serial", evse, "Main House", display_suffix="Garage"
+ "panel-serial",
+ evse,
+ "Main House",
+ display_suffix="Garage",
+ panel_device_id="panel-device-id",
)
identifiers = info.get("identifiers")
assert identifiers is not None
@@ -187,45 +191,64 @@ def test_evse_device_info_full_metadata(self):
assert info.get("model") == "SPAN Drive"
assert info.get("serial_number") == "SN123"
assert info.get("sw_version") == "2.0.0"
- via = info.get("via_device")
- assert via == ("span_panel", "panel-serial")
+ assert info.get("via_device_id") == "panel-device-id"
def test_evse_device_info_fallback_names(self):
evse = SpanEvseSnapshotFactory.create(
vendor_name=None,
- product_name=None,
+ model=None,
+ )
+ info = evse_device_info(
+ "panel-serial", evse, "Span Panel", display_suffix=None, panel_device_id="pd"
)
- info = evse_device_info("panel-serial", evse, "Span Panel", display_suffix=None)
assert info.get("name") == "Span Panel EV Charger"
assert info.get("manufacturer") == "SPAN"
assert info.get("model") == "SPAN Drive"
def test_evse_device_info_serial_suffix(self):
evse = SpanEvseSnapshotFactory.create(
- product_name="SPAN Drive",
+ model="SPAN Drive",
serial_number="SN-EVSE-001",
)
info = evse_device_info(
- "panel-serial", evse, "Museum Garage", display_suffix="SN-EVSE-001"
+ "panel-serial",
+ evse,
+ "Museum Garage",
+ display_suffix="SN-EVSE-001",
+ panel_device_id="pd",
)
assert info.get("name") == "Museum Garage SPAN Drive (SN-EVSE-001)"
def test_evse_device_info_no_serial(self):
evse = SpanEvseSnapshotFactory.create(serial_number=None)
- info = evse_device_info("panel-serial", evse, "Span Panel")
+ info = evse_device_info("panel-serial", evse, "Span Panel", panel_device_id="pd")
assert info.get("serial_number") is None
class TestEvseStatusOptions:
- """Test EVSE enum options seed with 'unknown' only."""
+ """EVSE enums declare every state they can report.
+
+ These asserted the opposite -- that both seeded with `["unknown"]` and nothing
+ else -- pinning a bug rather than a contract. A sensor sitting at `charging` while
+ declaring only `unknown` as possible is what Home Assistant renders as
+ "Possible states: Unknown", and the runtime discovery meant to fill the gap could
+ only ever list states the charger had already reached.
- def test_status_options_seed_with_unknown(self):
+ The exhaustive comparison against the translations lives in
+ `test_enum_sensor_options.py`; these keep the EVSE-specific expectations local.
+ """
+
+ def test_status_declares_the_full_charging_lifecycle(self):
status_desc = next(d for d in EVSE_SENSORS if d.key == "evse_status")
- assert status_desc.options == ["unknown"]
+ options = set(status_desc.options or [])
+
+ assert {"available", "charging", "preparing", "finishing", "faulted"} <= options
+ assert "unknown" in options
- def test_lock_state_options_seed_with_unknown(self):
+ def test_lock_state_declares_both_positions(self):
lock_desc = next(d for d in EVSE_SENSORS if d.key == "evse_lock_state")
- assert lock_desc.options == ["unknown"]
+
+ assert set(lock_desc.options or []) == {"locked", "unlocked", "unknown"}
class TestEvseMultipleDevices:
@@ -248,8 +271,8 @@ def test_multiple_evse_in_snapshot(self):
def test_multiple_evse_device_infos_are_distinct(self):
evse_a = SpanEvseSnapshotFactory.create(node_id="evse-0")
evse_b = SpanEvseSnapshotFactory.create(node_id="evse-1")
- info_a = evse_device_info("panel", evse_a, "Span Panel")
- info_b = evse_device_info("panel", evse_b, "Span Panel")
+ info_a = evse_device_info("panel", evse_a, "Span Panel", panel_device_id="pd")
+ info_b = evse_device_info("panel", evse_b, "Span Panel", panel_device_id="pd")
assert info_a.get("identifiers") != info_b.get("identifiers")
@@ -263,7 +286,7 @@ def test_default_factory_values(self):
assert evse.lock_state == "LOCKED"
assert evse.advertised_current_a == 32.0
assert evse.vendor_name == "SPAN"
- assert evse.product_name == "SPAN Drive"
+ assert evse.model == "SPAN Drive"
def test_available_factory(self):
evse = SpanEvseSnapshotFactory.create_available()
diff --git a/tests/test_extension_entities.py b/tests/test_extension_entities.py
new file mode 100644
index 00000000..44ad55a6
--- /dev/null
+++ b/tests/test_extension_entities.py
@@ -0,0 +1,408 @@
+"""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,
+ extension_unique_id,
+ prominence_hint,
+ resolve_platform,
+ subject_key,
+)
+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), 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), er.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), er.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), er.async_get(hass))
+ assert len(adopted) == MAX_PER_DEVICE
+
+
+def test_the_cap_is_per_wire_device_not_per_card(
+ hass: HomeAssistant, registered_panel: tuple[str, str]
+) -> None:
+ """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"),
+ )
+ 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 ------------------------------------------------
+
+
+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_field_path_conformance.py b/tests/test_field_path_conformance.py
new file mode 100644
index 00000000..f018dcaa
--- /dev/null
+++ b/tests/test_field_path_conformance.py
@@ -0,0 +1,445 @@
+"""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, Iterator
+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 (
+ RESIDUAL_EXEMPT_PATHS,
+ Producibility,
+ declared_field_paths,
+ residual_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),
+]
+
+_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:
+ 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 declare a DerivedReason."
+ )
+
+
+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()
+ # 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 and path not in RESIDUAL_EXEMPT_PATHS
+ )
+ 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."
+ )
+
+
+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.
+ """
+ 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):
+ 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.
+
+ `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."
+ )
+
+
+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]
+) -> 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: 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}")
+
+
+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 an "
+ "entity's `_residual_field_paths` for a reader in entity code — so the "
+ "producible gate covers it again."
+ )
+
+
+_EXPECTED_EXEMPT_COUNTS: dict[Producibility, int] = {
+ # +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.
+ # +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.
+ # +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.
+ # +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.
+ # +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-
+ # but-unread while an entity was reading it.
+ # -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.
+ # +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
+ # 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.
+ # +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.
+ # +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.
+ # +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`."""
+
+
+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_path_introspection.py b/tests/test_field_path_introspection.py
new file mode 100644
index 00000000..53b56160
--- /dev/null
+++ b/tests/test_field_path_introspection.py
@@ -0,0 +1,373 @@
+"""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 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,
+ SpanPcsSnapshot,
+)
+
+from custom_components.span_panel.field_paths import (
+ DerivedReason,
+ 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
+
+# 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", "pcs"}
+
+
+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
+
+
+@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) -> DerivedReason | None: ...
+
+ @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,
+ EVSE_PANEL_LINK_SENSOR,
+ GRID_ISLANDABLE_SENSOR,
+ PCS_ACTIVE_SENSOR,
+ PV_PANEL_LINK_SENSOR,
+ )
+
+ for description in (
+ *all_sensor_descriptions(),
+ *BINARY_SENSORS,
+ *EVSE_BINARY_SENSORS,
+ GRID_ISLANDABLE_SENSOR,
+ BESS_CONNECTED_SENSOR,
+ PCS_ACTIVE_SENSOR,
+ PV_PANEL_LINK_SENSOR,
+ EVSE_PANEL_LINK_SENSOR,
+ ):
+ if not isinstance(description, _DeclaringDescription):
+ raise TypeError(
+ f"entity description '{description.key}' carries no field-path declaration"
+ )
+ yield description
+
+
+# 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",
+ SpanPcsSnapshot: "pcs",
+}
+
+
+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:
+ """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.field_path is None:
+ continue
+ try:
+ 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
+ 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)
+
+
+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 | 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
+ try:
+ sink = _record_reads(description)
+ except _UndeterminedPrefix as err:
+ offenders.append(
+ f"{description.key}: {err}, so its derived classification would go unverified"
+ )
+ continue
+ 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={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:
+ 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
new file mode 100644
index 00000000..1656583e
--- /dev/null
+++ b/tests/test_field_paths.py
@@ -0,0 +1,118 @@
+"""Every entity that reads one snapshot field must say which field."""
+
+from __future__ import annotations
+
+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
+
+
+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 None
+
+
+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 DerivedReason.NO_SOURCE_FIELD
+ 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
+
+
+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.
+
+ "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 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())
+ assert not (declared_field_paths() & RESIDUAL_EXEMPT_PATHS.keys())
diff --git a/tests/test_init_helpers.py b/tests/test_init_helpers.py
index a13774aa..327420a2 100644
--- a/tests/test_init_helpers.py
+++ b/tests/test_init_helpers.py
@@ -42,7 +42,7 @@ async def test_async_remove_config_entry_device_rejects_main_panel_device(
coordinator = MagicMock()
coordinator.data = snapshot
entry = MockConfigEntry(domain=DOMAIN, data={})
- entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator)
+ entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id")
device = MagicMock()
device.identifiers = {(DOMAIN, "sp3-main-001")}
@@ -57,7 +57,7 @@ async def test_async_remove_config_entry_device_allows_subdevice_removal(
coordinator = MagicMock()
coordinator.data = snapshot
entry = MockConfigEntry(domain=DOMAIN, data={})
- entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator)
+ entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id")
device = MagicMock()
device.identifiers = {(DOMAIN, "sp3-main-001_evse")}
@@ -176,7 +176,7 @@ async def test_async_unload_entry_shuts_down_runtime_data(
coordinator = MagicMock()
coordinator.async_shutdown = AsyncMock()
entry = MockConfigEntry(domain=DOMAIN, data={}, entry_id="entry-789")
- entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator)
+ entry.runtime_data = SpanPanelRuntimeData(coordinator=coordinator, panel_device_id="panel-device-id")
with patch.object(
hass.config_entries, "async_unload_platforms", AsyncMock(return_value=True)
diff --git a/tests/test_metadata_sweep.py b/tests/test_metadata_sweep.py
new file mode 100644
index 00000000..4e540481
--- /dev/null
+++ b/tests/test_metadata_sweep.py
@@ -0,0 +1,546 @@
+"""Identity that reaches a device card, an attribute or a diagnostic sensor.
+
+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.
+
+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.
+
+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
+
+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.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
+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 _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:
+ """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_moved_off_the_software_version_sensor() -> None:
+ """The old site, asserted absent — deliberately, and not coming back.
+
+ 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 _published(SCHEMA_ONE_PANEL, WIFI_SSID_TOPIC)
+ assert "wifi_ssid" not in attributes
+ # The attribute block did not collapse; only the SSID left it.
+ 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 -- 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 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(
+ 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_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`
+# ---------------------------------------------------------------------------
+
+
+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_mid_device.py b/tests/test_mid_device.py
new file mode 100644
index 00000000..ac203365
--- /dev/null
+++ b/tests/test_mid_device.py
@@ -0,0 +1,198 @@
+"""The Microgrid Interconnect Device, surfaced as its own device.
+
+v1.0 publishes a MID and puts the `grid` capability on it rather than on the enclosure.
+Everything here is additive: no flat panel publishes a MID, so `has_mid` is false on
+every existing install and nothing a user has today changes.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import MagicMock
+
+from span_panel_api import SpanMidSnapshot
+
+from custom_components.span_panel.helpers import detect_capabilities, has_mid
+from custom_components.span_panel.sensor import create_mid_sensors
+from custom_components.span_panel.sensor_definitions import MID_SENSORS
+from custom_components.span_panel.sensor_panel import _grid_forming_device_name
+from custom_components.span_panel.util import mid_device_info
+
+from .factories import SpanPanelSnapshotFactory
+
+
+def _mid(**overrides: str | None) -> SpanMidSnapshot:
+ defaults: dict[str, str | None] = {
+ "node_id": "SIM-BESS-40T-001-mid",
+ "serial_number": "SIM-BESS-40T-001-mid",
+ "vendor_name": "Span",
+ "model": None,
+ "islanding_state": "ON_GRID",
+ "grid_state": "UP",
+ "grid_forming_entity": "GRID",
+ }
+ defaults.update(overrides)
+ return SpanMidSnapshot(**defaults) # type: ignore[arg-type]
+
+
+def _coordinator() -> MagicMock:
+ coordinator = MagicMock()
+ coordinator.config_entry = MagicMock()
+ coordinator.config_entry.title = "SPAN Panel"
+ coordinator.config_entry.data = {}
+ return coordinator
+
+
+def test_a_flat_panel_has_no_mid() -> None:
+ """The whole reason this is additive rather than a migration risk."""
+ assert has_mid(SpanPanelSnapshotFactory.create()) is False
+ assert create_mid_sensors(_coordinator(), SpanPanelSnapshotFactory.create()) == []
+
+
+def test_presence_needs_no_sentinel() -> None:
+ """`snapshot.mid is not None`, unlike `has_bess`, which infers from soe_percentage
+ because the battery field is always present.
+ """
+ assert has_mid(SpanPanelSnapshotFactory.create(mid=_mid())) is True
+
+
+def test_the_mid_becomes_its_own_device_hung_off_the_panel() -> None:
+ """Identity renders on a device card rather than being folded onto the panel.
+
+ Linked to the panel even though the wire tree makes the MID a child of the
+ BESS: Home Assistant's device graph is what a user navigates, and every SPAN
+ sub-device hangs off the panel there.
+ """
+ info = mid_device_info(
+ "sim-40t-001", _mid(), "SPAN Panel", panel_device_id="panel-device-id"
+ )
+
+ assert info["identifiers"] == {("span_panel", "sim-40t-001_mid")}
+ assert info["name"] == "SPAN Panel Microgrid Interconnect"
+ assert info["manufacturer"] == "Span"
+ assert info["serial_number"] == "SIM-BESS-40T-001-mid"
+ assert info["via_device_id"] == "panel-device-id"
+ # 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:
+ """Utility-supply health is genuinely new; islanding state is not.
+
+ `dsm_state` and `grid_forming_entity` already reach a user from the panel and must
+ keep their ids and history. Duplicating them here would show the same fact twice,
+ which is not the benign cell of the absorb-or-surface policy — adding a fact nobody
+ had is.
+ """
+ keys = {desc.key for desc in MID_SENSORS}
+
+ assert keys == {"mid_grid_state"}
+
+
+def test_grid_state_is_lowercased_into_its_enum_options() -> None:
+ """Home Assistant validates an ENUM sensor against `options`, and the wire sends
+ `UP` / `DOWN` / `DEGRADED`.
+ """
+ (grid_state,) = MID_SENSORS
+
+ assert grid_state.value_fn(_mid(grid_state="UP")) == "up"
+ assert grid_state.value_fn(_mid(grid_state="DEGRADED")) == "degraded"
+ # A MID mid-discovery has a description and no values yet.
+ assert grid_state.value_fn(_mid(grid_state=None)) == "unknown"
+ assert set(grid_state.options or []) >= {"up", "down", "degraded", "unknown"}
+
+
+def test_a_mid_appearing_is_a_capability_change() -> None:
+ """The coordinator reloads on a new capability, which is how the device and its
+ sensors get created on a panel that gains a MID mid-life.
+ """
+ without = detect_capabilities(SpanPanelSnapshotFactory.create())
+ with_mid = detect_capabilities(SpanPanelSnapshotFactory.create(mid=_mid()))
+
+ assert "mid" not in without
+ assert "mid" in with_mid
+
+
+def test_every_schema_conditional_is_findable() -> None:
+ """The integration serves flat and parent/child side by side until every panel has
+ hot-loaded v1.0, and the branches that make that work have to be findable when the
+ flat path is finally retired.
+
+ Asserted rather than left to a convention, because the failure mode is silent: a
+ later addition that assumes parent/child would work on the developer's panel and
+ break on everyone else's, and nothing would say so.
+ """
+ component = Path(__file__).resolve().parent.parent / "custom_components" / "span_panel"
+ marked = sorted(
+ path.name for path in component.rglob("*.py") if "DUAL-SCHEMA" in path.read_text()
+ )
+
+ assert marked == ["binary_sensor.py", "helpers.py", "sensor.py", "sensor_panel.py"], (
+ "the set of schema-conditional modules moved. If a conditional was added, mark it "
+ "DUAL-SCHEMA so it can be found when flat support is dropped; if one was removed, "
+ "update this list."
+ )
+
+
+def test_nothing_reads_the_mid_without_checking_it_is_there() -> None:
+ """A flat snapshot must survive every MID code path untouched.
+
+ This is the constraint that matters more than any single feature: the integration
+ has to keep working on a panel that will never publish a MID, for as long as such
+ panels exist.
+ """
+ flat = SpanPanelSnapshotFactory.create()
+
+ assert flat.mid is None
+ assert has_mid(flat) is False
+ 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/tests/test_new_entity_announcement.py b/tests/test_new_entity_announcement.py
new file mode 100644
index 00000000..fe14b70d
--- /dev/null
+++ b/tests/test_new_entity_announcement.py
@@ -0,0 +1,559 @@
+"""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.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
+from pytest_homeassistant_custom_component.common import MockConfigEntry
+
+from custom_components.span_panel.additions import (
+ _SECTION,
+ COLLAPSE_ABOVE,
+ 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"
+
+
+@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:
+ """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.
+ """
+ _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
+
+
+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 ------------------------------------------------------------
+
+
+@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, _SECTION)
+ 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", _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", _SECTION) == read_translations("pt", _SECTION)
+
+
+# -- 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
+
+
+# -- Sub-device entities are told apart --------------------------------------
+
+
+def _sub_device(hass: HomeAssistant, entry: MockConfigEntry, name: str, ident: str) -> str:
+ """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"
+ )
+ 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
+
+
+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
+
+
+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
diff --git a/tests/test_notices.py b/tests/test_notices.py
new file mode 100644
index 00000000..91c5b348
--- /dev/null
+++ b/tests/test_notices.py
@@ -0,0 +1,397 @@
+"""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,
+ _STORE_VERSION,
+ async_forget,
+ async_raise,
+ async_restore,
+ read_translations,
+)
+
+_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, {})
+
+
+# -- 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"]
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_pcs.py b/tests/test_pcs.py
new file mode 100644
index 00000000..d4500660
--- /dev/null
+++ b/tests/test_pcs.py
@@ -0,0 +1,847 @@
+"""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_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."""
+ 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_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 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
diff --git a/tests/test_promoted_sensors.py b/tests/test_promoted_sensors.py
index d1111c0c..b7ec0b6d 100644
--- a/tests/test_promoted_sensors.py
+++ b/tests/test_promoted_sensors.py
@@ -161,9 +161,30 @@ def test_value_function_false(self):
snapshot = SpanPanelSnapshotFactory.create(grid_islandable=False)
assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is False
- def test_value_function_none(self):
+ def test_absent_flat_property_falls_back_to_mid_presence(self):
+ """v1.0 publishes no `grid-islandable`, and that is not "unknown".
+
+ `devices/bess.md` retires the panel-level boolean deliberately -- "there is no
+ single 'islanded?' bit to reconcile" -- and nominates the capability set as the
+ classifier: "a MID `grid` child means premises-segment backup ... neither means
+ no backup". So an absent property with no MID is a definite *no*, not a
+ missing reading.
+
+ This used to return None, which reached a user as the sensor going
+ `unavailable` with `restored: true` after a firmware upgrade -- indistinguishable
+ from a sensor that broke.
+ """
snapshot = SpanPanelSnapshotFactory.create(grid_islandable=None)
- assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is None
+ assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is False
+
+ def test_a_published_flat_value_still_wins(self):
+ """Flat firmware keeps answering for itself.
+
+ DUAL-SCHEMA: a flat panel publishes the property and has no MID, so reading MID
+ presence first would invert the answer for every panel not yet upgraded.
+ """
+ snapshot = SpanPanelSnapshotFactory.create(grid_islandable=True)
+ assert GRID_ISLANDABLE_SENSOR.value_fn(snapshot) is True
# ---------------------------------------------------------------------------
@@ -201,22 +222,24 @@ class TestBessDeviceInfo:
def test_bess_device_info_basic(self):
battery = SpanBatterySnapshotFactory.create(
vendor_name="Tesla",
- product_name="Powerwall 2",
+ model="Powerwall 2",
serial_number="TW-001",
software_version="2.1.0",
)
- info = bess_device_info("sp3-242424-001", battery, "My Panel")
+ info = bess_device_info(
+ "sp3-242424-001", battery, "My Panel", panel_device_id="panel-device-id"
+ )
assert info.get("identifiers") == {(DOMAIN, "sp3-242424-001_bess")}
assert info.get("name") == "My Panel Battery"
assert info.get("manufacturer") == "Tesla"
assert info.get("model") == "Powerwall 2"
assert info.get("serial_number") == "TW-001"
assert info.get("sw_version") == "2.1.0"
- assert info.get("via_device") == (DOMAIN, "sp3-242424-001")
+ assert info.get("via_device_id") == "panel-device-id"
def test_bess_device_info_defaults_when_none(self):
battery = SpanBatterySnapshotFactory.create()
- info = bess_device_info("serial", battery, "Panel")
+ info = bess_device_info("serial", battery, "Panel", panel_device_id="pd")
assert info.get("manufacturer") == "Unknown"
assert info.get("model") == "Battery Storage"
assert info.get("serial_number") is None
@@ -227,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:
@@ -247,10 +270,24 @@ def test_vendor_value_function(self):
assert desc.value_fn(battery) == "Enphase"
def test_model_value_function(self):
- battery = SpanBatterySnapshotFactory.create(product_name="IQ Battery 10")
+ battery = SpanBatterySnapshotFactory.create(model="IQ Battery 10")
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")
@@ -293,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",
@@ -334,7 +372,7 @@ def test_pv_vendor_value_function(self):
def test_pv_product_value_function(self):
snapshot = SpanPanelSnapshotFactory.create(
- pv=SpanPVSnapshot(product_name="SE7600H")
+ pv=SpanPVSnapshot(model="SE7600H")
)
desc = next(d for d in PV_METADATA_SENSORS if d.key == "pv_product")
assert desc.value_fn(snapshot) == "SE7600H"
@@ -363,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_pv_device.py b/tests/test_pv_device.py
new file mode 100644
index 00000000..49e19fba
--- /dev/null
+++ b/tests/test_pv_device.py
@@ -0,0 +1,824 @@
+"""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.
+
+ `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.SCHEMA_0_ONLY
+ 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_recreate_entity_ids.py b/tests/test_recreate_entity_ids.py
new file mode 100644
index 00000000..c5ea9fd9
--- /dev/null
+++ b/tests/test_recreate_entity_ids.py
@@ -0,0 +1,515 @@
+"""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.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
+
+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_is_offered_its_own_tab_based_id(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """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))
+
+ 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
+
+ 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)
+
+ 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_releasing_the_name_is_idempotent(
+ hass: HomeAssistant, entry: MockConfigEntry
+) -> None:
+ """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))
+
+ 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.
+ """
+ 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)
+ 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.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 -------------
+#
+# 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"
+ )
diff --git a/tests/test_schema_availability.py b/tests/test_schema_availability.py
new file mode 100644
index 00000000..9a69c347
--- /dev/null
+++ b/tests/test_schema_availability.py
@@ -0,0 +1,310 @@
+"""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
+import pytest
+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.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
+
+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 DerivedReason.NO_SOURCE_FIELD
+
+ entity = SpanPanelBinarySensor(coordinator, description)
+
+ assert entity.available is True
+
+
+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
+
+
+@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 == key)
+ 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",
+ }
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/tests/test_schema_repairs.py b/tests/test_schema_repairs.py
new file mode 100644
index 00000000..82e6d229
--- /dev/null
+++ b/tests/test_schema_repairs.py
@@ -0,0 +1,945 @@
+from __future__ import annotations
+
+import logging
+
+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"
+_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
+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, _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()), _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
+
+
+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, _AFFECTED)
+
+ 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, _AFFECTED)
+
+ 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."""
+ affected = {"a.one": ["sensor.one"], "b.two": ["sensor.two"]}
+ async_sync_schema_issues(
+ 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"))
+
+
+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`.
+ """
+ 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()), affected
+ )
+
+ 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()), _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.
+
+ 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"})),
+ {"pv.model": ["sensor.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(_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, _UNIT_PATH))
+ assert issue is not None
+ assert issue.translation_key == "schema_unit_mismatch"
+ assert issue.translation_placeholders == {
+ "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(_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, _UNIT_PATH)) 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(_UNIT_PATH, "V", "kV")
+ async_sync_schema_issues(
+ 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, _UNIT_PATH)):
+ 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"]
+
+
+# --- 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)
+ 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:
+ """Matches the `span_panel_current_alert` pattern so automations can react."""
+ events = []
+ hass.bus.async_listen(EVENT_SCHEMA_ISSUE, events.append)
+
+ mismatch = UnitMismatch(_UNIT_PATH, "V", "kV")
+ async_sync_schema_issues(
+ hass,
+ entry,
+ SchemaFindings(frozenset({_PATH}), (mismatch,), frozenset()),
+ _AFFECTED | _UNIT_AFFECTED,
+ )
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].data == {
+ "entry_id": entry.entry_id,
+ "unresolved": [_PATH],
+ "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)
+
+ 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, _AFFECTED)
+ async_sync_schema_issues(hass, kept, findings, _AFFECTED)
+
+ 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_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"something_else_{entry.entry_id}",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="something_else",
+ )
+
+ async_sync_schema_issues(hass, entry, SchemaFindings(frozenset(), (), frozenset()), {})
+
+ 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:
+ """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()), _AFFECTED
+ )
+ 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
+
+
+# --- 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)
+
+
+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.
+
+ 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
+
+ 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.field_path == "battery.connected"
+
+ added = MagicMock()
+ await binary_setup(hass, config_entry, added)
+ conditional = [
+ entity
+ for entity in added.call_args.args[0]
+ if getattr(entity, "entity_description", None) is BESS_CONNECTED_SENSOR
+ ]
+ assert conditional
+
+ await _add_to_platform(hass, config_entry, conditional, "binary_sensor")
+
+ assert coordinator.entity_ids_by_field_path["battery.connected"]
+ 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, switches, "switch")
+
+ 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. 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:
+ """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_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 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)
+
+
+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
+
+ 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_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
+
+ 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")
+
+ 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)
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}")
diff --git a/tests/test_schema_validation.py b/tests/test_schema_validation.py
index 9bd2ade3..1bfab80b 100644
--- a/tests/test_schema_validation.py
+++ b/tests/test_schema_validation.py
@@ -1,269 +1,356 @@
-"""Tests for schema validation and sensor-to-field mapping."""
-
from __future__ import annotations
-import logging
-from unittest.mock import MagicMock
+from collections.abc import Callable
import pytest
-from span_panel_api import (
- SpanBatterySnapshot,
- SpanCircuitSnapshot,
- SpanEvseSnapshot,
- SpanPanelSnapshot,
- SpanPVSnapshot,
-)
-
-from custom_components.span_panel.schema_expectations import (
- SENSOR_FIELD_MAP,
- all_referenced_field_paths,
+from span_panel_api.models import FieldMetadata
+
+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 (
- 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,
+ 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:
+ findings = evaluate_field_metadata(
+ {"circuit.instant_power_w": FieldMetadata(None, "unknown", resolved=False)},
+ sensor_defs={},
+ )
+ assert "circuit.instant_power_w" in findings.unresolved
-_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
-# ---------------------------------------------------------------------------
-
-
-class TestNoOp:
- """Tests for graceful handling when library doesn't expose metadata."""
-
- 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_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_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 UnitOfElectricPotential
+
+ description = SensorEntityDescription(
+ key="l1_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT
+ )
+ findings = evaluate_field_metadata(
+ {"panel.l1_voltage": FieldMetadata("kV", "float")},
+ sensor_defs={"panel.l1_voltage": description},
+ )
+ 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:
+ """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_empty_metadata_is_healthy_not_unknown() -> None:
+ """A pass over a healthy panel is expressible and is not the None sentinel.
+
+ 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({})
+ 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() | 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
+ # 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:
+ """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_raise(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Both consumers of the shared traversal reject an undeclared description.
+
+ 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
+
+ monkeypatch.setattr(
+ sensor_definitions,
+ "all_sensor_descriptions",
+ lambda: (SensorEntityDescription(key="undeclared"),),
+ )
+ 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_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 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}"
+
+
+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"
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 61def958..4c114415 100644
--- a/tests/test_sensor_entities.py
+++ b/tests/test_sensor_entities.py
@@ -10,6 +10,7 @@
from span_panel_api import SpanPVSnapshot
from homeassistant.components.sensor import SensorDeviceClass
+from custom_components.span_panel import SpanPanelRuntimeData
from custom_components.span_panel.const import (
ENABLE_ENERGY_DIP_COMPENSATION,
USE_CIRCUIT_NUMBERS,
@@ -89,6 +90,9 @@ def _make_coordinator(snapshot, *, options: dict | None = None) -> MagicMock:
title="SPAN Panel",
unique_id=snapshot.serial_number,
)
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
coordinator.request_reload = MagicMock()
coordinator.register_circuit_energy_sensor = MagicMock()
coordinator.get_circuit_dip_offset = MagicMock(return_value=0.0)
@@ -106,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:
@@ -119,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:
@@ -150,7 +203,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"
@@ -174,7 +232,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
@@ -397,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"
)
@@ -420,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:
@@ -589,6 +652,9 @@ def test_energy_sensor_coerces_invalid_grace_period_value() -> None:
title="SPAN Panel",
unique_id=snapshot.serial_number,
)
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
description = next(
desc for desc in PANEL_ENERGY_SENSORS if desc.key == "mainMeterEnergyConsumedWh"
)
@@ -658,6 +724,9 @@ def test_evse_sensor_uses_evse_subdevice_info_and_name() -> None:
title="SPAN Panel",
unique_id=snapshot.serial_number,
)
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
description = next(desc for desc in EVSE_SENSORS if desc.key == "evse_status")
sensor = SpanEvseSensor(coordinator, description, snapshot, "evse-0")
@@ -1129,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})
@@ -1145,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})
@@ -1179,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"
diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py
index 1bb17f56..4370eb20 100644
--- a/tests/test_sensor_platform.py
+++ b/tests/test_sensor_platform.py
@@ -7,6 +7,7 @@
import pytest
+from custom_components.span_panel import SpanPanelRuntimeData
from custom_components.span_panel.const import (
DOMAIN,
ENABLE_CIRCUIT_NET_ENERGY_SENSORS,
@@ -25,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 (
@@ -236,6 +238,9 @@ def test_build_evse_device_info_map_uses_feed_circuit_and_display_suffix() -> No
title="SPAN Panel",
options={USE_CIRCUIT_NUMBERS: False},
)
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
mapping = _build_evse_device_info_map(coordinator, snapshot)
@@ -274,6 +279,9 @@ def test_create_circuit_sensors_skips_unmapped_and_optional_net_sensors() -> Non
unique_id=snapshot.serial_number,
)
coordinator.config_entry = entry
+ entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
entities = create_circuit_sensors(coordinator, snapshot, entry)
keys = [entity.original_key for entity in entities]
@@ -306,6 +314,9 @@ def test_create_unmapped_circuit_sensors_only_creates_unmapped_entities() -> Non
coordinator.config_entry = MockConfigEntry(
domain=DOMAIN, data={}, title="SPAN Panel"
)
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
entities = create_unmapped_circuit_sensors(coordinator, snapshot)
@@ -317,7 +328,7 @@ def test_create_battery_sensors_returns_expected_entities_when_bess_present() ->
"""Battery helpers should create battery power, SoE, and metadata sensors."""
snapshot = SpanPanelSnapshotFactory.create(
battery=SpanBatterySnapshotFactory.create(
- soe_percentage=75.0, vendor_name="Tesla", product_name="Powerwall"
+ soe_percentage=75.0, vendor_name="Tesla", model="Powerwall"
)
)
coordinator = MagicMock()
@@ -328,14 +339,18 @@ def test_create_battery_sensors_returns_expected_entities_when_bess_present() ->
data={"device_name": "Main House"},
title="SPAN Panel",
)
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
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:
@@ -352,6 +367,9 @@ def test_create_power_flow_sensors_gate_pv_and_site_flow() -> None:
coordinator.config_entry = MockConfigEntry(
domain=DOMAIN, data={}, title="SPAN Panel"
)
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
entities = create_power_flow_sensors(coordinator, snapshot)
keys = [entity.entity_description.key for entity in entities]
@@ -376,8 +394,11 @@ def test_create_evse_sensors_creates_all_descriptions_for_each_charger() -> None
coordinator.config_entry = MockConfigEntry(
domain=DOMAIN, data={}, title="SPAN Panel"
)
+ coordinator.config_entry.runtime_data = SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
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"}
diff --git a/tests/test_setup_entry.py b/tests/test_setup_entry.py
index 2152db52..86c11752 100644
--- a/tests/test_setup_entry.py
+++ b/tests/test_setup_entry.py
@@ -4,7 +4,18 @@
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 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
+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,18 +28,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 .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."""
@@ -76,7 +78,7 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title(
) as mock_coordinator_cls,
patch(
"custom_components.span_panel.ensure_device_registered",
- AsyncMock(),
+ AsyncMock(return_value="panel-device-id"),
) as mock_ensure_device,
patch.object(
hass.config_entries, "async_forward_entry_setups", AsyncMock()
@@ -85,7 +87,11 @@ async def test_async_setup_entry_v2_success_sets_runtime_data_and_title(
):
assert await async_setup_entry(hass, entry) is True
- assert entry.runtime_data == SpanPanelRuntimeData(coordinator=coordinator)
+ # The panel's registry id is carried forward, not recomputed: every sub-device
+ # links to it with `via_device_id`, and registration is the only place it is known.
+ assert entry.runtime_data == SpanPanelRuntimeData(
+ coordinator=coordinator, panel_device_id="panel-device-id"
+ )
assert hass.data[DOMAIN]["websocket_registered"] is True
mock_ws.assert_called_once_with(hass)
mock_client_cls.assert_called_once()
@@ -98,6 +104,82 @@ 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_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:
@@ -284,3 +366,118 @@ 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"]
+
+
+async def test_setup_announces_additions_after_the_platforms(
+ hass: HomeAssistant,
+) -> None:
+ """The announcement has to run after the forward, and that is the whole ordering.
+
+ 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)
+ 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,
+ )
+
+ async def _announce(_hass, _entry) -> None:
+ order.append("announce")
+
+ 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_announce_new_entities",
+ side_effect=_announce,
+ ),
+ ):
+ assert await async_setup_entry(hass, entry) is True
+
+ assert order == ["forward", "sync", "announce"]
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_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"
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:
diff --git a/tests/test_unit_vocabulary.py b/tests/test_unit_vocabulary.py
new file mode 100644
index 00000000..a9f90e02
--- /dev/null
+++ b/tests/test_unit_vocabulary.py
@@ -0,0 +1,69 @@
+"""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,
+ UnitOfTime,
+)
+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),
+ # `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,
+}
+
+_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.
+
+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:
+ 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(
+ 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}. "
+ "Add a UNIT_TRANSLATIONS entry, or fix the mapping — do not let this "
+ "reach the unit check, where it becomes one Repair per sensor."
+ )
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index ea45eb5a..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={},
@@ -392,7 +398,8 @@ async def test_sub_device_id_rejected(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(SpanPanelSnapshotFactory.create())
+ coordinator=_make_coordinator(SpanPanelSnapshotFactory.create()),
+ panel_device_id="panel-device-id",
)
panel_device = _register_panel_device(
@@ -418,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
@@ -479,7 +486,8 @@ async def test_successful_topology_basic(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
device = _register_panel_device(hass, "span_entry", serial="sp3-test-001")
@@ -542,7 +550,8 @@ async def test_unmapped_circuits_excluded(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
device = _register_panel_device(hass, "span_entry")
@@ -571,7 +580,8 @@ async def test_sub_devices_included(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
panel_device = _register_panel_device(hass, "span_entry", serial="sp3-sub-001")
@@ -649,7 +659,8 @@ async def test_evse_feed_circuit_entities_found(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
panel_device = _register_panel_device(hass, "span_entry", serial="sp3-evse-001")
@@ -722,7 +733,8 @@ async def test_topology_includes_always_on_and_priority(self, hass: HomeAssistan
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
device = _register_panel_device(hass, "span_entry", serial="sp3-prio-001")
@@ -758,7 +770,8 @@ async def test_topology_includes_panel_status_entity(self, hass: HomeAssistant):
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001")
@@ -797,7 +810,8 @@ async def test_topology_omits_panel_status_when_entity_missing(self, hass: HomeA
entry.add_to_hass(hass)
entry.mock_state(hass, ConfigEntryState.LOADED)
entry.runtime_data = SpanPanelRuntimeData(
- coordinator=_make_coordinator(snapshot)
+ coordinator=_make_coordinator(snapshot),
+ panel_device_id="panel-device-id",
)
panel_device = _register_panel_device(hass, "span_entry", serial="sp3-242424-001")
diff --git a/uv.lock b/uv.lock
index 195f6f51..340c79c8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -20,14 +20,14 @@ wheels = [
[[package]]
name = "aiodns"
-version = "4.0.0"
+version = "4.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycares" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/10/da/97235e953109936bfeda62c1f9f1a7c5652d4dc49f2b5911f9ae1043afa9/aiodns-4.0.0.tar.gz", hash = "sha256:17be26a936ba788c849ba5fd20e0ba69d8c46e6273e846eb5430eae2630ce5b1", size = 26204, upload-time = "2026-01-10T22:33:27.211Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/22/a2d928e0e42baad0471d12ec44c71152ac870486e8298dddb2893b888c29/aiodns-4.0.4.tar.gz", hash = "sha256:cb10e0c0d2591636716ad2fe402e977c16d71bdaf76bb8cb49e8a6633596f736", size = 29918, upload-time = "2026-05-20T01:54:15.557Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d0/60/14ac40c03e8a26216e4f2642497b776e52f9e3214e4fd537628829bbb082/aiodns-4.0.0-py3-none-any.whl", hash = "sha256:a188a75fb8b2b7862ac8f84811a231402fb74f5b4e6f10766dc8a4544b0cf989", size = 11334, upload-time = "2026-01-10T22:33:25.65Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/70/72e4ab117425ccdc4d10bd523a94c1baa051a15586057d64a4c6888f9e3f/aiodns-4.0.4-py3-none-any.whl", hash = "sha256:c24dd605bac70a1676ce503f967a98483ff163507198557d8e9db16267e6cfd2", size = 12696, upload-time = "2026-05-20T01:54:14.134Z" },
]
[[package]]
@@ -54,21 +54,21 @@ wheels = [
[[package]]
name = "aiohasupervisor"
-version = "0.4.3"
+version = "0.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "mashumaro" },
{ name = "orjson" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/35/74/529860af5e305647c469e6ec2ac78f4d61b17349d201ab03a8073e683656/aiohasupervisor-0.4.3.tar.gz", hash = "sha256:8b6e56f385879616a6e520a092f57f41c8ba3bd3cd05b33a87e89b62604d14b8", size = 46583, upload-time = "2026-03-24T08:41:45.457Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/55/76/24e578dcf937b27017befb2fc2402565810a7716cdd43abbb8cefc2f425c/aiohasupervisor-0.6.0.tar.gz", hash = "sha256:debee1dc3b99114f5b653959cb828a74a13181011cae3ac013ae9aefb0cda062", size = 47453, upload-time = "2026-07-20T15:15:23.821Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/35/bf/0a61a4632930a19601c99044c2f293f98ee44c0b4a9907a7bc83e70307aa/aiohasupervisor-0.4.3-py3-none-any.whl", hash = "sha256:ae05a59e890c6ed83dd801e9ebb7e12ca67ac5aed7cef29315f5422fdf7022b5", size = 42070, upload-time = "2026-03-24T08:41:44.177Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/0d/71721a72bf81c00039c147b5f2dba913213de526940f82c697fbeed65ee7/aiohasupervisor-0.6.0-py3-none-any.whl", hash = "sha256:6f78f474160e6f90fda1879f0fe7d341d8cfd5f69eec4cb0cb60483816f73d60", size = 42596, upload-time = "2026-07-20T15:15:22.604Z" },
]
[[package]]
name = "aiohttp"
-version = "3.13.5"
+version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -79,56 +79,63 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
- { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
- { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
- { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
- { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
- { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
- { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
- { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
- { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
- { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
- { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
- { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
- { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
- { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
- { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
- { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
- { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
- { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
- { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
- { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
- { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
- { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
- { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
- { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
- { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
- { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
- { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
- { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
- { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
- { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
- { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
- { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
- { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
- { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
+ { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
+ { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
+ { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
+ { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
+ { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
+ { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
+ { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
+ { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
+ { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
[[package]]
name = "aiohttp-asyncmdnsresolver"
-version = "0.1.1"
+version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiodns" },
{ name = "aiohttp" },
{ name = "zeroconf" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/05/83/09fb97705e7308f94197a09b486669696ea20f28074c14b5811a38bdedc3/aiohttp_asyncmdnsresolver-0.1.1.tar.gz", hash = "sha256:8c65d4b08b42c8a260717a2766bd5967a1d437cee852a9b21f3928b5171a7c81", size = 36129, upload-time = "2025-02-14T14:46:44.402Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c1/86/1b94f5669df188f5267e26d48c4cc734b704310e53d695af5f39ee1af67d/aiohttp_asyncmdnsresolver-0.2.0.tar.gz", hash = "sha256:aab2cef7ef8ae45b94abf1fc92aa9f4d70cbef7ac0a3d495a86fb96fce357262", size = 37744, upload-time = "2026-05-20T20:18:27.183Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/d1/4f61508a43de82bb5c60cede3bb89cc57c5e8af7978d93ca03ad60b99368/aiohttp_asyncmdnsresolver-0.1.1-py3-none-any.whl", hash = "sha256:d04ded993e9f0e07c07a1bc687cde447d9d32e05bcf55ecbf94f63b33dcab93e", size = 13582, upload-time = "2025-02-14T14:46:41.985Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/d5/c08b4e242d50d2aded314a16105ee708946fa3dfd37b837b1ca625d26397/aiohttp_asyncmdnsresolver-0.2.0-py3-none-any.whl", hash = "sha256:1e6dc01719dc563346ae051122fa7dc3a757317e3e6bf4ef8b23cfd034089c22", size = 10806, upload-time = "2026-05-20T20:18:25.58Z" },
]
[[package]]
@@ -276,35 +283,51 @@ wheels = [
[[package]]
name = "attrs"
-version = "25.4.0"
+version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "audioop-lts"
-version = "0.2.1"
+version = "0.2.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/dd/3b/69ff8a885e4c1c42014c2765275c4bd91fe7bc9847e9d8543dbcbb09f820/audioop_lts-0.2.1.tar.gz", hash = "sha256:e81268da0baa880431b68b1308ab7257eb33f356e57a5f9b1f915dfb13dd1387", size = 30204, upload-time = "2024-08-04T21:14:43.957Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/01/91/a219253cc6e92db2ebeaf5cf8197f71d995df6f6b16091d1f3ce62cb169d/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd1345ae99e17e6910f47ce7d52673c6a1a70820d78b67de1b7abb3af29c426a", size = 46252, upload-time = "2024-08-04T21:13:56.209Z" },
- { url = "https://files.pythonhosted.org/packages/ec/f6/3cb21e0accd9e112d27cee3b1477cd04dafe88675c54ad8b0d56226c1e0b/audioop_lts-0.2.1-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:e175350da05d2087e12cea8e72a70a1a8b14a17e92ed2022952a4419689ede5e", size = 27183, upload-time = "2024-08-04T21:13:59.966Z" },
- { url = "https://files.pythonhosted.org/packages/ea/7e/f94c8a6a8b2571694375b4cf94d3e5e0f529e8e6ba280fad4d8c70621f27/audioop_lts-0.2.1-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:4a8dd6a81770f6ecf019c4b6d659e000dc26571b273953cef7cd1d5ce2ff3ae6", size = 26726, upload-time = "2024-08-04T21:14:00.846Z" },
- { url = "https://files.pythonhosted.org/packages/ef/f8/a0e8e7a033b03fae2b16bc5aa48100b461c4f3a8a38af56d5ad579924a3a/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cd3c0b6f2ca25c7d2b1c3adeecbe23e65689839ba73331ebc7d893fcda7ffe", size = 80718, upload-time = "2024-08-04T21:14:01.989Z" },
- { url = "https://files.pythonhosted.org/packages/8f/ea/a98ebd4ed631c93b8b8f2368862cd8084d75c77a697248c24437c36a6f7e/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff3f97b3372c97782e9c6d3d7fdbe83bce8f70de719605bd7ee1839cd1ab360a", size = 88326, upload-time = "2024-08-04T21:14:03.509Z" },
- { url = "https://files.pythonhosted.org/packages/33/79/e97a9f9daac0982aa92db1199339bd393594d9a4196ad95ae088635a105f/audioop_lts-0.2.1-cp313-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a351af79edefc2a1bd2234bfd8b339935f389209943043913a919df4b0f13300", size = 80539, upload-time = "2024-08-04T21:14:04.679Z" },
- { url = "https://files.pythonhosted.org/packages/b2/d3/1051d80e6f2d6f4773f90c07e73743a1e19fcd31af58ff4e8ef0375d3a80/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2aeb6f96f7f6da80354330470b9134d81b4cf544cdd1c549f2f45fe964d28059", size = 78577, upload-time = "2024-08-04T21:14:09.038Z" },
- { url = "https://files.pythonhosted.org/packages/7a/1d/54f4c58bae8dc8c64a75071c7e98e105ddaca35449376fcb0180f6e3c9df/audioop_lts-0.2.1-cp313-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c589f06407e8340e81962575fcffbba1e92671879a221186c3d4662de9fe804e", size = 82074, upload-time = "2024-08-04T21:14:09.99Z" },
- { url = "https://files.pythonhosted.org/packages/36/89/2e78daa7cebbea57e72c0e1927413be4db675548a537cfba6a19040d52fa/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbae5d6925d7c26e712f0beda5ed69ebb40e14212c185d129b8dfbfcc335eb48", size = 84210, upload-time = "2024-08-04T21:14:11.468Z" },
- { url = "https://files.pythonhosted.org/packages/a5/57/3ff8a74df2ec2fa6d2ae06ac86e4a27d6412dbb7d0e0d41024222744c7e0/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_i686.whl", hash = "sha256:d2d5434717f33117f29b5691fbdf142d36573d751716249a288fbb96ba26a281", size = 85664, upload-time = "2024-08-04T21:14:12.394Z" },
- { url = "https://files.pythonhosted.org/packages/16/01/21cc4e5878f6edbc8e54be4c108d7cb9cb6202313cfe98e4ece6064580dd/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f626a01c0a186b08f7ff61431c01c055961ee28769591efa8800beadd27a2959", size = 93255, upload-time = "2024-08-04T21:14:13.707Z" },
- { url = "https://files.pythonhosted.org/packages/3e/28/7f7418c362a899ac3b0bf13b1fde2d4ffccfdeb6a859abd26f2d142a1d58/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:05da64e73837f88ee5c6217d732d2584cf638003ac72df124740460531e95e47", size = 87760, upload-time = "2024-08-04T21:14:14.74Z" },
- { url = "https://files.pythonhosted.org/packages/6d/d8/577a8be87dc7dd2ba568895045cee7d32e81d85a7e44a29000fe02c4d9d4/audioop_lts-0.2.1-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:56b7a0a4dba8e353436f31a932f3045d108a67b5943b30f85a5563f4d8488d77", size = 84992, upload-time = "2024-08-04T21:14:19.155Z" },
- { url = "https://files.pythonhosted.org/packages/ef/9a/4699b0c4fcf89936d2bfb5425f55f1a8b86dff4237cfcc104946c9cd9858/audioop_lts-0.2.1-cp313-abi3-win32.whl", hash = "sha256:6e899eb8874dc2413b11926b5fb3857ec0ab55222840e38016a6ba2ea9b7d5e3", size = 26059, upload-time = "2024-08-04T21:14:20.438Z" },
- { url = "https://files.pythonhosted.org/packages/3a/1c/1f88e9c5dd4785a547ce5fd1eb83fff832c00cc0e15c04c1119b02582d06/audioop_lts-0.2.1-cp313-abi3-win_amd64.whl", hash = "sha256:64562c5c771fb0a8b6262829b9b4f37a7b886c01b4d3ecdbae1d629717db08b4", size = 30412, upload-time = "2024-08-04T21:14:21.342Z" },
- { url = "https://files.pythonhosted.org/packages/c4/e9/c123fd29d89a6402ad261516f848437472ccc602abb59bba522af45e281b/audioop_lts-0.2.1-cp313-abi3-win_arm64.whl", hash = "sha256:c45317debeb64002e980077642afbd977773a25fa3dfd7ed0c84dccfc1fafcb0", size = 23578, upload-time = "2024-08-04T21:14:22.193Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" },
+ { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" },
+ { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" },
+ { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" },
+ { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" },
+ { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" },
+ { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" },
+ { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" },
+ { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" },
+ { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" },
+ { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" },
+ { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" },
+ { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" },
]
[[package]]
@@ -647,33 +670,41 @@ wheels = [
[[package]]
name = "coverage"
-version = "7.10.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" },
- { url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" },
- { url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" },
- { url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" },
- { url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" },
- { url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" },
- { url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" },
- { url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" },
- { url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" },
- { url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" },
- { url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" },
- { url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" },
- { url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" },
- { url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" },
- { url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" },
- { url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" },
- { url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" },
- { url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" },
- { url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" },
- { url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" },
- { url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" },
- { url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" },
- { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" },
+version = "7.15.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
+ { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
+ { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
+ { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
+ { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
+ { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
]
[[package]]
@@ -686,55 +717,55 @@ wheels = [
[[package]]
name = "cryptography"
-version = "46.0.7"
+version = "48.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
- { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
- { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
- { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
- { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
- { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
- { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
- { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
- { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
- { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
- { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
- { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
- { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
- { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
- { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
- { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
- { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
- { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
- { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
- { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
- { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
- { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
- { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
- { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
- { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
- { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
- { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
- { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
- { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
- { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
- { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
- { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
- { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
- { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
- { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
- { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
- { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
- { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
- { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
- { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
- { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
- { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
+ { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
+ { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" },
+ { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
+ { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
+ { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" },
+ { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
+ { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
+ { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
+ { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" },
+ { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
+ { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" },
+ { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" },
+ { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
+ { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
+ { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" },
]
[[package]]
@@ -763,6 +794,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
]
+[[package]]
+name = "ebus-mqtt-client"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "paho-mqtt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b9/05/43c255aac2fe76e51642080315897306751fee1d4414fc6a099a1a5d9af5/ebus_mqtt_client-0.4.0.tar.gz", hash = "sha256:83ac9cfe4672fbbc1622d46ad7fe53d345654ca0dd85109c0894cb9fea8c73b1", size = 27690, upload-time = "2026-08-03T19:58:51.972Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/f3/e5549b9d340c958bf9ee8927b23ce724b7296a76b395ff0f5cbe55be1f77/ebus_mqtt_client-0.4.0-py3-none-any.whl", hash = "sha256:d64d6ac7f39f42791a59c932ce1cebefadb35accf88b1b3367258fa5fb7f54ff", size = 16625, upload-time = "2026-08-03T19:58:50.606Z" },
+]
+
+[[package]]
+name = "ebus-sdk"
+version = "0.19.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ebus-mqtt-client" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/45/044c4cd557850d7dc76e7ce664fedb5ce3ee4dbc2a58e97fb17e24991993/ebus_sdk-0.19.0.tar.gz", hash = "sha256:7987d3cae7c86e31656df9cd6e31e5a2ef950c757ba24f433adf019ca9aaa51c", size = 151155, upload-time = "2026-08-07T14:42:08.602Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/86/aad23b5bd10abb72c3d6bc659ffd67f6b19384aa9b5507050df707ee6cbb/ebus_sdk-0.19.0-py3-none-any.whl", hash = "sha256:33aeec8d61b88373b8d1902bb8338449644d0d5aa4d75835567ad76ffebefd10", size = 95231, upload-time = "2026-08-07T14:42:07.211Z" },
+]
+
[[package]]
name = "envs"
version = "1.4"
@@ -783,14 +838,28 @@ wheels = [
[[package]]
name = "fnv-hash-fast"
-version = "2.0.0"
+version = "2.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fnvhash" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8d/d3/a7af16d8e1d43d3c1d1cbde7901229773b62696e377f89c6834183680ede/fnv_hash_fast-2.0.0.tar.gz", hash = "sha256:e830b6316be36b2aa629f8a2c6b83833f1f3cc8ecf1da93c7e9e934844826646", size = 5718, upload-time = "2026-03-15T00:17:39.143Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/48/ef/7d94271729042f4905800f9034adb149f2a716af26f2cfe9c46343637322/fnv_hash_fast-2.0.3.tar.gz", hash = "sha256:57b499a80ea8f7daf901aff047377264ef21577b40575183807dba37bcc00d6f", size = 5765, upload-time = "2026-05-16T23:52:11.417Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/6e/03f61d06f3ed61b10d0a07ac921d077045052db6fdd6847107e4c3405727/fnv_hash_fast-2.0.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:ddf25f8f8b5cf54f068eaf5e3700c96f6c8da679e6efaf2084e57bdbad9d92ee", size = 7756, upload-time = "2026-03-15T00:17:37.392Z" },
+ { url = "https://files.pythonhosted.org/packages/03/35/7c68c9f8d51e31200a1371b396b772da4d32f269e02cdff7cada22594656/fnv_hash_fast-2.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:381df4fd4891fb20483673d7ff1973395a5a81c4fa736a685ad05e74653c4dbf", size = 7457, upload-time = "2026-05-16T23:58:42.243Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/14/5b5cea95c30a51bb76b84661be9de3cc7625fb0edc3252b7509864d36f4d/fnv_hash_fast-2.0.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2f50c18835bdc7c92a62801778dd5fdc6593c603b4cbadd7adae9d5f17305a52", size = 8374, upload-time = "2026-05-16T23:58:43.268Z" },
+ { url = "https://files.pythonhosted.org/packages/00/c9/1d0a2299ddcbcc9168c3bdc99dcae08de1dae276705baddab323b912468a/fnv_hash_fast-2.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45838d0383438696d815b6de92c841215d4f9f39b8dbaf58071a1af84ea35fc", size = 9207, upload-time = "2026-05-16T23:58:44.324Z" },
+ { url = "https://files.pythonhosted.org/packages/18/8b/356df1dc62dd05cfdc3fec24e8e66101be1544f03a388a8043f7eb6d9577/fnv_hash_fast-2.0.3-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:cabf3cc36b0f5eb26eb19716842edf709b032a54e1d18a72981c21458c52c632", size = 7745, upload-time = "2026-05-16T23:52:09.972Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/3c/c740792c230608204a4dac1ba2aaaec1615115778ba33836143a937c7996/fnv_hash_fast-2.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe4da9446b554365a24d026c0f08b6ee96e30f31b8db7eb683ea167c923755e6", size = 9117, upload-time = "2026-05-16T23:58:45.351Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/09/f859b56caab1e3b564d9ec05771f380f7965e39a1b8c2e3fdaa341655a26/fnv_hash_fast-2.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c89aa84df2bfe2cc280694a8cf9fca3955640ce5f5c6f30f6b1bb020b30f2175", size = 8609, upload-time = "2026-05-16T23:58:46.374Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/fa/3f7aeae26885762d7ad20c77a643c8671f7ae63069dc70cddc77483eb5ee/fnv_hash_fast-2.0.3-cp314-cp314-win32.whl", hash = "sha256:f25e37526b2898677f0b6a99a441bf0a86eab9e354d5d9222c4960f49e3795d7", size = 9813, upload-time = "2026-05-16T23:58:47.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/c9ed5f7e54889103de1754f53d8d294510d7e7f04c1075ec5dcaeee1562a/fnv_hash_fast-2.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:7a45f7050c4e99e2f04122508ec4efb568c93fcbece8482371a177ac89e6db8c", size = 10052, upload-time = "2026-05-16T23:58:48.47Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/a3/a54bdc9a843e3d2988c8c35224ae51cdcffd8e55d9031039ccbf51b8e2f6/fnv_hash_fast-2.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:642bc26dbc906f86ee7a888cb66c149455c1a2a841a7ea72f19ffb969d0f0b14", size = 9085, upload-time = "2026-05-16T23:58:49.54Z" },
+ { url = "https://files.pythonhosted.org/packages/75/45/bbb6e0741d945569ae13e91d774b613e06fb184841e25bfc3f188b98888f/fnv_hash_fast-2.0.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d63dcae2fd15c307f8a333f77aa6a5146538e9e9ef606b1883c8b5f06d5f86e8", size = 10910, upload-time = "2026-05-16T23:58:50.572Z" },
+ { url = "https://files.pythonhosted.org/packages/37/84/e95767cbba632270b6d7f14108c800bd9883b3596b939c52c457b671b779/fnv_hash_fast-2.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a852d5c3278153378c04dcf5e45514477beeccd4268ceb775a3cf553dc5102b0", size = 12580, upload-time = "2026-05-16T23:58:51.691Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/37/51d20e8444eb87a6396762d0bc160c6698963753e3cff6f765f76a58dbec/fnv_hash_fast-2.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a805cc43c45e3452ff4a4b76e77fb40b452c795bf04d0f6f76c06fb7b02f755", size = 12409, upload-time = "2026-05-16T23:58:52.827Z" },
+ { url = "https://files.pythonhosted.org/packages/28/37/0d6a033e957cc2976cc453db5271976107168ddf0a49361f4b4187a1fc47/fnv_hash_fast-2.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd413f1b171188cbf8ea6bb10f1e8c3aed3a88d239a682870e3727021b246700", size = 11380, upload-time = "2026-05-16T23:58:53.86Z" },
+ { url = "https://files.pythonhosted.org/packages/93/fa/962407e8d475f8b502c5df7f8f9bcc4d73386628b76cf836f6a739d9982a/fnv_hash_fast-2.0.3-cp314-cp314t-win32.whl", hash = "sha256:13f4284c5521df53b2fc54e2f595e8534fba03656ef3d92f099503980c09b15b", size = 14180, upload-time = "2026-05-16T23:58:54.894Z" },
+ { url = "https://files.pythonhosted.org/packages/84/19/e0b9e51d9f69efb6a6d155c2a5aa489f4366e34211ca2e843954c9053b44/fnv_hash_fast-2.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b4e44c9764bf0fa8387251bb806538976b0c641b89bfb6de3c8f242eb6bffdda", size = 14645, upload-time = "2026-05-16T23:58:56.033Z" },
]
[[package]]
@@ -804,14 +873,14 @@ wheels = [
[[package]]
name = "freezegun"
-version = "1.5.2"
+version = "1.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c7/75/0455fa5029507a2150da59db4f165fbc458ff8bb1c4f4d7e8037a14ad421/freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181", size = 34855, upload-time = "2025-05-24T12:38:47.051Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b5/b2/68d4c9b6431121b6b6aa5e04a153cac41dcacc79600ed6e2e7c3382156f5/freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b", size = 18715, upload-time = "2025-05-24T12:38:45.274Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
]
[[package]]
@@ -855,6 +924,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
+[[package]]
+name = "greenlet"
+version = "3.5.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" },
+ { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" },
+ { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" },
+ { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" },
+ { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" },
+]
+
[[package]]
name = "grpcio"
version = "1.78.0"
@@ -951,19 +1043,19 @@ wheels = [
[[package]]
name = "home-assistant-bluetooth"
-version = "1.13.1"
+version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "habluetooth" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b4/0e/c05ee603cab1adb847a305bc8f1034cbdbc0a5d15169fcf68c0d6d21e33f/home_assistant_bluetooth-1.13.1.tar.gz", hash = "sha256:0ae0e2a8491cc762ee9e694b8bc7665f1e2b4618926f63969a23a2e3a48ce55e", size = 7607, upload-time = "2025-02-04T16:11:15.259Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bc/6f/914fd8086f502748907751766e26e3f7fe5204347a679703ee9954e55e4d/home_assistant_bluetooth-2.0.0.tar.gz", hash = "sha256:3febd16b194812b156024d9bc8ce8ec7622cdd8d159047cac504cc77ca034ea1", size = 7604, upload-time = "2025-07-09T18:01:47.541Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/85/9b/9904cec885cc32c45e8c22cd7e19d9c342e30074fdb7c58f3d5b33ea1adb/home_assistant_bluetooth-1.13.1-py3-none-any.whl", hash = "sha256:cdf13b5b45f7744165677831e309ee78fbaf0c2866c6b5931e14d1e4e7dae5d7", size = 7915, upload-time = "2025-02-04T16:11:13.163Z" },
+ { url = "https://files.pythonhosted.org/packages/15/3e/2ed9e59da8fc26c43cbd9d6abbd27217138a31c608b1d334cc5e680e4069/home_assistant_bluetooth-2.0.0-py3-none-any.whl", hash = "sha256:8d7c2ac48c303adacad73cb6cdfe7710f2fccd2a367d4c3106acf301b0d22b91", size = 7907, upload-time = "2025-07-09T18:01:46.506Z" },
]
[[package]]
name = "homeassistant"
-version = "2026.4.4"
+version = "2026.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiodns" },
@@ -1017,21 +1109,21 @@ dependencies = [
{ name = "yarl" },
{ name = "zeroconf" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/96/3d/041a66485642537286c4b1a3ee66ace7ce43cfb61737e224cf01e6b59c97/homeassistant-2026.4.4.tar.gz", hash = "sha256:10f997fb7c00b2f8abbe30f343469428665b68d5a1e665feee2b827d9d815212", size = 32333699, upload-time = "2026-04-24T18:58:32.073Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8d/30/1e25c48871a24942de234439ac66ddbe649138c217b358c20162c1d29481/homeassistant-2026.8.0.tar.gz", hash = "sha256:2e966cf00edda6ba04ff1c6f707c073b9df5e4480b52dbd59451cec8f30610f9", size = 36732240, upload-time = "2026-08-05T15:34:54.073Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/05/cb/120eb528c6eb5a21bc726aff1bc47898e5353a677ec18d33d65b11b8f3f8/homeassistant-2026.4.4-py3-none-any.whl", hash = "sha256:cd83240d320e9842822810b4c4aa13a132dbf7954d10576f406e66fca645467f", size = 53499095, upload-time = "2026-04-24T18:58:24.834Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b7/4fb4e27596058f0a7014140b4fb75056d518fdda8f04c8aeecca5bf6fd4b/homeassistant-2026.8.0-py3-none-any.whl", hash = "sha256:3b1ff7a5db94d4c33a4228c08950bde1bcecb08893fae3c7513e743fba20c059", size = 59723818, upload-time = "2026-08-05T15:34:49.578Z" },
]
[[package]]
name = "homeassistant-stubs"
-version = "2026.4.4"
+version = "2026.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "homeassistant" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/2cc8df737b883498964e1a96d00497b3dfb04de5aa0ef17b567973584837/homeassistant_stubs-2026.4.4.tar.gz", hash = "sha256:5bfd79ba36b87d594d403185fbf7885b81f5f6967cc3ea4d641e825a8b469b8f", size = 1310585, upload-time = "2026-04-25T03:36:20.52Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/27/e0af3058503214caeed1767bea22f1c4e41bc876e961cd9cbc2fd49fbe2c/homeassistant_stubs-2026.8.0.tar.gz", hash = "sha256:ea1a418c126aed6f2894bea17ffacdeaa4c8073e57b05417fb76e88ce1c5452e", size = 1435706, upload-time = "2026-08-06T03:43:53.938Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/d2/7bf64e4a1cd1fa6f188e5378cd72339de1b6424676000279561a1d3ae598/homeassistant_stubs-2026.4.4-py3-none-any.whl", hash = "sha256:53ecbe27c5428dc21506c111a9710d7f8c48ed8c112404f2c41d132f4896b88d", size = 3700990, upload-time = "2026-04-25T03:36:17.791Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/9c/c73a3ff954b9525ad8c051136a5600b5ef865827e848cdb2f70fae27d39c/homeassistant_stubs-2026.8.0-py3-none-any.whl", hash = "sha256:034c6c62b9d96d4a7b3b5f367e8a3c6e250193cf4389aaf882a19c6de664b5a0", size = 4080910, upload-time = "2026-08-06T03:43:51.928Z" },
]
[[package]]
@@ -1188,9 +1280,36 @@ wheels = [
[[package]]
name = "lru-dict"
-version = "1.3.0"
+version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/96/e3/42c87871920602a3c8300915bd0292f76eccc66c38f782397acbf8a62088/lru-dict-1.3.0.tar.gz", hash = "sha256:54fd1966d6bd1fcde781596cb86068214edeebff1db13a2cea11079e3fd07b6b", size = 13123, upload-time = "2023-11-06T01:40:12.951Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/06/0a/dec86efe38b350314c49a8d39ef01ba7cf8bbbef1d177646320eedea7159/lru_dict-1.4.1.tar.gz", hash = "sha256:cc518ff2d38cc7a8ab56f9a6ae557f91e2e1524b57ed8e598e97f45a2bd708fc", size = 13439, upload-time = "2025-11-02T10:02:13.548Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/02/8e04a8d744b466d4153502e2d92b453c2e5a549d49bf7fabfdca1621828a/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b770c7db258625e57b6ea8e2e0503ba0fbbdcde374baacf9adb256eb9c5adfa", size = 11119, upload-time = "2025-11-02T10:01:36.239Z" },
+ { url = "https://files.pythonhosted.org/packages/50/f8/ee96f30127ff47c29966603f040e0485700fe0ca0e7d7b1ecbc9bf999eea/lru_dict-1.4.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:45d4dc338237cedcbacedab1afd9707b8f9867d8b601ec04e0395ec73f57405c", size = 11435, upload-time = "2025-11-02T10:01:36.857Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/5a/897b33ba1974b6487848cafa5de7e93a7c4f5d9d3f43319ee010f6882830/lru_dict-1.4.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:5b31e9b6636f8945ad69c630c1891d810d62a91d99e792ef0b9ca865b6c26745", size = 10988, upload-time = "2025-11-02T10:01:37.531Z" },
+ { url = "https://files.pythonhosted.org/packages/19/8e/b87d0f2bfcad0169afc00e23e014bad9af252206ec2cbc6079f12bece58e/lru_dict-1.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f9335d46c83882a1b5deffed8098a2dd9ad66d2bd6263f416fc4c73f63e26904", size = 16733, upload-time = "2025-11-02T10:01:38.194Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/19/d2384266864b1e5b1cc20527ae468550d3b23a71636371b40e4663276294/lru_dict-1.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:17844b4f8dd996144d53380395d73832e2508159ad49ed4fbcb62f1787a5feaf", size = 11222, upload-time = "2025-11-02T10:01:39.093Z" },
+ { url = "https://files.pythonhosted.org/packages/66/8a/94dec42ae6b5c8bdc53a86867924fa22634516434f129dca187ccc0853b8/lru_dict-1.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2b569c7813adb753b7b631097c34e6dbc194cb1814f22299c2d2a94894779877", size = 11733, upload-time = "2025-11-02T10:01:39.739Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/19/0b6de1db804cf094e201c5541d58e6a96359eb5beed048fa64d0589b6520/lru_dict-1.4.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:33cf1eb368d3989b8f00945937cfbfc2095d8ad2b1d2274ce1bde0af6f6d1e66", size = 32251, upload-time = "2025-11-02T10:01:40.421Z" },
+ { url = "https://files.pythonhosted.org/packages/97/38/89d9425dde436b9bd894234171988289b259aeeab5965bd2c21d5104cb41/lru_dict-1.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5879ec5d5955f9dde105997bdf7ec9e0522bf99612a80b55b09f356a08368", size = 33405, upload-time = "2025-11-02T10:01:41.917Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/24/f1a189399ee107a64f955c9d6c84d3b0aee9b64b31fc5684b1eaeb3a6fc0/lru_dict-1.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2084363e4488aa5b4f8b26bd3cc148d70a15be92e3d347621a5b830b2b1e0a82", size = 35135, upload-time = "2025-11-02T10:01:42.935Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/57/58e9dcf0853d639e2995e5d9f84649ff8d6792a04a418628672a130137f4/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8198ab8ad7cc81b86340243ddd5cca882ead87daed0c9fa6cce377a10a7f2e47", size = 32620, upload-time = "2025-11-02T10:01:43.627Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/bb/664922f0cf076b1e3c2e43e8258582d507b07c19bd441a72dd5547a483e9/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1f4ae6967d5873e684ce8b986e2e43985d0a1be735b09584737ad5634ff48f3", size = 34077, upload-time = "2025-11-02T10:01:44.694Z" },
+ { url = "https://files.pythonhosted.org/packages/58/38/b7a6fa85b150232cada26a50c89dc4bcf9acd6ada00e987b074c3b4e57f2/lru_dict-1.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a9bb130b5eaddd6453ca3dc38ce4a75f743512ad135b6f3994999dde0680bd79", size = 31905, upload-time = "2025-11-02T10:01:45.668Z" },
+ { url = "https://files.pythonhosted.org/packages/86/7d/9c86393946d621f4aec852d543df4023241d85106e9e1e2a0e4057861f71/lru_dict-1.4.1-cp314-cp314-win32.whl", hash = "sha256:5534c69a52add5757714456d08ce3831d36b86c98972394ba900493bb0bd97f8", size = 13435, upload-time = "2025-11-02T10:01:46.397Z" },
+ { url = "https://files.pythonhosted.org/packages/20/3f/b017cbeea55a8a1d18037840a8a9c9cdae29554e9985b55d4e8694305035/lru_dict-1.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:96fd677b6d912229f2d02ba61a5a1210176963c4770c1bb765b8da937cec3834", size = 14423, upload-time = "2025-11-02T10:01:47.473Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/73/13132af7a5155edde66979b53eb509465304e6e5a2b00769246448479c73/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6699bfebbf11dd9ff1387be7996fac6d1009fe6a6f48091ef6e069e6f19c7bce", size = 17184, upload-time = "2025-11-02T10:01:48.161Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/82/094985beb3e49461bf65a3c40df8de2018b8484e4ef129295090460ca5d9/lru_dict-1.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a276f8f6f43861c3f05986824741d00e3133a973c3396598375310129535382d", size = 11459, upload-time = "2025-11-02T10:01:48.823Z" },
+ { url = "https://files.pythonhosted.org/packages/14/29/836abc49f8c2b6c2efccd2ac2b2c0ad3e55b7d75a05a20cc061f17871e39/lru_dict-1.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:090c7b6a3d54fa7f3d69ba4802abe2f33c9583b16b33f52bcb521c701f7ea46c", size = 11938, upload-time = "2025-11-02T10:01:49.448Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/83/1bb4e8fbc0b753fea825564d9d96180813a71715d46a9b6bb30a6dea4ce0/lru_dict-1.4.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b21d06dec64fb1952385262d9fcefaec147921dc0b55210007091a79da440d93", size = 36389, upload-time = "2025-11-02T10:01:50.49Z" },
+ { url = "https://files.pythonhosted.org/packages/54/f2/7df3b6d0dbc66f3be9aa6261750967cdc5619c89a563420c52200d2dd547/lru_dict-1.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9613908a38cf8aa47f6c138ba031a8ac4ed38460299e84a2b07dba7b3b45aae", size = 38706, upload-time = "2025-11-02T10:01:51.197Z" },
+ { url = "https://files.pythonhosted.org/packages/97/5f/e3ba3eeb9b864a09b92e24fbf179aef4ef48588e763a9d8d2bc10bd2c6f8/lru_dict-1.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7558302ce8bbfcd29f08e695e07bf7a0d799c2979636d6a6a0b4e207f840969f", size = 38892, upload-time = "2025-11-02T10:01:51.899Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e3/12e0888aab0bf3ab9ce35e9849f239994a0feff6fe49380859bf57124a17/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3910396142322fb2718546115bb2a56f50ebc9144b5140327053cca084e0d375", size = 37254, upload-time = "2025-11-02T10:01:52.587Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/dc/06cd981718d039eb07a9c03263094aca6269721c470f765a292b24381a20/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f3f4fad5c4a9458954b275de6a6e31c67a26fbef7037c6a7354e22523a77db26", size = 37422, upload-time = "2025-11-02T10:01:53.271Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/82/ea88e618f39d78ff3c15b71f01a0b1a6c6ac2034ce5c6428ae41b1c30ea5/lru_dict-1.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85fc29363e2d3ba0a5f87b5e17f54b1078aea6d24c6dfc792725854b9d0f8d17", size = 35909, upload-time = "2025-11-02T10:01:54.356Z" },
+ { url = "https://files.pythonhosted.org/packages/89/36/1dd91c602f623839cec24d6c77fa3fd1a8878bf2d716871197cd3bf084dc/lru_dict-1.4.1-cp314-cp314t-win32.whl", hash = "sha256:b3853518dfa50f28af0d6e2dcf8bb8b0a1687c5f4eb913c0b35b0da5c6d276ce", size = 13816, upload-time = "2025-11-02T10:01:55.417Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/a3/113410f7b2e61e9d6f13f1f17c584dbd08b5796e65d772ecd5b063fab3af/lru_dict-1.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ff3af42922205620fdc920dcdf580c4c16b32c84a537a03b04b523e5c641a8a9", size = 15204, upload-time = "2025-11-02T10:01:56.06Z" },
+]
[[package]]
name = "mando"
@@ -1406,25 +1525,25 @@ wheels = [
[[package]]
name = "orjson"
-version = "3.11.7"
+version = "3.11.9"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" },
- { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" },
- { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" },
- { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" },
- { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" },
- { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" },
- { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" },
- { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" },
- { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" },
- { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" },
- { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" },
- { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" },
- { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" },
- { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" },
- { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" },
+ { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" },
+ { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" },
+ { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" },
+ { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" },
+ { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" },
]
[[package]]
@@ -1456,35 +1575,31 @@ wheels = [
[[package]]
name = "pillow"
-version = "12.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
- { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
- { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
- { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
- { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
- { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
- { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
- { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
- { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
- { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
- { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
- { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
- { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
- { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
- { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
- { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
- { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
- { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
- { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
- { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
- { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
- { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
- { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
- { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
- { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
+version = "12.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
+ { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
+ { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
+ { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
+ { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
+ { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
+ { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
]
[[package]]
@@ -1562,41 +1677,45 @@ wheels = [
[[package]]
name = "propcache"
-version = "0.4.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
- { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
- { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
- { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
- { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
- { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
- { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
- { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
- { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
- { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
- { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
- { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
- { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
- { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
- { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
- { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
- { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
- { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
- { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
- { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
- { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
- { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
- { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
- { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
- { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
- { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
- { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
- { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
- { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
- { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
- { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
+ { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
+ { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
+ { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
+ { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
+ { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
+ { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
+ { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
]
[[package]]
@@ -1694,7 +1813,7 @@ wheels = [
[[package]]
name = "pydantic"
-version = "2.12.2"
+version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -1702,39 +1821,66 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8d/35/d319ed522433215526689bad428a94058b6dd12190ce7ddd78618ac14b28/pydantic-2.12.2.tar.gz", hash = "sha256:7b8fa15b831a4bbde9d5b84028641ac3080a4ca2cbd4a621a661687e741624fd", size = 816358, upload-time = "2025-10-14T15:02:21.842Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl", hash = "sha256:25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae", size = 460628, upload-time = "2025-10-14T15:02:19.623Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
-version = "2.41.4"
+version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" },
- { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" },
- { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" },
- { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" },
- { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" },
- { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" },
- { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" },
- { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" },
- { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" },
- { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" },
- { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" },
- { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" },
- { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" },
- { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" },
- { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" },
- { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" },
- { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" },
- { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" },
- { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+]
+
+[[package]]
+name = "pygithub"
+version = "2.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyjwt", extra = ["crypto"] },
+ { name = "pynacl" },
+ { name = "requests" },
+ { name = "typing-extensions" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" },
]
[[package]]
@@ -1748,11 +1894,11 @@ wheels = [
[[package]]
name = "pyjwt"
-version = "2.10.1"
+version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[package.optional-dependencies]
@@ -1778,15 +1924,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
]
-[[package]]
-name = "pylint-per-file-ignores"
-version = "1.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a5/3d/21bec2f2f432519616c34a64ba0766ef972fdfb6234a86bb1b8baf4b0c7c/pylint_per_file_ignores-1.4.0.tar.gz", hash = "sha256:c0de7b3d0169571aefaa1ac3a82a265641b8825b54a0b6f5ef27c3b76b988609", size = 4419, upload-time = "2025-01-17T21:35:02.383Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/94/0e/bf3473d86648a17e6dd6ee9e6abce526b077169031177f4f2031368f864a/pylint_per_file_ignores-1.4.0-py3-none-any.whl", hash = "sha256:0cd82d22551738b4e63a0aa1dab2a1fc4016e8f27f1429159616483711e122fd", size = 4888, upload-time = "2025-01-17T21:35:00.371Z" },
-]
-
[[package]]
name = "pynacl"
version = "1.6.2"
@@ -1875,14 +2012,14 @@ wheels = [
[[package]]
name = "pyopenssl"
-version = "26.0.0"
+version = "26.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" },
+ { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
]
[[package]]
@@ -1915,7 +2052,7 @@ wheels = [
[[package]]
name = "pytest"
-version = "9.0.0"
+version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1924,49 +2061,49 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-aiohttp"
-version = "1.1.0"
+version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/4d/c6621fc79022f6c84a806e23d9b7eca24fae4f3ee779219bbe524339d666/pytest_aiohttp-1.1.1.tar.gz", hash = "sha256:3aa9c9fe26e543eaccc7eb0add381c685ba3ed3e2fed0af74540f63bcd31458d", size = 13704, upload-time = "2026-06-07T23:56:34.173Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b0/5056ed4c3f68a4db2b4a39fb0ec61b1e4cf1d89ee14effe5261cc587264c/pytest_aiohttp-1.1.1-py3-none-any.whl", hash = "sha256:f293441ad4f8446a1e12257130c26c7de03a615c2a5572a8cb046e5b3b4e5211", size = 9007, upload-time = "2026-06-07T23:56:33.333Z" },
]
[[package]]
name = "pytest-asyncio"
-version = "1.3.0"
+version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "pytest-cov"
-version = "7.0.0"
+version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
@@ -1984,19 +2121,19 @@ wheels = [
[[package]]
name = "pytest-github-actions-annotate-failures"
-version = "0.3.0"
+version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/39/d4/c54ee6a871eee4a7468e3a8c0dead28e634c0bc2110c694309dcb7563a66/pytest_github_actions_annotate_failures-0.3.0.tar.gz", hash = "sha256:d4c3177c98046c3900a7f8ddebb22ea54b9f6822201b5d3ab8fcdea51e010db7", size = 11248, upload-time = "2025-01-17T22:39:32.722Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/a0/bdb91581b03c41016c78e16b8ec36c34d8508206fcb30f1951c9cdff2e97/pytest_github_actions_annotate_failures-0.4.2.tar.gz", hash = "sha256:5dd18304512361788bc7b5c5c805db853f03f4950c6be09b088de6bab8e2e6c9", size = 12158, upload-time = "2026-06-19T15:59:17.445Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6d/73/7b0b15cb8605ee967b34aa1d949737ab664f94e6b0f1534e8339d9e64ab2/pytest_github_actions_annotate_failures-0.3.0-py3-none-any.whl", hash = "sha256:41ea558ba10c332c0bfc053daeee0c85187507b2034e990f21e4f7e5fef044cf", size = 6030, upload-time = "2025-01-17T22:39:31.701Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/09/c44e658f3a27c588c2017d858c8b0fa962612af9b74326beabbf010c839c/pytest_github_actions_annotate_failures-0.4.2-py3-none-any.whl", hash = "sha256:02911cd3b55f235328a334f8ca6037a89944398b8e8b028c82de97111eff4071", size = 6151, upload-time = "2026-06-19T15:59:16.486Z" },
]
[[package]]
name = "pytest-homeassistant-custom-component"
-version = "0.13.325"
+version = "0.13.354"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohasupervisor" },
@@ -2009,7 +2146,7 @@ dependencies = [
{ name = "paho-mqtt" },
{ name = "pipdeptree" },
{ name = "pydantic" },
- { name = "pylint-per-file-ignores" },
+ { name = "pygithub" },
{ name = "pytest" },
{ name = "pytest-aiohttp" },
{ name = "pytest-asyncio" },
@@ -2021,15 +2158,17 @@ dependencies = [
{ name = "pytest-timeout" },
{ name = "pytest-unordered" },
{ name = "pytest-xdist" },
+ { name = "requests" },
{ name = "requests-mock" },
{ name = "respx" },
{ name = "sqlalchemy" },
{ name = "syrupy" },
{ name = "tqdm" },
+ { name = "unidiff" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f2/40/e14846938decc2073b78d2c88a08444973aae82b7fb263ba0073aca89793/pytest_homeassistant_custom_component-0.13.325.tar.gz", hash = "sha256:12924ad407b6601748d3a3c7883423fd0d34b1f782f14b615d1b61d994f371fc", size = 69950, upload-time = "2026-04-25T05:37:23.337Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/48/ac4af2bb13ad79148cd62ca0ef92cb3b36d6d5fc2f9e8703880e27e298d3/pytest_homeassistant_custom_component-0.13.354.tar.gz", hash = "sha256:c2522b9b05143dcca4034d34db7bf65fab19f63efa4f4b0307e6ec36d55251bc", size = 76934, upload-time = "2026-08-06T07:36:43.766Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/54/c7/51cac710f1b6679550d04e162899dbdf4e7289ba715e5c4a9bff6bec7cfa/pytest_homeassistant_custom_component-0.13.325-py3-none-any.whl", hash = "sha256:9b758a092f74722e060d252ac80d71b6ae1ec2a712d55d0c9d378266e74e2c05", size = 75792, upload-time = "2026-04-25T05:37:21.529Z" },
+ { url = "https://files.pythonhosted.org/packages/68/b0/2284352838e69d4261abfceb120266afcd17d44a3d143342becfd92ada0a/pytest_homeassistant_custom_component-0.13.354-py3-none-any.whl", hash = "sha256:9dff5671d081612221905ad937da2ff2c025908314ccadb9c58f64f89754bfbe", size = 82841, upload-time = "2026-08-06T07:36:42.213Z" },
]
[[package]]
@@ -2046,14 +2185,14 @@ wheels = [
[[package]]
name = "pytest-socket"
-version = "0.7.0"
+version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/05/ff/90c7e1e746baf3d62ce864c479fd53410b534818b9437413903596f81580/pytest_socket-0.7.0.tar.gz", hash = "sha256:71ab048cbbcb085c15a4423b73b619a8b35d6a307f46f78ea46be51b1b7e11b3", size = 12389, upload-time = "2024-01-28T20:17:23.177Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/59/3c/f9b58e57830e58980dbe8867d0e348f45701d3f3ea065672d448f4366da5/pytest_socket-0.8.0.tar.gz", hash = "sha256:af9bb5f487da72be63573a6194cfac033b6c7a1c1561e150521105970f9e99f2", size = 13912, upload-time = "2026-05-21T16:50:22.552Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754, upload-time = "2024-01-28T20:17:22.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/e8/4a8568580bae3dcd678599ed8e86a82d505a44df71c1ced4246c1aa14b4b/pytest_socket-0.8.0-py3-none-any.whl", hash = "sha256:81821ba59f07d7600fe2b551d8714f40b068bd46e8b6704c48664e9d60cdacb8", size = 8414, upload-time = "2026-05-21T16:50:21.022Z" },
]
[[package]]
@@ -2070,14 +2209,14 @@ wheels = [
[[package]]
name = "pytest-unordered"
-version = "0.7.0"
+version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/bd/3e/6ec9ec74551804c9e005d5b3cbe1fd663f03ed3bd4bdb1ce764c3d334d8e/pytest_unordered-0.7.0.tar.gz", hash = "sha256:0f953a438db00a9f6f99a0f4727f2d75e72dd93319b3d548a97ec9db4903a44f", size = 7930, upload-time = "2025-06-03T12:56:04.289Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/0c/ab409d508c92ea6737875cfc39a696a0e0bd44359915e1376831dfb5a456/pytest_unordered-0.8.0.tar.gz", hash = "sha256:3c369ed86919d3eb35e11fd27bb679c1e3506ead9327e25aa8a07307256be65a", size = 7978, upload-time = "2026-06-16T02:48:53.209Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/95/ae2875e19472797e9672b65412858ab6639d8e55defd9859241e5ff80d02/pytest_unordered-0.7.0-py3-none-any.whl", hash = "sha256:486b26d24a2d3b879a275c3d16d14eda1bd9c32aafddbb17b98ac755daba7584", size = 6210, upload-time = "2025-06-03T12:36:06.66Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d4/2f63db7b6292856f51a76e3eb0acb5e53fd63ae6b6ec4dd11af24985f5b0/pytest_unordered-0.8.0-py3-none-any.whl", hash = "sha256:c792b879032b33141205e1a38a80234eb0117474bf9459ae65557e29837c2a1c", size = 6280, upload-time = "2026-06-16T02:48:52.399Z" },
]
[[package]]
@@ -2216,7 +2355,7 @@ wheels = [
[[package]]
name = "requests"
-version = "2.33.1"
+version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -2224,9 +2363,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
@@ -2243,14 +2382,14 @@ wheels = [
[[package]]
name = "respx"
-version = "0.22.0"
+version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
]
[[package]]
@@ -2305,15 +2444,15 @@ wheels = [
[[package]]
name = "securetar"
-version = "2026.4.0"
+version = "2026.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pynacl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cb/25/138e19209d8f04765e6e51f94997d7e1141448103bae8ba30af98a755595/securetar-2026.4.0.tar.gz", hash = "sha256:d6e8d8c747655ac4564543f35f7fc70a2ebc7426f23c77f16238e7cb763691e8", size = 28254, upload-time = "2026-04-07T13:25:20.925Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/54/c7084733ce23f33c5f863734d6fd0c1789ba9ec771257cc54529c7a999ac/securetar-2026.4.1.tar.gz", hash = "sha256:536108c7cec0bdd6fb51abd8d293deba02930124d76ec5dd4599ae5e0c755638", size = 28289, upload-time = "2026-04-07T17:02:07.231Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/26/16/47a417d1264ad238191185ddd1a1d6585c6f33462e60408be09aee74147b/securetar-2026.4.0-py3-none-any.whl", hash = "sha256:72e09a864282ec96b05b5a61cc4febca7a1a7978b4083687369b6eb4835abe27", size = 19176, upload-time = "2026-04-07T13:25:19.803Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/56/4ef84868d1ff2f77ac080929117283ac9d18f7d018ae64fa1e7f957a8d20/securetar-2026.4.1-py3-none-any.whl", hash = "sha256:7cf66c204d678b9d1e2d17814c83048a6468e928ea00edb7e6c1efe72aab0540", size = 19339, upload-time = "2026-04-07T17:02:05.635Z" },
]
[[package]]
@@ -2352,11 +2491,13 @@ wheels = [
[[package]]
name = "span"
-version = "2.0.7"
+version = "2.1.0b6"
source = { virtual = "." }
dependencies = [
{ name = "homeassistant" },
{ name = "span-panel-api" },
+ { name = "span-panel-api-schema-0" },
+ { name = "span-panel-api-schema-1" },
]
[package.dev-dependencies]
@@ -2381,21 +2522,23 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "homeassistant", specifier = "==2026.4.4" },
- { name = "span-panel-api", editable = "../span-panel-api" },
+ { name = "homeassistant", specifier = "==2026.8.0" },
+ { 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]
dev = [
{ name = "bandit", extras = ["toml"], specifier = "==1.9.4" },
- { name = "homeassistant-stubs", specifier = "==2026.4.4" },
+ { name = "homeassistant-stubs", specifier = "==2026.8.0" },
{ name = "isort" },
{ name = "mypy", specifier = "==1.20.2" },
{ name = "prek", specifier = ">=0.3.11" },
{ name = "prettier" },
{ name = "pylint", specifier = "==4.0.5" },
{ name = "pyright", specifier = "==1.1.409" },
- { name = "pytest-homeassistant-custom-component", specifier = ">=0.13.325" },
+ { name = "pytest-homeassistant-custom-component", specifier = "==0.13.354" },
{ name = "python-direnv" },
{ name = "radon", specifier = "==6.0.1" },
{ name = "ruff", specifier = "==0.15.12" },
@@ -2407,8 +2550,8 @@ dev = [
[[package]]
name = "span-panel-api"
-version = "2.6.4"
-source = { editable = "../span-panel-api" }
+version = "3.0.1"
+source = { editable = "../../span/span-panel-api" }
dependencies = [
{ name = "httpx" },
{ name = "paho-mqtt" },
@@ -2417,16 +2560,20 @@ 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/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"]
[package.metadata.requires-dev]
dev = [
{ name = "bandit", specifier = ">=1.9.4" },
{ name = "black" },
{ name = "coverage" },
+ { name = "cryptography", specifier = ">=50.0.0" },
{ name = "mypy" },
{ name = "pre-commit" },
{ name = "pylint" },
@@ -2435,21 +2582,64 @@ dev = [
{ name = "pytest-cov" },
{ name = "radon" },
{ name = "ruff", specifier = ">=0.15.5" },
- { name = "twine" },
+ { 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" },
]
+[[package]]
+name = "span-panel-api-schema-0"
+version = "1.0.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" }]
+
+[[package]]
+name = "span-panel-api-schema-1"
+version = "1.0.0"
+source = { editable = "../../span/span-panel-api/packages/schema-1" }
+dependencies = [
+ { name = "ebus-sdk" },
+ { name = "span-panel-api" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "ebus-sdk", specifier = ">=0.19.0,<0.24" },
+ { name = "span-panel-api", editable = "../../span/span-panel-api" },
+]
+
[[package]]
name = "sqlalchemy"
-version = "2.0.41"
+version = "2.0.51"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
+ { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
+ { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
+ { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
+ { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
+ { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
+ { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
]
[[package]]
@@ -2494,14 +2684,14 @@ wheels = [
[[package]]
name = "syrupy"
-version = "5.0.0"
+version = "5.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c1/90/1a442d21527009d4b40f37fe50b606ebb68a6407142c2b5cc508c34b696b/syrupy-5.0.0.tar.gz", hash = "sha256:3282fe963fa5d4d3e47231b16d1d4d0f4523705e8199eeb99a22a1bc9f5942f2", size = 48881, upload-time = "2025-09-28T21:15:12.783Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/39/17dde9f0c76cc5abcdeef1b2243791bb850d498f784147b8e460dd23abe8/syrupy-5.5.3.tar.gz", hash = "sha256:fa21e4ae77c89ec5abfca513338d8a7eb916da6618ca0f8db301476e0768e57a", size = 91614, upload-time = "2026-07-11T15:54:31.46Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/9d/9a/6c68aad2ccfce6e2eeebbf5bb709d0240592eb51ff142ec4c8fbf3c2460a/syrupy-5.0.0-py3-none-any.whl", hash = "sha256:c848e1a980ca52a28715cd2d2b4d434db424699c05653bd1158fb31cf56e9546", size = 49087, upload-time = "2025-09-28T21:15:11.639Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/f9/617a194c1a4203279998e1859426cf2635042533a99a93d63d3ee1fb967e/syrupy-5.5.3-py3-none-any.whl", hash = "sha256:0b260a0c9dad55e1fb83818973dc36fbc1aea3fd5381592f442a986686c4171a", size = 54959, upload-time = "2026-07-11T15:54:29.836Z" },
]
[[package]]
@@ -2596,29 +2786,38 @@ wheels = [
[[package]]
name = "ulid-transform"
-version = "2.2.0"
+version = "2.2.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/69/80e64aff3bbfd719ef3519774cf489cd972aac89cf8f85cd52009757be71/ulid_transform-2.2.9.tar.gz", hash = "sha256:6a8b573de0158ab4bd0424f26ed4d1cef42f15afaa718c924a8427fe24a3838f", size = 17886, upload-time = "2026-05-25T05:39:04.274Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/2e/6351361aefb2bfe44036519bb60ef9689511c7351c42e2edb7f86e28cb0a/ulid_transform-2.2.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b64afbdb9143210e7f9f240a91fa8fd54a559c6627e8f4cdb9f40771f8b8a498", size = 25212, upload-time = "2026-05-25T05:45:49.977Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/2c/db41412391950083c387c8185641db32e6f67862b52391c992cdd1824b5c/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fcbef4c1c537da14fa098529167f64f7d351e7aebeeeafd6779ed2ffe10f4649", size = 27462, upload-time = "2026-05-25T05:45:51.311Z" },
+ { url = "https://files.pythonhosted.org/packages/71/aa/8a2b3b3b6fd638f88117e6852f5b1aaaf5f73de25e2d7a8bb483a9adaa58/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:73fef8bdfae646ec4c54f5cc96dc27510fdb55c1aaed3c20d6661e8b51d01bd9", size = 27809, upload-time = "2026-05-25T05:45:52.782Z" },
+ { url = "https://files.pythonhosted.org/packages/78/f5/520fc50bfd97a616e5c367cd820eee65ffc3537acc68fdacb09f31fef149/ulid_transform-2.2.9-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6db97794ce8638ee2bd11ace5ddc9e23e450e971b264556c2c42ab43fc3d580", size = 26406, upload-time = "2026-05-25T05:45:54.1Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/33/27c3f9ed60426b514b1501c8acd47ab05a305fbb1e294dce6a13c52a88be/ulid_transform-2.2.9-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:f62d23bf7ed08e086e27dc5f5c89f9aec4d79316174529070073505bc367d8fa", size = 16808, upload-time = "2026-05-25T05:39:02.653Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/43/b246e2c3e4936789f9797c5e61ebde1d1c6fecbc9a1d6cdf6c427518006e/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae8473948da794f27ecf800511ff7e9bc340e1e5d07f28f1f640a860240d1d39", size = 1007530, upload-time = "2026-05-25T05:45:55.2Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/17/c1ab2d3901899962afcbddce47dc796f7eaa6694f96c3f4977ab43883712/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a978e55081259c95a14bd7d48db6540645ca228c2d07da6c176c291cd955e85b", size = 877349, upload-time = "2026-05-25T05:45:57.625Z" },
+ { url = "https://files.pythonhosted.org/packages/af/b1/2bd4509651123caa37b447e0d4e5bb321067fd79a1bcaefa2dfc7fe41206/ulid_transform-2.2.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:364651ca7bf17e56b7570855d185e10ae461d5bfe15943936ee6f79fde71bb02", size = 1058955, upload-time = "2026-05-25T05:45:59.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/d6/e48135a3992562630043f57a85ba36109787b91d27da842ae8e7136fd24f/ulid_transform-2.2.9-cp314-cp314-win32.whl", hash = "sha256:6daaaadfe2d7e465f0850c05dfcd5a785426571361433e84fc3f5a8ed78321ae", size = 25741, upload-time = "2026-05-25T05:46:00.438Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/8e/ff96df79000ed277a9fc73e8825f27c81fba2446acb7fe33f53a1489ba70/ulid_transform-2.2.9-cp314-cp314-win_amd64.whl", hash = "sha256:f586c25a2457b88511e1acceae9aeb90f002aee06a4d37e68280ba0ff6af4b9d", size = 25837, upload-time = "2026-05-25T05:46:01.955Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/c6/59879019153dbbd34eff5f3c633f329b5d75519c1a95f3271948ea51715f/ulid_transform-2.2.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a873a51c8ec7f3d51c6d3d6ecdf3720d29637d5619e7c7df16dbd451f19ceb82", size = 25198, upload-time = "2026-05-25T05:46:03.022Z" },
+ { url = "https://files.pythonhosted.org/packages/51/c6/293ba411f0e32e17086baed7c955e4287e368275f31f59ef7a005d401eac/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65711af19211c89c115954aab2965abad5287596d24f5754fcd142e80dd7656c", size = 27474, upload-time = "2026-05-25T05:46:04.058Z" },
+ { url = "https://files.pythonhosted.org/packages/57/17/2eabe1ba6e1ed341d372c996380a812a5278217582349fc609d61fb57342/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9456b9bd8a572448cbddd9f3c5266d7354c68a3c0dc25209108ea904846fac3", size = 27826, upload-time = "2026-05-25T05:46:05.166Z" },
+ { url = "https://files.pythonhosted.org/packages/65/36/1f3f2e6c1243cafd75d9c2736b6b73e5530d96b49f955493e8917b2fe716/ulid_transform-2.2.9-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12639c5dda80d6d90437e9427dd87c6bb71bb291dcfe8d6063703394c2e15e3a", size = 26405, upload-time = "2026-05-25T05:46:06.268Z" },
+ { url = "https://files.pythonhosted.org/packages/00/6b/200c79dc081b4c9673923dd9d9bc9dc0e47ccdc90eeae8e85acbb1914751/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8cf746c642569dfe3efd94fe45672954f6257ff910f993bca80ab6dcb5a34f92", size = 1007528, upload-time = "2026-05-25T05:46:07.417Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/24/228a44957e37a3c7541989d47c481c9f51ae045ad07fdacc0deb49d4e120/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5c19d10c7ad9a24c456aba5cbd74aeeca84a4b33cd35077b4bec61d2cf92e30c", size = 877361, upload-time = "2026-05-25T05:46:09.145Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/7e/4beaa80ac820b3243e376f79fde4a8bca2b477d8b6529ff5afcfd83f5f74/ulid_transform-2.2.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35084b9ade29b462472485966789e5edc66dcbc376ffaf89759e7ada0c7d1f6c", size = 1058912, upload-time = "2026-05-25T05:46:10.736Z" },
+ { url = "https://files.pythonhosted.org/packages/39/f8/ab3af64aa6ced7c65b09aa4b1c33d2cd4bf48646c9957a17659cea69e1c5/ulid_transform-2.2.9-cp314-cp314t-win32.whl", hash = "sha256:ccf6b173ae1e90a589935fd99176dc9d71d461c9f413b1a754d542ba2d399860", size = 25725, upload-time = "2026-05-25T05:46:12.117Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/c8/cbb9374e8a0e26d18e94ffb3e1f861beab5312b3e0be4fbad1bf60cdb47c/ulid_transform-2.2.9-cp314-cp314t-win_amd64.whl", hash = "sha256:fed352965a8682a156000d29e0efff8b85e7c2b8cb74f2e953a1e6d0157648b9", size = 25827, upload-time = "2026-05-25T05:46:13.164Z" },
+]
+
+[[package]]
+name = "unidiff"
+version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9f/c2/8548ec850a03fdfbc84ba4728bed5d3b5570ec913502954b40830e84f5aa/ulid_transform-2.2.0.tar.gz", hash = "sha256:cef07d688483e7573b835423f242d0d6700d92429abc438c0941de61eacdf215", size = 15159, upload-time = "2026-03-18T18:18:14.101Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5d/47/f2d1b8c04a5b7276198ff4f0dadfea463fd6f73aa4e1ddc3589aff073ca3/ulid_transform-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae98a8510ea6498c67d7810d9030c36dfd3e56d79232144fdd9e9430db8827a9", size = 23114, upload-time = "2026-03-18T18:25:15.783Z" },
- { url = "https://files.pythonhosted.org/packages/91/d3/479dddda0e7446a1f444c5df72941dd88611dcfc8fb3f8a1056d54be68fd/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d44baef447136f0d3216dc63066f4999c4e81fe10d08620300685d59155c9a17", size = 25359, upload-time = "2026-03-18T18:25:17.719Z" },
- { url = "https://files.pythonhosted.org/packages/63/e0/3ffd17d17d78aceb903d3a11fcc494b97f0e313e3692b7bdac43fcb18972/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dc6b2c01b6922f8c7c75d6504d428d66d0caabbcafbb4c5083d42aec23bf3a93", size = 25745, upload-time = "2026-03-18T18:25:19.179Z" },
- { url = "https://files.pythonhosted.org/packages/06/1c/60c4cb168710bc51f896d532a37e6d2c291d2eba7e5997ffaf532abfdf2a/ulid_transform-2.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4e96f23690491397218ecff013d44efa5b868ca393c497c83cd1d6844d29e14", size = 24344, upload-time = "2026-03-18T18:25:20.65Z" },
- { url = "https://files.pythonhosted.org/packages/2a/7a/8b6b7216aca6102fbede41249b6dbd203d17eccaef221e36440307948bb9/ulid_transform-2.2.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:0faf6937d2a36392af323292819aeaca3ce12161ba414a265fd1f5ea13f5efef", size = 15101, upload-time = "2026-03-18T18:18:12.987Z" },
- { url = "https://files.pythonhosted.org/packages/f0/27/54063f2df35713bc0f5e70dad04ff609e788ef8109583a6666e08a48149d/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41d05fd5191c01a751d222586a88945806573b036a4a31b14952834c3e085654", size = 1005342, upload-time = "2026-03-18T18:25:22.02Z" },
- { url = "https://files.pythonhosted.org/packages/a8/2b/6909e1f875eb88612718eaad0259831d1d17908c38047e710d451b0a52b7/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:e01a2168b702277c78b313bd126c34165ba8f6b4def3db389de3a125d1348521", size = 875203, upload-time = "2026-03-18T18:25:23.461Z" },
- { url = "https://files.pythonhosted.org/packages/9f/60/a1217d58e8591f6d76d6ef05d8296abc734acf61e1a8aef3ae09a2d1f933/ulid_transform-2.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8779b38f6a0c788087990c4c54ae9034beef4117936a5dd1a2d43b8cbb4f3203", size = 1056867, upload-time = "2026-03-18T18:25:24.955Z" },
- { url = "https://files.pythonhosted.org/packages/68/ab/cc26752afdf2e8cc643f1aa6b4514715d249d9ebf5c57185c6f68ba1dc51/ulid_transform-2.2.0-cp314-cp314-win32.whl", hash = "sha256:b0782a5a399b44d65ad2fc9c8f8b61f455af5d4cca335eafb2927e3dac6e92e6", size = 24255, upload-time = "2026-03-18T18:25:26.35Z" },
- { url = "https://files.pythonhosted.org/packages/a3/36/0cd5c74a77e72f80e2ffbf4d08fb89fe641b1f6e683421a335774731db6f/ulid_transform-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:b904d40eab4f3efced841cfb5b80777dd36c0db99a66261fc1b37e0b98d68167", size = 24280, upload-time = "2026-03-18T18:25:27.519Z" },
- { url = "https://files.pythonhosted.org/packages/b9/ac/44abd959a306c84a8917a65e731e2b25d4aaeafa29a2b15deaa1a62041d2/ulid_transform-2.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e737a0b06be45585806bd71f91a63c846b0aa3bf90dd787544d5d8482f324c61", size = 30697, upload-time = "2026-03-18T18:25:28.632Z" },
- { url = "https://files.pythonhosted.org/packages/88/cd/f4625a6c4be6f6b217c9116ede638fd969a1a27fc11be76997c0381e3f20/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f61b636694420afd6e5a2050e4fecca4f1ca17ef0673a9d4653522900b2d7132", size = 35171, upload-time = "2026-03-18T18:25:29.919Z" },
- { url = "https://files.pythonhosted.org/packages/e0/3e/cddcccf159527d028c49b2cd88abc9d323fd9fd6ef8fcc1960f9c57bb417/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a815c51ef310c4594792864fbd8845cfe935005b631ae1c099fd3b2d1404062c", size = 35956, upload-time = "2026-03-18T18:25:31.03Z" },
- { url = "https://files.pythonhosted.org/packages/02/ef/f67e94817dcfea4699cf764db8ba427675c879bcc53610a490fcc5ab38b8/ulid_transform-2.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1628ca45e5ea9ec79bf90f9f7e3a2f036faca7bb081db143f7a36e54c1ef184", size = 33127, upload-time = "2026-03-18T18:25:32.162Z" },
- { url = "https://files.pythonhosted.org/packages/29/64/8c94866f6cf635437c6c106548f7b70023e0d4e82f0c95503551c3c3c27b/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0c56296f82be1e272d58e53dfe231a789458758ce01addeca47a1671d73bfaa9", size = 1015637, upload-time = "2026-03-18T18:25:33.908Z" },
- { url = "https://files.pythonhosted.org/packages/3a/3a/6beaccdf3acb27a2197cbb1fff44391ff92100ffe0ad0ae3273cb827cdf5/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9fd78e7002a10a47ceb2ede42f0aaca428d0ae7ed712fd6bacf46406f806c3f4", size = 885015, upload-time = "2026-03-18T18:25:36.043Z" },
- { url = "https://files.pythonhosted.org/packages/55/b1/258765b8d209187a19228c89733400b751db223b7b68c7b79e4c924962a9/ulid_transform-2.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3adf5a417a2250264c6b6b290f1393d41efff0ac692ea19d4fa677bf29a67137", size = 1066233, upload-time = "2026-03-18T18:25:38.194Z" },
- { url = "https://files.pythonhosted.org/packages/21/f5/c29d4e164f5a6302b3f2025d16dfde9a6a8704efaf01c7d3c70ee69a4a12/ulid_transform-2.2.0-cp314-cp314t-win32.whl", hash = "sha256:66b66189322bccb54491b91a9c0e25c9240156d5d5671ca59860d8db1ce4ff9f", size = 33249, upload-time = "2026-03-18T18:25:39.673Z" },
- { url = "https://files.pythonhosted.org/packages/c6/45/f9c7400646f89d71dd5c04693ed259feb459e13b036c98193ecc8467cb4d/ulid_transform-2.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:212ba7218ea61cafb06772cd3967f60f16d4b028869a470cddcffad3ca5fac1c", size = 33277, upload-time = "2026-03-18T18:25:41.235Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/98/48/6ebfbda867e1a07bab3bbffe820e980bff8262c97ff77d1496a4fa15e711/unidiff-1.0.0.tar.gz", hash = "sha256:5e5d5cfab2dc98be819b74747ab7d9f5af8695369ec8710b93f9ab0f0ae6a449", size = 29365, upload-time = "2026-07-25T19:13:59.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/ca/860142913b2fee25c78b3af733054e248c488bd83cf6cfb97969e98e3bcf/unidiff-1.0.0-py3-none-any.whl", hash = "sha256:2e1fb4eebe2354a26a1f3d51efe2e5d504cae5764b98ed8bdbb4e7a000baff28", size = 18279, upload-time = "2026-07-25T19:13:58.797Z" },
]
[[package]]
@@ -2641,28 +2840,28 @@ wheels = [
[[package]]
name = "uv"
-version = "0.11.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" },
- { url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" },
- { url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" },
- { url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" },
- { url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" },
- { url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" },
- { url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" },
- { url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" },
- { url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" },
- { url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" },
- { url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" },
- { url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" },
- { url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" },
- { url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" },
- { url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" },
- { url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" },
- { url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" },
- { url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" },
+version = "0.11.31"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/f0/501fe8a234ac96ea8869e84cb47b3bd77e39a0e80ee01950713e24fe1c4a/uv-0.11.31.tar.gz", hash = "sha256:763609d59721af5b8522e16deac6cffe8055f82bb837740c708917506f305185", size = 6045932, upload-time = "2026-07-22T01:48:45.407Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/6a/065e1e7feaf375eee8d1bb05e5276185708149dd48c27a230f320a0fc8bf/uv-0.11.31-py3-none-linux_armv6l.whl", hash = "sha256:6adaaf151f53fef04dec685f0816d304c09a091b2b609746f86ee7c55ada6bcd", size = 25838313, upload-time = "2026-07-22T01:47:21.787Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/15/529b573723a36badbda1e13a432c3b21a7554b8ddef3b20a2200037051c2/uv-0.11.31-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d84b6dd6b1eaf42fc923203d21a5efd052e1982e4f961eccecc2a6905ffbecd", size = 24795386, upload-time = "2026-07-22T01:47:26.882Z" },
+ { url = "https://files.pythonhosted.org/packages/52/be/a809b3fe20c3d37bc667de33f38475c4c94f860979d07049ccddb6d91801/uv-0.11.31-py3-none-macosx_11_0_arm64.whl", hash = "sha256:335f3262c4350c004cf6e3b7061200148d670e579bcee7ba0e31c7535f125018", size = 23410594, upload-time = "2026-07-22T01:47:31.43Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/9a/ebaacd8b7713fd755d23623e0e8de78dfd001f6abc818034f2e9058035c7/uv-0.11.31-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e1cf5803c39221387b2fe8be2b522b0529ac732831a2e52a92330e053539995e", size = 25358933, upload-time = "2026-07-22T01:47:36.544Z" },
+ { url = "https://files.pythonhosted.org/packages/81/34/c30568a0f9e556be766c341106bf6ca2ef5c8067be6c11665a53df0549f1/uv-0.11.31-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:68ae6974ffbd04703e138654e83220a16e7b0b679271a8f209f928928dd399f8", size = 25346175, upload-time = "2026-07-22T01:47:41.132Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/63/18467b66f578dc121ec6d4af78074a0db06b27627b072fc433226a99a384/uv-0.11.31-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48f7ec906eaebf9717a01ba0f7635cd0cac648ff5c8fff3a57b8805e6bd49078", size = 25381240, upload-time = "2026-07-22T01:47:45.659Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/43/b51d6b8ad1307f51dd75154d623d6a527c6de600086bb0446251047d2e5e/uv-0.11.31-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a2cfd1638420f9a2a7dbca71c808edaf3929b6d8f4ec2ceac2f27014150d0e3", size = 26661822, upload-time = "2026-07-22T01:47:50.42Z" },
+ { url = "https://files.pythonhosted.org/packages/30/9f/008c859ea3fc0d25d6ac32e1293a0795c737b0a472a8603b5e511b56659c/uv-0.11.31-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aec65d8f54403e60f32c50e44d98b6420de55211ad22a340927efc5db6ef4205", size = 27594901, upload-time = "2026-07-22T01:47:55.444Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/ca/65a2856e79a208f8a1ece0ac077fbee531db7455608c06ab677b2513cbc4/uv-0.11.31-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5610fea306dc6ce5021482d272e6372f0c3dfd1e24ec061f90b1b9287263ac58", size = 26708620, upload-time = "2026-07-22T01:48:00.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/c3/019ecbf3564d909c55fcf065592aff90b8b386d679e379caf356de4473f9/uv-0.11.31-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44ac79fca5807122676701279a1f36d7917a922f25a0ab5c5cf58a252f666e7e", size = 26894006, upload-time = "2026-07-22T01:48:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/9b/b67aa8736f9f82a9f99cec93c28d66d77ff42126914784a3f680bc737b56/uv-0.11.31-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c4d4b34264017dc9047d0d49f09363a5e20b388481cddc39d5c44b16b3c2a57c", size = 25504398, upload-time = "2026-07-22T01:48:09.859Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d1/37e3a30f55e1c623fca484efbb80b6e157b922ee79f5cb7b1c0ff5005f0f/uv-0.11.31-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:9ce168c7323aee61ef07220c815f1b3e3a1b74241acb9f56c0b7fc4794dad600", size = 26307040, upload-time = "2026-07-22T01:48:14.555Z" },
+ { url = "https://files.pythonhosted.org/packages/00/cc/f607ba28a93100c55b3e048838f85481f8b55a24e3a338e42c151f5884ae/uv-0.11.31-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f3f8f58030ba4f711542d581b5fc3cde54db75a773fc873178f7b353f68f8711", size = 26425088, upload-time = "2026-07-22T01:48:19.204Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/ae/dd865e1d680799f05ff32895689700a23f37e905aac9807c93521fc76d8c/uv-0.11.31-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b1384887f8a4a0b0dfb8c6c81b2f819d1771015a96c70f89ef12559df8206b28", size = 25920399, upload-time = "2026-07-22T01:48:23.866Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/0a/45ebfd783235a7a39ae1e99dc0bf26c083ea24a37584e530c7fbb6e38a21/uv-0.11.31-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c6e052de498086b2014020536829b7e2b6f173ba95b07e55e9e0f85ac00a3927", size = 27126383, upload-time = "2026-07-22T01:48:28.376Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/c7/4cf78823c123efd3bdac50eb26f4b8fc2c222962d47918a7bb2b465b6522/uv-0.11.31-py3-none-win32.whl", hash = "sha256:03e18e463ecf0e1c347f901f9a8739059d07e2e2ebce72c0f8f1b9328a349c6f", size = 24644301, upload-time = "2026-07-22T01:48:33.094Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/4f/f2c3d0993ebab255a2dd7c476678c0307da03d890fb98761e8221d7bb043/uv-0.11.31-py3-none-win_amd64.whl", hash = "sha256:1a4bb0030d9070a4831a4f3115c5489998da7ca936e569a72696c90af469177a", size = 27699662, upload-time = "2026-07-22T01:48:37.708Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/8b/259e12b510c655f743f9a0e3171e6e9276dfd35a058d04d6aeef1fc4a897/uv-0.11.31-py3-none-win_arm64.whl", hash = "sha256:88ab5fdbeff4ab10ac890ab2dd01b7ad62b92251665423e4f68b1cf977fbe635", size = 25849721, upload-time = "2026-07-22T01:48:42.513Z" },
]
[[package]]
@@ -2676,14 +2875,14 @@ wheels = [
[[package]]
name = "voluptuous-openapi"
-version = "0.2.0"
+version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "voluptuous" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/15/ac7a98afd478e9afc804354fe9d9715e0e560a590fdd425b22b65a152bb3/voluptuous_openapi-0.2.0.tar.gz", hash = "sha256:2366be934c37bb5fd8ed6bd5a2a46b1079b57dfbdf8c6c02e88f4ca13e975073", size = 15789, upload-time = "2025-08-21T04:49:16.755Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/60/a5/517c143607fd82cfc8842fcdccf5cf2c07cdd96528c936bde196e05dece3/voluptuous_openapi-0.4.1.tar.gz", hash = "sha256:745d0d60940a851d9a90c87eebf659c57072c9ef305c0f6f04f4fd7c063dc0ac", size = 21432, upload-time = "2026-06-26T13:31:35.924Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/72/eb/2ae58431a078318f03267f196137282ea6f01ea7f7e0fcba2b25a30b0bf2/voluptuous_openapi-0.2.0-py3-none-any.whl", hash = "sha256:d51f07be8af44b11570b7366785d90daa716b7fd11ea2845803763ae551f35cf", size = 10180, upload-time = "2025-08-21T04:49:15.885Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/bb/64db054ad3852e3b63841a570942d3ce29e6b44979d5ac4be806973a2338/voluptuous_openapi-0.4.1-py3-none-any.whl", hash = "sha256:b30abc527c622cd62dba1269a05099e6b6170678c2838908d347fa85ab0cdb44", size = 12824, upload-time = "2026-06-26T13:31:35.126Z" },
]
[[package]]
@@ -2861,81 +3060,82 @@ wheels = [
[[package]]
name = "yarl"
-version = "1.23.0"
+version = "1.24.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
- { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
- { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
- { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
- { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
- { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
- { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
- { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
- { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
- { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
- { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
- { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
- { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
- { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
- { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
- { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
- { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
- { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
- { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
- { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
- { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
- { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
- { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
- { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
- { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
- { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
- { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
- { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
- { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
- { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
- { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
- { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
- { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
- { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
- { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
- { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
- { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
+ { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
+ { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
+ { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
+ { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
+ { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
+ { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
+ { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
+ { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
+ { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
+ { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
]
[[package]]
name = "zeroconf"
-version = "0.148.0"
+version = "0.150.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ifaddr" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/67/46/10db987799629d01930176ae523f70879b63577060d63e05ebf9214aba4b/zeroconf-0.148.0.tar.gz", hash = "sha256:03fcca123df3652e23d945112d683d2f605f313637611b7d4adf31056f681702", size = 164447, upload-time = "2025-10-05T00:21:19.199Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a5/46/ac86e3a3ff355058cd0818b01a3a97ca3f2abc0a034f1edb8eea27cea65c/zeroconf-0.148.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:2158d8bfefcdb90237937df65b2235870ccef04644497e4e29d3ab5a4b3199b6", size = 1714870, upload-time = "2025-10-05T01:08:47.624Z" },
- { url = "https://files.pythonhosted.org/packages/de/02/c5e8cd8dfda0ca16c7309c8d12c09a3114e5b50054bce3c93da65db8b8e4/zeroconf-0.148.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:695f6663bf8df30fe1826a2c4d5acd8213d9cbd9111f59d375bf1ad635790e98", size = 1697756, upload-time = "2025-10-05T01:08:49.472Z" },
- { url = "https://files.pythonhosted.org/packages/63/04/a66c1011d05d7bb8ae6a847d41ac818271a942390f3d8c83c776389ca094/zeroconf-0.148.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa65a24ec055be0a1cba2b986ac3e1c5d97a40abe164991aabc6a6416cc9df02", size = 2146784, upload-time = "2025-10-05T01:08:51.766Z" },
- { url = "https://files.pythonhosted.org/packages/7c/d4/2239d87c3f60f886bd2dd299e9c63b811efd58b8b6fc659d8fd0900db3bc/zeroconf-0.148.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79890df4ff696a5cdc4a59152957be568bea1423ed13632fc09e2a196c6721d5", size = 1899394, upload-time = "2025-10-05T01:08:53.457Z" },
- { url = "https://files.pythonhosted.org/packages/fb/60/534a4b576a8f9f5edff648ac9a5417323bef3086a77397f2f2058125a3c8/zeroconf-0.148.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c0ca6e8e063eb5a385469bb8d8dec12381368031cb3a82c446225511863ede3", size = 2221319, upload-time = "2025-10-05T01:08:55.271Z" },
- { url = "https://files.pythonhosted.org/packages/b5/8c/1c8e9b7d604910830243ceb533d796dae98ed0c72902624a642487edfd61/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ece6f030cc7a771199760963c11ce4e77ed95011eedffb1ca5186247abfec24a", size = 2178586, upload-time = "2025-10-05T01:08:56.966Z" },
- { url = "https://files.pythonhosted.org/packages/16/55/178c4b95840dc687d45e413a74d2236a25395ab036f4813628271306ab9d/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c3f860ad0003a8999736fa2ae4c2051dd3c2e5df1bc1eaea2f872f5fcbd1f1c1", size = 1972371, upload-time = "2025-10-05T01:08:59.103Z" },
- { url = "https://files.pythonhosted.org/packages/fb/86/b599421fe634d9f3a2799f69e6e7db9f13f77d326331fa2bb5982e936665/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ab8e687255cf54ebeae7ede6a8be0566aec752c570e16dbea84b3f9b149ba829", size = 2244286, upload-time = "2025-10-05T01:09:01.029Z" },
- { url = "https://files.pythonhosted.org/packages/3e/cb/a30c42057be5da6bb4cbe1ab53bc3a7d9a29cd59caae097d3072a9375c14/zeroconf-0.148.0-cp314-cp314-win32.whl", hash = "sha256:6b1a6ddba3328d741798c895cecff21481863eb945c3e5d30a679461f4435684", size = 1321693, upload-time = "2025-10-05T01:09:02.715Z" },
- { url = "https://files.pythonhosted.org/packages/2c/38/06873cdf769130af463ef5acadbaf4a50826a7274374bc3b9a4ec5d32678/zeroconf-0.148.0-cp314-cp314-win_amd64.whl", hash = "sha256:2588f1ca889f57cdc09b3da0e51175f1b6153ce0f060bf5eb2a8804c5953b135", size = 1563980, upload-time = "2025-10-05T01:09:04.857Z" },
- { url = "https://files.pythonhosted.org/packages/36/fb/53d749793689279bc9657d818615176577233ad556d62f76f719e86ead1d/zeroconf-0.148.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:40fe100381365c983a89e4b219a7ececcc2a789ac179cd26d4a6bbe00ae3e8fe", size = 3418152, upload-time = "2025-10-05T01:09:06.71Z" },
- { url = "https://files.pythonhosted.org/packages/b9/19/5eb647f7277378cbfdb6943dc8e60c3b17cdd1556f5082ccfdd6813e1ce8/zeroconf-0.148.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b9c7bcae8af8e27593bad76ee0f0c21d43c6a2324cd1e34d06e6e08cb3fd922", size = 3389671, upload-time = "2025-10-05T01:09:08.903Z" },
- { url = "https://files.pythonhosted.org/packages/86/12/3134aa54d30a9ae2e2473212eab586fe1779f845bf241e68729eca63d2ab/zeroconf-0.148.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf8ba75dacd58558769afb5da24d83da4fdc2a5c43a52f619aaa107fa55d3fdc", size = 4123125, upload-time = "2025-10-05T01:09:11.064Z" },
- { url = "https://files.pythonhosted.org/packages/12/23/4a0284254ebce373ff1aee7240932a0599ecf47e3c711f93242a861aa382/zeroconf-0.148.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75f9a8212c541a4447c064433862fd4b23d75d47413912a28204d2f9c4929a59", size = 3651426, upload-time = "2025-10-05T01:09:13.725Z" },
- { url = "https://files.pythonhosted.org/packages/76/9a/7b79ef986b5467bb8f17b9a9e6eea887b0b56ecafc00515c81d118e681b4/zeroconf-0.148.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be64c0eb48efa1972c13f7f17a7ac0ed7932ebb9672e57f55b17536412146206", size = 4263151, upload-time = "2025-10-05T01:09:15.732Z" },
- { url = "https://files.pythonhosted.org/packages/dd/0a/caa6d05548ca7cf28a0b8aa20a9dbb0f8176172f28799e53ea11f78692a3/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac1d4ee1d5bac71c27aea6d1dc1e1485423a1631a81be1ea65fb45ac280ade96", size = 4191717, upload-time = "2025-10-05T01:09:18.071Z" },
- { url = "https://files.pythonhosted.org/packages/46/f6/dbafa3b0f2d7a09315ed3ad588d36de79776ce49e00ec945c6195cad3f18/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8da9bdb39ead9d5971136046146cd5e11413cb979c011e19f717b098788b5c37", size = 3793490, upload-time = "2025-10-05T01:09:20.045Z" },
- { url = "https://files.pythonhosted.org/packages/c4/05/f8b88937659075116c122355bdd9ce52376cc46e2269d91d7d4f10c9a658/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6e3dd22732df47a126aefb5ca4b267e828b47098a945d4468d38c72843dd6df", size = 4311455, upload-time = "2025-10-05T01:09:22.042Z" },
- { url = "https://files.pythonhosted.org/packages/58/c0/359bdb3b435d9c573aec1f877f8a63d5e81145deb6c160de89647b237363/zeroconf-0.148.0-cp314-cp314t-win32.whl", hash = "sha256:cdc8083f0b5efa908ab6c8e41687bcb75fd3d23f49ee0f34cbc58422437a456f", size = 2755961, upload-time = "2025-10-05T01:09:24.041Z" },
- { url = "https://files.pythonhosted.org/packages/d8/ab/7b487afd5d1fd053c5a018565be734ac6d5e554bce938c7cc126154adcfc/zeroconf-0.148.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f72c1f77a89638e87f243a63979f0fd921ce391f83e18e17ec88f9f453717701", size = 3309977, upload-time = "2025-10-05T01:09:26.039Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/09/ea/34bb185645ecaa18d34e5883bffea71aa9bffbbb994634884e8b2f3ad0c4/zeroconf-0.150.0.tar.gz", hash = "sha256:a5fe7feab1de6ef5e541e0a3d07e534fd91629b813fc27281593584100f63164", size = 213634, upload-time = "2026-06-22T17:52:37.982Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/08/22ccf6d3ba5f29d343dc8f9d45e2d8adbfc80b780c55a601fa7811d30d6f/zeroconf-0.150.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcfba6f9d344ade1f60aa8b5621a2fa8f65fbe740edc40b05627516bc55eec8f", size = 1686768, upload-time = "2026-06-22T18:24:50.421Z" },
+ { url = "https://files.pythonhosted.org/packages/de/4a/f4df1ee10bf74cff25c8825d3490b8b71f57d7ec559c84b76bae42ee3444/zeroconf-0.150.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31832790ed1bcf829b91d4697c6497fade20dbe1094c35d10d5af456cd0e8360", size = 2105247, upload-time = "2026-06-22T18:24:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/d6/8af2192c3ffd8d67c36a369cdbf1ab388b672ef1a94e5baf73c8f003f08f/zeroconf-0.150.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ed611401d1e39f7d27e9cec8ef0b06fffad06955cc3c54714075b037d7b6a8b", size = 1859144, upload-time = "2026-06-22T18:24:54.615Z" },
+ { url = "https://files.pythonhosted.org/packages/52/d8/09ae5f45ad6bde1decf246152c5a3c5183829ab3966d90cec2b06e9dc468/zeroconf-0.150.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0aac92f26cfe8075d6e210e5edb8194cc0b1d16eecdcdb04f31056a14cbc6466", size = 2191079, upload-time = "2026-06-22T18:24:56.439Z" },
+ { url = "https://files.pythonhosted.org/packages/60/78/3d46201c05a22b3eae1e48e82540590c8689a7646183f9f2c28b70e6edfa/zeroconf-0.150.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2fdaffee6f71432c64b609ade44e48763484b6084c7954eafeeff0cf4dbc4aef", size = 2125023, upload-time = "2026-06-22T18:24:58.322Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/d9/65bf08d612551306227cc0843d0d468678d88dc1b101fdc2861c6938c40b/zeroconf-0.150.0-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:2bce10cb490119ad051f3caf74088317fec9f5e9acbe36e3690abe8d3de2ac31", size = 2187807, upload-time = "2026-06-22T17:52:35.597Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/a6/fa1f712409f438d6a5b8f61c6592747f54894ae770841f8a2d659d79c638/zeroconf-0.150.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:048533286135c467a3bb90a268a9ca3ee3b98c3809d0106359e4d4c0e375e8ad", size = 2136974, upload-time = "2026-06-22T18:25:00.098Z" },
+ { url = "https://files.pythonhosted.org/packages/35/a4/e8075345b6a61cbc8c7d75ef4d6bb58d0055dbb75887c6f3789f64214b60/zeroconf-0.150.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12f14afb42c08b8365d638ba2a01e5a73641dc8d5433fcdf8751d636210db053", size = 1944626, upload-time = "2026-06-22T18:25:02.376Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/b7/417e588bb208d9298e081f00aa41134becf6d05738169f67f68f9a0faa0f/zeroconf-0.150.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cf19d67827af63c54175ef76a62a004734c0d18ac67ff383a5df36c26049707e", size = 2133245, upload-time = "2026-06-22T18:25:04.358Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/b9/6de53f203e36275f2ea97566c36af7ad28e0def6a84da639ad4eef84910d/zeroconf-0.150.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49ae4f9109b2b7aa04fd75677ce33d552bb3db6f9e757acc6e2f6a547cbe26b2", size = 2213535, upload-time = "2026-06-22T18:25:06.239Z" },
+ { url = "https://files.pythonhosted.org/packages/32/2d/e3509c21b146048759ea63af392972d5d3b39a218db58415ae84fbf3cfce/zeroconf-0.150.0-cp314-cp314-win32.whl", hash = "sha256:afad9aae3a10c4e54a0b55925c29b9d82d1e807f996e5e106a2b2fade6a9e3cc", size = 1306214, upload-time = "2026-06-22T18:25:08.291Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/97/d0d1221906963596d59f7a76086d22392a530f5851278fbe093db46809bf/zeroconf-0.150.0-cp314-cp314-win_amd64.whl", hash = "sha256:cd09bf1242af33769c88e1f0236ce52f6c74144a741c848fe38a79e68df80414", size = 1549661, upload-time = "2026-06-22T18:25:11.245Z" },
+ { url = "https://files.pythonhosted.org/packages/81/4f/631f56f09dbe1aad76a3dd38bb92d4e5cce50a6630064590926d578d21da/zeroconf-0.150.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fed39ccd107ee219ad805ee4c38dca253effc1c96541b4954dee3c70dca13a71", size = 3362888, upload-time = "2026-06-22T18:25:13.066Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/9a/53d1111468a69f395272531cb292c09e7b03ecf6186476d2c4a6dbe78198/zeroconf-0.150.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55d0c37be291364268688fe5dcd33bd5156f669289bb9b71a23b041ca53c9017", size = 4023043, upload-time = "2026-06-22T18:25:15.023Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/d7/4ce3504120afa854fb55799b4f0e36c27b8d3def3d41f2d1731179bd7c19/zeroconf-0.150.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b97234eda197d4243d859b64004d175de76353dbca17527d11705f841fb02977", size = 3560681, upload-time = "2026-06-22T18:25:17.158Z" },
+ { url = "https://files.pythonhosted.org/packages/82/60/5804e6362e4e3dae64923423b46c0fcc291a341d1e8ed50fec7af1cd73e7/zeroconf-0.150.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a31f0ec4bc3f125ae541fb96259a081f3f83d201f1fca59929155b90185df4", size = 4178405, upload-time = "2026-06-22T18:25:19.735Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/2c/502e92caa81a7932fca22f78778e622ddca318f8d2243eddcf24d24ec0e7/zeroconf-0.150.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ee162b43e455043e22753cdc1fae0f2990d3fe3767f2e88695fb92273d7229b", size = 4044586, upload-time = "2026-06-22T18:25:21.812Z" },
+ { url = "https://files.pythonhosted.org/packages/45/a3/a37dd7fc0b22e0c2e13b279611ae8060fb046d3712a7d33313c8b7b556a9/zeroconf-0.150.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:178b6141e34a777593a16b8bf986b2d7ae806e47849a3ad517597f63bcaade47", size = 4089064, upload-time = "2026-06-22T18:25:24.169Z" },
+ { url = "https://files.pythonhosted.org/packages/41/7f/c2e7aeb8feb1a6ef3474d66774fad1c7b6040ac1bc48b188110d3c9d17a2/zeroconf-0.150.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:410e30a80db9b73bfdee3c54dfe306ebb140e053dfe3b28cefd54756a19a73fe", size = 3717837, upload-time = "2026-06-22T18:25:26.305Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/ee/b6b5b5f4f51296b7c686f57642003369478fe60a33a917f4421a6e20fc85/zeroconf-0.150.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c539950bba739bc3980dd0f01187637e6237ada8bd70d64f93d8eb5eb2837a7b", size = 4072641, upload-time = "2026-06-22T18:25:28.356Z" },
+ { url = "https://files.pythonhosted.org/packages/54/77/3c292c091b636c3a0af5c69e1d1ffe83261737d34075efc889fe25380b32/zeroconf-0.150.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c8ccc9e1772ce0030f77e6a49542e9b7ca4e8ae9ce1bef84a3a983be68ddf81c", size = 4225357, upload-time = "2026-06-22T18:25:30.468Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/5b/54758b003fe687757f9d8313a8ceaa7560e1c392ac109890782283ba1840/zeroconf-0.150.0-cp314-cp314t-win32.whl", hash = "sha256:576e72a9e9140d96ae110ceb0935f06fa41eeea8b2be53e442a6a00c84035194", size = 2599274, upload-time = "2026-06-22T18:25:32.463Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/be/9eb576ae5eeede25cd1e59f0be1ce6b2fcf7aa5a4ab4d6d05de2d30efc4a/zeroconf-0.150.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bdae152e80b848117691ab59aa8b16bf15323c0e9f3ddc38c6345af95a700a84", size = 3106484, upload-time = "2026-06-22T18:25:34.89Z" },
]
diff --git a/websocket-api.md b/websocket-api.md
index 79e95fc7..2eeb6f8f 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
@@ -109,15 +109,15 @@ 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) |
-| `sub_devices` | object | HA device ID keyed map of BESS/EVSE (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
@@ -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 |