From d52fe52d965aea7f1e11c4c18b0bb325c0e5a493 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 6 Dec 2024 23:16:29 +0100 Subject: [PATCH] Various services improved (#110) --- App/Source/Main.cpp | 6 +- Boards/LilygoTdeck/Source/Sdcard.cpp | 2 +- Documentation/app-lifecycle.puml | 10 +- Documentation/ideas.md | 16 +- Documentation/pics/app-lifecycle.png | Bin 13321 -> 18301 bytes Documentation/pics/app-lifecycle.png~ | Bin 0 -> 59865 bytes Tactility/Private/service/loader/Loader_i.h | 6 +- Tactility/Source/Tactility.cpp | 8 +- Tactility/Source/Tactility.h | 2 +- Tactility/Source/app/power/Power.cpp | 2 +- .../Source/app/wificonnect/WifiConnect.cpp | 4 +- .../Source/app/wifimanage/WifiManage.cpp | 5 +- Tactility/Source/lvgl/Statusbar.cpp | 4 +- Tactility/Source/service/loader/Loader.cpp | 14 +- Tactility/Source/service/loader/Loader.h | 2 +- .../Source/service/statusbar/Statusbar.cpp | 51 +- TactilityCore/Source/Dispatcher.cpp | 51 +- TactilityCore/Source/Dispatcher.h | 28 +- TactilityCore/Source/Mutex.cpp | 5 +- TactilityCore/Source/Mutex.h | 28 + TactilityCore/Source/Pubsub.cpp | 49 +- TactilityCore/Source/Pubsub.h | 40 +- TactilityCore/Source/Timer.cpp | 6 +- TactilityCore/Source/Timer.h | 7 +- .../Source/TactilityHeadless.cpp | 8 + TactilityHeadless/Source/TactilityHeadless.h | 3 + .../Source/service/sdcard/Sdcard.cpp | 80 +- TactilityHeadless/Source/service/wifi/Wifi.h | 6 +- .../Source/service/wifi/WifiEsp.cpp | 884 ++++++++++-------- .../Source/service/wifi/WifiMock.cpp | 7 +- Tests/TactilityCore/DispatcherTest.cpp | 37 +- Tests/TactilityCore/TimerTest.cpp | 36 +- 32 files changed, 771 insertions(+), 636 deletions(-) create mode 100644 Documentation/pics/app-lifecycle.png~ diff --git a/App/Source/Main.cpp b/App/Source/Main.cpp index 6042c859..b4f1096e 100644 --- a/App/Source/Main.cpp +++ b/App/Source/Main.cpp @@ -4,7 +4,7 @@ #include "Tactility.h" namespace tt::service::wifi { - extern void wifi_main(void*); + extern void wifi_task(void*); } extern const tt::app::AppManifest hello_world_app; @@ -25,9 +25,7 @@ void app_main() { .auto_start_app_id = nullptr }; - tt::init(config); - - tt::service::wifi::wifi_main(nullptr); + tt::run(config); } } // extern diff --git a/Boards/LilygoTdeck/Source/Sdcard.cpp b/Boards/LilygoTdeck/Source/Sdcard.cpp index b0f975fa..d5664302 100644 --- a/Boards/LilygoTdeck/Source/Sdcard.cpp +++ b/Boards/LilygoTdeck/Source/Sdcard.cpp @@ -144,7 +144,7 @@ static bool sdcard_is_mounted(void* context) { * Writing and reading to the bus from 2 devices at the same time causes crashes. * This work-around ensures that this check is only happening when LVGL isn't rendering. */ - bool locked = tt::lvgl::lock(100); // TODO: Refactor to a more reliable locking mechanism + bool locked = tt::lvgl::lock(50); // TODO: Refactor to a more reliable locking mechanism if (!locked) { TT_LOG_W(TAG, "Failed to get LVGL lock"); } diff --git a/Documentation/app-lifecycle.puml b/Documentation/app-lifecycle.puml index 2ed42a27..a88b3f6c 100644 --- a/Documentation/app-lifecycle.puml +++ b/Documentation/app-lifecycle.puml @@ -1,9 +1,9 @@ @startuml -[*] --> on_create : app is started -on_create --> on_show : app becomes visible -on_show --> on_hide : app is no longer visible -on_hide --> on_destroy : app is being closed -on_destroy --> [*] +[*] --> onStart : app is created +onStart --> onShow : app becomes visible +onShow --> onHide : app is no longer visible +onHide --> onStop : app is preparing to be destroyed +onStop --> [*] : app is destroyed skinparam ranksep 25 skinparam padding 2 @enduml \ No newline at end of file diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 52143817..c0139738 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -1,32 +1,30 @@ # TODOs -- Bug: sdcard file reading fails (due to `A:/` prefix?) -- Publish firmwares with upload tool -- Bug: When closing a top level app, there's often an error "can't stop root app" - Bug: I2C Scanner is on M5Stack devices is broken +- WiFi AP Connect app: add "Forget" option. +- T-Deck Plus: Implement battery status +- Make firmwares available via release process +- Make firmwares available via web serial website +- Bug: When closing a top level app, there's often an error "can't stop root app" - Create more unit tests for `tactility-core` and `tactility` (PC-only for now) -- WiFi on-at-boot should be a setting in its app -- Create app to edit WiFi settings (e.g. "forget" and "auto-connect" option) - Show a warning screen if firmware encryption or secure boot are off when saving WiFi credentials. - Show a warning screen when a user plugs in the SD card on a device that only supports mounting at boot. -- T-Deck has random sdcard SPI crashes due to sharing bus with screen SPI: make it use the LVGL lock for sdcard operations? - Check service/app id on registration to see if it is a duplicate id - Fix screenshot app on ESP32: it currently blocks when allocating memory -- Localisation of texts +- Localisation of texts (load in boot app from sd?) - Portrait support for GPIO app -- App lifecycle docs mention on_create/on_destroy but app lifecycle is on_start/on_stop - Explore LVGL9's FreeRTOS functionality - Explore LVGL9's ILI93414 driver for 2.4" Yellow Board - Bug: in LVGL9 with M5Core2, crash when bottom item is clicked without scrolling first - Replace M5Unified and M5GFX with custom drivers (so we can fix the Core2 SD card mounting bug, and so we regain some firmware space) - Commit fix to esp_lvgl_port to have `esp_lvgl_port_disp.c` user driver_data instead of user_data - Wifi bug: when pressing disconnect while between `WIFI_EVENT_STA_START` and `IP_EVENT_STA_GOT_IP`, then auto-connect becomes activate again. +- T-Deck Plus: Create separate board config # Core Ideas - Support for displays with different DPI. Consider the layer-based system like on Android. - If present, use LED to show boot status - 2 wire speaker support - tt::app::start() and similar functions as proxies for Loader app start/stop/etc. -- Wi-Fi using dispatcher to dispatch its main functionality to the dedicated Wi-Fi CPU core (to avoid main loop hack) # App Ideas - System logger diff --git a/Documentation/pics/app-lifecycle.png b/Documentation/pics/app-lifecycle.png index 9b5edf024a9d86c656f067590a2085472bdf1bb9..669df49baa9a1238bc2688a0f2188a97dac83703 100644 GIT binary patch literal 18301 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 literal 13321 zcmche2{@F0+xH9Enz1j#SQ7~`_9aX9EJcY(ma(SnWXqCmtPxTwQYf-yBudCK_C!gV zvhT{iW#>KB|Nq?g{XEb69?$z8_whQ8ImUIFxt8Dc`<>_a`#DcBhG(^r)a=v;4je%0 zoYpuG@9z&BIM_!?0>7;(wJyV(khi9(x2>DIzq7rA_W^BtS9?#ax4j*YjX%#7Z*O-m z1u-#qXRNFDbr)w*TQ?V9Gc*daq6di7UOL#(1fd5rA zw-Ty8>gZ{{b3-=q_kEgQKFM(Lc`dztZ3nj(q+E3q9f~kym3-8 zB7ujzP{@$wEJDX4M&aY!s$104J3o>|q}uXd@pzm)=~+Bx%zI5xYSQk-B4gKZa-rm= zU&U!14;r&4vo+0ZSNz>XXH-2R$@MTT6ne}W*#-8I=gSWzp%~x)vM3R~-7jONs@^2@ zf$mqfs?&N`ES@ zMr!$D@wNU2oBW_+`6hf?w90IL>e<=bmZb*{NSNtps9x~1oXxm#fl2#ab5Nu9g}~yQ zTJ%Vvmk;u;+MotA!eNPttSvN zgEyVrjCc~VTj&k=ax@eOFJHd2^cy~OPMGg@xc;J@n(~;$%*2O>>QOYzE)t{?Nn$Lj zr*Rs%FFif)eto}|rJc{i?HF^2UV=9Xn;G=${*~K@hD1tu)C7)7IL{DEvXohFmNtY@ z-oM?5pZ;1LH)o3IV2%alHqv+T#9_t7#k1sC!o3@$@3Wlu)D;LTE9mGK zk?}b3sn>ZHr2a^wk-0fh9Obc41mCO3f7|Qx*2YGLoQGPxZf%Ke^V{pw0$LeJiJ3vG zEw8mX3-L>=xo0!l+S{43^G$D1vzdfkxtiuuwb+Edu{Ov2_dZG-wwq1D?)29e7D`G= z7mF=FgiRqP8ziei@{SKL^0W$FImeG4t? zKuE2Si((^J`Tp9i#hKJ0Cb zWBn#-B2#s8bY#R7UszapE?eu{w{JooG_vubRIWrk_WeDvJ$^zruRiuNYpT$3J(EXE z{l0@`S3apNpu+i^@Z2HWNwdMb88K^vvcF)tIM4#)5z&-C_*Kph3VzX@u_DF z4L_E4D#txd)J-QSwsdxOwzk%Z>NdGEb8&Sr>T|Kny7pyh#;xEc55PH)Mvt;&3kee) z8F4WTtF7hu7;5i`~YqMs(qS{`~peDEHUYC)CaQ2vhOp(jVuqf-0$A_2><>uy2BMGR6`U?qBChUZtw5h4e6dAlC`0i1SouTu%;}nR5 z3b}JCx3LlBZxXN(>HEL@{W^U$ZQ*&Ub=soirHEIjuM#23eD3O6f6;|$@VGP2kj(cS z`}FBkxSq=ruBao?qf=tfBl=EXMNv{y|NK^ZEG3=|=b}N!YD5(Sk#1(jIeL6{7FmK1 zz36PKzW#wy&2vl|u|4Nov%IphvbY#(x*z;(7PDww$zP{d~(x(VR_f)*DuFj_A6CNFN>(+

qQBzZMaVhefj94{wL~Zg&NO(d> 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%0Aj~!Zc=e+Q~^it`OhKp$a8Fcu1OfS zDbtC*3pOBAOw=@Ih4lRkMTh4(ne0KJQ{~VO(cvG{I!~xgm-h--nW{T#i^HPn8yYXD z*^EhxoIHuP@%Q&v6>$btL!=9q47<(Yk0fP0b~fTX;PQ#$=x7~)@GhoMgJL|K!k5xM zmU)a$g0Bo)J2*JFL;p7_zwv7Z!U)dNud&=O?KampWWn?2ivZ!Veq{?^eW;$_?#EOQ zhkON^JM>@Zxkt`EZiY;G3Cq*=1Z-Ix@0N9H{HCGuN6s+(!d7B|bw*l(Y`4*tmxT2S>XH6WvLh@t+G~mNgIl1W;h>wp zyPqBjT?5~~{s!pDLG%n*6&ZHiG41wbQ2x}iZUgZN2`uyX@l@srVG)tOHy+u<6E6|Hk;Y{nfoW(_igmh=9BSyP^Dh&lXJ7Py_C{b*~wTU7GsG zkN-VX&)$0NHmDrD?bo2BsK_E_xxMb67;1Mk9xY4L3M{G3?y1&b$llIaYf4i1`y!Ck zAfvYpot&IbpFTa=k+DyL(yOWzEyEQ4oCGxV>W|M926n8Xmno4*z)uz?CU-lmwIEN* zx_tNk(e?Drn>WwSJmgXiG*#T7Kiw1qqg&xQt{WIwC1K`71O&LXI!zG+{9hLE4%8o( z)#t006KZPqpm?>gz4aGEZ=8-mcu`(r^O^2B;q9fSMv@3?_a!|guyXP6=FA&&754A% zE?OMq^@n1Y;(fGlU!{Z!dn| zNgF1fZR$N#Uzbi>TMy;vqUAGFC|<)Y^w%S@9meggW_+SCM+iFbcvW zdo3}hAF}(S=Okki`A-29svSMr zV?Oga_PEir1@$yWLVdj=aPF~vk-4!vS?ZT@8%v|APDtm3t7!q2Z{VuMp%ieTy+9i# zBUJYp)Nt}-673=|Rd@*P{}&tPh{Fg({M7^P>GDQZUiAU69AcX=^)@Y-QaEKOLh>+- z_kV04g9>hD1H}c9UGE7Wm65r-<5G0kYj=C2p+RG@gK8T5`S$j9S{kav;Bs8qwXXw} zz6O7eix9DRdtru=HLI`?A$)N05#ZoY_=t!Ic-AG3mm^(z%b}2PrpmEiz*(xWyj(_6 z@$wAYJ)S=pdj!c?Zj=2RW50OKE`0oW-N~qTe8f+BnaTvDRsGw|1n9>Z8=eU9Vjj>ZM?qh7Hvm}7AQGYP@3HO`W$B+e;US0i? zA0Ho|pP%2>rdxvMCzaMvg7vcYNt;^}2;FtD0fu274qH_Avu6{at_!4V`}-{r@+9r^ zD<%0oa$T6&n^>D@cZL>>n)$!yrL;Bq)@H>6njm~;h~nZ`$@QpTu@ned^9s^6#-7tw z$NgYrWCYS8ti|1&Pg9RL%Y35yzTC+r!{j3nU5KF#jovr8+7hKSp!e>6m>Y(7(y z>iO?vcE;L&mf2wjEkLQy)CBLidjpoLx*Zl4M#U(=6V3Rw*s3$-K9Wc*yT&T;0Wv8K zyOiLG6Sw%mx@6IHJJ#Mj2&qbZZUU;CRlA$ha);&PSWa{aw~72w>2sW{8PeD8p3PPN zXg7YQ2Pv)1_t>=WJvg#kVzch)U3fsaRu!Iy(2(w3zP~#D;Jn*Z_|2O)p^W*pyBlT? zf90z}TzTuBogKkxtc9vndDqUAZC{24Qo$ z`}yauRv{k_+o3@NYOX@Sj4pi?^vlfT`#&`HR2bQ)tJcKKx6*kOv!t;i;r5@GI|o%8-NOv zQtRmIs)_jok%fUe7P*hcarjUlJvDDbdd%lXS0t-_6{`ODKIYb1No*r)Q#ULEgXVmj zQOND3tDMD7*QKFA9H&Zf--i!Kb&_7qI4=S?E>zCHyY?w7awu!pgW%WNhjF$Zs*`FS*>7xl%l1N$LWo>Ambsqd2~@(c ztVUc0qp7lHvOb%MD!VN{Dtp^r5;H1-NcBoTfBzjI*8zlN?k0Bv9xo^=+SJA;C`hTt zRv3I?grmnFMFklGAD1kSiinCjERT`^f*m<``HlM%Pfi)%xy$-mf|6olw8Gw&k3H3k zF^Ky$JF{-AVwUlnckbP@Z58vDWLO}>ICgNPk)elTEzfmF)ANOehw}5RPX%i z4Iuc+qAw1-am|zDI_b{jWSaJFq5iNPF~pGF#KPiM)N?MSfX79Ae0;#2-Xr4@5`HZ% z*2R+bH(k06tw0&R6YXqPR#xp2e#)8~Q#b7YhT2SFIwl(;F{?}a-60YM4=FLtOGsh^<5WeIc4iLC@U*t5XDV3#riFYGq_OW0u%rLPCS=%{G;{6IPtf5K7o}* zq0#Sm4dd|?ddPonJee5X`$)70`u#7xC&VKW_T4V1?+SnQp3q96BqSuDSI{6+`GgD- zd+<8S2TF+mIv{997Y!^#i}T3PonyWFkKU78{f4kgzrVDPBkD&8UBzLk)UxlL0^$-% zO3`rlNV{zhMr0Pf^j$9!K=wd|m$tK5J;DJ=Na1tPDY;ISG51&V$qjl3jEgB2I}ByS z#%KIof0|EB`1G;g@vz2a(mSi2dHDE7A=TLw&_Bbt5M!Bz8?^YDE*Kjx&L{GBi@TU! zxNwJj@rlAvR!Cc4?8&j0C-Orm(6|Ul0=QYL}bwpwvrKYvP5EcI=JG-oOxD6=7Mxdb(@sE}h zzGKjE(g>k+a(1>qOY^XSK_`5}%JE~tCqB!dRpX2&G=Q(i3(ZOmJvB5UI`mz@TCCSm zaN}*R}m{#g0HX-v`2RIawF37(qwn(R1}p-=|j z@5B2J_%{y@?(L&Fe9(`l_n?FrJ_O5Sh-D_!9i%11GsjlBd=A1kg6zb0ESsy5~XC zzlb-b>!;pjw@v^Y*6v4yz=t$ieXP8GvdxsM-e%i>)PUfA z+;7g>#PmNqgZ2@n!QKUT8#EKq49!{2kck~+oolw2-Y-vfPDVv2%$a^XKl8vGfkkoSaZWk)6^8jQGcWwY@u_sLddQn z7seb-Nl97Dct8&)8?F9PcEC^F@d8E0#`iE-Us#$T2=q)ppvGhtZGo#mfYnq&jCK~? zg&n7N*l^N!AJRjunRz)Fxx1Jc0@O<{K40zk-vo}Ks?R=B+~_tkC^9S6QhWw|GQ7xs zJj50oK_oTx%fMTmJOq)PCuj#rcn;7$1K*=$a{!U~f7nQq5y4#DZ@yDuGHDI&RR`+eVQT%US)PNex27981(+cm0`bd?Zgaq)A`()CQwkncM?6JuiN zdsF=yKsL_zR~{>8$5MaBg+eJYc2*Z9C@f46Qu`AC^}I)D3(X-;2XU-29z zQ}oB?{9m>i*cz7q=eC)+92^uS1bq(?BC;de|Gf>Oq#oj_&|r@wt=a0I2P+#C7|6q7 zS#m2nO{3*jH|}Xp4k)$jU|V23{?Ru|PEN};vrFk57e)0Wsec~{7tTFlwJiCoZhiIY zRcIQ7GWFke>u0io&qWZ~Vv%0w(`)fvUFQ+jE5{2`fYQ-|#lHUY!i5Xn(8Qt))htEL z5$4`#=))hOiYSEcdhM2Ai$; z?0|Msxc*pNGiB@T7R=OAv&yaq%wr(!BjWpg{9`G0$$UvzR0_|n@g^R|4LLoK@wtYAuv6TVO^A$`8-M>9t#ZsERhmw8@DoHuyMO2v<4eerU~N7i-xk zrm3wf{%P%}v1zTHD^EUw)_``IFlm;)df{?<)pX_0zDIH}`fd}G$!XN;>grHfu|`>z z6s2>lu6XG?_oPmg~*=}m9p|F=0RXgi*>HFtC|vNxl%u(7o^ zJLq6yZ)RrWaM{*zbVZpg9o;UvQzwp{cYXM)!|gWvfY$)cWy*`wVdkq?!1u9xCxatT z1l=`0?LX|qqJ3wBsTlnhwHNF5tmF1sIZs(FK|T2oH$D3S(PJ#!ip8b9yY3!1&1!ab zceRKA!{CSWrG*1OGD_2b&IEYt*Ka(f@x)yG`|p`m^e+Nh7Nt9md;d%wo&0z%$cSyV zJUv%XkXeq@E??$vAHv7B%HP_}-6QDBtZ=fz5Wo7@I!Ee76l3MlW)a59*Fubr`brwC zlXB@TSE#mJzMqwYL>DIKq|?%bj~uCf`<5XsEg@~6K;*j2)?ms&Vs`f3>}+8x(U^yH zzRbGH8h$?eB6e>HJ|rpmwxVKrmiF!HY6X_Sm35|fPwc+lJXkd_*)#6Szjtr^%a`}= z-evhYG?bc=QPVqqDeGje3jMh0{-Ci|r&tx<%7z9;F`KX5a@1ZqHa50PMn=ohTNpfD zdH!uQ&uX*!JnVd6%eumhUOVy2mX%!TkoNq@*Z!b0w6xG%6bdtYyP`T}=nA>3C zyM+bm6t&oIUvjU0Ep`iUYt#8QF5YJK>A=8OgUNjD1N=cZjM%gfY{4u0`uapfL|#}> zIPS1ZRAlHEeP=jIKVJ1EyRx;ybfhsxtiW-QWwdl&Am66L*v97F!|#i)S~p*guEA{? zcYWyRx9a}=`zMpmK23Xz7mvA@*t0OpOJB>gnjM&QyKv!xxYJKoxy3(ZH$(QDJc zbhd2WdMc&)s^aer^QCQ8_q7iw`uNDvW@uEy!lI(q)K+==@gG)oVKm%+85x;}QZ9FT zdW`PYeNSQXeqGFo&pj+AR_nKJ>(h*kYTC>RAI_}=yUPmXV4=pVU`ywv3iuy7e@3Bq|E6W%%&C#*>`l`Gw z$3l#Ib{%8ouJAg$#XnAZ0$XEaV`n{OqZT&HOK;3Of7UEVhnJ6U+m0P3=H~uZq8=1Y znYar^mOs9I+l>!XQBiqOSXjtqy_T!Wz5Q%YoC@#Y@UR)~^D~OANTtvj&4?H=+w2!F zUi1u;KlyyNCo^rIzP>(QX!YYM&3VrAQD2`~hGE&ehK2_Jd_G_1A*0iNKD^;Zj*j;( ziv(p1g+5W$Z#|WC_V(@D%)!+hr($rkEUi<=D1G(^4jg!#_H&Q5SWKg^l+^wsK3r9s zOz>)>75M=L!86TyF)F-uefFU*^|^dqwRL$H)@>?|PL|2OOBuGdRV>0Ay~tCUUB~4+ z`yKz@nrG}>pcm?y=@ndkqqeua$4uuHPxjt3iXYk2yZ_Kl9zjb1_IQyOT8+;Tr?8MpeW*0ABjkaO=djg>)%Ox-1|oPD7(#=!MR zTUT~reI?m+Qd0GA-q4qnloZc&dfdKqC%d@#cFUC`akVk^eJ?IP|7<0?RM1TF+ws7A zUSD6qE^hZvO^sq&diq*FdExT?zOKgBb7N&4KY#AS<>-~TN7U7wBE^84*U1ud

JYJ8hFZ;t7ZLFYJ=t*1cxi*FgTJS*2)A1+^h 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≠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 pubsub_internal = std::make_shared(); + std::shared_ptr pubsub_external = std::make_shared(); MessageQueue queue = MessageQueue(2, sizeof(LoaderMessage)); // 2 entries, so you can stop the current app while starting a new one without blocking - Mutex* mutex; + Mutex mutex = Mutex(MutexTypeRecursive); std::stack app_stack; }; diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 4af506e2..bda82b4c 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -1,3 +1,4 @@ +#include #include "Tactility.h" #include "app/ManifestRegistry.h" @@ -127,7 +128,7 @@ static void register_and_start_user_services(const service::ServiceManifest* con } } -void init(const Configuration& config) { +void run(const Configuration& config) { TT_LOG_I(TAG, "init started"); tt_assert(config.hardware); @@ -160,6 +161,11 @@ void init(const Configuration& config) { } TT_LOG_I(TAG, "init complete"); + + TT_LOG_I(TAG, "Processing main dispatcher"); + while (true) { + getMainDispatcher().consume(TtWaitForever); + } } const Configuration* _Nullable getConfiguration() { diff --git a/Tactility/Source/Tactility.h b/Tactility/Source/Tactility.h index 521616d5..5dfa5e0f 100644 --- a/Tactility/Source/Tactility.h +++ b/Tactility/Source/Tactility.h @@ -19,7 +19,7 @@ typedef struct { * Attempts to initialize Tactility and all configured hardware. * @param config */ -void init(const Configuration& config); +void run(const Configuration& config); /** * While technically nullable, this instance is always set if tt_init() succeeds. diff --git a/Tactility/Source/app/power/Power.cpp b/Tactility/Source/app/power/Power.cpp index 1168ea41..5d518c99 100644 --- a/Tactility/Source/app/power/Power.cpp +++ b/Tactility/Source/app/power/Power.cpp @@ -13,7 +13,7 @@ namespace tt::app::power { #define TAG "power" extern const AppManifest manifest; -static void on_timer(TT_UNUSED void* context); +static void on_timer(TT_UNUSED std::shared_ptr context); struct Data { std::unique_ptr update_timer = std::unique_ptr(new Timer(Timer::TypePeriodic, &on_timer, nullptr)); diff --git a/Tactility/Source/app/wificonnect/WifiConnect.cpp b/Tactility/Source/app/wificonnect/WifiConnect.cpp index 71784c89..d8873b4f 100644 --- a/Tactility/Source/app/wificonnect/WifiConnect.cpp +++ b/Tactility/Source/app/wificonnect/WifiConnect.cpp @@ -54,7 +54,7 @@ static void onConnect(const service::wifi::settings::WifiApSettings* ap_settings } WifiConnect::WifiConnect() { - PubSub* wifi_pubsub = service::wifi::getPubsub(); + auto wifi_pubsub = service::wifi::getPubsub(); wifiSubscription = tt_pubsub_subscribe(wifi_pubsub, &eventCallback, this); bindings = (Bindings) { .onConnectSsid = onConnect, @@ -63,7 +63,7 @@ WifiConnect::WifiConnect() { } WifiConnect::~WifiConnect() { - PubSub* pubsub = service::wifi::getPubsub(); + auto pubsub = service::wifi::getPubsub(); tt_pubsub_unsubscribe(pubsub, wifiSubscription); } diff --git a/Tactility/Source/app/wifimanage/WifiManage.cpp b/Tactility/Source/app/wifimanage/WifiManage.cpp index 986bf6f9..64807aa5 100644 --- a/Tactility/Source/app/wifimanage/WifiManage.cpp +++ b/Tactility/Source/app/wifimanage/WifiManage.cpp @@ -108,7 +108,7 @@ static void wifiManageEventCallback(const void* message, void* context) { } void WifiManage::onShow(AppContext& app, lv_obj_t* parent) { - PubSub* wifi_pubsub = service::wifi::getPubsub(); + auto wifi_pubsub = service::wifi::getPubsub(); wifiSubscription = tt_pubsub_subscribe(wifi_pubsub, &wifiManageEventCallback, this); // State update (it has its own locking) @@ -128,6 +128,7 @@ void WifiManage::onShow(AppContext& app, lv_obj_t* parent) { bool can_scan = radio_state == service::wifi::WIFI_RADIO_ON || radio_state == service::wifi::WIFI_RADIO_CONNECTION_PENDING || radio_state == service::wifi::WIFI_RADIO_CONNECTION_ACTIVE; + TT_LOG_I(TAG, "%d %d", radio_state, service::wifi::isScanning()); if (can_scan && !service::wifi::isScanning()) { service::wifi::scan(); } @@ -135,7 +136,7 @@ void WifiManage::onShow(AppContext& app, lv_obj_t* parent) { void WifiManage::onHide(TT_UNUSED AppContext& app) { lock(); - PubSub* wifi_pubsub = service::wifi::getPubsub(); + auto wifi_pubsub = service::wifi::getPubsub(); tt_pubsub_unsubscribe(wifi_pubsub, wifiSubscription); wifiSubscription = nullptr; isViewEnabled = false; diff --git a/Tactility/Source/lvgl/Statusbar.cpp b/Tactility/Source/lvgl/Statusbar.cpp index 8e9e6ff2..0c72d5db 100644 --- a/Tactility/Source/lvgl/Statusbar.cpp +++ b/Tactility/Source/lvgl/Statusbar.cpp @@ -22,7 +22,7 @@ typedef struct { typedef struct { Mutex* mutex; - PubSub* pubsub; + std::shared_ptr pubsub; StatusbarIcon icons[STATUSBAR_ICON_LIMIT]; } StatusbarData; @@ -40,7 +40,7 @@ typedef struct { static void statusbar_init() { statusbar_data.mutex = tt_mutex_alloc(MutexTypeRecursive); - statusbar_data.pubsub = tt_pubsub_alloc(); + statusbar_data.pubsub = std::make_shared(); for (int i = 0; i < STATUSBAR_ICON_LIMIT; i++) { statusbar_data.icons[i].image = nullptr; statusbar_data.icons[i].visible = false; diff --git a/Tactility/Source/service/loader/Loader.cpp b/Tactility/Source/service/loader/Loader.cpp index 75892c95..dad3d873 100644 --- a/Tactility/Source/service/loader/Loader.cpp +++ b/Tactility/Source/service/loader/Loader.cpp @@ -29,38 +29,30 @@ static Loader* loader_singleton = nullptr; static Loader* loader_alloc() { assert(loader_singleton == nullptr); loader_singleton = new Loader(); - loader_singleton->pubsub_internal = tt_pubsub_alloc(); - loader_singleton->pubsub_external = tt_pubsub_alloc(); loader_singleton->thread = new Thread( "loader", 4096, // Last known minimum was 2400 for starting Hello World app &loader_main, nullptr ); - loader_singleton->mutex = tt_mutex_alloc(MutexTypeRecursive); return loader_singleton; } static void loader_free() { tt_assert(loader_singleton != nullptr); delete loader_singleton->thread; - tt_pubsub_free(loader_singleton->pubsub_internal); - tt_pubsub_free(loader_singleton->pubsub_external); - tt_mutex_free(loader_singleton->mutex); delete loader_singleton; loader_singleton = nullptr; } static void loader_lock() { tt_assert(loader_singleton); - tt_assert(loader_singleton->mutex); - tt_check(tt_mutex_acquire(loader_singleton->mutex, TtWaitForever) == TtStatusOk); + tt_check(loader_singleton->mutex.acquire(TtWaitForever) == TtStatusOk); } static void loader_unlock() { tt_assert(loader_singleton); - tt_assert(loader_singleton->mutex); - tt_check(tt_mutex_release(loader_singleton->mutex) == TtStatusOk); + tt_check(loader_singleton->mutex.release() == TtStatusOk); } LoaderStatus startApp(const std::string& id, bool blocking, std::shared_ptr parameters) { @@ -107,7 +99,7 @@ app::AppContext* _Nullable getCurrentApp() { return dynamic_cast(app); } -PubSub* getPubsub() { +std::shared_ptr getPubsub() { tt_assert(loader_singleton); // it's safe to return pubsub without locking // because it's never freed and loader is never exited diff --git a/Tactility/Source/service/loader/Loader.h b/Tactility/Source/service/loader/Loader.h index f3303c01..d3df1f73 100644 --- a/Tactility/Source/service/loader/Loader.h +++ b/Tactility/Source/service/loader/Loader.h @@ -37,6 +37,6 @@ app::AppContext* _Nullable getCurrentApp(); /** * @brief PubSub for LoaderEvent */ -PubSub* getPubsub(); +std::shared_ptr getPubsub(); } // namespace diff --git a/Tactility/Source/service/statusbar/Statusbar.cpp b/Tactility/Source/service/statusbar/Statusbar.cpp index d6fad481..15ed97da 100644 --- a/Tactility/Source/service/statusbar/Statusbar.cpp +++ b/Tactility/Source/service/statusbar/Statusbar.cpp @@ -1,11 +1,13 @@ #include "Assets.h" +#include "Mutex.h" +#include "Timer.h" +#include "Tactility.h" + #include "hal/Power.h" #include "hal/sdcard/Sdcard.h" -#include "Mutex.h" +#include "lvgl/Statusbar.h" #include "service/ServiceContext.h" #include "service/wifi/Wifi.h" -#include "Tactility.h" -#include "lvgl/Statusbar.h" #include "service/ServiceRegistry.h" namespace tt::service::statusbar { @@ -16,8 +18,7 @@ extern const ServiceManifest manifest; struct ServiceData { Mutex mutex; - Thread thread; - bool service_interrupted = false; + std::unique_ptr updateTimer; int8_t wifi_icon_id = lvgl::statusbar_icon_add(nullptr); const char* wifi_last_icon = nullptr; int8_t sdcard_icon_id = lvgl::statusbar_icon_add(nullptr); @@ -153,51 +154,35 @@ static void service_data_free(ServiceData* data) { free(data); } -int32_t serviceMain(TT_UNUSED void* parameter) { - TT_LOG_I(TAG, "Started main loop"); - delay_ms(20); // TODO: Make service instance findable earlier on (but expose "starting" state?) - auto context = tt::service::findServiceById(manifest.id); - if (context == nullptr) { - TT_LOG_E(TAG, "Service not found"); - return -1; - } - - auto data = std::static_pointer_cast(context->getData()); - - while (!data->service_interrupted) { - update_wifi_icon(data); - update_sdcard_icon(data); - update_power_icon(data); - delay_ms(1000); - } - return 0; +static void onUpdate(std::shared_ptr parameter) { + auto data = std::static_pointer_cast(parameter); + // TODO: Make thread-safe for LVGL + update_wifi_icon(data); + update_sdcard_icon(data); + update_power_icon(data); } static void onStart(ServiceContext& service) { auto data = std::make_shared(); service.setData(data); + // TODO: Make thread-safe for LVGL lvgl::statusbar_icon_set_visibility(data->wifi_icon_id, true); update_wifi_icon(data); update_sdcard_icon(data); // also updates visibility update_power_icon(data); - - data->thread.setCallback(serviceMain, nullptr); - data->thread.setPriority(Thread::PriorityLow); - data->thread.setStackSize(3000); - data->thread.setName("statusbar"); - data->thread.start(); + data->updateTimer = std::make_unique(Timer::TypePeriodic, onUpdate, data); + // We want to try and scan more often in case of startup or scan lock failure + data->updateTimer->start(1000); } static void onStop(ServiceContext& service) { auto data = std::static_pointer_cast(service.getData()); // Stop thread - data->lock(); - data->service_interrupted = true; - data->unlock(); - data->thread.join(); + data->updateTimer->stop(); + data->updateTimer = nullptr; } extern const ServiceManifest manifest = { diff --git a/TactilityCore/Source/Dispatcher.cpp b/TactilityCore/Source/Dispatcher.cpp index e06f0aff..db2d1bf7 100644 --- a/TactilityCore/Source/Dispatcher.cpp +++ b/TactilityCore/Source/Dispatcher.cpp @@ -1,39 +1,50 @@ #include "Dispatcher.h" +#include "Check.h" namespace tt { -Dispatcher::Dispatcher(size_t queueLimit) : - queue(queueLimit, sizeof(DispatcherMessage)), - mutex(MutexTypeNormal), - buffer({ .callback = nullptr, .context = nullptr }) { } +#define TAG "Dispatcher" +#define BACKPRESSURE_WARNING_COUNT 100 + +Dispatcher::Dispatcher() : + mutex(MutexTypeNormal) +{} Dispatcher::~Dispatcher() { - queue.reset(); // Wait for Mutex usage mutex.acquire(TtWaitForever); mutex.release(); } -void Dispatcher::dispatch(Callback callback, void* context) { - DispatcherMessage message = { - .callback = callback, - .context = context - }; +void Dispatcher::dispatch(Callback callback, std::shared_ptr context) { + auto message = std::make_shared(callback, std::move(context)); + // Mutate mutex.acquire(TtWaitForever); - queue.put(&message, TtWaitForever); + queue.push(std::move(message)); + if (queue.size() == BACKPRESSURE_WARNING_COUNT) { + TT_LOG_W(TAG, "Backpressure: You're not consuming fast enough (100 queued)"); + } mutex.release(); + // Signal + eventFlag.set(1); } -bool Dispatcher::consume(uint32_t timeout_ticks) { - mutex.acquire(TtWaitForever); - if (queue.get(&buffer, timeout_ticks) == TtStatusOk) { - buffer.callback(buffer.context); - mutex.release(); - return true; - } else { - mutex.release(); - return false; +uint32_t Dispatcher::consume(uint32_t timeout_ticks) { + // Wait for signal and clear + eventFlag.wait(1, TtFlagWaitAny, timeout_ticks); + eventFlag.clear(1); + + // Mutate + if (mutex.acquire(1 / portTICK_PERIOD_MS) == TtStatusOk) { + auto item = queue.front(); + queue.pop(); + // Don't keep lock as callback might be slow + tt_check(mutex.release() == TtStatusOk); + + item->callback(item->context); } + + return true; } } // namespace diff --git a/TactilityCore/Source/Dispatcher.h b/TactilityCore/Source/Dispatcher.h index dd0dd095..522674f4 100644 --- a/TactilityCore/Source/Dispatcher.h +++ b/TactilityCore/Source/Dispatcher.h @@ -7,29 +7,39 @@ #include "MessageQueue.h" #include "Mutex.h" +#include "EventFlag.h" +#include +#include namespace tt { -typedef void (*Callback)(void* data); +typedef void (*Callback)(std::shared_ptr data); class Dispatcher { private: - typedef struct { + struct DispatcherMessage { Callback callback; - void* context; - } DispatcherMessage; + std::shared_ptr context; // Can't use unique_ptr with void, so we use shared_ptr + + DispatcherMessage(Callback callback, std::shared_ptr context) : + callback(callback), + context(std::move(context)) + {} + + ~DispatcherMessage() = default; + }; - MessageQueue queue; Mutex mutex; - DispatcherMessage buffer; // Buffer for consuming a message + std::queue> queue; + EventFlag eventFlag; public: - explicit Dispatcher(size_t queueLimit = 8); + explicit Dispatcher(); ~Dispatcher(); - void dispatch(Callback callback, void* context); - bool consume(uint32_t timeout_ticks); + void dispatch(Callback callback, std::shared_ptr context); + uint32_t consume(uint32_t timeout_ticks); }; } // namespace diff --git a/TactilityCore/Source/Mutex.cpp b/TactilityCore/Source/Mutex.cpp index ff887aad..5df8e39c 100644 --- a/TactilityCore/Source/Mutex.cpp +++ b/TactilityCore/Source/Mutex.cpp @@ -111,6 +111,10 @@ ThreadId Mutex::getOwner() const { } +std::unique_ptr Mutex::scoped() const { + return std::move(std::make_unique(*this)); +} + Mutex* tt_mutex_alloc(MutexType type) { return new Mutex(type); } @@ -125,7 +129,6 @@ TtStatus tt_mutex_acquire(Mutex* mutex, uint32_t timeout) { TtStatus tt_mutex_release(Mutex* mutex) { return mutex->release(); - } ThreadId tt_mutex_get_owner(Mutex* mutex) { diff --git a/TactilityCore/Source/Mutex.h b/TactilityCore/Source/Mutex.h index da9de57f..5c823268 100644 --- a/TactilityCore/Source/Mutex.h +++ b/TactilityCore/Source/Mutex.h @@ -7,9 +7,13 @@ #include "CoreTypes.h" #include "Thread.h" #include "RtosCompatSemaphore.h" +#include "Check.h" +#include namespace tt { +class ScopedMutexUsage; + typedef enum { MutexTypeNormal, MutexTypeRecursive, @@ -30,6 +34,30 @@ public: TtStatus acquire(uint32_t timeout) const; TtStatus release() const; ThreadId getOwner() const; + + std::unique_ptr scoped() const; +}; + +class ScopedMutexUsage { + + const Mutex& mutex; + bool acquired = false; + +public: + + ScopedMutexUsage(const Mutex& mutex) : mutex(mutex) {} + + ~ScopedMutexUsage() { + if (acquired) { + tt_check(mutex.release() == TtStatusOk); + } + } + + bool acquire(uint32_t timeout) { + TtStatus result = mutex.acquire(timeout); + acquired = (result == TtStatusOk); + return acquired; + } }; /** Allocate Mutex diff --git a/TactilityCore/Source/Pubsub.cpp b/TactilityCore/Source/Pubsub.cpp index 220cde86..73f8567f 100644 --- a/TactilityCore/Source/Pubsub.cpp +++ b/TactilityCore/Source/Pubsub.cpp @@ -1,42 +1,11 @@ #include "Pubsub.h" #include "Check.h" -#include "Mutex.h" #include namespace tt { -struct PubSubSubscription { - uint64_t id; - PubSubCallback callback; - void* callback_context; -}; - -typedef std::list Subscriptions; - -struct PubSub { - uint64_t last_id = 0; - Subscriptions items; - Mutex* mutex; -}; - -PubSub* tt_pubsub_alloc() { - auto* pubsub = new PubSub(); - - pubsub->mutex = tt_mutex_alloc(MutexTypeNormal); - tt_assert(pubsub->mutex); - - return pubsub; -} - -void tt_pubsub_free(PubSub* pubsub) { - tt_assert(pubsub); - tt_check(pubsub->items.empty()); - tt_mutex_free(pubsub->mutex); - delete pubsub; -} - -PubSubSubscription* tt_pubsub_subscribe(PubSub* pubsub, PubSubCallback callback, void* callback_context) { - tt_check(tt_mutex_acquire(pubsub->mutex, TtWaitForever) == TtStatusOk); +PubSubSubscription* tt_pubsub_subscribe(std::shared_ptr pubsub, PubSubCallback callback, void* callback_context) { + tt_check(pubsub->mutex.acquire(TtWaitForever) == TtStatusOk); PubSubSubscription subscription = { .id = (++pubsub->last_id), .callback = callback, @@ -46,16 +15,16 @@ PubSubSubscription* tt_pubsub_subscribe(PubSub* pubsub, PubSubCallback callback, subscription ); - tt_check(tt_mutex_release(pubsub->mutex) == TtStatusOk); + tt_check(pubsub->mutex.release() == TtStatusOk); return (PubSubSubscription*)pubsub->last_id; } -void tt_pubsub_unsubscribe(PubSub* pubsub, PubSubSubscription* pubsub_subscription) { +void tt_pubsub_unsubscribe(std::shared_ptr pubsub, PubSubSubscription* pubsub_subscription) { tt_assert(pubsub); tt_assert(pubsub_subscription); - tt_check(tt_mutex_acquire(pubsub->mutex, TtWaitForever) == TtStatusOk); + tt_check(pubsub->mutex.acquire(TtWaitForever) == TtStatusOk); bool result = false; auto id = (uint64_t)pubsub_subscription; for (auto it = pubsub->items.begin(); it != pubsub->items.end(); it++) { @@ -66,19 +35,19 @@ void tt_pubsub_unsubscribe(PubSub* pubsub, PubSubSubscription* pubsub_subscripti } } - tt_check(tt_mutex_release(pubsub->mutex) == TtStatusOk); + tt_check(pubsub->mutex.release() == TtStatusOk); tt_check(result); } -void tt_pubsub_publish(PubSub* pubsub, void* message) { - tt_check(tt_mutex_acquire(pubsub->mutex, TtWaitForever) == TtStatusOk); +void tt_pubsub_publish(std::shared_ptr pubsub, void* message) { + tt_check(pubsub->mutex.acquire(TtWaitForever) == TtStatusOk); // Iterate over subscribers for (auto& it : pubsub->items) { it.callback(message, it.callback_context); } - tt_check(tt_mutex_release(pubsub->mutex) == TtStatusOk); + tt_check(pubsub->mutex.release() == TtStatusOk); } } // namespace diff --git a/TactilityCore/Source/Pubsub.h b/TactilityCore/Source/Pubsub.h index 6db6bb36..134e892c 100644 --- a/TactilityCore/Source/Pubsub.h +++ b/TactilityCore/Source/Pubsub.h @@ -4,30 +4,30 @@ */ #pragma once +#include "Mutex.h" +#include + namespace tt { /** PubSub Callback type */ typedef void (*PubSubCallback)(const void* message, void* context); -/** PubSub type */ -typedef struct PubSub PubSub; +struct PubSubSubscription { + uint64_t id; + PubSubCallback callback; + void* callback_context; +}; -/** PubSubSubscription type */ -typedef struct PubSubSubscription PubSubSubscription; +struct PubSub { + typedef std::list Subscriptions; + uint64_t last_id = 0; + Subscriptions items; + Mutex mutex; -/** Allocate PubSub - * - * Reentrable, Not threadsafe, one owner - * - * @return pointer to PubSub instance - */ -PubSub* tt_pubsub_alloc(); - -/** Free PubSub - * - * @param pubsub PubSub instance - */ -void tt_pubsub_free(PubSub* pubsub); + ~PubSub() { + tt_check(items.empty()); + } +}; /** Subscribe to PubSub * @@ -40,7 +40,7 @@ void tt_pubsub_free(PubSub* pubsub); * @return pointer to PubSubSubscription instance */ PubSubSubscription* -tt_pubsub_subscribe(PubSub* pubsub, PubSubCallback callback, void* callback_context); +tt_pubsub_subscribe(std::shared_ptr pubsub, PubSubCallback callback, void* callback_context); /** Unsubscribe from PubSub * @@ -50,7 +50,7 @@ tt_pubsub_subscribe(PubSub* pubsub, PubSubCallback callback, void* callback_cont * @param pubsub pointer to PubSub instance * @param pubsub_subscription pointer to PubSubSubscription instance */ -void tt_pubsub_unsubscribe(PubSub* pubsub, PubSubSubscription* pubsub_subscription); +void tt_pubsub_unsubscribe(std::shared_ptr pubsub, PubSubSubscription* pubsub_subscription); /** Publish message to PubSub * @@ -59,6 +59,6 @@ void tt_pubsub_unsubscribe(PubSub* pubsub, PubSubSubscription* pubsub_subscripti * @param pubsub pointer to PubSub instance * @param message message pointer to publish */ -void tt_pubsub_publish(PubSub* pubsub, void* message); +void tt_pubsub_publish(std::shared_ptr pubsub, void* message); } // namespace diff --git a/TactilityCore/Source/Timer.cpp b/TactilityCore/Source/Timer.cpp index 6207ac0f..b17fd1e5 100644 --- a/TactilityCore/Source/Timer.cpp +++ b/TactilityCore/Source/Timer.cpp @@ -1,4 +1,6 @@ #include "Timer.h" + +#include #include "Check.h" #include "Kernel.h" #include "RtosCompat.h" @@ -13,11 +15,11 @@ static void timer_callback(TimerHandle_t hTimer) { } } -Timer::Timer(Type type, Callback callback, void* callbackContext) { +Timer::Timer(Type type, Callback callback, std::shared_ptr callbackContext) { tt_assert((kernel_is_irq() == 0U) && (callback != nullptr)); this->callback = callback; - this->callbackContext = callbackContext; + this->callbackContext = std::move(callbackContext); UBaseType_t reload; if (type == TypeOnce) { diff --git a/TactilityCore/Source/Timer.h b/TactilityCore/Source/Timer.h index 1310ced4..1a6d92a0 100644 --- a/TactilityCore/Source/Timer.h +++ b/TactilityCore/Source/Timer.h @@ -3,6 +3,7 @@ #include "CoreTypes.h" #include "RtosCompatTimers.h" +#include namespace tt { @@ -11,12 +12,12 @@ private: TimerHandle_t timerHandle; public: - typedef void (*Callback)(void* context); + typedef void (*Callback)(std::shared_ptr context); typedef void (*PendingCallback)(void* context, uint32_t arg); Callback callback; - void* callbackContext; + std::shared_ptr callbackContext; typedef enum { TypeOnce = 0, ///< One-shot timer. @@ -28,7 +29,7 @@ public: * @param[in] callback The callback function * @param callbackContext The callback context */ - Timer(Type type, Callback callback, void* callbackContext); + Timer(Type type, Callback callback, std::shared_ptr callbackContext); ~Timer(); diff --git a/TactilityHeadless/Source/TactilityHeadless.cpp b/TactilityHeadless/Source/TactilityHeadless.cpp index 93f6db13..3ad9a787 100644 --- a/TactilityHeadless/Source/TactilityHeadless.cpp +++ b/TactilityHeadless/Source/TactilityHeadless.cpp @@ -1,3 +1,4 @@ +#include #include "TactilityHeadless.h" #include "hal/Configuration.h" #include "hal/Hal_i.h" @@ -15,6 +16,8 @@ namespace tt { namespace service::wifi { extern const ServiceManifest manifest; } namespace service::sdcard { extern const ServiceManifest manifest; } +static Dispatcher mainDispatcher; + static const service::ServiceManifest* const system_services[] = { &service::sdcard::manifest, &service::wifi::manifest @@ -40,6 +43,11 @@ void initHeadless(const hal::Configuration& config) { register_and_start_system_services(); } + +Dispatcher& getMainDispatcher() { + return mainDispatcher; +} + namespace hal { const Configuration& getConfiguration() { diff --git a/TactilityHeadless/Source/TactilityHeadless.h b/TactilityHeadless/Source/TactilityHeadless.h index 2b117fb5..bf92b5f2 100644 --- a/TactilityHeadless/Source/TactilityHeadless.h +++ b/TactilityHeadless/Source/TactilityHeadless.h @@ -2,11 +2,14 @@ #include "hal/Configuration.h" #include "TactilityHeadlessConfig.h" +#include "Dispatcher.h" namespace tt { void initHeadless(const hal::Configuration& config); +Dispatcher& getMainDispatcher(); + } // namespace namespace tt::hal { diff --git a/TactilityHeadless/Source/service/sdcard/Sdcard.cpp b/TactilityHeadless/Source/service/sdcard/Sdcard.cpp index 5d4ad9e9..8a7b1707 100644 --- a/TactilityHeadless/Source/service/sdcard/Sdcard.cpp +++ b/TactilityHeadless/Source/service/sdcard/Sdcard.cpp @@ -1,11 +1,13 @@ -#include - #include "Mutex.h" +#include "Timer.h" + #include "service/ServiceContext.h" #include "TactilityCore.h" #include "TactilityHeadless.h" #include "service/ServiceRegistry.h" +#include + #define TAG "sdcard_service" namespace tt::service::sdcard { @@ -15,21 +17,11 @@ extern const ServiceManifest manifest; struct ServiceData { Mutex mutex; - Thread thread = Thread( - "sdcard", - 3000, // Minimum is ~2800 @ ESP-IDF 5.1.2 when ejecting sdcard - &sdcard_task, - nullptr - ); + std::unique_ptr updateTimer; hal::sdcard::State lastState = hal::sdcard::StateUnmounted; - bool interrupted = false; - ServiceData() { - thread.setPriority(Thread::PriorityLow); - } - - void lock() const { - tt_check(mutex.acquire(TtWaitForever) == TtStatusOk); + bool lock(TickType_t timeout) const { + return mutex.acquire(timeout) == TtStatusOk; } void unlock() const { @@ -38,46 +30,36 @@ struct ServiceData { }; -static int32_t sdcard_task(TT_UNUSED void* context) { - delay_ms(20); // TODO: Make service instance findable earlier on (but expose "starting" state?) - auto service = findServiceById(manifest.id); - if (service == nullptr) { - TT_LOG_E(TAG, "Service not found"); - return -1; +static void onUpdate(std::shared_ptr context) { + auto data = std::static_pointer_cast(context); + + if (!data->lock(50)) { + TT_LOG_W(TAG, "Failed to acquire lock"); + return; } - auto data = std::static_pointer_cast(service->getData()); + hal::sdcard::State new_state = hal::sdcard::getState(); - bool interrupted = false; + if (new_state == hal::sdcard::StateError) { + TT_LOG_W(TAG, "Sdcard error - unmounting. Did you eject the card in an unsafe manner?"); + hal::sdcard::unmount(ms_to_ticks(1000)); + } - do { - data->lock(); + if (new_state != data->lastState) { + data->lastState = new_state; + } - interrupted = data->interrupted; - - hal::sdcard::State new_state = hal::sdcard::getState(); - - if (new_state == hal::sdcard::StateError) { - TT_LOG_W(TAG, "Sdcard error - unmounting. Did you eject the card in an unsafe manner?"); - hal::sdcard::unmount(ms_to_ticks(1000)); - } - - if (new_state != data->lastState) { - data->lastState = new_state; - } - - data->lock(); - delay_ms(2000); - } while (!interrupted); - - return 0; + data->unlock(); } static void onStart(ServiceContext& service) { if (hal::getConfiguration().sdcard != nullptr) { auto data = std::make_shared(); service.setData(data); - data->thread.start(); + + data->updateTimer = std::make_unique(Timer::TypePeriodic, onUpdate, data); + // We want to try and scan more often in case of startup or scan lock failure + data->updateTimer->start(1000); } else { TT_LOG_I(TAG, "task not started due to config"); } @@ -85,12 +67,10 @@ static void onStart(ServiceContext& service) { static void onStop(ServiceContext& service) { auto data = std::static_pointer_cast(service.getData()); - if (data != nullptr) { - data->lock(); - data->interrupted = true; - data->unlock(); - - data->thread.join(); + if (data->updateTimer != nullptr) { + // Stop thread + data->updateTimer->stop(); + data->updateTimer = nullptr; } } diff --git a/TactilityHeadless/Source/service/wifi/Wifi.h b/TactilityHeadless/Source/service/wifi/Wifi.h index 5ca51b80..ddc7ca34 100644 --- a/TactilityHeadless/Source/service/wifi/Wifi.h +++ b/TactilityHeadless/Source/service/wifi/Wifi.h @@ -59,7 +59,7 @@ enum WifiRadioState { WIFI_RADIO_CONNECTION_PENDING, WIFI_RADIO_CONNECTION_ACTIVE, WIFI_RADIO_OFF_PENDING, - WIFI_RADIO_OFF + WIFI_RADIO_OFF, }; struct WifiEvent { @@ -74,9 +74,9 @@ struct WifiApRecord { /** * @brief Get wifi pubsub - * @return PubSub* + * @return PubSub */ -PubSub* getPubsub(); +std::shared_ptr getPubsub(); WifiRadioState getRadioState(); /** diff --git a/TactilityHeadless/Source/service/wifi/WifiEsp.cpp b/TactilityHeadless/Source/service/wifi/WifiEsp.cpp index 9b7806df..2e991968 100644 --- a/TactilityHeadless/Source/service/wifi/WifiEsp.cpp +++ b/TactilityHeadless/Source/service/wifi/WifiEsp.cpp @@ -5,15 +5,18 @@ #include "MessageQueue.h" #include "Mutex.h" #include "Check.h" -#include "freertos/FreeRTOS.h" #include "Log.h" -#include "Pubsub.h" +#include "Timer.h" #include "service/ServiceContext.h" #include "WifiSettings.h" +#include "TactilityCore.h" +#include "TactilityHeadless.h" + +#include "freertos/FreeRTOS.h" + #include #include #include -#include namespace tt::service::wifi { @@ -22,37 +25,33 @@ namespace tt::service::wifi { #define WIFI_FAIL_BIT BIT1 #define AUTO_SCAN_INTERVAL 10000 // ms -typedef enum { - WifiMessageTypeRadioOn, - WifiMessageTypeRadioOff, - WifiMessageTypeScan, - WifiMessageTypeConnect, - WifiMessageTypeDisconnect, - WifiMessageTypeAutoConnect, -} WifiMessageType; - -typedef struct { -} WifiConnectMessage; - -typedef struct { - WifiMessageType type; - union { - WifiConnectMessage connect_message; - }; -} WifiMessage; +// Forward declarations +class Wifi; +static void scan_list_free_safely(std::shared_ptr wifi); +// Methods for main thread dispatcher +static void dispatchAutoConnect(std::shared_ptr context); +static void dispatchEnable(std::shared_ptr context); +static void dispatchDisable(std::shared_ptr context); +static void dispatchScan(std::shared_ptr context); +static void dispatchConnect(std::shared_ptr context); +static void dispatchDisconnectButKeepActive(std::shared_ptr context); class Wifi { -public: - Wifi(); - ~Wifi(); - std::atomic radio_state; +private: + + std::atomic radio_state = WIFI_RADIO_OFF; + bool scan_active = false; + bool secure_connection = false; + +public: + /** @brief Locking mechanism for modifying the Wifi instance */ - Mutex mutex = Mutex(MutexTypeRecursive); + Mutex radioMutex = Mutex(MutexTypeRecursive); + Mutex dataMutex = Mutex(MutexTypeRecursive); + std::unique_ptr autoConnectTimer; /** @brief The public event bus */ - PubSub* pubsub = nullptr; - /** @brief The internal message queue */ - MessageQueue queue = MessageQueue(1, sizeof(WifiMessage)); + std::shared_ptr pubsub = std::make_shared(); // TODO: Deal with messages that come in while an action is ongoing // for example: when scanning and you turn off the radio, the scan should probably stop or turning off // the radio should disable the on/off button in the app as it is pending. @@ -64,10 +63,8 @@ public: uint16_t scan_list_count = 0; /** @brief Maximum amount of records to scan (value > 0) */ uint16_t scan_list_limit = TT_WIFI_SCAN_RECORD_LIMIT; - bool scan_active = false; /** @brief when we last requested a scan. Loops around every 50 days. */ - TickType_t last_scan_time; - bool secure_connection = false; + TickType_t last_scan_time = portMAX_DELAY; esp_event_handler_instance_t event_handler_any_id = nullptr; esp_event_handler_instance_t event_handler_got_ip = nullptr; EventFlag connection_wait_flags; @@ -78,167 +75,236 @@ public: }; bool pause_auto_connect = false; // Pause when manually disconnecting until manually connecting again bool connection_target_remember = false; // Whether to store the connection_target on successful connection or not + + WifiRadioState getRadioState() const { + auto lock = dataMutex.scoped(); + lock->acquire(TtWaitForever); + return radio_state; + } + + void setRadioState(WifiRadioState newState) { + auto lock = dataMutex.scoped(); + lock->acquire(TtWaitForever); + radio_state = newState; + } + + bool isScanning() const { + auto lock = dataMutex.scoped(); + lock->acquire(TtWaitForever); + return radio_state; + } + + void setScanning(bool newState) { + auto lock = dataMutex.scoped(); + lock->acquire(TtWaitForever); + scan_active = newState; + } + + bool isScanActive() const { + auto lock = dataMutex.scoped(); + lock->acquire(TtWaitForever); + return scan_active; + } + + void setScanActive(bool newState) { + auto lock = dataMutex.scoped(); + lock->acquire(TtWaitForever); + scan_active = newState; + } + + bool isSecureConnection() const { + auto lock = dataMutex.scoped(); + lock->acquire(TtWaitForever); + return secure_connection; + } + + void setSecureConnection(bool newState) { + auto lock = dataMutex.scoped(); + lock->acquire(TtWaitForever); + secure_connection = newState; + } }; static std::shared_ptr wifi_singleton; -// Forward declarations -static void scan_list_free_safely(std::shared_ptr wifi); -static void disconnect_internal_but_keep_active(std::shared_ptr wifi); -static void lock(std::shared_ptr wifi); -static void unlock(std::shared_ptr wifi); - -// region Alloc - -Wifi::Wifi() : radio_state(WIFI_RADIO_OFF) { - pubsub = tt_pubsub_alloc(); -} - -Wifi::~Wifi() { - tt_pubsub_free(pubsub); -} - -// endregion Alloc // region Public functions -PubSub* getPubsub() { - tt_assert(wifi_singleton); - return wifi_singleton->pubsub; +std::shared_ptr getPubsub() { + auto wifi = wifi_singleton; + if (wifi == nullptr) { + tt_crash("Service not running"); + } + + return wifi->pubsub; } WifiRadioState getRadioState() { - tt_assert(wifi_singleton); - lock(wifi_singleton); - WifiRadioState state = wifi_singleton->radio_state; - unlock(wifi_singleton); - return state; + auto wifi = wifi_singleton; + if (wifi != nullptr) { + return wifi->getRadioState(); + } else { + return WIFI_RADIO_OFF; + } } std::string getConnectionTarget() { - lock(wifi_singleton); - std::string result; - switch (wifi_singleton->radio_state) { - case WIFI_RADIO_CONNECTION_PENDING: - case WIFI_RADIO_CONNECTION_ACTIVE: - result = wifi_singleton->connection_target.ssid; - break; - case WIFI_RADIO_ON: - case WIFI_RADIO_ON_PENDING: - case WIFI_RADIO_OFF_PENDING: - case WIFI_RADIO_OFF: - result = ""; - break; + auto wifi = wifi_singleton; + if (wifi == nullptr) { + return ""; } - unlock(wifi_singleton); - return result; + + WifiRadioState state = wifi->getRadioState(); + if ( + state != WIFI_RADIO_CONNECTION_PENDING && + state != WIFI_RADIO_CONNECTION_ACTIVE + ) { + return ""; + } + + return wifi->connection_target.ssid; } void scan() { TT_LOG_I(TAG, "scan()"); - tt_assert(wifi_singleton); - lock(wifi_singleton); - WifiMessage message = {.type = WifiMessageTypeScan}; - // No need to lock for queue - wifi_singleton->queue.put(&message, 100 / portTICK_PERIOD_MS); - unlock(wifi_singleton); + auto wifi = wifi_singleton; + if (wifi == nullptr) { + return; + } + + getMainDispatcher().dispatch(dispatchScan, wifi); } bool isScanning() { - tt_assert(wifi_singleton); - lock(wifi_singleton); - bool is_scanning = wifi_singleton->scan_active; - unlock(wifi_singleton); - return is_scanning; + auto wifi = wifi_singleton; + if (wifi == nullptr) { + return false; + } else { + return wifi->isScanActive(); + } } void connect(const settings::WifiApSettings* ap, bool remember) { TT_LOG_I(TAG, "connect(%s, %d)", ap->ssid, remember); - tt_assert(wifi_singleton); + auto wifi = wifi_singleton; + if (wifi == nullptr) { + return; + } + + auto lock = wifi->dataMutex.scoped(); + if (!lock->acquire(10 / portTICK_PERIOD_MS)) { + return; + } + // Manual connect (e.g. via app) should stop auto-connecting until the connection is established - wifi_singleton->pause_auto_connect = true; - lock(wifi_singleton); - memcpy(&wifi_singleton->connection_target, ap, sizeof(settings::WifiApSettings)); - wifi_singleton->connection_target_remember = remember; - WifiMessage message = {.type = WifiMessageTypeConnect}; - wifi_singleton->queue.put(&message, 100 / portTICK_PERIOD_MS); - unlock(wifi_singleton); + wifi->pause_auto_connect = true; + memcpy(&wifi->connection_target, ap, sizeof(settings::WifiApSettings)); + wifi->connection_target_remember = remember; + getMainDispatcher().dispatch(dispatchConnect, wifi); } void disconnect() { TT_LOG_I(TAG, "disconnect()"); - tt_assert(wifi_singleton); - lock(wifi_singleton); - wifi_singleton->connection_target = (settings::WifiApSettings) { + auto wifi = wifi_singleton; + if (wifi == nullptr) { + return; + } + + auto lock = wifi->dataMutex.scoped(); + if (!lock->acquire(10 / portTICK_PERIOD_MS)) { + return; + } + + wifi->connection_target = (settings::WifiApSettings) { .ssid = { 0 }, .password = { 0 }, .auto_connect = false }; // Manual disconnect (e.g. via app) should stop auto-connecting until a new connection is established - wifi_singleton->pause_auto_connect = true; - WifiMessage message = {.type = WifiMessageTypeDisconnect}; - wifi_singleton->queue.put(&message, 100 / portTICK_PERIOD_MS); - unlock(wifi_singleton); + wifi->pause_auto_connect = true; + getMainDispatcher().dispatch(dispatchDisconnectButKeepActive, wifi); } void setScanRecords(uint16_t records) { TT_LOG_I(TAG, "setScanRecords(%d)", records); - tt_assert(wifi_singleton); - lock(wifi_singleton); - if (records != wifi_singleton->scan_list_limit) { - scan_list_free_safely(wifi_singleton); - wifi_singleton->scan_list_limit = records; + auto wifi = wifi_singleton; + if (wifi == nullptr) { + return; + } + + auto lock = wifi->dataMutex.scoped(); + if (!lock->acquire(10 / portTICK_PERIOD_MS)) { + return; + } + + if (records != wifi->scan_list_limit) { + scan_list_free_safely(wifi); + wifi->scan_list_limit = records; } - unlock(wifi_singleton); } std::vector getScanResults() { TT_LOG_I(TAG, "getScanResults()"); - tt_assert(wifi_singleton); + auto wifi = wifi_singleton; std::vector records; - lock(wifi_singleton); - if (wifi_singleton->scan_list_count > 0) { + if (wifi == nullptr) { + return records; + } + + auto lock = wifi->dataMutex.scoped(); + if (!lock->acquire(10 / portTICK_PERIOD_MS)) { + return records; + } + + if (wifi->scan_list_count > 0) { uint16_t i = 0; - for (; i < wifi_singleton->scan_list_count; ++i) { + for (; i < wifi->scan_list_count; ++i) { records.push_back((WifiApRecord) { - .ssid = (const char*)wifi_singleton->scan_list[i].ssid, - .rssi = wifi_singleton->scan_list[i].rssi, - .auth_mode = wifi_singleton->scan_list[i].authmode + .ssid = (const char*)wifi->scan_list[i].ssid, + .rssi = wifi->scan_list[i].rssi, + .auth_mode = wifi->scan_list[i].authmode }); } } - unlock(wifi_singleton); return records; } void setEnabled(bool enabled) { TT_LOG_I(TAG, "setEnabled(%d)", enabled); - tt_assert(wifi_singleton); - lock(wifi_singleton); - if (enabled) { - WifiMessage message = {.type = WifiMessageTypeRadioOn}; - // No need to lock for queue - wifi_singleton->queue.put(&message, 100 / portTICK_PERIOD_MS); - } else { - WifiMessage message = {.type = WifiMessageTypeRadioOff}; - // No need to lock for queue - wifi_singleton->queue.put(&message, 100 / portTICK_PERIOD_MS); - // Reset pause state + auto wifi = wifi_singleton; + if (wifi == nullptr) { + return; } - wifi_singleton->pause_auto_connect = false; - wifi_singleton->last_scan_time = 0; - unlock(wifi_singleton); + + auto lock = wifi->dataMutex.scoped(); + if (!lock->acquire(10 / portTICK_PERIOD_MS)) { + return; + } + + if (enabled) { + getMainDispatcher().dispatch(dispatchEnable, wifi); + } else { + getMainDispatcher().dispatch(dispatchDisable, wifi); + } + wifi->pause_auto_connect = false; + wifi->last_scan_time = 0; } bool isConnectionSecure() { - tt_assert(wifi_singleton); - lock(wifi_singleton); - bool is_secure = wifi_singleton->secure_connection; - unlock(wifi_singleton); - return is_secure; + auto wifi = wifi_singleton; + if (wifi == nullptr) { + return false; + } + + auto lock = wifi->dataMutex.scoped(); + if (!lock->acquire(10 / portTICK_PERIOD_MS)) { + return false; + } + + return wifi->isSecureConnection(); } int getRssi() { @@ -253,55 +319,66 @@ int getRssi() { // endregion Public functions -static void lock(std::shared_ptr wifi) { - tt_assert(wifi); - wifi->mutex.acquire(ms_to_ticks(100)); -} - -static void unlock(std::shared_ptr wifi) { - tt_assert(wifi); - wifi->mutex.release(); -} - static void scan_list_alloc(std::shared_ptr wifi) { - tt_assert(wifi->scan_list == nullptr); - wifi->scan_list = static_cast(malloc(sizeof(wifi_ap_record_t) * wifi->scan_list_limit)); - wifi->scan_list_count = 0; + auto lock = wifi->dataMutex.scoped(); + if (lock->acquire(TtWaitForever)) { + tt_assert(wifi->scan_list == nullptr); + wifi->scan_list = static_cast(malloc(sizeof(wifi_ap_record_t) * wifi->scan_list_limit)); + wifi->scan_list_count = 0; + } } static void scan_list_alloc_safely(std::shared_ptr wifi) { - if (wifi->scan_list == nullptr) { - scan_list_alloc(wifi); + auto lock = wifi->dataMutex.scoped(); + if (lock->acquire(TtWaitForever)) { + if (wifi->scan_list == nullptr) { + scan_list_alloc(wifi); + } } } static void scan_list_free(std::shared_ptr wifi) { - tt_assert(wifi->scan_list != nullptr); - free(wifi->scan_list); - wifi->scan_list = nullptr; - wifi->scan_list_count = 0; + auto lock = wifi->dataMutex.scoped(); + if (lock->acquire(TtWaitForever)) { + tt_assert(wifi->scan_list != nullptr); + free(wifi->scan_list); + wifi->scan_list = nullptr; + wifi->scan_list_count = 0; + } } static void scan_list_free_safely(std::shared_ptr wifi) { - if (wifi->scan_list != nullptr) { - scan_list_free(wifi); + auto lock = wifi->dataMutex.scoped(); + if (lock->acquire(TtWaitForever)) { + if (wifi->scan_list != nullptr) { + scan_list_free(wifi); + } } } static void publish_event_simple(std::shared_ptr wifi, WifiEventType type) { - WifiEvent turning_on_event = {.type = type}; - tt_pubsub_publish(wifi->pubsub, &turning_on_event); + auto lock = wifi->dataMutex.scoped(); + if (lock->acquire(TtWaitForever)) { + WifiEvent turning_on_event = {.type = type}; + tt_pubsub_publish(wifi->pubsub, &turning_on_event); + } } static bool copy_scan_list(std::shared_ptr wifi) { - bool can_fetch_results = (wifi->radio_state == WIFI_RADIO_ON || wifi->radio_state == WIFI_RADIO_CONNECTION_ACTIVE) && - wifi->scan_active; + auto state = wifi->getRadioState(); + bool can_fetch_results = (state == WIFI_RADIO_ON || state == WIFI_RADIO_CONNECTION_ACTIVE) && + wifi->isScanActive(); if (!can_fetch_results) { TT_LOG_I(TAG, "Skip scan result fetching"); return false; } + auto lock = wifi->dataMutex.scoped(); + if (!lock->acquire(TtWaitForever)) { + return false; + } + // Create scan list if it does not exist scan_list_alloc_safely(wifi); wifi->scan_list_count = 0; @@ -322,166 +399,204 @@ static bool copy_scan_list(std::shared_ptr wifi) { } } -static void auto_connect(std::shared_ptr wifi) { - TT_LOG_I(TAG, "auto_connect()"); - for (int i = 0; i < wifi->scan_list_count; ++i) { - auto ssid = reinterpret_cast(wifi->scan_list[i].ssid); - if (settings::contains(ssid)) { - static_assert(sizeof(wifi->scan_list[i].ssid) == (TT_WIFI_SSID_LIMIT + 1), "SSID size mismatch"); - settings::WifiApSettings ap_settings; - if (settings::load(ssid, &ap_settings)) { - if (ap_settings.auto_connect) { - TT_LOG_I(TAG, "Auto-connecting to %s", ap_settings.ssid); - connect(&ap_settings, false); +static bool find_auto_connect_ap(std::shared_ptr context, settings::WifiApSettings& settings) { + auto wifi = std::static_pointer_cast(context); + auto lock = wifi->dataMutex.scoped(); + + if (lock->acquire(10 / portTICK_PERIOD_MS)) { + TT_LOG_I(TAG, "auto_connect()"); + for (int i = 0; i < wifi->scan_list_count; ++i) { + auto ssid = reinterpret_cast(wifi->scan_list[i].ssid); + if (settings::contains(ssid)) { + static_assert(sizeof(wifi->scan_list[i].ssid) == (TT_WIFI_SSID_LIMIT + 1), "SSID size mismatch"); + if (settings::load(ssid, &settings)) { + if (settings.auto_connect) { + return true; + } + } else { + TT_LOG_E(TAG, "Failed to load credentials for ssid %s", ssid); } - } else { - TT_LOG_E(TAG, "Failed to load credentials for ssid %s", ssid); + break; } - break; } } + + return false; +} + +static void dispatchAutoConnect(std::shared_ptr context) { + TT_LOG_I(TAG, "dispatchAutoConnect()"); + auto wifi = std::static_pointer_cast(context); + + settings::WifiApSettings settings; + if (find_auto_connect_ap(context, settings)) { + TT_LOG_I(TAG, "Auto-connecting to %s", settings.ssid); + connect(&settings, false); + } } -static void event_handler(TT_UNUSED void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { - lock(wifi_singleton); +static void eventHandler(TT_UNUSED void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { + auto wifi = wifi_singleton; + if (wifi == nullptr) { + TT_LOG_E(TAG, "eventHandler: no wifi instance"); + return; + } + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { - TT_LOG_I(TAG, "event_handler: sta start"); - if (wifi_singleton->radio_state == WIFI_RADIO_CONNECTION_PENDING) { + TT_LOG_I(TAG, "eventHandler: sta start"); + if (wifi->getRadioState() == WIFI_RADIO_CONNECTION_PENDING) { esp_wifi_connect(); } } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { - TT_LOG_I(TAG, "event_handler: disconnected"); - if (wifi_singleton->radio_state == WIFI_RADIO_CONNECTION_PENDING) { - wifi_singleton->connection_wait_flags.set(WIFI_FAIL_BIT); + TT_LOG_I(TAG, "eventHandler: disconnected"); + if (wifi->getRadioState() == WIFI_RADIO_CONNECTION_PENDING) { + wifi->connection_wait_flags.set(WIFI_FAIL_BIT); } - wifi_singleton->radio_state = WIFI_RADIO_ON; - publish_event_simple(wifi_singleton, WifiEventTypeDisconnected); + wifi->setRadioState(WIFI_RADIO_ON); + publish_event_simple(wifi, WifiEventTypeDisconnected); } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { auto* event = static_cast(event_data); - TT_LOG_I(TAG, "event_handler: got ip:" IPSTR, IP2STR(&event->ip_info.ip)); - if (wifi_singleton->radio_state == WIFI_RADIO_CONNECTION_PENDING) { - wifi_singleton->connection_wait_flags.set(WIFI_CONNECTED_BIT); + TT_LOG_I(TAG, "eventHandler: got ip:" IPSTR, IP2STR(&event->ip_info.ip)); + if (wifi->getRadioState() == WIFI_RADIO_CONNECTION_PENDING) { + wifi->connection_wait_flags.set(WIFI_CONNECTED_BIT); // We resume auto-connecting only when there was an explicit request by the user for the connection - wifi_singleton->pause_auto_connect = false; // Resume auto-connection + // TODO: Make thread-safe + wifi->pause_auto_connect = false; // Resume auto-connection } } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { auto* event = static_cast(event_data); - TT_LOG_I(TAG, "event_handler: wifi scanning done (scan id %u)", event->scan_id); - bool copied_list = copy_scan_list(wifi_singleton); + TT_LOG_I(TAG, "eventHandler: wifi scanning done (scan id %u)", event->scan_id); + bool copied_list = copy_scan_list(wifi); + auto state = wifi->getRadioState(); if ( - wifi_singleton->radio_state != WIFI_RADIO_OFF && - wifi_singleton->radio_state != WIFI_RADIO_OFF_PENDING + state != WIFI_RADIO_OFF && + state != WIFI_RADIO_OFF_PENDING ) { - wifi_singleton->scan_active = false; + wifi->setScanActive(false); esp_wifi_scan_stop(); } publish_event_simple(wifi_singleton, WifiEventTypeScanFinished); - TT_LOG_I(TAG, "Finished scan"); + TT_LOG_I(TAG, "eventHandler: Finished scan"); - if (copied_list && wifi_singleton->radio_state == WIFI_RADIO_ON) { - WifiMessage message = {.type = WifiMessageTypeAutoConnect}; - // No need to lock for queue - wifi_singleton->queue.put(&message, 100 / portTICK_PERIOD_MS); + if (copied_list && wifi_singleton->getRadioState() == WIFI_RADIO_ON && !wifi->pause_auto_connect) { + getMainDispatcher().dispatch(dispatchAutoConnect, wifi); } } - unlock(wifi_singleton); } -static void enable(std::shared_ptr wifi) { - WifiRadioState state = wifi->radio_state; +static void dispatchEnable(std::shared_ptr context) { + TT_LOG_I(TAG, "dispatchEnable()"); + auto wifi = std::static_pointer_cast(context); + + WifiRadioState state = wifi->getRadioState(); if ( state == WIFI_RADIO_ON || state == WIFI_RADIO_ON_PENDING || state == WIFI_RADIO_OFF_PENDING - ) { + ) { TT_LOG_W(TAG, "Can't enable from current state"); return; } - TT_LOG_I(TAG, "Enabling"); - wifi->radio_state = WIFI_RADIO_ON_PENDING; - publish_event_simple(wifi, WifiEventTypeRadioStateOnPending); + auto lock = std::make_unique(wifi->radioMutex); - if (wifi->netif != nullptr) { - esp_netif_destroy(wifi->netif); - } - wifi->netif = esp_netif_create_default_wifi_sta(); + if (lock->acquire(50 / portTICK_PERIOD_MS)) { + TT_LOG_I(TAG, "Enabling"); + wifi->setRadioState(WIFI_RADIO_ON_PENDING); + publish_event_simple(wifi, WifiEventTypeRadioStateOnPending); - // Warning: this is the memory-intensive operation - // It uses over 117kB of RAM with default settings for S3 on IDF v5.1.2 - wifi_init_config_t config = WIFI_INIT_CONFIG_DEFAULT(); - esp_err_t init_result = esp_wifi_init(&config); - if (init_result != ESP_OK) { - TT_LOG_E(TAG, "Wifi init failed"); - if (init_result == ESP_ERR_NO_MEM) { - TT_LOG_E(TAG, "Insufficient memory"); + if (wifi->netif != nullptr) { + esp_netif_destroy(wifi->netif); } - wifi->radio_state = WIFI_RADIO_OFF; - publish_event_simple(wifi, WifiEventTypeRadioStateOff); - return; - } + wifi->netif = esp_netif_create_default_wifi_sta(); - esp_wifi_set_storage(WIFI_STORAGE_RAM); - - // TODO: don't crash on check failure - ESP_ERROR_CHECK(esp_event_handler_instance_register( - WIFI_EVENT, - ESP_EVENT_ANY_ID, - &event_handler, - nullptr, - &wifi->event_handler_any_id - )); - - // TODO: don't crash on check failure - ESP_ERROR_CHECK(esp_event_handler_instance_register( - IP_EVENT, - IP_EVENT_STA_GOT_IP, - &event_handler, - nullptr, - &wifi->event_handler_got_ip - )); - - if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) { - TT_LOG_E(TAG, "Wifi mode setting failed"); - wifi->radio_state = WIFI_RADIO_OFF; - esp_wifi_deinit(); - publish_event_simple(wifi, WifiEventTypeRadioStateOff); - return; - } - - esp_err_t start_result = esp_wifi_start(); - if (start_result != ESP_OK) { - TT_LOG_E(TAG, "Wifi start failed"); - if (start_result == ESP_ERR_NO_MEM) { - TT_LOG_E(TAG, "Insufficient memory"); + // Warning: this is the memory-intensive operation + // It uses over 117kB of RAM with default settings for S3 on IDF v5.1.2 + wifi_init_config_t config = WIFI_INIT_CONFIG_DEFAULT(); + esp_err_t init_result = esp_wifi_init(&config); + if (init_result != ESP_OK) { + TT_LOG_E(TAG, "Wifi init failed"); + if (init_result == ESP_ERR_NO_MEM) { + TT_LOG_E(TAG, "Insufficient memory"); + } + wifi->setRadioState(WIFI_RADIO_OFF); + publish_event_simple(wifi, WifiEventTypeRadioStateOff); + return; } - wifi->radio_state = WIFI_RADIO_OFF; - esp_wifi_set_mode(WIFI_MODE_NULL); - esp_wifi_deinit(); - publish_event_simple(wifi, WifiEventTypeRadioStateOff); - return; - } - wifi->radio_state = WIFI_RADIO_ON; - publish_event_simple(wifi, WifiEventTypeRadioStateOn); - TT_LOG_I(TAG, "Enabled"); + esp_wifi_set_storage(WIFI_STORAGE_RAM); + + // TODO: don't crash on check failure + ESP_ERROR_CHECK(esp_event_handler_instance_register( + WIFI_EVENT, + ESP_EVENT_ANY_ID, + &eventHandler, + nullptr, + &wifi->event_handler_any_id + )); + + // TODO: don't crash on check failure + ESP_ERROR_CHECK(esp_event_handler_instance_register( + IP_EVENT, + IP_EVENT_STA_GOT_IP, + &eventHandler, + nullptr, + &wifi->event_handler_got_ip + )); + + if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) { + TT_LOG_E(TAG, "Wifi mode setting failed"); + wifi->setRadioState(WIFI_RADIO_OFF); + esp_wifi_deinit(); + publish_event_simple(wifi, WifiEventTypeRadioStateOff); + return; + } + + esp_err_t start_result = esp_wifi_start(); + if (start_result != ESP_OK) { + TT_LOG_E(TAG, "Wifi start failed"); + if (start_result == ESP_ERR_NO_MEM) { + TT_LOG_E(TAG, "Insufficient memory"); + } + wifi->setRadioState(WIFI_RADIO_OFF); + esp_wifi_set_mode(WIFI_MODE_NULL); + esp_wifi_deinit(); + publish_event_simple(wifi, WifiEventTypeRadioStateOff); + return; + } + + wifi->setRadioState(WIFI_RADIO_ON); + publish_event_simple(wifi, WifiEventTypeRadioStateOn); + TT_LOG_I(TAG, "Enabled"); + } else { + TT_LOG_E(TAG, "enable() mutex timeout"); + } } -static void disable(std::shared_ptr wifi) { - WifiRadioState state = wifi->radio_state; +static void dispatchDisable(std::shared_ptr context) { + TT_LOG_I(TAG, "dispatchDisable()"); + auto wifi = std::static_pointer_cast(context); + auto lock = wifi->radioMutex.scoped(); + + if (!lock->acquire(50 / portTICK_PERIOD_MS)) { + TT_LOG_E(TAG, "disable() mutex timeout"); + return; + } + + WifiRadioState state = wifi->getRadioState(); if ( state == WIFI_RADIO_OFF || state == WIFI_RADIO_OFF_PENDING || state == WIFI_RADIO_ON_PENDING - ) { + ) { TT_LOG_W(TAG, "Can't disable from current state"); return; } TT_LOG_I(TAG, "Disabling"); - wifi->radio_state = WIFI_RADIO_OFF_PENDING; + wifi->setRadioState(WIFI_RADIO_OFF_PENDING); publish_event_simple(wifi, WifiEventTypeRadioStateOffPending); // Free up scan list memory @@ -489,7 +604,7 @@ static void disable(std::shared_ptr wifi) { if (esp_wifi_stop() != ESP_OK) { TT_LOG_E(TAG, "Failed to stop radio"); - wifi->radio_state = WIFI_RADIO_ON; + wifi->setRadioState(WIFI_RADIO_ON); publish_event_simple(wifi, WifiEventTypeRadioStateOn); return; } @@ -499,18 +614,18 @@ static void disable(std::shared_ptr wifi) { } if (esp_event_handler_instance_unregister( - WIFI_EVENT, - ESP_EVENT_ANY_ID, - wifi->event_handler_any_id - ) != ESP_OK) { + WIFI_EVENT, + ESP_EVENT_ANY_ID, + wifi->event_handler_any_id + ) != ESP_OK) { TT_LOG_E(TAG, "Failed to unregister id event handler"); } if (esp_event_handler_instance_unregister( - IP_EVENT, - IP_EVENT_STA_GOT_IP, - wifi->event_handler_got_ip - ) != ESP_OK) { + IP_EVENT, + IP_EVENT_STA_GOT_IP, + wifi->event_handler_got_ip + ) != ESP_OK) { TT_LOG_E(TAG, "Failed to unregister ip event handler"); } @@ -521,38 +636,60 @@ static void disable(std::shared_ptr wifi) { tt_assert(wifi->netif != nullptr); esp_netif_destroy(wifi->netif); wifi->netif = nullptr; - wifi->scan_active = false; - wifi->radio_state = WIFI_RADIO_OFF; + wifi->setScanActive(false); + wifi->setRadioState(WIFI_RADIO_OFF); publish_event_simple(wifi, WifiEventTypeRadioStateOff); TT_LOG_I(TAG, "Disabled"); } -static void scan_internal(std::shared_ptr wifi) { - WifiRadioState state = wifi->radio_state; +static void dispatchScan(std::shared_ptr context) { + TT_LOG_I(TAG, "dispatchScan()"); + auto wifi = std::static_pointer_cast(context); + auto lock = wifi->radioMutex.scoped(); + + if (!lock->acquire(10 / portTICK_PERIOD_MS)) { + TT_LOG_E(TAG, "dispatchScan() mutex timeout"); + return; + } + + WifiRadioState state = wifi->getRadioState(); if (state != WIFI_RADIO_ON && state != WIFI_RADIO_CONNECTION_ACTIVE && state != WIFI_RADIO_CONNECTION_PENDING) { TT_LOG_W(TAG, "Scan unavailable: wifi not enabled"); return; } - if (!wifi->scan_active) { - wifi->last_scan_time = tt::get_ticks(); - if (esp_wifi_scan_start(nullptr, false) == ESP_OK) { - TT_LOG_I(TAG, "Starting scan"); - wifi->scan_active = true; - publish_event_simple(wifi, WifiEventTypeScanStarted); - } else { - TT_LOG_I(TAG, "Can't start scan"); - } - } else { + if (wifi->isScanActive()) { TT_LOG_W(TAG, "Scan already pending"); + return; } + + // TODO: Thread safety + wifi->last_scan_time = tt::get_ticks(); + + if (esp_wifi_scan_start(nullptr, false) != ESP_OK) { + TT_LOG_I(TAG, "Can't start scan"); + return; + } + + TT_LOG_I(TAG, "Starting scan"); + wifi->setScanActive(true); + publish_event_simple(wifi, WifiEventTypeScanStarted); } -static void connect_internal(std::shared_ptr wifi) { +static void dispatchConnect(std::shared_ptr context) { + TT_LOG_I(TAG, "dispatchConnect()"); + auto wifi = std::static_pointer_cast(context); + auto lock = wifi->radioMutex.scoped(); + + if (!lock->acquire(50 / portTICK_PERIOD_MS)) { + TT_LOG_E(TAG, "dispatchConnect() mutex timeout"); + return; + } + TT_LOG_I(TAG, "Connecting to %s", wifi->connection_target.ssid); // Stop radio first, if needed - WifiRadioState radio_state = wifi->radio_state; + WifiRadioState radio_state = wifi->getRadioState(); if ( radio_state == WIFI_RADIO_ON || radio_state == WIFI_RADIO_CONNECTION_ACTIVE || @@ -560,14 +697,14 @@ static void connect_internal(std::shared_ptr wifi) { ) { TT_LOG_I(TAG, "Connecting: Stopping radio first"); esp_err_t stop_result = esp_wifi_stop(); - wifi->scan_active = false; + wifi->setScanActive(false); if (stop_result != ESP_OK) { TT_LOG_E(TAG, "Connecting: Failed to disconnect (%s)", esp_err_to_name(stop_result)); return; } } - wifi->radio_state = WIFI_RADIO_CONNECTION_PENDING; + wifi->setRadioState(WIFI_RADIO_CONNECTION_PENDING); publish_event_simple(wifi, WifiEventTypeConnectionPending); @@ -621,11 +758,9 @@ static void connect_internal(std::shared_ptr wifi) { memcpy(wifi_config.sta.ssid, wifi_singleton->connection_target.ssid, sizeof(wifi_config.sta.ssid)); memcpy(wifi_config.sta.password, wifi_singleton->connection_target.password, sizeof(wifi_config.sta.password)); - wifi->secure_connection = (wifi_config.sta.password[0] != 0x00); - esp_err_t set_config_result = esp_wifi_set_config(WIFI_IF_STA, &wifi_config); if (set_config_result != ESP_OK) { - wifi->radio_state = WIFI_RADIO_ON; + wifi->setRadioState(WIFI_RADIO_ON); TT_LOG_E(TAG, "Failed to set wifi config (%s)", esp_err_to_name(set_config_result)); publish_event_simple(wifi, WifiEventTypeConnectionFailed); return; @@ -633,7 +768,7 @@ static void connect_internal(std::shared_ptr wifi) { esp_err_t wifi_start_result = esp_wifi_start(); if (wifi_start_result != ESP_OK) { - wifi->radio_state = WIFI_RADIO_ON; + wifi->setRadioState(WIFI_RADIO_ON); TT_LOG_E(TAG, "Failed to start wifi to begin connecting (%s)", esp_err_to_name(wifi_start_result)); publish_event_simple(wifi, WifiEventTypeConnectionFailed); return; @@ -643,9 +778,11 @@ static void connect_internal(std::shared_ptr wifi) { * or connection failed for the maximum number of re-tries (WIFI_FAIL_BIT). * The bits are set by wifi_event_handler() */ uint32_t bits = wifi_singleton->connection_wait_flags.wait(WIFI_FAIL_BIT | WIFI_CONNECTED_BIT); + TT_LOG_I(TAG, "Waiting for EventFlag by event_handler()"); if (bits & WIFI_CONNECTED_BIT) { - wifi->radio_state = WIFI_RADIO_CONNECTION_ACTIVE; + wifi->setSecureConnection(wifi_config.sta.password[0] != 0x00); + wifi->setRadioState(WIFI_RADIO_CONNECTION_ACTIVE); publish_event_simple(wifi, WifiEventTypeConnectionSuccess); TT_LOG_I(TAG, "Connected to %s", wifi->connection_target.ssid); if (wifi->connection_target_remember) { @@ -656,11 +793,11 @@ static void connect_internal(std::shared_ptr wifi) { } } } else if (bits & WIFI_FAIL_BIT) { - wifi->radio_state = WIFI_RADIO_ON; + wifi->setRadioState(WIFI_RADIO_ON); publish_event_simple(wifi, WifiEventTypeConnectionFailed); TT_LOG_I(TAG, "Failed to connect to %s", wifi->connection_target.ssid); } else { - wifi->radio_state = WIFI_RADIO_ON; + wifi->setRadioState(WIFI_RADIO_ON); publish_event_simple(wifi, WifiEventTypeConnectionFailed); TT_LOG_E(TAG, "UNEXPECTED EVENT"); } @@ -668,7 +805,16 @@ static void connect_internal(std::shared_ptr wifi) { wifi_singleton->connection_wait_flags.clear(WIFI_FAIL_BIT | WIFI_CONNECTED_BIT); } -static void disconnect_internal_but_keep_active(std::shared_ptr wifi) { +static void dispatchDisconnectButKeepActive(std::shared_ptr context) { + TT_LOG_I(TAG, "dispatchDisconnectButKeepActive()"); + auto wifi = std::static_pointer_cast(context); + auto lock = wifi->radioMutex.scoped(); + + if (!lock->acquire(50 / portTICK_PERIOD_MS)) { + TT_LOG_E(TAG, "disconnect_internal_but_keep_active() mutex timeout"); + return; + } + esp_err_t stop_result = esp_wifi_stop(); if (stop_result != ESP_OK) { TT_LOG_E(TAG, "Failed to disconnect (%s)", esp_err_to_name(stop_result)); @@ -691,7 +837,7 @@ static void disconnect_internal_but_keep_active(std::shared_ptr wifi) { esp_err_t set_config_result = esp_wifi_set_config(WIFI_IF_STA, &wifi_config); if (set_config_result != ESP_OK) { // TODO: disable radio, because radio state is in limbo between off and on - wifi->radio_state = WIFI_RADIO_OFF; + wifi->setRadioState(WIFI_RADIO_OFF); TT_LOG_E(TAG, "failed to set wifi config (%s)", esp_err_to_name(set_config_result)); publish_event_simple(wifi, WifiEventTypeRadioStateOff); return; @@ -700,122 +846,96 @@ static void disconnect_internal_but_keep_active(std::shared_ptr wifi) { esp_err_t wifi_start_result = esp_wifi_start(); if (wifi_start_result != ESP_OK) { // TODO: disable radio, because radio state is in limbo between off and on - wifi->radio_state = WIFI_RADIO_OFF; + wifi->setRadioState(WIFI_RADIO_OFF); TT_LOG_E(TAG, "failed to start wifi to begin connecting (%s)", esp_err_to_name(wifi_start_result)); publish_event_simple(wifi, WifiEventTypeRadioStateOff); return; } - wifi->radio_state = WIFI_RADIO_ON; + wifi->setRadioState(WIFI_RADIO_ON); publish_event_simple(wifi, WifiEventTypeDisconnected); TT_LOG_I(TAG, "Disconnected"); } static bool shouldScanForAutoConnect(std::shared_ptr wifi) { - bool is_radio_in_scannable_state = wifi->radio_state == WIFI_RADIO_ON && - !wifi->scan_active && - !wifi->pause_auto_connect; + auto lock = wifi->dataMutex.scoped(); - if (is_radio_in_scannable_state) { - TickType_t current_time = tt::get_ticks(); - bool scan_time_has_looped = (current_time < wifi->last_scan_time); - bool no_recent_scan = (current_time - wifi->last_scan_time) > (AUTO_SCAN_INTERVAL / portTICK_PERIOD_MS); - return scan_time_has_looped || no_recent_scan; - } else { + if (!lock->acquire(100)) { return false; } + + bool is_radio_in_scannable_state = wifi->getRadioState() == WIFI_RADIO_ON && + !wifi->isScanActive() && + !wifi->pause_auto_connect; + + if (!is_radio_in_scannable_state) { + return false; + } + + TickType_t current_time = tt::get_ticks(); + bool scan_time_has_looped = (current_time < wifi->last_scan_time); + bool no_recent_scan = (current_time - wifi->last_scan_time) > (AUTO_SCAN_INTERVAL / portTICK_PERIOD_MS); + + return scan_time_has_looped || no_recent_scan; +} + +void onAutoConnectTimer(std::shared_ptr context) { + auto wifi = std::static_pointer_cast(wifi_singleton); + // Automatic scanning is done so we can automatically connect to access points + bool should_auto_scan = shouldScanForAutoConnect(wifi); + if (should_auto_scan) { + getMainDispatcher().dispatch(dispatchScan, wifi); + } } -// ESP Wi-Fi APIs need to run from the main task, so we can't just spawn a thread -_Noreturn int32_t wifi_main(TT_UNUSED void* parameter) { - TT_LOG_I(TAG, "Started main loop"); - tt_assert(wifi_singleton != nullptr); - auto wifi = wifi_singleton; - MessageQueue& queue = wifi->queue; +static void onStart(ServiceContext& service) { + tt_assert(wifi_singleton == nullptr); + wifi_singleton = std::make_shared(); + + service.setData(wifi_singleton); + + wifi_singleton->autoConnectTimer = std::make_unique(Timer::TypePeriodic, onAutoConnectTimer, wifi_singleton); + // We want to try and scan more often in case of startup or scan lock failure + wifi_singleton->autoConnectTimer->start(TT_MIN(2000, AUTO_SCAN_INTERVAL)); if (settings::shouldEnableOnBoot()) { TT_LOG_I(TAG, "Auto-enabling due to setting"); - enable(wifi); - scan_internal(wifi); - } - - WifiMessage message; - while (true) { - if (queue.get(&message, 10000 / portTICK_PERIOD_MS) == TtStatusOk) { - TT_LOG_I(TAG, "Processing message of type %d", message.type); - switch (message.type) { - case WifiMessageTypeRadioOn: - lock(wifi); - enable(wifi); - unlock(wifi); - break; - case WifiMessageTypeRadioOff: - lock(wifi); - disable(wifi); - unlock(wifi); - break; - case WifiMessageTypeScan: - lock(wifi); - scan_internal(wifi); - unlock(wifi); - break; - case WifiMessageTypeConnect: - lock(wifi); - connect_internal(wifi); - unlock(wifi); - break; - case WifiMessageTypeDisconnect: - lock(wifi); - disconnect_internal_but_keep_active(wifi); - unlock(wifi); - break; - case WifiMessageTypeAutoConnect: - lock(wifi); - if (!wifi->pause_auto_connect) { - auto_connect(wifi_singleton); - } - unlock(wifi); - break; - } - } - - // Automatic scanning is done so we can automatically connect to access points - lock(wifi); - bool should_auto_scan = shouldScanForAutoConnect(wifi); - unlock(wifi); - if (should_auto_scan) { - scan_internal(wifi); - } + getMainDispatcher().dispatch(dispatchEnable, wifi_singleton); } } -static void service_start(ServiceContext& service) { - tt_assert(wifi_singleton == nullptr); - wifi_singleton = std::make_shared(); - service.setData(wifi_singleton); -} +static void onStop(ServiceContext& service) { + auto wifi = wifi_singleton; + tt_assert(wifi != nullptr); -static void service_stop(ServiceContext& service) { - tt_assert(wifi_singleton != nullptr); - - WifiRadioState state = wifi_singleton->radio_state; + WifiRadioState state = wifi->getRadioState(); if (state != WIFI_RADIO_OFF) { - disable(wifi_singleton); + dispatchDisable(wifi); } + wifi->autoConnectTimer->stop(); + wifi->autoConnectTimer = nullptr; // Must release as it holds a reference to this Wifi instance + + // Acquire all mutexes + wifi->dataMutex.acquire(TtWaitForever); + wifi->radioMutex.acquire(TtWaitForever); + + // Detach wifi_singleton = nullptr; - // wifi_main() cannot be stopped yet as it runs in the main task. - // We could theoretically exit it, but then we wouldn't be able to restart the service. - tt_crash("not fully implemented"); + // Release mutexes + wifi->dataMutex.release(); + wifi->radioMutex.release(); + + // Release (hopefully) last Wifi instance by scope } extern const ServiceManifest manifest = { .id = "Wifi", - .onStart = &service_start, - .onStop = &service_stop + .onStart = onStart, + .onStop = onStop }; } // namespace -#endif // ESP_TARGET \ No newline at end of file +#endif // ESP_TARGET diff --git a/TactilityHeadless/Source/service/wifi/WifiMock.cpp b/TactilityHeadless/Source/service/wifi/WifiMock.cpp index 96d5f0d2..a98e457e 100644 --- a/TactilityHeadless/Source/service/wifi/WifiMock.cpp +++ b/TactilityHeadless/Source/service/wifi/WifiMock.cpp @@ -21,7 +21,7 @@ typedef struct { /** @brief Locking mechanism for modifying the Wifi instance */ Mutex* mutex; /** @brief The public event bus */ - PubSub* pubsub; + std::shared_ptr pubsub; /** @brief The internal message queue */ MessageQueue queue; bool scan_active; @@ -50,7 +50,7 @@ static void publish_event_simple(Wifi* wifi, WifiEventType type) { static Wifi* wifi_alloc() { auto* instance = static_cast(malloc(sizeof(Wifi))); instance->mutex = tt_mutex_alloc(MutexTypeRecursive); - instance->pubsub = tt_pubsub_alloc(); + instance->pubsub = std::make_shared(); instance->scan_active = false; instance->radio_state = WIFI_RADIO_CONNECTION_ACTIVE; instance->secure_connection = false; @@ -59,7 +59,6 @@ static Wifi* wifi_alloc() { static void wifi_free(Wifi* instance) { tt_mutex_free(instance->mutex); - tt_pubsub_free(instance->pubsub); free(instance); } @@ -67,7 +66,7 @@ static void wifi_free(Wifi* instance) { // region Public functions -PubSub* getPubsub() { +std::shared_ptr getPubsub() { tt_assert(wifi); return wifi->pubsub; } diff --git a/Tests/TactilityCore/DispatcherTest.cpp b/Tests/TactilityCore/DispatcherTest.cpp index c6c9e9b6..f6045431 100644 --- a/Tests/TactilityCore/DispatcherTest.cpp +++ b/Tests/TactilityCore/DispatcherTest.cpp @@ -4,16 +4,26 @@ using namespace tt; -void increment_callback(void* context) { - auto* counter = (uint32_t*)context; - (*counter)++; +static uint32_t counter = 0; +static const uint32_t value_chacker_expected = 123; + +void increment_callback(TT_UNUSED std::shared_ptr context) { + counter++; +} + +void value_checker(std::shared_ptr context) { + auto value = std::static_pointer_cast(context); + if (*value != value_chacker_expected) { + tt_crash_implementation(); + } } TEST_CASE("dispatcher should not call callback if consume isn't called") { + counter = 0; Dispatcher dispatcher; - uint32_t counter = 0; - dispatcher.dispatch(&increment_callback, &counter); + auto context = std::make_shared(); + dispatcher.dispatch(&increment_callback, std::move(context)); delay_ticks(10); CHECK_EQ(counter, 0); @@ -21,16 +31,25 @@ TEST_CASE("dispatcher should not call callback if consume isn't called") { TEST_CASE("dispatcher should be able to dealloc when message is not consumed") { auto* dispatcher = new Dispatcher(); - uint32_t counter = 0; - dispatcher->dispatch(increment_callback, &counter); + auto context = std::make_shared(); + dispatcher->dispatch(increment_callback, std::move(context)); delete dispatcher; } TEST_CASE("dispatcher should call callback when consume is called") { + counter = 0; Dispatcher dispatcher; - uint32_t counter = 0; - dispatcher.dispatch(increment_callback, &counter); + auto context = std::make_shared(); + dispatcher.dispatch(increment_callback, std::move(context)); dispatcher.consume(100); CHECK_EQ(counter, 1); } + +TEST_CASE("message should be passed on correctly") { + Dispatcher dispatcher; + + auto context = std::make_shared(value_chacker_expected); + dispatcher.dispatch(value_checker, std::move(context)); + dispatcher.consume(100); +} diff --git a/Tests/TactilityCore/TimerTest.cpp b/Tests/TactilityCore/TimerTest.cpp index 602d4cc5..8c8d1b84 100644 --- a/Tests/TactilityCore/TimerTest.cpp +++ b/Tests/TactilityCore/TimerTest.cpp @@ -2,32 +2,34 @@ #include "TactilityCore.h" #include "Timer.h" +#include + using namespace tt; -void* timer_callback_context = NULL; -static void timer_callback_with_context(void* context) { - timer_callback_context = context; +std::shared_ptr timer_callback_context = NULL; +static void timer_callback_with_context(std::shared_ptr context) { + timer_callback_context = std::move(context); } -static void timer_callback_with_counter(void* context) { - int* int_ptr = (int*)context; +static void timer_callback_with_counter(std::shared_ptr context) { + auto int_ptr = std::static_pointer_cast(context); (*int_ptr)++; } TEST_CASE("a timer passes the context correctly") { - int foo = 1; - auto* timer = new Timer(Timer::TypeOnce, &timer_callback_with_context, &foo); + auto foo = std::make_shared(1); + auto* timer = new Timer(Timer::TypeOnce, &timer_callback_with_context, foo); timer->start(1); delay_ticks(10); timer->stop(); delete timer; - CHECK_EQ(timer_callback_context, &foo); + CHECK_EQ(*std::static_pointer_cast(timer_callback_context), *foo); } TEST_CASE("TimerTypePeriodic timers can be stopped and restarted") { - int counter = 0; - auto* timer = new Timer(Timer::TypePeriodic, &timer_callback_with_counter, &counter); + auto counter = std::make_shared(0); + auto* timer = new Timer(Timer::TypePeriodic, &timer_callback_with_counter, counter); timer->start(1); delay_ticks(10); timer->stop(); @@ -36,24 +38,24 @@ TEST_CASE("TimerTypePeriodic timers can be stopped and restarted") { timer->stop(); delete timer; - CHECK_GE(counter, 2); + CHECK_GE(*counter, 2); } TEST_CASE("TimerTypePeriodic calls the callback periodically") { - int counter = 0; + auto counter = std::make_shared(0); int ticks_to_run = 10; - auto* timer = new Timer(Timer::TypePeriodic, &timer_callback_with_counter, &counter); + auto* timer = new Timer(Timer::TypePeriodic, &timer_callback_with_counter, counter); timer->start(1); delay_ticks(ticks_to_run); timer->stop(); delete timer; - CHECK_EQ(counter, ticks_to_run); + CHECK_EQ(*counter, ticks_to_run); } TEST_CASE("restarting TimerTypeOnce timers calls the callback again") { - int counter = 0; - auto* timer = new Timer(Timer::TypeOnce, &timer_callback_with_counter, &counter); + auto counter = std::make_shared(0); + auto* timer = new Timer(Timer::TypeOnce, &timer_callback_with_counter, counter); timer->start(1); delay_ticks(10); timer->stop(); @@ -62,5 +64,5 @@ TEST_CASE("restarting TimerTypeOnce timers calls the callback again") { timer->stop(); delete timer; - CHECK_EQ(counter, 2); + CHECK_EQ(*counter, 2); }