3m-DsFW?@usj|bs`ClUNnWF;pnzG
zENeSNGA&NoIWzIwg2sHq4o7jC-$g)tTE1!({jbnv*};O%ZCL~DpDSPfgis&GfaSHe
z8D!hHLnjP>Fp?Q{$y+G~!=}=?&ow&O5_vH;rK*qqz&@Y>O9;
z_YZsXwYvfa-=F7Il=p5f=7OaF+#pt3iEPWc8Q2+gy5h2W?&85#
z771Q8#zpDSPv)_5yW>05;Gl+SW}s0_o2T46<1Cn1}yh+)hMr
zZwi&YxOGRjT4bavM1xc4frOm{
zL*f!nG2XGHYB5$bo+lt7#*rh^QH2{c?|0V8ckXf$a?{t`66!?!x{MTQR?N3Jg0$*Y
zy82*O4?QzPf*9(nTzzM+n~_86SGDi-V|{xiPMbr|9y3i9S*+rc>b;#d8@aNR8G2RE
zDDjJnzO{Cif#Ow?{hX&*Vt3e*ldHsbOj(|g2D7ypGtAb#{O4U6af`LnxWp_;=hG3m
zfcPwiP9#S$MnS)${|25HM&GqB1Rv!M*k$vVzXSE>k~-`>sU$%&F8Ow^xE```oGwHX
zWuB|=BWrsPwL(ryn~rY}=5uRimaFS;T5y7&!YwU5O?isXz9(wCzW2@5Erb#ii2LR?
zc-I08t)%gvDZc7%<{>JEqyb?W$fCmW5y^ei3ugv)d=Yf+>4uG2?Mq5mhao#TfX?%@
z^`(nJShE1d^KtWXWfmT=v%7NEQIQpWi{0}g{M~=0WfvBkTE;)YWF1St9N$aME@{~8
zY$-iANy_!jXW<3d)Q@ay4bN`73(QeTYb(A#J)wEQQ|nz>C~Ai5sovhBO7TW@&;+k1
zSQW*GzG$j&*NSM?d@`rSB6*~;Fa
zE8nL)yAp3RN8c&c_K>5JWKF*lKXH{zpimbs04$Vx
zG}JSDn4&Z%haLe;6iTj!&XzlA`u=4pMUwdtAm^!H1f56q;@kzCOCQp=m|0e
zdYFId`6~K=m|}i0N8D~K!M(Nkx>up2}yITA)SSymu;xq$xw2l5@~W-
zzN_!trz7_!zF6qJ?>*9N&%t@-UNPQILUXSfqk%38m00K!Q9JYV>Ehq1EW|A8xZ5uT
zSKG;-9OF-|RSih9>G&=r
zy-0t394!H@OlMRcq3tTJ?PO|`BwoD1mPGbemrzSiPxdyGg$?a;`w}ckdAdv3WH(-1X_KwWwiJbDhTiI&5by+$y;{N>qUL?Pz+*ihHOY+)PaOJDFhqx5(Zef*kb;!Te+4kFyTKG#0ThF#+F!SCP
z3=*+CKbaoO%U$#vz^{*`rt0e&OCt$2yZ%@Xt&S=TEj7oz2brlErrH^;
z4R?YD-VA;X9YWvIqPG50-<5qc@?Ng}FRnD(soj-tZA~2FN1yy8x~N0=gd9jCTi>CQ>bRQGPc;)R)+7*QDnxm1nhxz(-Fk1rl<*DF8fBkQL(Zv2|xI
z^YYoc%J5Z_2C5vsrRzXMk!WN7_iBIP26j$R1*-grY3K4JJbk~L4yLBge#2Qp}bX_TI|2kT&^YDsciC1i*!QuqPoxay8(LCl}ByA?5q-Zd{o~NE00@jG6HUGd)n37dW9>U
z;flHZCmz51B??lp4ryw3jp>(xT<0uOPCdDYgDgU%=O_k&})17LjI)~i;UO%rCg
zA+8fT#rp%ZxdJ;057wT=VpD7aw#7L>Y26~>pD5EtdXrJU$a@pC*vZM;pp(YE!{!S{
zl}T2u@MutI+-b&HHpJdLC!!PSBAW>GIK&Q{hCn2v5vj+gp~>`!y>i72m_FU|y)5^g
zj(-C0EB$TTef9hd9H;!{{gicEkCunco>3HexPb&2wr-e5C&J94>%YEk1I1ntL;5A_
zCzl)Stq}eey)baaUO8)URGYeba4eXAK>%Sr1$rTDAR7{qVs_dp7^?hNp^kY$3jAI9
zbSlRBnvKFy+DWaOd5~y(kz07!g!0X4<<~rn_(H=mPlWU95kI5*!1TGWA#Fy`*)P>uOj38}_u-Mvn$?YXDm9ZHx9a3L(mW9Ad>aO;s7$#P^)MGP
zS)_J7zzWn#@fU)f>r3?POnN~kjrVAqY@ax-op8<`CgUM?zA1e31suqL-vn;ey|!MP
zT{GU}*r2Jp*7K1CBm}NutYa1ISi5)_oMK{Z@7k3FA3s7!7A=z9x||pc<8@8X8OjFp
z5x{OxMd>dl=s0_{A~eG$@@3V6WpmE=pRM+*>^)jiD5EO+{tTXTA1gJVt~t7uy(
zc-59JoQjh|)2yxVK;Khijxcf=6>RkA?!x)$`>LIWEP4;PRN!M9rhO!frye9?hjw7A
zB&^ccv^2=(SnB4Q$L_rr!XW`DrZ?a}F^t)B3E4m0W}J-u>(Rf}2hIMyf@GGdRAxS&
z0aa9)ki^SFQ`g@;y?GJxL#)``%w72qcxg(*8)ErmQRCa3)r^(^aNX+f!eTKA#XTpf
zO@5xTM9}@j_38v0CLuKeuHZK|X4N(B<_<=tb7G!0Bb6$SFTgz;v^w9M0lRMF289>P
zm&38`V2=F4%2K&J{KKE~xYSoFCEePLiDSW8+CW8VQQFG((Fi5`&v(Lnf#k-X=5n}V
z*lX3b+wp~IFY3&10iWd`&MLHXJ}%b@62K0xOhKJGm?Mg{Viex6JqkXA<>H9
z!0a~L$52fFtd5y>Tc{b?{AuA}GSS28ULCyGs-9v0GxC>o!`2D5@7
zC;NQ~(a-vEgP)Cvzix3_t%6$;6|ADdEo(NQLbxAO|1_
z(#=HIq&uCQkrkwrnwD8MMG5EE0&ts3-HeoysKgsUfx)tJ9UqV&RlBIn@g_Q1Yu{0r
zH^FSPShw8iiixTQEgjR668qv%w=35z4G#JF&l|0Uah)CGqxU64Jq$<_zXhaE-ar;@
z-8p-Bp}%|QU)m&qq&*XgCD0apnT!<$W_3~OPwH%3IBao@W?2HD^*O_!;LVh`7o0Q3
ztPJUW+FJeI%rVO@^+l(C*@0?b@aLq(BI^aR3u!`r5t|a{lx3aXrnO?T_$o7wT7um6
z5uYY5Azo)K>l0nYCnHch^V?mtQ9OVfAkPB!1OSj9!4+|@`r8nbztyfszqYC%aguyi2ALS&Rg%_j9alg;
zsb>8hG|cvF%`@=XQb+G$us2>pbBtvk(i)({WGgiynzdH>OqVXZ`r3T$G
zbw4;I(xw!T*{$Dux
zVNF(@my)@^$zLFVQ)$_1q#F$wNv_!D+nTB@EOyw%jg-E<v$^fsqpE&f{x4<9WT8!LW-zxxbfsZ*?GDZlTc?
z7u_3>aB<{jb!h=H_343&@FUT@Aq0lM#3%uL4h<}wP_4<^JDAuP&e3xhbQk%v-
zrv)9d!eEbw@YV*hJuyzB;!KOtc4>HPwgi*~XB$m4yX
zI#aH$%MJS3nd9Yrh;hhLX7v8v&TGtkr5aVy2oIf|C9y>Em!rAZoYtFZ0V{smp4!lX
z-ld?An1ui#@6P+Ki7pETFW?pXvwHsc#QPD0mKPYeWv(Y_@Nsh#pACBePdOW`igy
zZe2~dVQ@91b9)w-dvZtbXap0dRxbkmu-|sa*THv8cF>O~LdI0h`w9thKi|=MP9OqSm3Iu{}{jv9MkYBmHn*f9es2ahNxBvPZk+n1{+{
z>Vg$t6H??#&Wl{qtw>Nr-?0bCJAF4GswX01-OXhSrkoQ(%{N7G@rCeW2XhCw$S7+;
z^eY3L1$kZ0-Sg`|8;<1#2(k2i!LHzxhDKsxxxJ?+`}pSPC%2B(bAy^(_=NWmn<+~HeqFAfKRgQTPX
z&&eROH~;s?l>P^R(f>}zY;Lb){h?ln(SSV`v7L}S88?pBU7p7^a3@_oh{x8D3<^M<={+!`327c+%)~ynWW{=piaI^K&Fw=~
zC$!kg)jJz;J0)4iVnZm7Kki|hz{lmG_gVqNAsPj$>b2^mBTTwb>FTqW55o7N``UT#
zCVomQa9gIhx1SZ(e_Wdm1wwvnG{P=}J54s?SGN
zwfkk?DSLg%Z3mR_GxYup5|Uk3T2fh=iP5)FUa$C3qLDGDzM!GEf=cE3LD?k@$VQm9
z%u+|K=s#(BDkz5SIWEjNP;O5T?AVM)2q&z)%BtRfB_{ZmE>2z3Y(rS1I2i6tUxNTL
zB>A<3(pTj#r
zyu(==usOuXCU@QY!9hJqYdEZS4`jy}T#+prpzsq`A*-iswn`MzFar!;V}0(?ym(r0
zh>-AR_LnZo7f$1c(u|&QxJ@H{`D%7LcsfdTqjHXpqk5QaqcdAD5()LlN%CCS!O6+y
z=yMw?gkxN*bbo7UK48q}aJ%7+uWuL;>NFgxMgogR{F37<2iXhHw_wSV5}Ol#^?|3$
zHL!BlgwWsG%3e0p(Cr5)yV;!wx*K*p3W*=j6~@Q?W
z@nd7y($df_KxdMb5VQ2)P_bxadCtX|d(4C?rH9$)wBLQL5jnx;qr3iV#OJV=IeDK<
zzHH{JEkB9h_!hq9A+6R6IMcJe7xebYKCvdx=^>se+vyL^UjHi7S7cIJX)$>xgzUpX
zl&{ikKH7D~ZR(qe3&8Z;8w_0~s?MjsoKVHMnKh}Hxz5g5Alfr~VLo-}X!f8~
zZs)EL9(cMGCV5A5**NEpd(0W4yg}`AsTDwLLvWt`@AE0wh86{*>4q@h6Ffk*1l@U4
zz>uy=VSo=7QDx`zeq>$p^e_!zO=kr!nAP~3hbo;?Ua=6WI3Kn?r-QS7P)Hadhn>&R
z@YgIsyBFP3;%+QTd0zb}-#@vW9A{flItG>*VqdR%(We&$*27`06yNu)O5C1`*2{6b
zvL+pN4bbgv9Q-Pe{@7Bv-Q8+#zv|6*eD)l&DPHcyV~OWEb;i%N@))gSfKWC;4{y|w
ztV!ab`vo)Z?5J#DWEaQci`=nq`f_X^7S)$?N8NS--x(4&k-2^6mGG;%oUz^a(iH_m
zZ#g&~KE{4xZ+c|t+XAlmVE#9q_eMVIrSD9Xg$aNPB=`u}62g$pIEw0HpBI{7k%XHU
zcHZrt`aC$Omj-z#3Lj3TbhX|~YG;;ZjO630pKKwgAbnuNgXN7ST*gXpAVJcSa`Y7WgVlpR?nB9P!*r1Yh)BKq*AOmO*V%Nhma{x
zl*b%g0*@25&*&ud)z#v58v@7-LLP85<|vn0ccWejWl*<&)YY52=?de~7T2Sr#U`Jv
z-X-dgAb^4wisRP4;}L&*Uciq+CHD@}x-uM7)0A9-W8AmDvu}5bFk=?I?1`6jueqC>
zM%|3J%NlTUH;3HVnf*58|@Gb@`I$RCt-$`+)$e40%W@yeJ{{}lJaNQyPnS}5>~@{@d$yxJh}0Gflu}TzAbdJ01gym28y}ZY@1*UC8lV>YU1S|d?LF*#7{xJ
z2DX!Pl;A0DCS_~O%;qum8XbFm%pp^^0#GAXR6s12t%&P^kls0p2wez;*q}W$yi#;f
z`m?unz2FWGEUulk6BrDFJcJEC*CE4gH5*+juTdt%mA~w=W
zZubU=14r_;)A=W}&CDbuv30)F-}3MI-)>=1pLG_!8cR8HV&igrgn0t0$j7}h9HYtX
zUU}I-xx43j2)V)QRG_2+O>(!=CkyxO^hK0Dj)^5NGO5mD*wnMptb$296?}*P;JT
znB-UI(1>YddpUHiEM2YXqxn1MiAHnRV>XkNSX=_et!ITD(w=DU`U6TtdRu?;m&vv%
zwzkgp>V}nQRL8
zn7?8WZoRRDltCz6!j4?9@@t}QnrtRfdiVLWZAU01gv+UU%$CA4qf#Kt6&0&+rv>^L
z#I~u2vA=ZJN1ns<{)ukNeOV(D*nqKXb3d>v1RoWNiyKN2IoMAOdQjsQUfQqaV%ot9
z2+`a{_Mc{cPq-B<+j0I^Kb>(7=0s`osPlmZgmo78P%Ulg_O80hoRoKDLC)C7gq*fD)u?jTCKCEDRNHdMw~G2yUI
zETXSHpWA(`dRe&2^YijhRRLhl|G_fx)nkeFpro8hznhyjn!A0aVukc{kC+}-tLim|
zN_&S;)EL*Y#U&pI!5IQ|;?;K8C0XGOdJhY?qPbZG9WA7EDPwpD4?A`=y0ld7TiAGC
zN*;XNYP+_ZUDHm1k8g_aofr;DqNM}2Z*N2e>gqKu
z(jL2M#1v+KwpH8r%3JsYs^EKBOnxZ;$l0rCw)!bkns`k1YDHHm8nJJ@b4s+kDzy5j
z1pC#!Z}N+ysE_mJstlebRqbi)?W7p?jVk?0MyNcq2UO#;Q2Kk!wS)ZuK#b&c$^Y3P
zjYWcg*5X21e&;YIr&=kKX1
zL3NDZy))Kv^x4aVS$X>uyqLK02ob9})0fM`UCgPQD+1tcK{gvae*plC2}V}WOis2C
zg9Tx2K|eZ+1s+TGDr!G^{4!$C+uJ>A%=tx#&dT54k#g#BdUUr!bmZFEC|VWWM8JG+
zGe)A53KlWC77l|?pQg{MTl3ki8`8$HrP5CMRX3Ysj5((=6}9Q<9v6vGa6Ff`GZ%T-
zJcxYyAm}I2MQi6XpKjEc<#Q9ENB3U}311MQq6IB)<5K7u64XsA&?5`{VJTgC_6E>~
zIKle1d$kJY^LdV}0@ubqma4EmTZ`{35J=Fy{UU?)A+0d9c!;(z!;W>SpzXYI$jT+V
z)n5Syvt50qX4k7JX!HszEo+dS8?27Mlajn2pk-g-6aOeq&klu-He-LMnEF~&_+`d4
zCp*e4a56tWQ&CzJlgpS0Uv=#`~65@10S
zipTK*!~9{v$!%iN7hnSED3Yz;d`FNCu8b$Wk2ZiIYIUQFYewlpsj9O8>K>N(c%s66fa)9CAdp*E$&d<
zX^Rzix2CuTcWLpU!QCym1-Bde?z7K6-~Ikx{h4H~tU2d+=9ptVLsqfsPwe2lCK)gA
zcGzuBG6Wke!KNK&Fe7?%aXu6@%p=EbXIof&s=Wq^v1XLl>+ahJtgav|`ApuiVIiME
zo1$VKt_l27J-n-(byz2}udwIEAPqC?lHoqLZ>%`|I~F_SG_4HY)J8$t%^TrYLC6~g
zWr3^>m)cm0kRS3w&I|Y^33HKG`hw0uv%ph#7R6#{*B0;eY+qszqwaXr(C(S2XelAq
zpxkH_&lvZXkD^^j5GSs?&>1v>+^@Q+qz$hDxFeE^T@3f;548&=(_S4o7~f*R{p4P-
zD2Azaah};PBTb&0*6UH*A75)u~I5ATB(j
zqW*$bqntP84i)({H-sl{YAN)$OyE4puddd$L?=0boq;cj$&Gr)X0^epc(2crmPiQF
z%ne@ziiWU-C`g&XA@o{xb#EYeDKwTzj?JS9*bYhlCC>!{xA0p(-^M7@fJZZ2aIS9~;m>lycr&HK-m
z@5u$OI|S~EvJbMfnxe|%yeeasNo_NVZMC7uY=ipbkj)yjo%^^jej?*fuY+WtL=uP#
z{Ze+q@|`VjBQjSz{p^x^yCIuut6xZ9&md~BTjVqxY4OZ;`14)653!(C-j-M9Vc}YG
zTTN*2kx$n1jsQogDGHu5iroH24+=pQ4MAxUi&yWG{I7C@ZekbiEU3v#wtqEy!up4H
z2Rw#{3#tX{X;Y0c%;|8_zKl;k(3>)+Lmi~z`&S@HXO{2Bs
zIT|hzJyU88BwnFTNj=>(%9~%4Gj|1?KJY&5hlkU~dOT%{s57n>hNokRcX|nCNjdUa
z*$ADLH9U`kx-~lT1^hCWs2a3va&K5V=m&<;1Nv15upF`E8%XZjX>kP~>M8v+o%nPy
z29{nR4BCyd@+&ztwY2ZV*|EWxmMBYA_)u3Mly%SjV#7pEf?;tuVnR_MFA4i*vpNlFo
zxKd_8;NlQ8cs3EC*P>okGj&S$E8wX9yJ|PipSvaiamrcljHZ+;Ju_?D$h_vVc1mQ#
zwaY$->gqR4dYy2&bsgg;4Qeuq^nQ6i=0?I2)Y%F8jg3>8?jVM)>)|lBkdgQyN|xs<
z>G;Ll>{V(<;<=eV1+@)gn(*tB9W1Rsm0B{n6$;=V#9p^3ocb*4jta4H=wYE@xMp_k
z&aeWT#$UY*Cm)2nWj0D~MTa_b9Phb4RLrdBmJGgeN6M6>%~j1v(iK?Rm~F@lwsVue
zeSS;c+Z2o$A;rkebD+}PF1tVG9f}%<^X`+!N31wRDq|bL7os(ArNuD;ZVju3U&tI4
zjnb<*X~F74-~Gd#Pj7{|Xj|O?VXGUY!Lc6UlX*|S@Vxz_NQ{}&trjo@C7#vu(VEuG
zgi$@e?Dse%P_0#mc)b!zM$kROJ~-&O_fCAt_5{6npUd{*v3b0M{7hA+c7HFlhQ)H&e4q>;emEbEVq$cLnvu`rXQLgdrLv*ozD{%tKP)6J7QN
zJI;+u49hUUqb^XlD4f3x
za?RnJ{4JM;v*HD;{lCX(Cz3_9J`VY}N81??6ScGs?WU;+%MHt}JIBe$Z?cV=ykZLI
z{}GNaJdy_Hu9|6mXZ;(1X{$~?e~mHf50k<2#-Es!rEWu$NtwKcDC7|F
zSI{;H{6-IeeT$3Gfd?Qpttm3?(AD-Uv{uHd%V;uf2gj1F7fM=!96MI%Hr?(LM)9;{
z{NrwI=T0ttg9a*|FlaCLgjuOMHufvpZdVN(&F@(}BvKJo*7>?y61a+O1laV|vp(_#
zFd5J!@`-PVMNmtaBv=mtL_^I4r6}BidsFV6sBw9^k6z*lHR0)
z|75Kfh0dJ9wZdnB(|~EF>{@@g?)=)hZDS|tfH#^4U4{K&LBrx9i39(B;z2TF4~w@5sPNX?}p
z0YB`+Lro)Tf7h?*65Fk6u6VD>+PK^$l8QSA5eh8%#i;NE2Q1L~By~=Y}z^=?G=PGpqMEvQ&+rrH{*7jA@g2-`ew3Ih=o1ozrb}Gcu=ryxV}62fuPB@zd>$bCte^y{5!a_9w*i^o;xup<$3aVSw-C9T
z9iAvu_Owu>PNH`GV48Ac-EnWN>y7+<5YB;POzrU`Ypq#UJwJGrMu6mlNM8P>ma
zuoXc1@b&ot$dAl=E>NqGz
z-@}2NOHR^SHdIN^``dT#R6ItD`?5jjl8ez<3=_};SaAZ;aDdF=TAo{jUssZ(h!B;tNX=Mb+C|b{R
zPCW32cR5}~zO(rp(|trvmXVm9pW76G+?7yF6s!2$4dkm{2qtv
z__IeLQtu7@Ln=6WS%E&9nn&66jmles;_1+Ucjb&P`}~*$fk#^f7S<1B{9m7WTtGoF
zmIldQ!|PsXa-QMffM7)7_%+n1yCJI&ha$_fki%9%MLABo_Bw)#d={bE+z{s66*Lvg
zTVm{K(&TB8!b%;+P4O|*^0o!pa%0pUm1ywWrsK;iXnO|=78%z5A;QtXYTG73tYwOT
zo1Lepr><`L2^yawMa)_*f>63b+cPsxfg`&rz}9o_$}U{mjn48*V%;v^8`JmDcA`>$
z9ROS|CI{D)n1>Qb6S(42)vP+SlO{lR4SJT*tgAJwn-}T*{=h2SE$%6+C?@Hqu1+^z
znuPrv3H}^|MkpOY!TUi1h{oav@^~wH>VQ)aKa1~Iz~{^{S2dwYgMO~P)))@`fp1zF
zPKb5jh6@a=)+ny{q`9H}l>iAud(>+aC{b2qV%onn==dP0ZeY%H((v4X1vK?m(eqWt
zo2IvsIw+Z5EMBl->-j$5_D-`a|9q-Ul~!@L!D^3beyJ`;AY1enD}juGrHLAyjzok)
za$^57yNyU>=(07#0xR^
z6dLnF5BqRH=Y|~KD)I{2M7#;${*@|&n93oI_~ju5)sdpv)6TQA_2RpL
zz$~7<{B8(FoeoCqju|PlUv^;5zF@t?4$_nLz)S0N*D
z`SO^m==8?aoUL4G31Ol?jtq+~jmFkapsQCpLLGdiK-pqfSW@@s_UMvMAx|bl42uJu
ziIQ_z7^lVBh64_Qdr(XD8gyw=@2s%$=wo{tD||_E_Aa(Dm{&<$1qb#Tj*MA4@_Ext0A6{mONqu$ND*|%XciSj8x9f
z7M#KQ
zN*f7@4cg-4xRUo~hoT*HyQ}IZNnEL=K`dYF4Bqg6QC3#92bbG8JvWHImgOw2L}$mE
zi^^VY`gz21`h&?#OhO^|Wb)_{4jsU;2Re2%JMKiV0iFwtJ-Cunp}n
zRH#5PG(~O~c*vXFsH|ap+V)A
z6m;S%ibtVF`?(@m>Kc`N(v|8j8b(X}B|I(fWExf#-zm-JdeK^P3szH$0m-w1eM5%%
z(e*YPNs23s
zB6e}yV+-nq%U+14&z(8&IXmiV`vM-)9N@f@eW!67ejg*`@hih%;8(u8<@%N-h_tsD
z<|m
z@u4qFgoR7&nyJd13~dWtwHzQkxW;3(8blsw<}RP~k}0on-R?;e%16ShV^u$$OlHG_
zE4hW>vc8z>W571CGvXG(w&qNkg!}6?LUDjo&J9+?vLC);y#l9GaPehU)5Q)bL6&w@
zaXlk-t*Y|9^Q>MGH(-P8nzrrH7J$6UXnqS$RoK>1%+s0<$G^(rI_b}E6X-=R+lq3w
zTF%BmQCi!3?#M_SDDb8&5oQ^7A_8W`5#84IkzWCA$5~2WE26ji)Lh8u{b?+5{49=l
z@j@7(D&BmQKtWOrqL1sMe5WGF<=%Xhzp^8zh4gzEWh!-b(HwKRg57HT?X(3F8E;8a
zMQh%kZxn_*EDL#6N6Yv7D$>_1OpvPann@VX3E?Z&1Y?%S{Fkx(NbcT(6?mAuM^F)$
zZi!CTVXz&0|3d*A(sI7M1-#M9Ny8X2D?S1aQnr%v_Fce_OTNI$Btt~<#Xk^zT?srZ
zq(8+0Jw-f*3;PRlJ`5}H(_w5G7824W-)o|j0ka;VfEu5505
z!TcV7`SAN~&&UdJUz7qilThh{g%AMZ+uv0IjwGbQay==5xdEb^+8FnwOT
z7U4T^@$JHT@^SXBxbhD{GjFWuPsj1^cSsqcY
zt$mcF_cntkjCTx|$$q}YIEM?XEAjPu?4xK!e6WN}`{MoF)7?mrey?>e#NgjucM%$V
zvg406%+D8gLLwtIRV7A~!n`I+m}fL572LRPu+v@|QsyOtfLU_qUH{=zmU7
z`dDFQvZc7Pe#wUmi^VJ7VHf2geZa2(zI(yutHmLr>>~4;Cgxvg{%VdyPIlcDW1gM6
z^#Thch}6{opDAX#vj*3*_DIstwKgrOt9_;5TG#127n&F3B=fwzJgzbu)GjytqhsH4
z)=g|bkj}^Xo7V-Amvs4Rc@Eh2KGvpswR0Ktr##!n=`XBCaW9~&6$GYb{H`~P3$^nu
zMgO&8nHX3?wzJiBpP2QV-A~pbee!CEbfqKQ-{4^1W5^6l!_4MhRZ(XO>F
zUPBDB|DQoVy?|E2(KAmf5U!wl306iLd0K(sWU(b}kJsT+Nzy&R_xQg60USZWE$69*
zsar!CH#bX&I-7Wgy|&}yAIDob=u0~v{g%&rkF9j`MURX%V{C0rj{p-`
zDh3-Ti)1i5#xLWWTz9&>*SE{`zJAm9EVU__ps~?WDJ9?2L0&=@?al!z)n0f6F)zm$
zpcjf%^TZuT@I4J0&i
zGI%tNpy9ACe80$~#Tj1V`(>4S{XVn@>t2nbp-qvORQRru6Q4GfrMPHGvz-90
z=d20A@L2lDJLsGeNXC=hh`5~+=ntE&3;th6%E`$o9mM+ft)cl*uQP|+@oLvz_`81%
z^E$9A&FZ?lTX)Lp++I`4$??PJZoX&m4Y;+*a-&av!oZtzUccpJBXG9=?!GllKuwFu
zW%FR9!v4OVa+@|4bckh8?CalY>icPK!v4m%&??tz%X03>;-c^jO)BX8;Z`2l6~ru^
zc;0dFc}jeL^*HEFFO-7+d>_$njN!S+WylhFtzVcej|mKpXn`y7#>C>Q)-pB+9F2`e
ziihO(a+df0=AtL4$y!#U^|l?2jdgr7c3!S0A%te4@Zj6Q&V;neL=XnkRZa%Qf=903
zWtfU34g`94=OREj3fc%L@tL{?Vqeh~o;$b{hMK{YSR_Y@xtGxA7&U@&&!ql$L&-Bo
zT!rHCdjQ9ZuXlcY{F9mb`@f@#;c)&PCOtZZrC)t>x3DQMyN*(N-^-M6ZM`VvxninT
z*mXV#OMVAgYB#e;sPL+vl4JVVEj|vUZ?U^KOYZz)V&U%jzmbmW_bEIM;E0JeHxWik
zW?USuun*Pa^C&jaGs+zw#OJmWnEVTQE$<2!NAAOP5(GpJ$?f=(@iSd;b7uRO;B}v@
z)wIy!as+mJpV|Z^9<=ZaW8N95OkQI!O5<>F3F_?40R?eYU-};5wu#t05a^JYz@RPr
zCnqPO7qz%~uN@oGeMD*g^~AgOc3!L2tC=M|okG2qcPqJp#Nj*-7H%6mG=*g#KX_VL
zV@UuZZY7V#q$XxjVu8ZU-Cb6a87-|8Z`RKF(uEF|nYan;gAlxix+sSb!m){vkt$ex
z(vbq#q1w|b)XB*nxQrrleA97mU(xk3NFinYL35`5OZE|kfIym$=wJC$oTJN!CA*l-
zefTN3*4lQldY^FX!wK`@EA#2saE3BHz0$nZak_ZhHa&c|&~jfPd>SN<4M^3jzc=*H
zSZwqc++A%=kS^HxY98U^^~2l<^$(Ws>P7JWl2KR`dPe($!BVk$sdkgAt-gMyN+F)3
zlf?(mu2B3v-0#t#CuZM50M{b-Ya-M2{HM_oVnHtuV8=QxXL-`ei9jlg9S7Vbkwu5D
z6E-j)=SqeJATlKQkqSb#vC9C6rv23wOJu8`b)Nn(e_ZT&a|=<{Bt_+sZ|E5B(Bxu3
z0GnhV#_5Qc{*PMaKdzj!>4mBSgI**f8x2D_F*vw>L?Q+sZ2O8Lv*J$2rb+~8Zf4ec
zxf*6ujPzQhS0|ZalBz9s+eSCTqgO&f-R#BD^t9U#_vu8WRlr57v
zNMrPH2~0l&dV2b*5SL9#Vu%gj?>OTn%72e?Zf_WhM!@{K``bGrjMr|xb3B3ME_C+m
zA&1tnN?QZtBNmmPO-||T{@zCaKW<~V-BxEzh>g=CKK+($@-lOfONo4RR)_>quSUwM(?W6h0@TLhhZ5Jg-s@aq-+aW9+U
zTelQEVs>PhiJe;%A<1Fk$94EE_uDMGkR70{Vz_hULDcUw2_x2P9U5zQR`B>a
zGiG1hhZa?w-aM^oUgPECYmilX{zF2-%*Y=R$%OewffR}uepoM*hG~kgWvEewwg+;CL1y0+f{7N_W8#>mOsY=L!oW3q
z-I76wPL!bSRXwz54_;u|;^dO-
zUJXNMW@pLFoywVY)OD>7t$EgaQ59UTuS;Lxi}1-?T1c?;?ERmHV$o=GdU=TxIgV!+
z*jhGhEmV|f&EkrMF}600BT-nYwn9CIXV8!RW|mP4F6;Xi(p%{JHjIK`U$RHYAkJ!pUseGyv62&VE)w$^
zI=~fWp&v7F_0*2o*y0zmmH6zy|JBTbqgaMdJZRIY_Rv)?K|g&-{c@J?GO
zQ$4Osi9t3FFMSWbXvSHc10CN!(^gDE$&~U9<(8RoOa%dHX?vl=CmRBk>QisD;kOCxxZo=>%+ozGinYY_(OborS`UC!eRkSt|&;4?&}
zkCCd!W6f`#S+fSi(Qw2+B(!P$@<=rsr_ooRRp9Xv5QXB2YmQ
zOD~Aa3ZF>N;HOBKHEI?bVkZ-GJodOzvU!oBz0Msd9JQcr3^hS9W+=6E^_+_sv6~%$
z)O2^VVwLKUx$v~{`r0=gEAQhe{u&tFzi
zp+%lhCkmUB|K>H9VOe`|nl`bdb(8pxjG+=p_Gj-%5k)H70{{+OwAzngIU0i3}DL;S#+X6H0-tV?k!%
zuz^Yr<1C9_rvi%MKO{f51LetsIcl)yJ3nQfn1AMCv_=y}xc
z_J4bL%^VXY2nva7-_k*5i1D4jA#B&^~L-zaec^AgM9rh)M?$zIDXpX-UZv@>Bj}t(Mi6CzX$D8DuAN
zA-`-Gzn~QJG(DSa@67mH_ZgYNenxg&h34c&L?barPFHm1?g|YCdZUr=0;1
zKh#edm%l7dtjoax7jN
z{XNgK@P;Iijso&}p=>{}vs3U_3y|YH#?giRv>MJ~AsgW6V6@ycYZtFjf+4I(;6vFD
z#VWL1@vJ03lnJw(R`o%Ym-M+_qr-?bx0m95jGy}NU-PLghf}?QC*evgJ-5z<%^y;`
z!Dh-I|B%myT{*^-qi1B+kvzmm@Y8Ms%z
z87#Bb=7i59Hw0a09(o_JF|MCZrzkQ?TiUoSz0Z>~GXFs%?NQX>bC=BVE#sKPqZ2*j
ze&<}t6#3!wbA&EGCD5J@(3qO&(OhXc208x~VdXKiB-7W9#z&CXc5FFwyrU7=|GZ-d
zw8h5KCp7)(+@Sq>-zF$CtEK1{lhHF_ONQ6o?N6dX&o)`#3XZMa9LQ*$kE7H*hwS2a
z*Etf>94Yeov}{#zq`-QPrN-QOo_^ix0CGA@O~;Xa_c2S>jMRP==ENkj3m`eW@pGt7
zXN^4mkeb7>csb;=ks&&Rnchz&fOaSki@(7si>$nvtb`G<*DN4SJfy%62oi7`m|HGJ!;i*iky^Ca|tF7CpNj
zQn#>=!W$zj(jK=#O{FSt9;Et)1_wj-p;yn1_=uk%$x)u8-?jHMx!oVn@^%Y&r==CaQMvaa(9)pbQsw=`A*uTjD_)c5p+#p&u7Z6i4mOM
zq(S|N(Wh3DbV0@1tLea9H}8A$`KgB{s*8yYd=}xh<>16nd^FTyzIlUBM;-L9Dwl|M
z=N4L(mkc$0Hg>r*={Jb%y?xf&8<>Qaj-RoZUwunVO6Vr&IWumkz={*RwRw&-T;aCp
z5zN;ZYk);AOd?D>iGHUt`G+m2$G1F#QCXUhb-?q^5V+)UNY2aR9i!)9g(YIe!dmUj
zx8o*tX%OqEIPY-V5dLaJIy6?*uXW}8%IAQXa8ZR-N-Da|Nyz=>d0>Pajg+r-t78RK
zv^ODMYSZ*cbbto$nA+X)h0oNOW2@Zz-bZu^Od+adad$Bq&^9GZU~iouof?izLvbO(
zSxx!((`GX#vDb%BiPsO`#pa89sefO0SxfM{zRmWJ1efW$VNn5lGC>-oHcQ0Hv41pZ
z{GjnoD#BVpoBgT#169w_@PmGdR38e>e6lHE;5i0(`d$~OS@#D}MO=tX`4}!Wx#5zh
zPZgZkk-T40i_enJtdnvP^NIz-F2RZN#PluHWP-tu9+CSaFiibgmBC50(f;;xXR6Kv-I%x8>l6wx3FI>oXM!+
z3_?XN?a~>PSz~(LG(G2e!y9fX)eW>_Z(bVHV2Kbv`F*=A4gjT|Lb
zFW6+Y)#pYutScFWtFAsP5ez=)mwBlW-Ky-dBW3uO-_oV9A=k})?|$0B9vEo#*#Q(n
z<~c6oUbEMi0kvSq=s7^V2d9V7-Lz_B^5(6dlZ7TpSbHEdF`hO`HVXhJNq6U9x`r?I*&~
zpzNVsbJXCq;qE~`4*rq-R?$gZVNB9}tu~LEoO|o6H6!tZ;>!7FXP8-sFF2eB0M}n~
z@bTX0@RWwVT}MiOGz$-pck
zqv*7tjDPt=`;xm_GaTT#vYBcx;iXs@I}Mp@VnIR>Vv4j
zfl=D8X7E`I;?P-gHlH_ux)w}_Fnmy?MhjXOeezq^)$XJDlE{ov+Ne+#gH0@An+Ns$
zBNb(-ii#m)!{_MtRjsQoS}LO7#S+reGUCg>ix~uO$O~K1ONFxWlWg&_x${MX2*uLU
zS{qNUr+5`?U)SstJKO{No
zNBXVX9M&M+kni<
zn-j(TKMCHZ{AX(M_~)tP5StF9cg8unxjq*QZtH{mzo`+h1bw#rSORFQ2l-9AIshQu
z(!|2I67OHkKY%_mN%zjWxwtP|S<;cG8R!1RrVPq(PXubJdt@B)+aJXhXt3%YR|-OTV}FgaclU_^(hG
z$$k8X33w`wKlCX6(HP_u2C}ho)h)egLo&^5US$)~Sd2yNY`q(IWxaMI54$pE1}m1H
zezedBflqG+qkwf-i|?`7F@@v-+Ksv(O=;iC96&7d^#_n9tKM74Dm`OmW&L^=s3$fs
zNm)1ahhV;Zf*_e|EI+3LSnkAOxh2iwF4iOZDnGSLb8UzQ5l^l?an
zh3UPgcs+s?Vv~F}e~|seC5Y~*J4twcy?n+YJGC4!*09Z&Fts4`=i|mtLl(*L2Iu6f
zUE=(w8d!8RrM-h{%rC(kQlaO}kVsFIndPoqwqEF1D(Fcj|0BqP%wf+VwP-?miNBvj
zW>NX&ca5kp`0+`5>@{Y0G^dbsLPoAsgV)Azq@R+K0-8-(bzf^?-Q_j(_^gc+$L1iP
zta47FK>q0enF1;fK%L^^=lVjOln`M^BdUh0U~*pyX-}iCnYd0t3cbuNJbQ;cMWSa=
z5V|}$y6!>>Hc4>VKDiy(d+&>d_ReoCA?WT=BA!m;Jjq|f9Gp`E+7?BTZ!95P=sfszu8Ixk4G5+HG~uE3m9;+_O)>ApVjiv@#Eqt^I@
zZ)GFXBJ6`EfRY&kiu&i~lEdQKZ$){AM>l?+L0{coL}wA462?k;5R?Yfq4+&(Ss*JV
zzQsI)B3vG?zt|A~qPY;ljs6^4!tb-~e9J#)+MI?kMSi?aNWVcdUNLG^8V!v_BlW*0
zmwF%B%+^z@%JM)0kGdwYC2T+FXQu>?DO+7c;MTLDApMYIOvua6uTu43H-i!C%c+ny
zOg`+_^qon+dWBY{&sJ)Qh(MhGa%B&h5+H6Rtp)Hh%V`(P`W>hJkj2ufAgO`ffkgHJ
z@Z&lbe%y_FsB|cfY+48J4tev05%xi!h;)$tQC=6Rme|Szy~rrGHbvd9)-aLyQvAI*)ic;$~m7DjJ6)UIVuqA3g64X+)(B
zvwO=)A4}a$7H`=@ZtDi-K|g{ltwMQZF{?P81%GTH42ySxnw)Sn=WvgXnBt{dl7hhV
zIbucwowozh+IpT9O@4bxH@->xMqNSl4QwcabD|6=t1I@?J&~EQ8
zN%ly`LNo+THk1#+DDO}JL}rA4DS&HfS~@y2Gqczv)a((YBdOkx{v`apPXb6JL~#Rw
zsM3v2on#SoOf-L8%O106%Ri7MD>!^&TVuJE{9VgGK0kIeu8Ouc5g(`N69g?u!qp2h
zHu{y32kbK$d=gL&Fp*JHzgM4&*)!+DasBuL5hbAg>w^A^{Rk0Hot-|Y$?~#r#{3u*
zlppY)^k7ADG>A{@+X`-R%hDFPHp9qQ5ZoG&3
zJv~yzoEpdG>^XNQP6RU=7IxiY&{>qpEa1^0O%r|XC$p?sb%O;%(Yw3pnP&bREP?$D
z_O5)pGT5LM_DA0Qzo;`Bf*3$*e{rQpdJTWd5wSd^Z1ZGl@tupiz_0~Pp|epzA~06<
z=fvBMKAW)>kQO4(KUWks4S!xT|zfnQ+ppHdERoAO&m
zc79@eHdB6;?EE)7f?!59%MovVDKd+y8u>XC-CLs;feF6D%?O_a&I#((@FPo7Ro{PK
zK(wY|GMRO9wT}|7`@ZG~PbAA)Qp?x*I7Nvdj3w{92e~9bE64wH<&+QlL4`%Qc7HzG
z3u}}OE$7ROZ)8oUljXN`%+Aj<%qRJ)Zd*46q{(^h@4JGh)M@d)^;Rd&31gx1Ekphx
zNf3+;g1=nXq69SdM~Y&)i~gtRJ?Ib&nPggqkhd+@Z(KvfuFUia+C(i$N|Bib6<%V7>{y+N_&lqR5OfJ9AH9Q6q
z(_jKPw?s^(3h!@J0p}mBW9At|hkZ4aA@IP<$C1R`A&j833=@rpvk|eC)GHd~P1dM=
zlPCA8z~mo@t(Ue9xbBUiwz10m`oCO?lMm(fyro-i4HzjJC{`5X&GrExRr0py*
ziN~{Nim{}zLC`!lIvgD%?%xRhuLwuHMbN=17rD~tZ7OJ-tZ;wt!ZmYJN@a$YJ|VB9
z(sOSMp&TTXisW>R-FN_W5u1pkitjIWQ&q_<$R2tH)+_YhYRFR2U0>VONOEzoI!ga)
zt1#*zw`nbB5S}A<{ls-~(^C3k!!e?ve~Y{la7xRNuIu+_`@)MT&{)2?7nmw2n>oU>exW|nId$Nq`-#^<>F-+Iq|F^cuZRr0o02R<87iUhBg;hlKak*BX`T_
z20t9Vy^k(boyoveEhD-`z5%#cf{CxdhD%u@4HEi
zY7sp`BPEP0(!(nK78lN4kL20c_iQTPpqh?V`n7kSw_?IkZ?q`UVyQ)hqGyIJX>oE9
z!QQlv-BXXf(i%0L-WO
zGuxE;ZCnbqUDMMm#}Tn)hW5h6Te$D&$B2mMGE9C=eJ(DZWHwM;oF<7&5P4!zT&(0B&)%3}RgKgtVXiNA3X;CnI~(9JfAEwQD`6fU
z;d(*;Ge9c!pN7a6TaAuq1giPNht`}DLJ|YI?F}>J(`cBM795UQn5>PEIhT)7w$RVPdTHGGWflL15Hlmk7kaIf
z*9MdfIbd*kICDZ==~T4G27MZ&C>=v$RHr)ij2KgX)&o6QKEj(ktSU8)Fk@pbsaH`P|~DcZKuOL-RnSLrr!Z{i%wT%z?5)Mwj<
zM?>v&3$^l(N2O9wqNUue`MWS49S&dZl*|i(e}bU#$>=Ov2KsJuz#fn^*7gZMF?uRV
z&3TL2aCDVjUAXHwrs5X8=NYQUpA`xEh^pA&aA3Ynudti8{~E`~_4QHM3EyrJ7oW^v
zj70wMlr)F4M7)IM!^O1a54vK0>x<~Roj!}SCsmIsJqVqYC-k@9dd;Z8;tZk+%J5f7
z{iJjcP3WZ|Y4RI)4$gI86a&JXZ_DL~HV^mYhxC|cEiii%L{k4jm#2Qt2MlC!5Ev@^lNCrm;kP;}CUY%|P|pdj={+iH|vXYUPK
z#!Ek-@u+Z}(sq3iYh?()4qk>HI)}KA$sf0FA|lu{&&KSr8*KDgu1>v}Q@LlgQSOSb
zp7GJM<&rn>E20dcO}?R)2!S$Op6
zj#M1Z@3OFmep}e;Vh_c$T02EWr3>`Ctobn`O2O8vvc|?hP4{O0p=c%+_U73WYy0mk
zE4Q~$>@}(;@CTrumlA`{hvj}7w5?Vl0>5={o!jH`AzYHg(qM|Hz!;iyFa^r)oHLDz
zTlI{9j3lB(pjbbY5|=+pjP#`yQ#;So&TemOa(CoyUJ38lZz=&-*oDXSKupvk$JFXZ
zIpltN6N?lveVfYj)2ONxZRC7$rWo6mt=OPOqCphYyj?k=do4HJp9bQ)>8dtr@H=3e
zGma;??&{qG4@>98FZO&x9^cNjyt
zdBH7jxnBN3G&$XmI?jPj$GjXfMp5fF`lE#!hN%whYuM86@#x{ZCEOy%{JXoMzsVF_
zw6%%U;U+DM^W)pE_NIS7N$ej3#4^F{w14
z^7q(lcTlz|>{W(jvlC+{)ee&s3MuhZfcB+&lE>Md&tNx8CF~3;);3szHhB|dg-HDC
zLtlCQb3DT)X95%6e**!BdE&I+jE~tc=bApdrmk@nCW`gt*4j$9DgJdx-s-?46NGs@
zLmY)CMWeh6x^wPd9P1sWJ0M?q*YAz;{tm!jd%;cm1Y?>T^b{>13K&d&LC_yf^v;yu9d%^=OoKc}j%7LQTYw-&W%`Q|jaB$U$YPikK
zc)PdvUHR9JDHC3fvc-e4d1MR<8H@X8=BK66_-Np>J#YW5`hXJ@>`)wgdJ<-i;7;s#cle`e2hLjtuUIqsiQw7QRs@`<&%WGG<|t--jt
zuy1~eIAG;j06ayvun(K`eu&3wMEj!t0>=-3&aWXERI>slQXjQWT21ypn?RNeqDwjZ
zptfM!I?W3gl5;N=O;ypb4{#yNcTv|bSS-gUoW+#x)=W{z|
zSR$fq5QGQlQF1tf1~=7?8|=u!<;w-ltfBifqUoTBObhCh5=MfLkGRNLC|bm0?W@mE
zsqaRJa;<5F;xfChY?rf$O*s*^_+M@yn`DoQZgiFbl;alLFGhvc`niTA{g%ZunwI``
zO%Q9hfjift1j$PQUbJiuUtu74;Ui~oRaxi~)<&LsqlTRQ(b7b$lL<)W;+2ro*r>{i
z*Ac*1{__iiBmNy5VQVg5oO-cvebqPURJYuS?!I;LYe}id523fW9M>r4<3w=h&=nGq
zEb{jui6DAsvg0!L@0w!XF#PM4RD>m>D12i%?j3qMo^UdSr+}^|7nFAx_95EQ_gCB(
z+%g7lW7)1PXPGsxjqAGbh{)q%4XQL6AlvZDu20I4hlvhyX>ergKcjF7>(f;rqoS
zv(%n`Qa-V}aTt>zYS8m&H{d>UL}Q$A_=89wRzH-1p;{^xlQZ51{b6W`yjgF=1mHT8*~zOODm?mvMgcrTxiVMx4sH)wA?8T
zZijn7zN!8C(F^@-Hs7)@C+`Z5YHRomTXY3|q`p6W=GD(xbU4gC1$(dm^Cd^%S{pmP
zADI6!SqAqDn2Ee)YC=cR8O-z}FdDl9uGcUvfr4%MadRnXJ^E6=
z=3C*#M$?^4M8yc1g+s5t8>}LjG#^h0m~>3&BDS4=6=y#YaJO~~Z;65l1qP&!hkuaZ
z+Fw4jy*NT2e08CA6w|C(Q4z2p^7*3n)C1PCpV#(|yY|9m8HQJHXjeUk$H*lWhv?R{
zRik5FT-!^1U`yC2T(joy^9}WcqT;`VQ}u)e=GML7Q}F>E;g`?J4MQ0dLdDjp>z!$SmJd{&x!H|0li@!c_1X-`TFP;FtysYu1*X*Zv>Hop)4I+rIDJ7T6+XgIFj5
zl_p*39g!|gL_lf;=_Rz#0wgL;KtQ^LW~kDo1_(t&dhfmWUIK)gyoG!3d(XM&yfN;5
zWoZ?K&4#3w-=}&&|HshexqT&ZEf=gGYo)e*UzL{
zMWD#)*C7h=yU?3<$l|uO>vn+hh`nG|e=(95TwW1?nSwgY$dUlbW_c}7-0+^V+U!e#
zPW@nW_Uy>_4P`=s!_sS5r~uLp>-$qCl6y&A!*bH!0yDggi%?{J6FS%`4Wz4;avqtB
zR+d+VfqjQZ?S>W|-zhi<>;IsQS&SAwBo1uGs9v)Lgn_)=vOSH`C^iyX-_ekNzKmE@
zjqG7n1MMOSV5aVL!RW+ECZj43v~}W2ww}N9_SYu_O?qL3s%w4CtT5e{g!Jlriuuy^
z2cM4CzvlMk2oiH885eGTMXE$lzci>hPm1lkY@Xa@ci@5H8rxV2EuQZoPZ_r9TWnqQ
z4^Rkfel|>0a)Gq|1^y(+m{M}f2HJ=+>xWFf$=C0ex3CJ|H1w1aYHl4sa$nmk-Hq9u
z?-6<O>}8bafzKKT0@mTAgabF8+}
zQfdxGo@ccTB^H29!{wrL0@9S2xcKQnww5BR;!>pfNEV=werGcMTVDTnLf0h*$^@Ce
zC}p5_M=&Xl3`qQ@7vOWj97;S8^1bwnv?ikwQ(#Z5_xW*p?<}h=HAOhih^N%7vFNFo
zma~WZwC_zLGL;w09q|bf=e^x*)pZ*qwI6-@Dx&wN5gN(x>fKj1m&Lc)pBZ}<Ww+MMH7~KyAwmxZ{2+NGen|dld^A?!+4OfA(y(&9%dW1L%U;ss*VsknVI<
zM))FdQ^2VmSu@(A^UWO^VX!8w8aCW!X5n0%vw!AV9DIsXG#Zu~i(%T$2y#uZMdjdG8GinzuK(Nu_DjjFhI)jBSe~NT($2zKR^Kofen4BKs<}bf5Ri
z8~nSe+Swup-p4dzqFH@H5^f07faHWkMDh5xyOG$Z6qe35>46zqy7lu%h~hJ3Md|+9
z_a_ZqEisPAmYeu@I{iFl4+!qd7{&9+W?*|c<&Kld!FLXY+P1g!L=Du2i46}h=|Mqy
zrv>Ds?qIQW%>}ERRw4?4jnq*w&D{d~8OFz3+He->X-j_S1N1?0`}FX0U@-Ym!lQMT
zv7Et7nxX@2TP6D1%bol5{v`|2$jjoo`43Q!&H`0s#LQkRN>cyb=MEDsSE2{<$;>6|
zl6TIk2^zu_IBAh7D4$^2qqrgB>GI6^jRBb|LQ}v1XZ^5#sP(Z-J<)YEGdtv
zwY~7rW251&t&T{03^i>p86%szp)oA`;6?T~VX6#_6yx2a)R#zA{+jjq@1};kEOEIe
z>o3$@_SOcYj_IWz>uFC)PI$Rpv6c{>?o&y*)xf|qKG8Eg{<>0V=Sm7{*_n24V(3}p
zTv<#CSRBli$Mkr;r_AIIR_}pkRmnTMrh<&w)`We(cP6KTbjKSqfla(I3Sq@;cak?j
zTlN*+oh+md2uKe8Gov9RotqCOy&-XgpYtqOH?N`+Cg}MD&k`O%PBWre^!o3ble|tU
z=^j(rA$xg?_f#6AH*~-WvLH9N+N(J4+G=7{=8|$6uX_5UXuhX8j9XbnebNhSRQz^b
z@jPYgWyMN~RREmb(ulux(S4|8&{pbnQEoasP?91?sxK>tSKEWJ+5m$ver8g8P#=V6
zL~Ap&mwZ|{x79O$#Dh?s+Y3h~HM>pR?sI!6D=pHCJKs_fKGvz7u5hdJYD%X$!S9r<
zsp&BoH!K%F?sq)s{H%tH+Z-HA^Pl>K09mZhH?)1xsRYg^3MRG
zb1Sg067Qsw#!(~m-NFij&3M;4Wi#ku!yM5o_{_zAuFr_9;J0zb3|rlT$%9&ZkJ9mU
z^)LXMVby#l<#FVtQJ|Kb^|e-X!iVoumo?RSy-p>cgcB=Aj$OkQUF+10RxghY;<)v|
zBEW_A>5gfi)ibL7<$|dj80yBj%QQ%xVfs8SO}URr-R%#q!;fI*KSiL$sZ~C2LFUU|
zKSt2HoWkbHF6BCCa#BJAwUnxwd&|h@^{1<2T-PQk7Own6lf@)ZAD?#NPR=JwRo+^W
zXJy@}{3Ou@SVUXi?D2YXrOWcg`TW3=h;!WjyPl0>6H!_f&uBw%SpRmH7{ODfCe(Ib
z1WT9ltMa)%US=}~PJKZX!^!DcwW}>HA|nEKAkVCm9y@*gqinZc>B7%&wrXjIaW6ZA
zZeNb}*6Jp3INC_xX@@nmop{g%(IDJQc%vCFBI{@c(aab9^))o>hEoScVYy60_8y;?0tU(3SGLBl7bkTb@z!*
z=?Iqvi+_*1!E6C4B?p4P+d4x_fcH+zvvbhP?WSU2BAdlZl5SvNA&4~hgOZ`%sHB9W
zb%|e|QcmZd@)pr+AOz(;rgao6iT{AN%GI2qGrtynv7Dmki<*2=A9Iul?Ij3
z^JcF)I=^n+Ax#o-$UQLH_H9U+l-M{n$Emx*GEOVjPeGx;Qhplp>XvBGUW2@j*q)k>
zu~YWapa7iA97@(aPAGURNbRH0oV@zQ_31rX2QL@OI!W(mXE52@Q0
z<}u~u6ax;6%n>wIg5>=B67uHjPYUH_@d8wcG=QU$Qygt+376$H^s4q6af)T9Pv($i
zb4G^40*%Nro+>Riqr9d8?$_bN-9YuO+2(`kVYx~H7f5l_94$fVChKEcUD=delwa+G
zD9e?fd66mwj4)q$kPNmZS7N5>5APV+2mH{vJ>POh!)0Ol?jDzP0|2L}JrRd@dZV4x
zd^YEyGq~{QnsVX`KS6(VD>OXw@;6k%lclu$o7Sv%vZ~$i0IU+cs|g8!+=#Vxq!)Vt
zpt1>O^suf8bGBm+O1NbE3#wxd@{@IYTOrYR1Phw1sR$l$Y}M>5ICp-k)L8_4%3;7W
z|7i~S=_;o3S*#=zzW_Q{YQu|gyiX!}5-QD44hp0}63u~s1kN1K+%j&fF76ZBK!Zqb
zF@N$I>>QS5Q$e`9deg%o&aq;Md;zCEQb}P^LWTVo-n(CxuSy4Bm5z68j3e?ZCX*ro
zJCn*?T{#Z!bLT{py!)IN43L(?cUU>5Hpz9;0s-1lsUa=`A~i1g=>C%^vJq%3&1-Ux
zXr~pL2$ZGZ?CqPAxw?fX2!eyUqeqOy)>5D@-46KqH50|g_kW`kUdjB>Z$LKQIk`(i
zIjh(fohHSktL1pJd(MM(6jLF)D(Qf%`y|)NOvt2BJ#0nP1U+npiN@1WDeR9!uSY}8=u=3hsCMyAq6IlcGf6wM-9TJn
zTrC03#lX<9_jNq4vfo0{CrW=l@W0kuVA|h*Mxf4QzGF1!;I`|cmj+n+NzYwIEw}D&
zwMX2ah?_sMN7u$@?mP(~Hv}p1vZyAuhrdE7G=KKh&`_R)DR+Y#mEx@PJO$ONe(uj7
z^?PEcj@~mL&4Y~%b@^}d3y{f)caPN`OcTuQW-Mfnd_-BzRzzDlRp{vPr_%1a%c`no
z-4np9NK7_I)YJae-2w)>io>m={6f)#dZN=upp)}aSL>AbL<@52*joG79}6G+UTas{
z2NQ6HdgKkKKV!9>I>DEHKS$-CO(K_>mk}$$hWq%P<^3(UAQizu#A-(9F|pkp@;>VJ
za8Gcb`pD}kkAO^c>3sX>9gZAA{YS0v(W5bQ{ZL8OkRPLE9oNmQ5GkEYGu!PZ6ccf>
z&kFU@8`&tjd#<-0Gaisnr?=82KR?=J^}9a^nSeZ?-T3y}`$r?U{R7%Mg(d3U8j%+kx5HPZ#nj5il^LU32gfyG7E!S&?0Mj02|&MynJx=9t9
zT{3NLTjCV}5J99KSAIFaByQOe1<99^Zi0%3K|>F9AG%uFC6AX?ZS0bE<`))MCAtQZ
zEN~8-XlzW4mzf-ZF7(KL-7Ii@hSETjPxC
zfGozY$@{9+?-x+bFL&!NdMUH3$DqS4yz{egGOG1*F*jgoE1ceTDIcT>iJ^>-)1pO6
z0O(a`gasS-!VJk&iM(;u%DRN!_YB89(>F#K{ztY0kg4~iWL*Cwlgv@_3uHa)v5KbR
zkxzZtlTr>dEG1>Kb4}dKSFlVeE1I;H&WcHQZfoddP)vfzk329dJ66a|D>?k6Q1kRD
z!?;8e0GLqzH#Ck9^g%i+bdpf3cw_auU$=I~x~$BZ0q`inrUQ11ZFt!8#6FXok4M)2
zt4p7M1j^*{f4xlTGvo_I*BWK;qX0ng7_eJnxn5a6kFoE)S|}Ku$BQn#ZcaDVrO_6X
z&O(($m-rMEnANaEzXzZ)ZJ$v0ss8a2ldp@l*=?u$({IqfRs2u08h~S2%9J;nt*;CC
zHAdy
z0XkIYKB><_3#E{t#*>R*FrA~mmCrPl>BA}NMh=KUuK(x^_bLF!GOH!ewta8DO93Ms
zFf|7=I*{NmE%|LyBc;P9{Y7|UwpzS3p^-MjS(zlRu9sF{d8GRwq_}Z|Bz1l$yYLM5
zaIF}#Fku_o}G7fFGudA>WP5PeC?{?-b-KW$)
z)$zg7DR`4D0phteb-Fn9AvbMxpFtb;^XzvOENPb7>0Ww=nKf#5ZhF#vd$NuYh1s0<
z;V^+$nTBV)+1WZjt($N~xVmA!b+`WP&+oclyi!By6{VTLUQHEzp6}jJ?)(jc5zr40
zn)*YfYPTi~Q}>aO5ucE=MtJOji}P|f%Flcq^5_k_Xo2%;UtOM|XpT-4qV>(<%jV+c
z;f)c^e{3V!0J!5^m%DhU+PE~Mm_o@mb*pR8M$2$>E8jU`3`~$_Q9Ij1<1O=_M)>TQ
z_7bPqmrX3)Jgz&`N469KJO9Ppo{tcfkmVh%+Ev**svlzEc40*|^Ew7j`f!h-0me<5`*5?bA!iW{WT9UL~PL3cju>*zTLGVgVjwcqYN
zw!y+Fu8)EX+VrVC3YIZyUc5T{(jq4LDDJjg93JSfNoso?uLGvCHR1a=ulF<~JBoE*
zyfE(F@jS`e9M^I36g+MHAzb%zj}t+g?ioPD7Nm_+NCXiar^%Lt2B%8$YnX6;v#pyX
zvmFiQki49AM*?&k-|dU$pS+xbt3RF*5s$AWY?L-q!ksmb1QdY933qqnY
z=+uOX9paguvZ0%Lgs5WWl+(*aTJe<5ZcCXtAq$1o?%M@fn`crnFTm1w?Gr!y4V)CN
zk*yz{H?toKl99u$pDS5qx`WBNVk^;8o7Q6`VlJ4R*mS}NJywix5NEVl@Obx
zeLbz@^EBb;*vcdc9Os*R2o>x{`z-^qJK_OE$E4gFpEAFHKWmdzJNM1MWwN_*W{twxuXsKIpO3Z9n_dAhe~vupQD$9Z{BnEg23@)S%^Yi^-f(%>6n7mBX{1Y+E5H7MzIjOTeh#;m5SyX#b$Z4<)bkd@u(5`>rzG8zOy-f*K8&s(Kgo?wn@uF^?jOo8Ph!wh?enp>lia63XSUkC
z$m-3kaRvBN13lx6@H!3153Ky|LlpQ0fm>l&
z3Ma$oaxUCx7&yxE+x(k`rs~lB?z7dQQMJC6`5qG`rF`6kYh-$IvSo3&-ve>AR9R3{
z_0CXbHBL;SI{#G1#rqyZrSO6qMQ=FIg1qNSGL6HB+i5RpaLcht<~?JR4tLXn)=J-`iI-}%jl6(Lv}v_6o67k~LWNm9@I
z4k%R3-S^;ny?)r>o+Ozs6XADq<7v8$@
zF}d$ut3l>dWKu=UDYBZ$UQv5L4jQw&%>-Q#*>}z189q&%9WT$8piigjMWQVqbFhV}
zHv&NSfFF*>z`zuHcBm>=%h1xZz?)-3M{Awi73JJl-8NJY-r8*y-%s2bW|6N_9_ji(
zaf`yUGNsS5e)(zc)BB{^BtedGa*;nQQDFKA^9P`416&ylQhr3???Onpg;x`
zdn}%+t!K|C)Rze%Otd9a6m6WhR7%}y?O$*@*Y)}^^#+=DNYXUGSO+>KHDWTVTgszV
zRlSlL;&4u5u>#c|)Gb{Nx<+M~T=ZrE(%k!;HU=UvJ-3cxS#V%v3{;QF5xQk>*s6v^
z0Rx3BaUvs=N}Z!uRA9}%(NT(GcBWx(^
zajr_AThHxqqioO4r&yTY!$J*VeG0Y9w&x6}t@3&wjjw?`l`E`|-Ljq{<;;dtp7H*s+MRn6X-rU*DstpRr_|BVe9^j`!P?b+O1=8SUSLa1IvD2g
zX@$%j^7!sEtL&4bbfE9U+xDtA9a6S;rd+{c^q9qU)HY$jDK<|ghfko&T5|tIaguxh
z8uvAw0#T`qiT6LvIV8C@x+BLC#{(jZJ+5tvJ0_vge2D$G59tCq=|3S*^WhfnG6ox0
zLGsN%SWJt{dabXz!keY?Rnyo`St3Dq-0?AW(gM=%%Mst`M&w(2*t6GY#dbRGIPcty
zZ3)|P3?iLJvO9Owrki>rn@P28QoQ*zx=5l*t1JY-LRQ
z1YWawMzS{e-d!5&-Cd1&*}5}WqUDX%M!1)h&5a4hs?7~IvERA5iz}G!)JXTq5w}zh
zj;kRSbHQ8WZUfrA7lIYBZ9kjCiki8cHRhX{S-=vB8X8{Z?%pzLGnMxGP~36YIEEQ
zYWaH+zmwkk@D_>arA#`+vN+DuzI4`wQXc-~y(MI5;?Se26ZtQP&yPN6b+C0a&N{g7
z)t)l9_;%-PPAGZn?it3K7Ggwood&japMB@ED(eL7J+OG)Y{cU&X3_j#-qihly~y)=
zjb;Kat7b5Re+fbFH_Bs#6rKD{>>3U249%Ng@bcg9HDFeIB4N^#}-bFgp&zpx5
zB$v7`Unn{MkH7fw4~buwzn1{fUH}3G*p41vlDP3_z87#gcYa0hTzabrNMFDd{?d-s
zF9inJZx~2-yZGRHn#@K18L%U28d$us(Z{yygg&&QyJEd2wFVI2vGqmbDd~RMoe5czo_luzS27qwF#&
z|G{sgICk4x(vn2hljypqJ>hhv}*;y@WJ(VL+Gsd7AGwJk-ZGW
zJj83~WOw3P?-=rO|HDC74xG7L(OqUGH$%tMi96-WBcd9bKXUzJmJMJnwTuUq0HI^?q7U#bPM-7{P-ZKl0ycs9GiFljzoEh54$al4L-Q{r{y?&M79
ze5Eq4B(p4A{=?ilevkQ$rJZZ0Kh1WVwm0&qO^%Az&|&z0okbJy`XCg`26B9?(_4TKl~1^pEmpv
zfd3YKuC=wyUxHB&;Vsen(XaNc8NjZ+!evpQsnEo0isx7ob-
z%EkBCwj14N$RuuU8~3(NtzH<^wXN@*f*%THmaefHtJWTYcx3(Xu`?Gru%0w5!>Z62
z0~%>9C)hJ!$eDs}3u69?LVzT6{u}luQZf0T+@_7?W)n(a-ZYuLARtm9H8c)lt
zn~4X+DSo)ByuQR(&Tao*r)KfcRP{z$5Tt_etWnZNoNiA8T`WOsr;mo`V-!sYE?_9_
z;_0qh{Ns>rgQ;wK(fui%4bQR?i;+FY2ekX$?+>HWkp(pCgNLXUMC_fpvgEOVOrj14
z;Tnhl7r%5h^>ApQ8l@WtXPP~%7Iv2<1|QuX!T2T_ApG6tlNb+)&y%Oex(GWdnRjF6
z8`Vu_?7F_<)&C4`#th+hBrH9{wKF%P_UY{ao%NktGy=b8w
zz~BdQJ-6}o`}h`w$g=8Kc;;~Cr1-AL80Q6dX)o6y8I+_136lgQBZjVrkH-+lmMxW<
zoy5I^4{^2Mkg$u^%|EkfSihzgzr3_jMd~lkV*5C58WO@^SDs_r9up=($oGcya%^7h
zRW5Oa+q#}+FlGp)rW=Q4@i++m$EntTX~ioHoq;$A`CgUuhYa_te4X&l_%*|c;^1)6+qy2_xI2e_~^a)h-%xm(cCEEah{+7(WuCw|5UCs
z+_;~#-MF1ie#q?G4_itg43T6(t3d*`=p1XI4LYfa_^IPlL+O{)RxA{c@-jZC6&tlB
z>MZ~wwo2J6(9FVz3Um%@i+Ax1h_UMi;V%^V?<|Za=Uf+L5b}i>7BHAwy~$WRI5Xo}
z_%@VgbI^P4wHPg9;ZPNS>C#O3u>v3TEIMVR@FVBXHyjK}Mj!L9Xxk;%W-{j)JE{ec
z6ZgvGR~S2aMc@p9%}th5JUMhyLe`T)8g+!B=*{AfS$Akf1Lh4yt>_=ttbGOc)yqBI
z8%`yw$!%Z~4YKXeO@I}Gnmukp;be-g%M2s^%aGShOiFE;o9Clf7!tOVhl
zRjr%!%Y2y82aPhhCo^-_>xbJiHXyNcXB{3(D^K!WsbTXKTO#Gw9&4?gLWHr%5Mx_U
zgBWzp>bOFQcfl{#?ft#@9?bybPj~vX7>T-?YqyM?`a7>)x`gfoZ2PY55l;NvPuicn
zk#zGtHz5R=*$JG5hrW+su#8#f+jq3=JS$`AdcTyGc;Y!uhRL6sJ5FvZ)ttEgqrokU
zlXa+5N%AYa4k%RIXOPh*BwHGdS`g@aNLW-9~ipD@=eAA7?0+iAp-6}3f^)j-^}t+gU{
z7`A%Hr_HWb+D3=#4Q%X!KBMKjx3@CLoag_%dEFG0Wk(;K^oaDfX04n>(iNvw_J(6q
z>?;=3fJm
zOC=3SK9MP7eAOj>e=5dBp%(UsyK628yLcybOaUSUu~?BP87x20j|6MipLP^Vw^$>S
z5veaM_684BWOJ{~(kfnu?Y1EUR6AZYV*k=W&rpnLb*G&gObp{g-KbT^+@B=W3{YdKd56bIzaZ&Z;z?%lTr7V*QZ_l`q6W7|!Rw&jA@zs#QqFb+N>}QzTgSja{g~by&5Yj@{))hrq3O@6h$1jUflfJJ@Bs*A#^;(zpwjj&`NRo6c`WBh|30aLG*p}
zczMncJiyTDsNfTdA6b+WcwWy|SNpiP`6A6=8yHyw-?jWnSDXDwn-~Ad2ao=_WcXKz
z!k^iZ_x=Q!Km8dI`rkPW0S~})0%vbvC4jE&SRP^dFGjyR_Ww7}@oSX)Kfkp9>0$UA
Zl bool:
+ with open(MM_DATA_FILE) as f:
+ mm_data = json.load(f)
+ authorized_tags = mm_data.get("tags", [])
+ locked_out = mm_data.get("locked_out", False)
+ if locked_out:
+ app.logger.info("This MemberMatters device was set to locked_out by the server!")
+ return False
+
+ app.logger.debug(f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}")
+ if card_number in authorized_tags:
+ app.logger.info(f"card_number: {card_number} is authorized by MemberMatters")
+ return True
-def lookup_card(card_number):
+ app.logger.debug(f"card_number: {card_number} not authorized by MemberMatters")
+ return False
+
+def _lookup_ldap(card_number: str) -> bool:
ldap_server = ldap3.Server(LDAP_SERVER, use_ssl=LDAP_USE_SSL)
with ldap3.Connection(ldap_server, LDAP_USER_DN, LDAP_PASS, auto_bind=True) as conn:
app.logger.info(conn)
@@ -81,6 +102,13 @@ def lookup_card(card_number):
return False
+def lookup_card(card_number: str) -> bool:
+ ret = _lookup_mm(card_number)
+ if not ret:
+ ret = _lookup_ldap(card_number)
+ return ret
+
+
def lookup_pin(input_value):
for pin in ACCESS_PINS:
if input_value == str(pin):
diff --git a/envrc.example b/envrc.example
index b28c834..9fdc8df 100644
--- a/envrc.example
+++ b/envrc.example
@@ -4,8 +4,16 @@ export FLASK_HOST=0.0.0.0
export FLASK_ENV=development
export FLASK_APP=doorman.app
+# Websocket connection to Member Matters
+export DOORMAN_LDAP_ENABLE=true
+export DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access
+export DOORMAN_MM_DEVICE_NAME=frontdoor_test
+export DOORMAN_MM_API_KEY=super.secret
+
+# LDAP Configuration
+export DOORMAN_LDAP_ENABLE=true
export DOORMAN_LDAP_SERVER=ipa.pawprint.space
-export DOORMAN_LDAP_USE_SSL=1
+export DOORMAN_LDAP_USE_SSL=true
# Service account details for LDAP bind
export DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space"
@@ -16,9 +24,3 @@ export DOORMAN_FANVIL_USER=admin
export DOORMAN_FANVIL_PASS=admin
# Unset to skip SSL verification
export DOORMAN_FANVIL_CA=pawprint-ca.crt
-
-# Websocket connection to Member Matters
-export DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access
-export DOORMAN_MM_DEVICE_NAME=frontdoor_test
-export DOORMAN_MM_API_KEY=super.secret
-
diff --git a/requirements.txt b/requirements.txt
index 00f5caa..505775f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,7 @@
-Flask>=2.2.5
+Flask>=3.1.2
ldap3>=2.9.1
-requests~=2.32.3
+requests~=2.32.5
#json_logging>=1.2
websocket-client~=1.9.0
-rel==0.4.9.21
+rel~=0.4.9.21
+rich~=14.2.0
diff --git a/websocket_client.py b/websocket_client.py
index 37f5560..9c044e0 100644
--- a/websocket_client.py
+++ b/websocket_client.py
@@ -5,10 +5,11 @@
import json
import logging
+from rich.logging import RichHandler
import websocket
import rel
-from doorman.app import open_door
+from doorman.app import open_door, env_bool
class AccessDeviceType(Enum):
DOOR = "door"
@@ -21,70 +22,97 @@ def __str__(self):
MM_DEVICE_TYPE = AccessDeviceType[os.environ.get("DOORMAN_MM_DEVICE_TYPE", "DOOR").upper()]
MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json")
MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset")
-
-logger = logging.getLogger(__name__)
-
-def save_tags(data):
- with open(MM_DATA_FILE, "w") as tags_file:
- tags_doc = {
- "tags": data["tags"],
- "hash": data["hash"],
- }
- json.dump(tags_doc, tags_file)
-
-
-def parse_command(data):
- match data.get("command"):
- case "ping":
+DEBUG = env_bool(os.environ.get("DEBUG", False))
+
+logging.basicConfig(level=logging.NOTSET, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
+logger = logging.getLogger("doorman_client")
+logger.setLevel(logging.INFO)
+if DEBUG:
+ logger.setLevel(logging.DEBUG)
+
+class MMAccessClient(websocket.WebSocketApp):
+ def __init__(self, *args, **kwargs):
+ websocket.enableTrace(kwargs.get("debug", False))
+ self.locked_out = False
+ super().__init__(f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}",
+ on_open=self.on_open,
+ on_message=self.on_message,
+ on_error=self.on_error,
+ on_close=self.on_close)
+
+
+ def run(self):
+ self.run_forever(dispatcher=rel,
+ reconnect=15) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly
+ rel.signal(2, rel.abort) # Keyboard Interrupt
+ rel.dispatch()
+
+ def save_tags(self, data):
+ with open(MM_DATA_FILE, "w") as tags_file:
+ tags_doc = {
+ "tags": data["tags"],
+ "hash": data["hash"],
+ "locked_out": self.locked_out,
+ }
+ json.dump(tags_doc, tags_file)
+
+ def load_tags(self):
+ with open(MM_DATA_FILE) as tags_file:
+ data = json.load(tags_file)
+ self.locked_out = data.get("locked_out", False)
+ return data
+
+ def update_device_locked_out(self, locked_out: bool) -> None:
+ logger.info(f"Update device lockout: locked_out = {locked_out}")
+ tags_doc = self.load_tags()
+ self.locked_out = locked_out
+ self.save_tags(tags_doc)
+
+ def parse_command(self, data):
+ command = data.get("command")
+ if command == "ping":
websocket.send(json.dumps({"command": "pong"}))
- case "reboot":
+ elif command == "reboot":
# Not implemented (lol)
pass
- case "bump":
+ elif command == "bump":
# Bump the door open
open_door()
- case "sync":
+ elif command == "sync":
# Save synced tags to a file
- save_tags(data)
-
-
-def on_message(ws, message):
- print(f"> {message}")
- try:
- data = json.loads(message)
- parse_command(data)
- except json.decoder.JSONDecodeError as e:
- logger.exception(e)
- return
-
-
-def on_error(ws, error):
- print(f"[ERROR]: {error}")
-
-def on_close(ws, close_status_code, close_msg):
- print("### closed ###")
- print("Status: {close_status_code}, Message: {close_msg}")
-
-def on_open(ws):
- print("Opened connection")
- # Send auth
- auth_packet = {
- "command": "authenticate",
- "secret_key": MM_API_KEY,
- }
- ws.send(json.dumps(auth_packet))
+ self.save_tags(data)
+
+ elif command == "update_device_locked_out":
+ self.update_device_locked_out(data.get("locked_out", False))
+
+ def on_message(self, ws, message):
+ logger.debug(f"> {message}")
+ try:
+ data = json.loads(message)
+ self.parse_command(data)
+ except json.decoder.JSONDecodeError as e:
+ logger.exception(e)
+ return
+
+ def on_error(self, ws, error):
+ logger.error(f"[SOCKET ERROR]: {error}")
+
+ def on_close(self, ws, close_status_code, close_msg):
+ logger.info("### closed ###")
+ logger.info(f"Status: {close_status_code}, Message: {close_msg}")
+
+ def on_open(self, data):
+ logger.info(f"Opened connection to {self.url}, sending auth.")
+ # Send auth
+ auth_packet = {
+ "command": "authenticate",
+ "secret_key": MM_API_KEY,
+ }
+ self.send(json.dumps(auth_packet))
if __name__ == "__main__":
- #websocket.enableTrace(True)
- ws = websocket.WebSocketApp(f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}",
- on_open=on_open,
- on_message=on_message,
- on_error=on_error,
- on_close=on_close)
-
- ws.run_forever(dispatcher=rel, reconnect=5) # Set dispatcher to automatic reconnection, 5 second reconnect delay if connection closed unexpectedly
- rel.signal(2, rel.abort) # Keyboard Interrupt
- rel.dispatch()
+ client = MMAccessClient(debug=DEBUG)
+ client.run()
\ No newline at end of file
From db1d5f374a0f0279b3fd687bda47cb46d3b84660 Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Thu, 16 Oct 2025 23:41:36 -0700
Subject: [PATCH 03/14] Update envrc example to be .env compatible
---
envrc.example | 40 ++++++++++++++++++++++------------------
1 file changed, 22 insertions(+), 18 deletions(-)
diff --git a/envrc.example b/envrc.example
index 9fdc8df..ce0641e 100644
--- a/envrc.example
+++ b/envrc.example
@@ -1,26 +1,30 @@
-source venv/bin/activate
-
-export FLASK_HOST=0.0.0.0
-export FLASK_ENV=development
-export FLASK_APP=doorman.app
+FLASK_HOST=0.0.0.0
+FLASK_ENV=development
+FLASK_APP=doorman.app
# Websocket connection to Member Matters
-export DOORMAN_LDAP_ENABLE=true
-export DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access
-export DOORMAN_MM_DEVICE_NAME=frontdoor_test
-export DOORMAN_MM_API_KEY=super.secret
+DOORMAN_MM_ENABLE=true
+DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access
+DOORMAN_MM_DEVICE_NAME=frontdoor_test
+DOORMAN_MM_API_KEY=super.secret
# LDAP Configuration
-export DOORMAN_LDAP_ENABLE=true
-export DOORMAN_LDAP_SERVER=ipa.pawprint.space
-export DOORMAN_LDAP_USE_SSL=true
+DOORMAN_LDAP_ENABLE=true
+DOORMAN_LDAP_SERVER=ipa.pawprint.space
+DOORMAN_LDAP_USE_SSL=true
# Service account details for LDAP bind
-export DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space"
-export DOORMAN_LDAP_PASS=foobar
+DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space"
+DOORMAN_LDAP_PASS=foobar
-exoprt DOORMAN_FANVIL_URL=fanvil
-export DOORMAN_FANVIL_USER=admin
-export DOORMAN_FANVIL_PASS=admin
+DOORMAN_FANVIL_URL=https://fanvil.local
+DOORMAN_FANVIL_USER=admin
+DOORMAN_FANVIL_PASS=admin
# Unset to skip SSL verification
-export DOORMAN_FANVIL_CA=pawprint-ca.crt
+DOORMAN_FANVIL_CA=pawprint-ca.crt
+
+# Uncomment for .envrc use:
+#export FLASK_HOST FLASK_ENV FLASK_APP
+#export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY
+#export DOORMAN_LDAP_ENABLE DOORMAN_LDAP_SERVER DOORMAN_LDAP_USE_SSL DOORMAN_LDAP_USER_DN DOORMAN_LDAP_PASS
+#export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA
\ No newline at end of file
From 8acd0b879aea4e1e8e27729c53741b7440e96ce1 Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Fri, 17 Oct 2025 00:01:35 -0700
Subject: [PATCH 04/14] pre-commit update and run
---
.flake8 | 2 +-
.gitignore | 2 +-
.isort.cfg | 2 +-
.pre-commit-config.yaml | 20 ++++++++++-------
README.md | 2 +-
doorman/app.py | 16 ++++++++++----
envrc.example | 2 +-
websocket_client.py | 49 +++++++++++++++++++++++++----------------
8 files changed, 59 insertions(+), 36 deletions(-)
diff --git a/.flake8 b/.flake8
index 7b46844..a99d7a4 100644
--- a/.flake8
+++ b/.flake8
@@ -1,6 +1,6 @@
[flake8]
max-line-length = 100
-ignore = E203, E266, E501, W503
+ignore = E203, E266, E501, W503, E731
exclude = .yaml,.json
[pycodestyle]
max_line_length = 100
diff --git a/.gitignore b/.gitignore
index e5e4ed9..6f0012c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -142,4 +142,4 @@ cython_debug/
.envrc
.idea
-mm-doorman.json
\ No newline at end of file
+mm-doorman.json
diff --git a/.isort.cfg b/.isort.cfg
index 3b6e9ad..2fa9621 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -1,2 +1,2 @@
[settings]
-known_third_party = flask,ldap3,requests
+known_third_party = flask,ldap3,rel,requests,rich,websocket
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7539c68..cb27d7b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,20 +1,24 @@
-default_language_version:
- python: python3.7
-
exclude: ^(migrations|venv)/
repos:
- repo: https://github.com/asottile/seed-isort-config
- rev: v1.9.4
+ rev: v2.2.0
hooks:
- id: seed-isort-config
- - repo: git://github.com/pre-commit/mirrors-isort
- rev: v4.3.21
+ - repo: https://github.com/pycqa/isort
+ rev: 7.0.0
hooks:
- id: isort
+ name: isort (python)
+
+ - repo: https://github.com/pycqa/flake8
+ rev: 7.3.0
+ hooks:
+ - id: flake8
+ name: flake8 (python)
- - repo: git://github.com/pre-commit/pre-commit-hooks
+ - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.4.0
hooks:
- id: check-builtin-literals
@@ -24,6 +28,6 @@ repos:
- id: check-ast
- repo: https://github.com/ambv/black
- rev: 19.10b0
+ rev: 25.9.0
hooks:
- id: black
diff --git a/README.md b/README.md
index 7239bc7..fb106b8 100644
--- a/README.md
+++ b/README.md
@@ -61,4 +61,4 @@ docker run -d --name=doorman_ws --env-file=.env -v ./mm-doorman.json:/tmp/mm-doo
1. Run `websocket_client.py`. Doorman will try to register with the server, but the server will disconnect the connector until it is authorized.
2. In the Django Admin, click on **Access** > **Doors**, then click `New Device (doorman)` (the value in perens will match `DOORMAN_MM_DEVICE_NAME` from earlier).
-3. Check `Is this device authorised to access the system?`, and set any of the option checkboxes according to your setup, and click **Save**.
\ No newline at end of file
+3. Check `Is this device authorised to access the system?`, and set any of the option checkboxes according to your setup, and click **Save**.
diff --git a/doorman/app.py b/doorman/app.py
index 1a14c59..69ddcbc 100644
--- a/doorman/app.py
+++ b/doorman/app.py
@@ -57,16 +57,21 @@ def open_door(url=FANVIL_URL):
requests.get(url, auth=auth, verify=FANVIL_VERIFY_CA)
+
def _lookup_mm(card_number: str) -> bool:
with open(MM_DATA_FILE) as f:
mm_data = json.load(f)
authorized_tags = mm_data.get("tags", [])
locked_out = mm_data.get("locked_out", False)
if locked_out:
- app.logger.info("This MemberMatters device was set to locked_out by the server!")
+ app.logger.info(
+ "This MemberMatters device was set to locked_out by the server!"
+ )
return False
- app.logger.debug(f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}")
+ app.logger.debug(
+ f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}"
+ )
if card_number in authorized_tags:
app.logger.info(f"card_number: {card_number} is authorized by MemberMatters")
return True
@@ -74,6 +79,7 @@ def _lookup_mm(card_number: str) -> bool:
app.logger.debug(f"card_number: {card_number} not authorized by MemberMatters")
return False
+
def _lookup_ldap(card_number: str) -> bool:
ldap_server = ldap3.Server(LDAP_SERVER, use_ssl=LDAP_USE_SSL)
with ldap3.Connection(ldap_server, LDAP_USER_DN, LDAP_PASS, auto_bind=True) as conn:
@@ -81,7 +87,9 @@ def _lookup_ldap(card_number: str) -> bool:
search_filter = f"(&(rfidbadge={card_number})(!(nsAccountLock=TRUE)))"
app.logger.debug(f"SearchDN: {LDAP_BASE_DN} Search filter: {search_filter}")
conn.search(
- LDAP_BASE_DN, search_filter, attributes=["cn", "uid"],
+ LDAP_BASE_DN,
+ search_filter,
+ attributes=["cn", "uid"],
)
app.logger.info(f"Response: {conn.response}")
@@ -90,7 +98,7 @@ def _lookup_ldap(card_number: str) -> bool:
# TODO: write cn to audit log (influxdb)
if SUCCESS_WEBHOOK:
webhook_data = {"_type": "CARD", "card_number": card_number}
- webhook_data.update(conn.response[0]['attributes'])
+ webhook_data.update(conn.response[0]["attributes"])
try:
requests.post(SUCCESS_WEBHOOK, webhook_data)
except requests.RequestException as e:
diff --git a/envrc.example b/envrc.example
index ce0641e..c871dc6 100644
--- a/envrc.example
+++ b/envrc.example
@@ -27,4 +27,4 @@ DOORMAN_FANVIL_CA=pawprint-ca.crt
#export FLASK_HOST FLASK_ENV FLASK_APP
#export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY
#export DOORMAN_LDAP_ENABLE DOORMAN_LDAP_SERVER DOORMAN_LDAP_USE_SSL DOORMAN_LDAP_USER_DN DOORMAN_LDAP_PASS
-#export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA
\ No newline at end of file
+#export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA
diff --git a/websocket_client.py b/websocket_client.py
index 9c044e0..665849b 100644
--- a/websocket_client.py
+++ b/websocket_client.py
@@ -1,49 +1,59 @@
-import os
-from enum import Enum
-import _thread
-import time
import json
import logging
+import os
+from enum import Enum
-from rich.logging import RichHandler
-import websocket
import rel
+import websocket
+from rich.logging import RichHandler
+
+from doorman.app import env_bool, open_door
-from doorman.app import open_door, env_bool
class AccessDeviceType(Enum):
DOOR = "door"
INTERLOCK = "interlock"
+
def __str__(self):
return f"{self.value}"
-MM_ACCESS_URL = os.environ.get("DOORMAN_MM_ACCESS_URL", "ws://membermatters.local/ws/access")
+
+MM_ACCESS_URL = os.environ.get(
+ "DOORMAN_MM_ACCESS_URL", "ws://membermatters.local/ws/access"
+)
MM_DEVICE_NAME = os.environ.get("DOORMAN_MM_DEVICE_NAME", "Unnamed")
-MM_DEVICE_TYPE = AccessDeviceType[os.environ.get("DOORMAN_MM_DEVICE_TYPE", "DOOR").upper()]
+MM_DEVICE_TYPE = AccessDeviceType[
+ os.environ.get("DOORMAN_MM_DEVICE_TYPE", "DOOR").upper()
+]
MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json")
MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset")
DEBUG = env_bool(os.environ.get("DEBUG", False))
-logging.basicConfig(level=logging.NOTSET, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()])
+logging.basicConfig(
+ level=logging.NOTSET, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()]
+)
logger = logging.getLogger("doorman_client")
logger.setLevel(logging.INFO)
if DEBUG:
logger.setLevel(logging.DEBUG)
+
class MMAccessClient(websocket.WebSocketApp):
def __init__(self, *args, **kwargs):
websocket.enableTrace(kwargs.get("debug", False))
self.locked_out = False
- super().__init__(f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}",
- on_open=self.on_open,
- on_message=self.on_message,
- on_error=self.on_error,
- on_close=self.on_close)
-
+ super().__init__(
+ f"{MM_ACCESS_URL}/{MM_DEVICE_TYPE}/{MM_DEVICE_NAME}",
+ on_open=self.on_open,
+ on_message=self.on_message,
+ on_error=self.on_error,
+ on_close=self.on_close,
+ )
def run(self):
- self.run_forever(dispatcher=rel,
- reconnect=15) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly
+ self.run_forever(
+ dispatcher=rel, reconnect=15
+ ) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly
rel.signal(2, rel.abort) # Keyboard Interrupt
rel.dispatch()
@@ -113,6 +123,7 @@ def on_open(self, data):
}
self.send(json.dumps(auth_packet))
+
if __name__ == "__main__":
client = MMAccessClient(debug=DEBUG)
- client.run()
\ No newline at end of file
+ client.run()
From 817a904d00cb27322e32c4eafca2f478281032ce Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Fri, 30 Jan 2026 15:39:27 -0800
Subject: [PATCH 05/14] Apply suggestions from code review
Co-authored-by: Kataze the Skunk
---
doorman/app.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/doorman/app.py b/doorman/app.py
index 69ddcbc..fbb4882 100644
--- a/doorman/app.py
+++ b/doorman/app.py
@@ -61,6 +61,9 @@ def open_door(url=FANVIL_URL):
def _lookup_mm(card_number: str) -> bool:
with open(MM_DATA_FILE) as f:
mm_data = json.load(f)
+ app.logger.debug(
+ f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}"
+ )
authorized_tags = mm_data.get("tags", [])
locked_out = mm_data.get("locked_out", False)
if locked_out:
@@ -69,9 +72,6 @@ def _lookup_mm(card_number: str) -> bool:
)
return False
- app.logger.debug(
- f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}"
- )
if card_number in authorized_tags:
app.logger.info(f"card_number: {card_number} is authorized by MemberMatters")
return True
From 9bfd5f8f42bffb2471491128567ba8cc8dc91708 Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Thu, 7 May 2026 16:11:43 -0700
Subject: [PATCH 06/14] Add door_access protocol support via threading
- Add doorman/ipc.py with shared threading.Queue
- Push door_access events from Flask on RFID auth success
- Poll queue in websocket client, send door_access to MM server
- Add main.py entrypoint running Flask + WS client in one process
- Update Dockerfile to use main module with WEB_CONCURRENCY=1
---
Dockerfile | 4 +++-
doorman/app.py | 8 ++++++++
doorman/ipc.py | 10 ++++++++++
main.py | 22 ++++++++++++++++++++++
websocket_client.py | 35 +++++++++++++++++++++++++++++++++++
5 files changed, 78 insertions(+), 1 deletion(-)
create mode 100644 doorman/ipc.py
create mode 100644 main.py
diff --git a/Dockerfile b/Dockerfile
index 1bb1b4c..633cb87 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,10 @@
FROM tiangolo/meinheld-gunicorn-flask:python3.9
COPY ./doorman /app/doorman
COPY ./websocket_client.py /app
+COPY ./main.py /app
COPY requirements.txt /app
RUN pip install --upgrade pip
RUN pip install -r /app/requirements.txt
-ENV MODULE_NAME=doorman.app
+ENV MODULE_NAME=main
+ENV WEB_CONCURRENCY=1
diff --git a/doorman/app.py b/doorman/app.py
index fbb4882..e4b350a 100644
--- a/doorman/app.py
+++ b/doorman/app.py
@@ -9,6 +9,7 @@
from requests.auth import HTTPBasicAuth
from . import fanvil
+from .ipc import door_access_queue
app = Flask(__name__)
@@ -144,6 +145,13 @@ def auth():
app.logger.info(f"Got keypad input: {input_value}")
success = lookup_pin(input_value)
if success:
+ # Notify the websocket client to send a door_access event
+ if input_type == fanvil.CARD_ID:
+ door_access_queue.put({
+ "id_number": input_value,
+ "success": True,
+ "method": "rfid",
+ })
return return_code_template(200), 200
return return_code_template(401), 401
diff --git a/doorman/ipc.py b/doorman/ipc.py
new file mode 100644
index 0000000..5998606
--- /dev/null
+++ b/doorman/ipc.py
@@ -0,0 +1,10 @@
+"""
+Inter-process communication between the Flask server and the websocket client.
+
+Uses a threading.Queue since both components run in the same process.
+"""
+
+import queue
+
+# Flask pushes door_access events here; the websocket client consumes them.
+door_access_queue: queue.Queue = queue.Queue()
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..3859f8e
--- /dev/null
+++ b/main.py
@@ -0,0 +1,22 @@
+"""
+Unified entrypoint that runs the Flask server and the MemberMatters
+websocket client in the same process.
+
+The Flask app is exposed as `app` for gunicorn/meinheld to pick up.
+The websocket client runs in a daemon thread so it dies with the process.
+"""
+
+import threading
+
+from doorman.app import app
+from websocket_client import MMAccessClient, DEBUG
+
+
+def start_websocket_client():
+ client = MMAccessClient(debug=DEBUG)
+ client.run()
+
+
+# Start the websocket client in a daemon thread
+ws_thread = threading.Thread(target=start_websocket_client, daemon=True, name="mm-ws-client")
+ws_thread.start()
diff --git a/websocket_client.py b/websocket_client.py
index 665849b..4ae1053 100644
--- a/websocket_client.py
+++ b/websocket_client.py
@@ -1,6 +1,8 @@
import json
import logging
import os
+import queue
+import time
from enum import Enum
import rel
@@ -8,6 +10,7 @@
from rich.logging import RichHandler
from doorman.app import env_bool, open_door
+from doorman.ipc import door_access_queue
class AccessDeviceType(Enum):
@@ -54,9 +57,40 @@ def run(self):
self.run_forever(
dispatcher=rel, reconnect=15
) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly
+ # Poll the door_access queue every 0.5s and forward events to the server
+ rel.timeout(0.5, self._poll_door_access_queue)
rel.signal(2, rel.abort) # Keyboard Interrupt
rel.dispatch()
+ def _poll_door_access_queue(self):
+ """Check the shared queue for door_access events and send them."""
+ try:
+ while True:
+ event = door_access_queue.get_nowait()
+ self.send_door_access(
+ id_number=event["id_number"],
+ success=event["success"],
+ method=event.get("method", "rfid"),
+ )
+ except queue.Empty:
+ pass
+ # Re-schedule ourselves — returning True keeps the rel timeout alive
+ return True
+
+ def send_door_access(self, id_number: str, success: bool, method: str = "rfid"):
+ """Send a door_access command to the MemberMatters server."""
+ packet = {
+ "command": "door_access",
+ "payload": {
+ "id_number": id_number,
+ "time": str(int(time.time())),
+ "success": success,
+ "method": method,
+ },
+ }
+ logger.info(f"Sending door_access: id={id_number} success={success} method={method}")
+ self.send(json.dumps(packet))
+
def save_tags(self, data):
with open(MM_DATA_FILE, "w") as tags_file:
tags_doc = {
@@ -125,5 +159,6 @@ def on_open(self, data):
if __name__ == "__main__":
+ # Standalone mode: run without Flask (useful for local testing)
client = MMAccessClient(debug=DEBUG)
client.run()
From aab628b1077e75cfd48617661fd1b448c4a8ee3f Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Thu, 7 May 2026 16:18:24 -0700
Subject: [PATCH 07/14] Update pre-commit, apply formatting
---
.isort.cfg | 1 +
.pre-commit-config.yaml | 7 +++----
doorman/app.py | 14 ++++++++------
main.py | 8 +++++---
websocket_client.py | 4 +++-
5 files changed, 20 insertions(+), 14 deletions(-)
diff --git a/.isort.cfg b/.isort.cfg
index 2fa9621..90b4d3c 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -1,2 +1,3 @@
[settings]
+profile = black
known_third_party = flask,ldap3,rel,requests,rich,websocket
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index cb27d7b..fea6d8b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -7,7 +7,7 @@ repos:
- id: seed-isort-config
- repo: https://github.com/pycqa/isort
- rev: 7.0.0
+ rev: 9.0.0a3
hooks:
- id: isort
name: isort (python)
@@ -19,15 +19,14 @@ repos:
name: flake8 (python)
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v2.4.0
+ rev: v6.0.0
hooks:
- id: check-builtin-literals
- id: trailing-whitespace
- id: end-of-file-fixer
- - id: flake8
- id: check-ast
- repo: https://github.com/ambv/black
- rev: 25.9.0
+ rev: 26.3.1
hooks:
- id: black
diff --git a/doorman/app.py b/doorman/app.py
index e4b350a..da1e465 100644
--- a/doorman/app.py
+++ b/doorman/app.py
@@ -62,10 +62,10 @@ def open_door(url=FANVIL_URL):
def _lookup_mm(card_number: str) -> bool:
with open(MM_DATA_FILE) as f:
mm_data = json.load(f)
+ authorized_tags = mm_data.get("tags", [])
app.logger.debug(
f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}"
)
- authorized_tags = mm_data.get("tags", [])
locked_out = mm_data.get("locked_out", False)
if locked_out:
app.logger.info(
@@ -147,11 +147,13 @@ def auth():
if success:
# Notify the websocket client to send a door_access event
if input_type == fanvil.CARD_ID:
- door_access_queue.put({
- "id_number": input_value,
- "success": True,
- "method": "rfid",
- })
+ door_access_queue.put(
+ {
+ "id_number": input_value,
+ "success": True,
+ "method": "rfid",
+ }
+ )
return return_code_template(200), 200
return return_code_template(401), 401
diff --git a/main.py b/main.py
index 3859f8e..01a5428 100644
--- a/main.py
+++ b/main.py
@@ -8,8 +8,8 @@
import threading
-from doorman.app import app
-from websocket_client import MMAccessClient, DEBUG
+from doorman.app import app # noqa: F401 — gunicorn imports `app` from this module
+from websocket_client import DEBUG, MMAccessClient
def start_websocket_client():
@@ -18,5 +18,7 @@ def start_websocket_client():
# Start the websocket client in a daemon thread
-ws_thread = threading.Thread(target=start_websocket_client, daemon=True, name="mm-ws-client")
+ws_thread = threading.Thread(
+ target=start_websocket_client, daemon=True, name="mm-ws-client"
+)
ws_thread.start()
diff --git a/websocket_client.py b/websocket_client.py
index 4ae1053..702c4a8 100644
--- a/websocket_client.py
+++ b/websocket_client.py
@@ -88,7 +88,9 @@ def send_door_access(self, id_number: str, success: bool, method: str = "rfid"):
"method": method,
},
}
- logger.info(f"Sending door_access: id={id_number} success={success} method={method}")
+ logger.info(
+ f"Sending door_access: id={id_number} success={success} method={method}"
+ )
self.send(json.dumps(packet))
def save_tags(self, data):
From 17d512b1b2a065c5fa7eb7b81fc6de8bc81c933c Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Fri, 8 May 2026 12:30:58 -0700
Subject: [PATCH 08/14] Add LDAP notification, mypy type checking
---
.isort.cfg | 2 +-
.pre-commit-config.yaml | 13 +++++++++
doorman/app.py | 59 ++++++++++++++++++++++++++++-------------
doorman/fanvil.py | 6 +++--
envrc.example | 4 +++
main.py | 2 +-
requirements.txt | 1 +
websocket_client.py | 24 +++++++++--------
8 files changed, 78 insertions(+), 33 deletions(-)
diff --git a/.isort.cfg b/.isort.cfg
index 90b4d3c..e4272d7 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -1,3 +1,3 @@
[settings]
profile = black
-known_third_party = flask,ldap3,rel,requests,rich,websocket
+known_third_party = apprise,flask,ldap3,rel,requests,rich,websocket
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index fea6d8b..f2e14d1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -30,3 +30,16 @@ repos:
rev: 26.3.1
hooks:
- id: black
+
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v1.16.0
+ hooks:
+ - id: mypy
+ additional_dependencies:
+ - flask
+ - types-requests
+ - apprise
+ - ldap3
+ - websocket-client
+ - rel
+ - rich
diff --git a/doorman/app.py b/doorman/app.py
index da1e465..943b1b4 100644
--- a/doorman/app.py
+++ b/doorman/app.py
@@ -3,6 +3,7 @@
import os
from functools import wraps
+import apprise
import ldap3
import requests
from flask import Flask, Response, request
@@ -13,7 +14,10 @@
app = Flask(__name__)
-env_bool = lambda s: str(s).lower() in ("1", "t", "true", "y", "yes")
+
+def env_bool(s) -> bool:
+ return str(s).lower() in ("1", "t", "true", "y", "yes")
+
LDAP_ENABLE = env_bool(os.environ.get("DOORMAN_LDAP_ENABLE", True))
MM_ENABLE = env_bool(os.environ.get("DOORMAN_MM_ENABLE", True))
@@ -35,22 +39,23 @@
DOORBELL_WEBHOOK = os.environ.get("DOORMAN_DOORBELL_WEBHOOK")
ACCESS_PINS = json.loads(os.environ.get("DOORMAN_ACCESS_PINS", "{}"))
SUCCESS_WEBHOOK = os.environ.get("DOORMAN_SUCCESS_WEBHOOK")
+LDAP_APPRISE_URL = os.environ.get("DOORMAN_LDAP_APPRISE_URL")
def returns_xml(f):
@wraps(f)
- def decorated_function(*args, **kwargs):
+ def decorated_function(*args, **kwargs) -> Response:
r, status = f(*args, **kwargs)
return Response(r, content_type="application/xml; charset=utf-8", status=status)
return decorated_function
-def return_code_template(status):
+def return_code_template(status: int) -> str:
return f"""{status}"""
-def open_door(url=FANVIL_URL):
+def open_door(url: str = FANVIL_URL) -> None:
# Change code= to match the value in EGS settings > features > calling
# password (* by default)
url = f"{FANVIL_URL}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*"
@@ -81,6 +86,22 @@ def _lookup_mm(card_number: str) -> bool:
return False
+def _notify_ldap(card_number: str, attributes: dict) -> None:
+ """Send a notification when access is granted via LDAP."""
+ if not LDAP_APPRISE_URL:
+ return
+ ap = apprise.Apprise()
+ ap.add(LDAP_APPRISE_URL)
+ cn = attributes.get("cn", "unknown")
+ uid = attributes.get("uid", "unknown")
+ ap.notify(
+ title="Doorman",
+ body=(
+ f"Card {card_number} was granted access via LDAP.\n" f"CN: {cn}, UID: {uid}"
+ ),
+ )
+
+
def _lookup_ldap(card_number: str) -> bool:
ldap_server = ldap3.Server(LDAP_SERVER, use_ssl=LDAP_USE_SSL)
with ldap3.Connection(ldap_server, LDAP_USER_DN, LDAP_PASS, auto_bind=True) as conn:
@@ -97,6 +118,8 @@ def _lookup_ldap(card_number: str) -> bool:
if len(conn.response) == 1:
app.logger.info(f"Card found: {conn.response[0]['attributes']}")
# TODO: write cn to audit log (influxdb)
+ if LDAP_APPRISE_URL:
+ _notify_ldap(card_number, conn.response[0]["attributes"])
if SUCCESS_WEBHOOK:
webhook_data = {"_type": "CARD", "card_number": card_number}
webhook_data.update(conn.response[0]["attributes"])
@@ -112,13 +135,21 @@ def _lookup_ldap(card_number: str) -> bool:
def lookup_card(card_number: str) -> bool:
- ret = _lookup_mm(card_number)
- if not ret:
- ret = _lookup_ldap(card_number)
- return ret
+ mm_authorized = _lookup_mm(card_number)
+ if mm_authorized:
+ # Only notify MemberMatters of access if the card was in its own tag list
+ door_access_queue.put(
+ {
+ "id_number": card_number,
+ "success": True,
+ "method": "rfid",
+ }
+ )
+ return True
+ return _lookup_ldap(card_number)
-def lookup_pin(input_value):
+def lookup_pin(input_value: str) -> bool:
for pin in ACCESS_PINS:
if input_value == str(pin):
app.logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}")
@@ -145,15 +176,6 @@ def auth():
app.logger.info(f"Got keypad input: {input_value}")
success = lookup_pin(input_value)
if success:
- # Notify the websocket client to send a door_access event
- if input_type == fanvil.CARD_ID:
- door_access_queue.put(
- {
- "id_number": input_value,
- "success": True,
- "method": "rfid",
- }
- )
return return_code_template(200), 200
return return_code_template(401), 401
@@ -161,5 +183,6 @@ def auth():
@app.route("/fanvil/doorbell", methods=["GET"])
@returns_xml
def doorbell():
+ """Historical function to work around webhook limitations in Home Assistant"""
requests.post(DOORBELL_WEBHOOK)
return return_code_template(200), 200
diff --git a/doorman/fanvil.py b/doorman/fanvil.py
index 74342f6..c751b19 100644
--- a/doorman/fanvil.py
+++ b/doorman/fanvil.py
@@ -22,13 +22,13 @@ class CommandParseError(FanvilParseError):
pass
-def parse_uri(uri):
+def parse_uri(uri: str) -> tuple[str, str, str]:
command, attr = uri.split(":")
key, val = attr.split("=")
return command, key, val.rstrip("@1")
-def parse_command(xml):
+def parse_command(xml: bytes) -> tuple[str, str]:
"""
Example card read POST payload:
@@ -44,6 +44,8 @@ def parse_command(xml):
"""
root = ET.fromstring(xml)
ei = root.find("ExecuteItem")
+ if ei is None or ei.text is None:
+ raise FanvilParseError("Missing ExecuteItem element or text")
uri = ei.text.split('"')[1]
try:
diff --git a/envrc.example b/envrc.example
index c871dc6..255eeff 100644
--- a/envrc.example
+++ b/envrc.example
@@ -17,6 +17,10 @@ DOORMAN_LDAP_USE_SSL=true
DOORMAN_LDAP_USER_DN="uid=doorman,cn=sysaccounts,cn=etc,dc=pawprint,dc=space"
DOORMAN_LDAP_PASS=foobar
+# Optional notification for LDAP lookup. Leave unset to disable.
+# https://appriseit.com/url-builder/
+#DOORMAN_LDAP_APPRISE_URL=
+
DOORMAN_FANVIL_URL=https://fanvil.local
DOORMAN_FANVIL_USER=admin
DOORMAN_FANVIL_PASS=admin
diff --git a/main.py b/main.py
index 01a5428..a195f7c 100644
--- a/main.py
+++ b/main.py
@@ -12,7 +12,7 @@
from websocket_client import DEBUG, MMAccessClient
-def start_websocket_client():
+def start_websocket_client() -> None:
client = MMAccessClient(debug=DEBUG)
client.run()
diff --git a/requirements.txt b/requirements.txt
index 505775f..4f6253b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,7 @@
Flask>=3.1.2
ldap3>=2.9.1
requests~=2.32.5
+apprise>=1.9.0
#json_logging>=1.2
websocket-client~=1.9.0
rel~=0.4.9.21
diff --git a/websocket_client.py b/websocket_client.py
index 702c4a8..9cbc8da 100644
--- a/websocket_client.py
+++ b/websocket_client.py
@@ -53,7 +53,7 @@ def __init__(self, *args, **kwargs):
on_close=self.on_close,
)
- def run(self):
+ def run(self) -> None:
self.run_forever(
dispatcher=rel, reconnect=15
) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly
@@ -62,7 +62,7 @@ def run(self):
rel.signal(2, rel.abort) # Keyboard Interrupt
rel.dispatch()
- def _poll_door_access_queue(self):
+ def _poll_door_access_queue(self) -> bool:
"""Check the shared queue for door_access events and send them."""
try:
while True:
@@ -77,7 +77,9 @@ def _poll_door_access_queue(self):
# Re-schedule ourselves — returning True keeps the rel timeout alive
return True
- def send_door_access(self, id_number: str, success: bool, method: str = "rfid"):
+ def send_door_access(
+ self, id_number: str, success: bool, method: str = "rfid"
+ ) -> None:
"""Send a door_access command to the MemberMatters server."""
packet = {
"command": "door_access",
@@ -93,7 +95,7 @@ def send_door_access(self, id_number: str, success: bool, method: str = "rfid"):
)
self.send(json.dumps(packet))
- def save_tags(self, data):
+ def save_tags(self, data: dict) -> None:
with open(MM_DATA_FILE, "w") as tags_file:
tags_doc = {
"tags": data["tags"],
@@ -102,7 +104,7 @@ def save_tags(self, data):
}
json.dump(tags_doc, tags_file)
- def load_tags(self):
+ def load_tags(self) -> dict:
with open(MM_DATA_FILE) as tags_file:
data = json.load(tags_file)
self.locked_out = data.get("locked_out", False)
@@ -114,10 +116,10 @@ def update_device_locked_out(self, locked_out: bool) -> None:
self.locked_out = locked_out
self.save_tags(tags_doc)
- def parse_command(self, data):
+ def parse_command(self, data: dict) -> None:
command = data.get("command")
if command == "ping":
- websocket.send(json.dumps({"command": "pong"}))
+ self.send(json.dumps({"command": "pong"}))
elif command == "reboot":
# Not implemented (lol)
@@ -134,7 +136,7 @@ def parse_command(self, data):
elif command == "update_device_locked_out":
self.update_device_locked_out(data.get("locked_out", False))
- def on_message(self, ws, message):
+ def on_message(self, ws, message: str) -> None:
logger.debug(f"> {message}")
try:
data = json.loads(message)
@@ -143,14 +145,14 @@ def on_message(self, ws, message):
logger.exception(e)
return
- def on_error(self, ws, error):
+ def on_error(self, ws, error: Exception) -> None:
logger.error(f"[SOCKET ERROR]: {error}")
- def on_close(self, ws, close_status_code, close_msg):
+ def on_close(self, ws, close_status_code: int, close_msg: str) -> None:
logger.info("### closed ###")
logger.info(f"Status: {close_status_code}, Message: {close_msg}")
- def on_open(self, data):
+ def on_open(self, data) -> None:
logger.info(f"Opened connection to {self.url}, sending auth.")
# Send auth
auth_packet = {
From 478733ea1d56bda534d30b45a72f28087bb7bbaa Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Wed, 20 May 2026 14:07:08 -0700
Subject: [PATCH 09/14] Rewrite as FastAPI app, add Sentry instrumentation
Replace Flask/requests/gunicorn with FastAPI/httpx/uvicorn. The
MemberMatters websocket client now runs in a daemon thread started
by the FastAPI lifespan, with a separate pump thread forwarding
door_access events to the server (drops the rel dispatcher, which
can't install signal handlers off the main thread).
Other fixes:
- Wrap blocking ldap3, file, and apprise calls in asyncio.to_thread
so they don't stall the event loop.
- Catch FanvilParseError in auth() and add a global exception handler
that ensures we always return the delicious XML it likes.
- Share a single httpx.AsyncClient on app.state with explicit timeouts
for success/doorbell webhooks; guard against None DOORBELL_WEBHOOK.
- open_door() is synchronous so the websocket worker thread doesn't
spin up a fresh event loop and httpx pool on every bump.
- Logging configuration is really pretty in a terminal now.
---
.isort.cfg | 2 +-
Dockerfile | 28 +++--
doorman/app.py | 264 +++++++++++++++++++++++++++++++-------------
doorman/ipc.py | 8 +-
envrc.example | 11 +-
main.py | 25 +----
requirements.txt | 8 +-
websocket_client.py | 67 ++++++-----
8 files changed, 271 insertions(+), 142 deletions(-)
diff --git a/.isort.cfg b/.isort.cfg
index e4272d7..4ec151a 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -1,3 +1,3 @@
[settings]
profile = black
-known_third_party = apprise,flask,ldap3,rel,requests,rich,websocket
+known_third_party = apprise,fastapi,httpx,ldap3,rich,sentry_sdk,websocket
diff --git a/Dockerfile b/Dockerfile
index 633cb87..269c74a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,18 @@
-FROM tiangolo/meinheld-gunicorn-flask:python3.9
-COPY ./doorman /app/doorman
-COPY ./websocket_client.py /app
-COPY ./main.py /app
-COPY requirements.txt /app
-RUN pip install --upgrade pip
-RUN pip install -r /app/requirements.txt
-
-ENV MODULE_NAME=main
-ENV WEB_CONCURRENCY=1
+FROM python:3.14-alpine3.23
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir --upgrade pip && \
+ pip install --no-cache-dir -r requirements.txt
+
+COPY ./doorman ./doorman
+COPY ./websocket_client.py .
+COPY ./main.py .
+
+EXPOSE 5000
+
+# Keep --workers at 1 (uvicorn's default). Each worker spawns its own
+# MemberMatters websocket client; multiple workers would produce duplicate
+# door_access events.
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"]
diff --git a/doorman/app.py b/doorman/app.py
index 943b1b4..7eedb92 100644
--- a/doorman/app.py
+++ b/doorman/app.py
@@ -1,18 +1,47 @@
#!/usr/bin/env python
+import asyncio
import json
+import logging
import os
-from functools import wraps
+from contextlib import asynccontextmanager
+from typing import AsyncIterator
import apprise
+import httpx
import ldap3
-import requests
-from flask import Flask, Response, request
-from requests.auth import HTTPBasicAuth
+import sentry_sdk
+from fastapi import FastAPI, Request
+from fastapi.responses import Response
+from rich.logging import RichHandler
from . import fanvil
from .ipc import door_access_queue
-app = Flask(__name__)
+logger = logging.getLogger("doorman")
+
+
+def _init_sentry() -> None:
+ """Initialize Sentry if DOORMAN_SENTRY_DSN is set; no-op otherwise.
+
+ The FastAPI integration is auto-enabled when sentry-sdk sees fastapi
+ installed, so we don't need to pass integrations= explicitly. This must
+ run before `app = FastAPI(...)` is constructed.
+ """
+ dsn = os.environ.get("DOORMAN_SENTRY_DSN")
+ if not dsn:
+ return
+ sentry_sdk.init(
+ dsn=dsn,
+ environment=os.environ.get("DOORMAN_SENTRY_ENVIRONMENT"),
+ release=os.environ.get("DOORMAN_SENTRY_RELEASE"),
+ traces_sample_rate=float(
+ os.environ.get("DOORMAN_SENTRY_TRACES_SAMPLE_RATE", "0.1")
+ ),
+ send_default_pii=True,
+ )
+
+
+_init_sentry()
def env_bool(s) -> bool:
@@ -40,49 +69,117 @@ def env_bool(s) -> bool:
ACCESS_PINS = json.loads(os.environ.get("DOORMAN_ACCESS_PINS", "{}"))
SUCCESS_WEBHOOK = os.environ.get("DOORMAN_SUCCESS_WEBHOOK")
LDAP_APPRISE_URL = os.environ.get("DOORMAN_LDAP_APPRISE_URL")
+DEBUG = env_bool(os.environ.get("DEBUG", False))
+
+HTTP_TIMEOUT = httpx.Timeout(5.0)
+
+
+def setup_logging() -> None:
+ """Attach a RichHandler to the doorman + doorman_client loggers.
+
+ Called from the FastAPI lifespan and from websocket_client when run
+ standalone, so logs appear regardless of which entrypoint is hosting us.
+ """
+ handler = RichHandler()
+ handler.setFormatter(logging.Formatter("%(message)s", datefmt="[%X]"))
+ level = logging.DEBUG if DEBUG else logging.INFO
+ for name in ("doorman", "doorman_client"):
+ log = logging.getLogger(name)
+ if not any(isinstance(h, RichHandler) for h in log.handlers):
+ log.addHandler(handler)
+ log.setLevel(level)
+ log.propagate = False
+
+
+def xml_response(status: int) -> Response:
+ body = f'{status}'
+ return Response(
+ content=body,
+ media_type="application/xml; charset=utf-8",
+ status_code=status,
+ )
-def returns_xml(f):
- @wraps(f)
- def decorated_function(*args, **kwargs) -> Response:
- r, status = f(*args, **kwargs)
- return Response(r, content_type="application/xml; charset=utf-8", status=status)
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncIterator[None]:
+ setup_logging()
- return decorated_function
+ # Single shared HTTP client with explicit timeouts for outbound webhooks.
+ app.state.http = httpx.AsyncClient(timeout=HTTP_TIMEOUT)
+ import threading
-def return_code_template(status: int) -> str:
- return f"""{status}"""
+ from websocket_client import MMAccessClient
+ ws_client: dict = {}
+
+ def start_websocket_client() -> None:
+ try:
+ client = MMAccessClient(debug=DEBUG)
+ ws_client["instance"] = client
+ client.run()
+ except Exception as e:
+ logger.exception("MemberMatters websocket client crashed")
+ sentry_sdk.capture_exception(e)
+
+ ws_thread = threading.Thread(
+ target=start_websocket_client, daemon=True, name="mm-ws-client"
+ )
+ ws_thread.start()
+
+ try:
+ yield
+ finally:
+ # Close the websocket so run_forever() returns and the pump thread exits.
+ client = ws_client.get("instance")
+ if client is not None:
+ try:
+ client.close()
+ except Exception:
+ logger.exception("Error closing websocket client")
+ await app.state.http.aclose()
-def open_door(url: str = FANVIL_URL) -> None:
- # Change code= to match the value in EGS settings > features > calling
- # password (* by default)
- url = f"{FANVIL_URL}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*"
- auth = HTTPBasicAuth(FANVIL_USER, FANVIL_PASS)
- requests.get(url, auth=auth, verify=FANVIL_VERIFY_CA)
+app = FastAPI(lifespan=lifespan)
+
+
+@app.exception_handler(Exception)
+async def xml_unhandled_exception_handler(request: Request, exc: Exception) -> Response:
+ """Fanvil expects XML for every response; never let a JSON 500 leak out."""
+ logger.exception("Unhandled exception", exc_info=exc)
+ sentry_sdk.capture_exception(exc)
+ return xml_response(500)
+
+
+def open_door() -> None:
+ """Trigger the Fanvil door relay via its HTTP API.
+
+ Synchronous on purpose: it is called from the websocket worker thread,
+ which has no event loop. FastAPI handlers should call via asyncio.to_thread.
+ """
+ url = f"{FANVIL_URL}/cgi-bin/ConfigManApp.com?Key=F_LOCK&code=*"
+ verify = FANVIL_VERIFY_CA if FANVIL_VERIFY_CA else False
+ with httpx.Client(verify=verify, timeout=HTTP_TIMEOUT) as client:
+ client.get(url, auth=(FANVIL_USER, FANVIL_PASS))
def _lookup_mm(card_number: str) -> bool:
with open(MM_DATA_FILE) as f:
mm_data = json.load(f)
authorized_tags = mm_data.get("tags", [])
- app.logger.debug(
+ logger.debug(
f"Loaded {len(authorized_tags)} tags from MemberMatters cache at {MM_DATA_FILE}"
)
locked_out = mm_data.get("locked_out", False)
if locked_out:
- app.logger.info(
- "This MemberMatters device was set to locked_out by the server!"
- )
+ logger.info("This MemberMatters device was set to locked_out by the server!")
return False
if card_number in authorized_tags:
- app.logger.info(f"card_number: {card_number} is authorized by MemberMatters")
+ logger.info(f"card_number: {card_number} is authorized by MemberMatters")
return True
- app.logger.debug(f"card_number: {card_number} not authorized by MemberMatters")
+ logger.debug(f"card_number: {card_number} not authorized by MemberMatters")
return False
@@ -102,40 +199,45 @@ def _notify_ldap(card_number: str, attributes: dict) -> None:
)
-def _lookup_ldap(card_number: str) -> bool:
+def _ldap_search(card_number: str) -> list:
+ """Synchronous LDAP search; call via asyncio.to_thread from async code."""
ldap_server = ldap3.Server(LDAP_SERVER, use_ssl=LDAP_USE_SSL)
with ldap3.Connection(ldap_server, LDAP_USER_DN, LDAP_PASS, auto_bind=True) as conn:
- app.logger.info(conn)
+ logger.info(str(conn))
search_filter = f"(&(rfidbadge={card_number})(!(nsAccountLock=TRUE)))"
- app.logger.debug(f"SearchDN: {LDAP_BASE_DN} Search filter: {search_filter}")
+ logger.debug(f"SearchDN: {LDAP_BASE_DN} Search filter: {search_filter}")
conn.search(
LDAP_BASE_DN,
search_filter,
attributes=["cn", "uid"],
)
- app.logger.info(f"Response: {conn.response}")
-
- if len(conn.response) == 1:
- app.logger.info(f"Card found: {conn.response[0]['attributes']}")
- # TODO: write cn to audit log (influxdb)
- if LDAP_APPRISE_URL:
- _notify_ldap(card_number, conn.response[0]["attributes"])
- if SUCCESS_WEBHOOK:
- webhook_data = {"_type": "CARD", "card_number": card_number}
- webhook_data.update(conn.response[0]["attributes"])
- try:
- requests.post(SUCCESS_WEBHOOK, webhook_data)
- except requests.RequestException as e:
- app.logger.error("Exception while trying to send success webhook:")
- app.logger.exception(e)
- return True
- else:
- app.logger.info("Card not found")
- return False
+ logger.info(f"Response: {conn.response}")
+ return list(conn.response)
-def lookup_card(card_number: str) -> bool:
- mm_authorized = _lookup_mm(card_number)
+async def _lookup_ldap(request: Request, card_number: str) -> bool:
+ response = await asyncio.to_thread(_ldap_search, card_number)
+ if len(response) != 1:
+ logger.info("Card not found")
+ return False
+ attributes = response[0]["attributes"]
+ logger.info(f"Card found: {attributes}")
+ if LDAP_APPRISE_URL:
+ await asyncio.to_thread(_notify_ldap, card_number, attributes)
+ if SUCCESS_WEBHOOK:
+ webhook_data = {"_type": "CARD", "card_number": card_number}
+ webhook_data.update(attributes)
+ try:
+ await request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data)
+ except httpx.HTTPError as e:
+ logger.error("Exception while trying to send success webhook:")
+ logger.exception(e)
+ sentry_sdk.capture_exception(e)
+ return True
+
+
+async def lookup_card(request: Request, card_number: str) -> bool:
+ mm_authorized = await asyncio.to_thread(_lookup_mm, card_number)
if mm_authorized:
# Only notify MemberMatters of access if the card was in its own tag list
door_access_queue.put(
@@ -146,43 +248,57 @@ def lookup_card(card_number: str) -> bool:
}
)
return True
- return _lookup_ldap(card_number)
+ return await _lookup_ldap(request, card_number)
-def lookup_pin(input_value: str) -> bool:
+async def lookup_pin(request: Request, input_value: str) -> bool:
for pin in ACCESS_PINS:
if input_value == str(pin):
- app.logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}")
+ logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}")
if SUCCESS_WEBHOOK:
webhook_data = {"_type": "PIN", "cn": ACCESS_PINS[pin]}
try:
- requests.post(SUCCESS_WEBHOOK, webhook_data)
- except requests.RequestException as e:
- app.logger.error("Exception while trying to send success webhook:")
- app.logger.exception(e)
+ await request.app.state.http.post(
+ SUCCESS_WEBHOOK, data=webhook_data
+ )
+ except httpx.HTTPError as e:
+ logger.error("Exception while trying to send success webhook:")
+ logger.exception(e)
+ sentry_sdk.capture_exception(e)
return True
return False
-@app.route("/", methods=["GET", "POST"])
-@returns_xml
-def auth():
+@app.api_route("/", methods=["GET", "POST"])
+async def auth(request: Request) -> Response:
if request.method == "POST":
- input_type, input_value = fanvil.parse_command(request.get_data())
+ body = await request.body()
+ try:
+ input_type, input_value = fanvil.parse_command(body)
+ except fanvil.FanvilParseError as e:
+ logger.warning(f"Failed to parse Fanvil command: {e}")
+ return xml_response(401)
+ success = False
if input_type == fanvil.CARD_ID:
- app.logger.info(f"Got card: {input_value}")
- success = lookup_card(input_value)
+ logger.info(f"Got card: {input_value}")
+ success = await lookup_card(request, input_value)
elif input_type == fanvil.KEYPAD_INPUT:
- app.logger.info(f"Got keypad input: {input_value}")
- success = lookup_pin(input_value)
+ logger.info(f"Got keypad input: {input_value}")
+ success = await lookup_pin(request, input_value)
if success:
- return return_code_template(200), 200
- return return_code_template(401), 401
-
-
-@app.route("/fanvil/doorbell", methods=["GET"])
-@returns_xml
-def doorbell():
- """Historical function to work around webhook limitations in Home Assistant"""
- requests.post(DOORBELL_WEBHOOK)
- return return_code_template(200), 200
+ return xml_response(200)
+ return xml_response(401)
+
+
+@app.get("/fanvil/doorbell")
+async def doorbell(request: Request) -> Response:
+ """Historical function to work around webhook limitations in Home Assistant."""
+ if not DOORBELL_WEBHOOK:
+ return xml_response(200)
+ try:
+ await request.app.state.http.post(DOORBELL_WEBHOOK)
+ except httpx.HTTPError as e:
+ logger.error("Exception while sending doorbell webhook:")
+ logger.exception(e)
+ sentry_sdk.capture_exception(e)
+ return xml_response(200)
diff --git a/doorman/ipc.py b/doorman/ipc.py
index 5998606..a108435 100644
--- a/doorman/ipc.py
+++ b/doorman/ipc.py
@@ -1,10 +1,12 @@
"""
-Inter-process communication between the Flask server and the websocket client.
+Inter-process communication between the FastAPI server and the websocket client.
-Uses a threading.Queue since both components run in the same process.
+Uses a threading.Queue since the websocket client runs in a separate thread.
+Both asyncio (FastAPI) and synchronous (websocket client) code can safely
+interact with threading.Queue.
"""
import queue
-# Flask pushes door_access events here; the websocket client consumes them.
+# FastAPI pushes door_access events here; the websocket client consumes them.
door_access_queue: queue.Queue = queue.Queue()
diff --git a/envrc.example b/envrc.example
index 255eeff..1268c06 100644
--- a/envrc.example
+++ b/envrc.example
@@ -1,6 +1,4 @@
-FLASK_HOST=0.0.0.0
-FLASK_ENV=development
-FLASK_APP=doorman.app
+# Run with: uvicorn main:app --host 0.0.0.0 --port 5000
# Websocket connection to Member Matters
DOORMAN_MM_ENABLE=true
@@ -27,8 +25,13 @@ DOORMAN_FANVIL_PASS=admin
# Unset to skip SSL verification
DOORMAN_FANVIL_CA=pawprint-ca.crt
+# Sentry error reporting. Leave DSN unset to disable.
+#DOORMAN_SENTRY_DSN=
+#DOORMAN_SENTRY_ENVIRONMENT=production
+#DOORMAN_SENTRY_RELEASE=
+#DOORMAN_SENTRY_TRACES_SAMPLE_RATE=0.1
+
# Uncomment for .envrc use:
-#export FLASK_HOST FLASK_ENV FLASK_APP
#export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY
#export DOORMAN_LDAP_ENABLE DOORMAN_LDAP_SERVER DOORMAN_LDAP_USE_SSL DOORMAN_LDAP_USER_DN DOORMAN_LDAP_PASS
#export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA
diff --git a/main.py b/main.py
index a195f7c..ae5ed15 100644
--- a/main.py
+++ b/main.py
@@ -1,24 +1,11 @@
"""
-Unified entrypoint that runs the Flask server and the MemberMatters
+Unified entrypoint that runs the FastAPI server and the MemberMatters
websocket client in the same process.
-The Flask app is exposed as `app` for gunicorn/meinheld to pick up.
-The websocket client runs in a daemon thread so it dies with the process.
-"""
-
-import threading
-
-from doorman.app import app # noqa: F401 — gunicorn imports `app` from this module
-from websocket_client import DEBUG, MMAccessClient
-
-
-def start_websocket_client() -> None:
- client = MMAccessClient(debug=DEBUG)
- client.run()
+Run with:
+ uvicorn main:app --host 0.0.0.0 --port 5000
+The websocket client is started via the FastAPI lifespan handler.
+"""
-# Start the websocket client in a daemon thread
-ws_thread = threading.Thread(
- target=start_websocket_client, daemon=True, name="mm-ws-client"
-)
-ws_thread.start()
+from doorman.app import app # noqa: F401 — uvicorn imports `app` from this module
diff --git a/requirements.txt b/requirements.txt
index 4f6253b..1189f72 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,8 +1,8 @@
-Flask>=3.1.2
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
ldap3>=2.9.1
-requests~=2.32.5
+httpx>=0.27.0
apprise>=1.9.0
-#json_logging>=1.2
websocket-client~=1.9.0
-rel~=0.4.9.21
rich~=14.2.0
+sentry-sdk>=2.0
diff --git a/websocket_client.py b/websocket_client.py
index 9cbc8da..a07eee7 100644
--- a/websocket_client.py
+++ b/websocket_client.py
@@ -2,12 +2,12 @@
import logging
import os
import queue
+import threading
import time
from enum import Enum
-import rel
+import sentry_sdk
import websocket
-from rich.logging import RichHandler
from doorman.app import env_bool, open_door
from doorman.ipc import door_access_queue
@@ -32,13 +32,7 @@ def __str__(self):
MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset")
DEBUG = env_bool(os.environ.get("DEBUG", False))
-logging.basicConfig(
- level=logging.NOTSET, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()]
-)
logger = logging.getLogger("doorman_client")
-logger.setLevel(logging.INFO)
-if DEBUG:
- logger.setLevel(logging.DEBUG)
class MMAccessClient(websocket.WebSocketApp):
@@ -54,28 +48,37 @@ def __init__(self, *args, **kwargs):
)
def run(self) -> None:
- self.run_forever(
- dispatcher=rel, reconnect=15
- ) # Set dispatcher to automatic reconnection, 15-second reconnect delay if connection closed unexpectedly
- # Poll the door_access queue every 0.5s and forward events to the server
- rel.timeout(0.5, self._poll_door_access_queue)
- rel.signal(2, rel.abort) # Keyboard Interrupt
- rel.dispatch()
-
- def _poll_door_access_queue(self) -> bool:
- """Check the shared queue for door_access events and send them."""
+ # rel's dispatcher installs a SIGINT handler at construction time,
+ # which fails off the main thread. Use run_forever()'s default select
+ # loop and pump the door_access queue from a separate daemon thread.
+ self._pump_stop = threading.Event()
+ pump = threading.Thread(
+ target=self._pump_door_access_queue,
+ daemon=True,
+ name="mm-ws-queue-pump",
+ )
+ pump.start()
try:
- while True:
- event = door_access_queue.get_nowait()
+ self.run_forever(reconnect=15)
+ finally:
+ self._pump_stop.set()
+
+ def _pump_door_access_queue(self) -> None:
+ """Forward queued door_access events to the server until stopped."""
+ while not self._pump_stop.is_set():
+ try:
+ event = door_access_queue.get(timeout=0.5)
+ except queue.Empty:
+ continue
+ try:
self.send_door_access(
id_number=event["id_number"],
success=event["success"],
method=event.get("method", "rfid"),
)
- except queue.Empty:
- pass
- # Re-schedule ourselves — returning True keeps the rel timeout alive
- return True
+ except Exception as e:
+ logger.exception("Error sending door_access event")
+ sentry_sdk.capture_exception(e)
def send_door_access(
self, id_number: str, success: bool, method: str = "rfid"
@@ -126,8 +129,13 @@ def parse_command(self, data: dict) -> None:
pass
elif command == "bump":
- # Bump the door open
- open_door()
+ # Bump the door open. open_door is synchronous so we don't need
+ # to spin up an event loop in this websocket worker thread.
+ try:
+ open_door()
+ except Exception as e:
+ logger.exception("Error opening door from bump command")
+ sentry_sdk.capture_exception(e)
elif command == "sync":
# Save synced tags to a file
@@ -143,10 +151,12 @@ def on_message(self, ws, message: str) -> None:
self.parse_command(data)
except json.decoder.JSONDecodeError as e:
logger.exception(e)
+ sentry_sdk.capture_exception(e)
return
def on_error(self, ws, error: Exception) -> None:
logger.error(f"[SOCKET ERROR]: {error}")
+ sentry_sdk.capture_exception(error)
def on_close(self, ws, close_status_code: int, close_msg: str) -> None:
logger.info("### closed ###")
@@ -163,6 +173,9 @@ def on_open(self, data) -> None:
if __name__ == "__main__":
- # Standalone mode: run without Flask (useful for local testing)
+ # Standalone mode: run without FastAPI (useful for local testing).
+ from doorman.app import setup_logging
+
+ setup_logging()
client = MMAccessClient(debug=DEBUG)
client.run()
From 580c3964fcdeac46e2940595919cb4fe38b9388d Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Wed, 20 May 2026 20:25:50 -0700
Subject: [PATCH 10/14] add server ping timeout and cron to send keep-alives
---
envrc.example | 5 ++++-
websocket_client.py | 44 +++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 47 insertions(+), 2 deletions(-)
diff --git a/envrc.example b/envrc.example
index 1268c06..9454ef3 100644
--- a/envrc.example
+++ b/envrc.example
@@ -6,6 +6,9 @@ DOORMAN_MM_ACCESS_URL=ws://membermatters.local/ws/access
DOORMAN_MM_DEVICE_NAME=frontdoor_test
DOORMAN_MM_API_KEY=super.secret
+# Keep-alive ping interval (seconds). Pong timeout = 3× this value.
+#DOORMAN_MM_PING_INTERVAL=30
+
# LDAP Configuration
DOORMAN_LDAP_ENABLE=true
DOORMAN_LDAP_SERVER=ipa.pawprint.space
@@ -32,6 +35,6 @@ DOORMAN_FANVIL_CA=pawprint-ca.crt
#DOORMAN_SENTRY_TRACES_SAMPLE_RATE=0.1
# Uncomment for .envrc use:
-#export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY
+#export DOORMAN_MM_ENABLE DOORMAN_MM_ACCESS_URL DOORMAN_MM_DEVICE_NAME DOORMAN_MM_API_KEY DOORMAN_MM_PING_INTERVAL
#export DOORMAN_LDAP_ENABLE DOORMAN_LDAP_SERVER DOORMAN_LDAP_USE_SSL DOORMAN_LDAP_USER_DN DOORMAN_LDAP_PASS
#export DOORMAN_FANVIL_URL DOORMAN_FANVIL_PASS DOORMAN_FANVIL_CA
diff --git a/websocket_client.py b/websocket_client.py
index a07eee7..cb6460c 100644
--- a/websocket_client.py
+++ b/websocket_client.py
@@ -30,6 +30,7 @@ def __str__(self):
]
MM_DATA_FILE = os.environ.get("DOORMAN_MM_DATA_FILE", "/tmp/mm-doorman.json")
MM_API_KEY = os.environ.get("DOORMAN_MM_API_KEY", "unset")
+MM_PING_INTERVAL = int(os.environ.get("DOORMAN_MM_PING_INTERVAL", "30"))
DEBUG = env_bool(os.environ.get("DEBUG", False))
logger = logging.getLogger("doorman_client")
@@ -50,14 +51,21 @@ def __init__(self, *args, **kwargs):
def run(self) -> None:
# rel's dispatcher installs a SIGINT handler at construction time,
# which fails off the main thread. Use run_forever()'s default select
- # loop and pump the door_access queue from a separate daemon thread.
+ # loop and pump work from daemon threads.
self._pump_stop = threading.Event()
+ self._last_pong = time.monotonic()
pump = threading.Thread(
target=self._pump_door_access_queue,
daemon=True,
name="mm-ws-queue-pump",
)
+ keepalive = threading.Thread(
+ target=self._pump_keepalive,
+ daemon=True,
+ name="mm-ws-keepalive",
+ )
pump.start()
+ keepalive.start()
try:
self.run_forever(reconnect=15)
finally:
@@ -80,6 +88,36 @@ def _pump_door_access_queue(self) -> None:
logger.exception("Error sending door_access event")
sentry_sdk.capture_exception(e)
+ def _pump_keepalive(self) -> None:
+ """Send periodic client→server pings; force reconnect on pong timeout."""
+ timeout_seconds = MM_PING_INTERVAL * 3
+ while not self._pump_stop.wait(MM_PING_INTERVAL):
+ sock = self.sock
+ if sock is None or not sock.connected:
+ # Reset so a freshly reopened socket isn't immediately stale.
+ self._last_pong = time.monotonic()
+ continue
+
+ if time.monotonic() - self._last_pong > timeout_seconds:
+ logger.warning(
+ "MemberMatters pong timeout (%ss), forcing reconnect",
+ timeout_seconds,
+ )
+ self._last_pong = time.monotonic()
+ try:
+ self.close()
+ except Exception as e:
+ logger.exception("Error closing socket on pong timeout")
+ sentry_sdk.capture_exception(e)
+ continue
+
+ try:
+ logger.debug("sending keep-alive ping")
+ self.send(json.dumps({"command": "ping"}))
+ except Exception as e:
+ logger.exception("Error sending keep-alive ping")
+ sentry_sdk.capture_exception(e)
+
def send_door_access(
self, id_number: str, success: bool, method: str = "rfid"
) -> None:
@@ -124,6 +162,9 @@ def parse_command(self, data: dict) -> None:
if command == "ping":
self.send(json.dumps({"command": "pong"}))
+ elif command == "pong":
+ self._last_pong = time.monotonic()
+
elif command == "reboot":
# Not implemented (lol)
pass
@@ -163,6 +204,7 @@ def on_close(self, ws, close_status_code: int, close_msg: str) -> None:
logger.info(f"Status: {close_status_code}, Message: {close_msg}")
def on_open(self, data) -> None:
+ self._last_pong = time.monotonic()
logger.info(f"Opened connection to {self.url}, sending auth.")
# Send auth
auth_packet = {
From 7a5fc60e2a0a64d2a6d65e98c89df5296906e600 Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Wed, 20 May 2026 20:27:08 -0700
Subject: [PATCH 11/14] Glitchtip-specific sentry config
---
doorman/app.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/doorman/app.py b/doorman/app.py
index 7eedb92..a4ca1b8 100644
--- a/doorman/app.py
+++ b/doorman/app.py
@@ -38,6 +38,8 @@ def _init_sentry() -> None:
os.environ.get("DOORMAN_SENTRY_TRACES_SAMPLE_RATE", "0.1")
),
send_default_pii=True,
+ auto_session_tracking=False, # GlitchTip does not support sessions
+ # enable_logs=True, # Opt-in: send logs to GlitchTip (uses disk space)
)
From 40b78fb7f993eabdfae8f5657f332ab07a9ef205 Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Wed, 20 May 2026 20:46:58 -0700
Subject: [PATCH 12/14] Webhook and telegram notifications to best-effort
---
doorman/app.py | 40 +++++++++++++++++++++++++---------------
1 file changed, 25 insertions(+), 15 deletions(-)
diff --git a/doorman/app.py b/doorman/app.py
index a4ca1b8..048a228 100644
--- a/doorman/app.py
+++ b/doorman/app.py
@@ -102,6 +102,24 @@ def xml_response(status: int) -> Response:
)
+_background_tasks: set[asyncio.Task] = set()
+
+
+def _fire_and_forget(coro) -> None:
+ """Schedule a coroutine as a background task with error logging."""
+
+ async def _wrapper():
+ try:
+ await coro
+ except Exception as e:
+ logger.error(f"Background task failed: {e}")
+ sentry_sdk.capture_exception(e)
+
+ task = asyncio.create_task(_wrapper())
+ _background_tasks.add(task)
+ task.add_done_callback(_background_tasks.discard)
+
+
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
setup_logging()
@@ -225,16 +243,13 @@ async def _lookup_ldap(request: Request, card_number: str) -> bool:
attributes = response[0]["attributes"]
logger.info(f"Card found: {attributes}")
if LDAP_APPRISE_URL:
- await asyncio.to_thread(_notify_ldap, card_number, attributes)
+ _fire_and_forget(asyncio.to_thread(_notify_ldap, card_number, attributes))
if SUCCESS_WEBHOOK:
webhook_data = {"_type": "CARD", "card_number": card_number}
webhook_data.update(attributes)
- try:
- await request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data)
- except httpx.HTTPError as e:
- logger.error("Exception while trying to send success webhook:")
- logger.exception(e)
- sentry_sdk.capture_exception(e)
+ _fire_and_forget(
+ request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data)
+ )
return True
@@ -259,14 +274,9 @@ async def lookup_pin(request: Request, input_value: str) -> bool:
logger.info(f"Access granted by PIN for {ACCESS_PINS[pin]}")
if SUCCESS_WEBHOOK:
webhook_data = {"_type": "PIN", "cn": ACCESS_PINS[pin]}
- try:
- await request.app.state.http.post(
- SUCCESS_WEBHOOK, data=webhook_data
- )
- except httpx.HTTPError as e:
- logger.error("Exception while trying to send success webhook:")
- logger.exception(e)
- sentry_sdk.capture_exception(e)
+ _fire_and_forget(
+ request.app.state.http.post(SUCCESS_WEBHOOK, data=webhook_data)
+ )
return True
return False
From 51703f19c9149ef05a058b91cfa76905b63d6e08 Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Thu, 21 May 2026 10:44:29 -0700
Subject: [PATCH 13/14] Update mypy dependencies list
---
.pre-commit-config.yaml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f2e14d1..291f70e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -36,10 +36,10 @@ repos:
hooks:
- id: mypy
additional_dependencies:
- - flask
- - types-requests
+ - fastapi
+ - httpx
- apprise
- ldap3
- websocket-client
- - rel
- rich
+ - sentry-sdk
From a8f75ab7bda0a474f21d6dd4d5a0161fa9ab766a Mon Sep 17 00:00:00 2001
From: Rechner Fox <659028+rechner@users.noreply.github.com>
Date: Sun, 24 May 2026 21:43:56 -0700
Subject: [PATCH 14/14] Add GHA to build docker container
---
.github/workflows/build-image.yml | 50 +++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
create mode 100644 .github/workflows/build-image.yml
diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
new file mode 100644
index 0000000..c47780b
--- /dev/null
+++ b/.github/workflows/build-image.yml
@@ -0,0 +1,50 @@
+name: Build and Push Docker Image
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+ pull_request:
+ branches: [main]
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}