From ea9852d5bc48d6a8f038215da5e2abcd039f4c50 Mon Sep 17 00:00:00 2001 From: Albin Chaboissier Date: Sun, 30 Nov 2025 22:11:47 +0100 Subject: [PATCH] Nice graphs --- Cargo.toml | 4 - assets/co2-icon.bmp | Bin 4278 -> 4278 bytes assets/co2-icon.svg | 4 +- assets/humidity-icon.bmp | Bin 4278 -> 4278 bytes assets/humidity-icon.svg | 6 +- assets/indic-falling.bmp | Bin 538 -> 538 bytes assets/indic-falling.svg | 4 +- assets/indic-rising.bmp | Bin 538 -> 538 bytes assets/indic-rising.svg | 4 +- assets/indic-steady.bmp | Bin 538 -> 538 bytes assets/indic-steady.svg | 4 +- assets/temperature-icon.bmp | Bin 4278 -> 4278 bytes assets/temperature-icon.svg | 6 +- assets/voc-icon.bmp | Bin 4278 -> 4278 bytes assets/voc-icon.svg | 4 +- src/bin/graph.rs | 135 ------------ src/bin/main.rs | 199 ----------------- src/bin/sampler.rs | 53 ----- src/{bin => }/colors.rs | 2 +- src/graph.rs | 264 +++++++++++++++++++++++ src/{bin => }/images.rs | 14 +- src/lib.rs | 1 - src/main.rs | 395 ++++++++++++++++++++++++++++++++++ src/sampler.rs | 209 ++++++++++++++++++ src/{bin => }/views.rs | 0 src/{bin => }/views/detail.rs | 0 src/{bin => }/views/icon.rs | 5 +- src/{bin => }/views/menu.rs | 31 ++- 28 files changed, 918 insertions(+), 426 deletions(-) delete mode 100644 src/bin/graph.rs delete mode 100644 src/bin/main.rs delete mode 100644 src/bin/sampler.rs rename src/{bin => }/colors.rs (93%) create mode 100644 src/graph.rs rename src/{bin => }/images.rs (76%) delete mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/sampler.rs rename src/{bin => }/views.rs (100%) rename src/{bin => }/views/detail.rs (100%) rename src/{bin => }/views/icon.rs (77%) rename src/{bin => }/views/menu.rs (75%) diff --git a/Cargo.toml b/Cargo.toml index 54b5827..bd1bf02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,6 @@ name = "co2-meter" rust-version = "1.88" version = "0.1.0" -[[bin]] -name = "co2-meter" -path = "./src/bin/main.rs" - [dependencies] esp-hal = { version = "1.0.0", features = ["defmt", "esp32c3", "unstable"] } diff --git a/assets/co2-icon.bmp b/assets/co2-icon.bmp index a74e2133d7232371e0944aa8aef6a37eb703df12..e69df2df6a9bc3385f496c967c6a31297b12f2cb 100644 GIT binary patch literal 4278 zcmeH}F>Dht6o#MAHi$+-kx-T(6=jJk6{L!aE)hdAG)o!E(50Df2p!52hAua8LaLCe zN@bv#)1e*8f`l?dmM(OOgxDC`B@A6!2{Q5Cb1s*Q6Xn_j!4O-Q*zeBYz4z?j=fbm3 zN#ODpe?7b&buIx(kl22R_rKgV7j{kg1@PlGz=KY=P<;Ms_w&`egB@N>9u`s)kyD=Uax(mLX!4{45N zCRSdKm=Q#4{*Q6wyhH6KZDEme!S)5q9(kzK$v{&Qr6NP`$?qLzBrQe*PQ(djXb{V` z8G2p$kz2>izK9ijWME$>4=J3Z{RsVf>DK4f=M_=aXIZ@4`n@l2KJfGsB9AFx?*H6GTaiL#4I;%WN_is X2~WCpRguwe#f_lTLDLNUPZ{8h#`L$K)Y_4y9__>tN=XnqW zkKwu3YPC|&AADQh@(*{+>sYVY9cX*gDn7jwNn36*g6y2|r?}=j(L!`&pD<(mFcAI`vc1Z@R7@}PV@)+nHX4c=G-IaYV)cWE& z)M%A86MAX-$%yXCjzn$Djbq`IXDxlvhXdIG+xJ)r+HUOm*;PrbQz!>Oud=Ko8+F7? zIumWB_JJ&mJvHXj_LSwGY|AUiy!to>uZxqeMDlcYm6Z4UbQ>%WAzpKq`j{JO$Q>Qu z*Rh)_yWS_VE3GnPxavv#`T$fgQG?MKjqOh6UkHAe#RbgQncGh&t24a{-^`9Qjv(72 zF@$dxVJ9NwQdza0u&Sc0`UJ#z?7Dgr*`7>I?9x8P_#`B!X4GhR>=Dilq}~a8Fvo?B zE7;%#R%Hct#L-)1Ykx2bqEQrtvMw-6(X#`Xk$ zY)@Xu;c9$l7HNT%$ML!-TdOpCm-{#DcpdE4?X!o8+|l`} ezw6n;-dAQ(^@#9Bm7LLFe?y0TOkF)>i-GBID+8QMs!NNtzSY5zfUGeYWR zfi=y_bW(SuCJPcHHkXV2`E19Bn#jqs{oePUKR-Wv|J~OP{rjAX-_N4GS~aE)Z3+3M znE&y(YNA_x{t(?fAbPgy?!P_wboTxC?)z^$Pj|lj;y8|ah&B;22ip17BFZV{f=l=ooN`V*zo~cjxrow4)qNDa{7?Lx zt{_$&RmoR8@y2JH+z!C4J#;yo{~irmAWIJe4&^>|BD>j7gF^3EkY!dRE9w%OiOi|_ zQ2$FCwJ>%09tmEP85)X$%ofd9EfKQchFQ9((t6W<>3egy_`JpEdle zfpduCR!umiTR=Z|?&uKoj+}bv=m_E&;^exw1nlGWxL-I1D_6FRv#$k6xkf_xMK znUx#7xORSF=!Km&Jf`NXPO@(A{{XS@c= zmT$r?+q`poG10qq lvb#-M%;>pyx5zSx`d+*{HuR;*sv3Gj>vmzXt=jr5{{m4MK-2&L literal 4278 zcmb7`J#X7E5QashXrT^8grb9%6a+-uL%TF{mn?CdWN7Ek*&=^r?b@YFfY@=6KhngJ zva~;$=xOe<*J9zkfEq_0k*W1TS1lz$@*?v<# z|M8-_%5L@f7X-hb1;Ojb?%w{#v-9tN2On;CUhRDOBX)OpU!I(t^vdh$_4Rf5{`t?X z!Kd?gX1cyVp`Egw$o8+2qd1Kx(KMPwX*`NmhdGICWA|kmFT-3E@Xf_COyge53QcS! zClRO8HF7bFn)rTp8fT!DQf)gEY3#G9o2}$Lvh&+o^T=&~7rQ5~!Zsd0zY1Npsk4<_ zddYt4FT=X_I{U(54ILFjsb?!Wl;bjTP`B>QB5rriK8pFQWq9C-wFAf79Wk#WTgf|6 zbT1|~HCA29blfq!AbZS4b0wBrwT{{NF({{OB{NXnq}i-81@B~_oU%utVg=(2i{F)8 zfu0djPT4cia7adRBJHF1FNl9An<}^q>HOw4wd9J-vuq`4^v%io&E$mT2fK$XyAN!C z2CA=g3VyP?!Lkp4-PPK9C5!>PVxoo->hYdYF@Gp0GAaBr3?oeh1}!iS>fMEoCS2 z5|l5=`x%w|j#UD^glvrQLL9f`6x=n1u=_@9KsHyN30I9Z+ItdD84DI$oXzd$s<9QD z*X&$STf1~8+#cxE4LiR_kwDmacykoVh`mnn3stGi^d_d zbS~^pH{`l6t0%n1uHf9%W2b>aoI2ylrCam)dGKakJyp$SzB=M#D|rjqr4WmU6QyGN zs}Q&ARjoCumXDpteP+`_Xnp*BvRdTX75r>nPcT<#jWjk2k&6-YvL*X`X-44r0J*x? zeC}$}tyjRji}W$u<3^FWx3Lq6Cn8IqwvgLwQYqm6(|ljt8O{3Mu3Mky7m(LA*;YkL v!*&<8o^eL#-N@Z^S87aM>|%{-MpxZ>w9cwsE3_WJ2|JOlY+Gq`{RY8*F9fa} diff --git a/assets/humidity-icon.svg b/assets/humidity-icon.svg index 6ac5f37..d5733b9 100644 --- a/assets/humidity-icon.svg +++ b/assets/humidity-icon.svg @@ -26,8 +26,8 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="9.5273334" - inkscape:cx="19.83766" - inkscape:cy="22.724092" + inkscape:cx="19.890141" + inkscape:cy="22.829053" inkscape:window-width="1916" inkscape:window-height="1032" inkscape:window-x="0" @@ -35,7 +35,7 @@ inkscape:window-maximized="0" inkscape:current-layer="svg1" /> _t*;r4jg5uV($ds`m~H0FnVhy<*O@h9`i)@PK{N|82bt}_;DF2pvLzT! z0HFkm=mdrvKsW(O%mFL{619Mfu7HcJfQnf#TwthBa8Phyr~tAcDjO6v7E3$^!bK8_ z8Vn5}m5K@=J`4gyfnYJ54-#J_z`&ru08;^@6|jn->s<`?iz5e27l?M`0NK$b;K;EU zY|mn#NE6Tw1_qG2CV@nbL?9og4%tMAX)u)#Ga;sd#26SB0d+y#3*m!Eh|3ia0$`RS H2SOhJ)V*)S literal 538 zcmZ?rm11H5gDxOh1H@cFECR%g3<3_t*;r4jg5uV($ds`m~H0FnVhy<*O@h9`i)@PK{N|82gr8hfIvkLMF$ug zN-Hp&U^wBZfGo-|gW(3l4TT8^F+~TE2nZtdGpvA#F|2^71gg5ASmEg4=%82uWPxo` zXmHdxEb$l!7fU#50C^ynXn^=I=%@h_I}GQ8#DQu#5; _t*;r4jg5uV($ds`m~H0FnVhy<*O@h9`i)@PK{N|82Z`;-;mCo+1u_*F z9!oryP(Tq}BmfdyB!CcfO+XVMDw_lxIUp(* z14Wv^W+F^On20b9BnGk@;xZ5q1{JW1F)%EKs{pBlxJHQ+rVB(Xae`c^&f>&*7_9cN zgcGMaIFOi`&agdaW?^Dsdd_@??J+PuKwuTC4m$*{VugqR!;}do#te;BkPIhS_t*;r4jg5uV($ds`m~H0FnVhy<*O@h9`i)@PK{N|82gr8hfWRbyBmo#3 zLML)OmUt|IDz+FX@>pUql4z0uNCX5C`VT`z9!ngCssyQ8B+vwei-0VM%0)mMAu108 zMHYc=N0^2%5n&og3}iRNWgs35CSnzH _t*;r4jg5uV($ds`m~H0FnVhy<*O@h9`i)@PK{N|82Z`;-;mCo+1u_*F z9!oryP(Tq}BmfdyB!Ccfg?41R!c)qClR&0tFZkMlVpnCWcKf&|fHaC;;tX lU_f;X#D7R`RzUa;({Bt6*uns0CMZ;p!wP$tg7hQ84gk+0i!uNJ literal 538 zcmZ?rm11H5gDxOh1H@cFECR%g3<3_t*;r4jg5uV($ds`m~H0FnVhy<*O@h9`i)@PK{N|82gr8hfWRbyBmo#3 zLML)OmUt|IDz+FX@>pUql4z0uNCX5C`VT`z9!ngCssyQeq`;)fr1?kzrgD*lqb0)@ zhp!G_8MZhY0(l^p7%IF4iGZNOTSr4jjz$TH8kp#V&5aTdHDEj#{ZIp&7`k4DEsY>s xEYa<7NYu^1c*$YJHk0SVJYXqbb23}hpRJpjj9obdnv diff --git a/assets/indic-steady.svg b/assets/indic-steady.svg index 4ac30f3..db12000 100644 --- a/assets/indic-steady.svg +++ b/assets/indic-steady.svg @@ -44,7 +44,7 @@ id="layer1" transform="matrix(0.10139507,0,0,0.10139507,4.4930246,0.14783758)"> }*(LYBTC>3nBf!UyS4=pE-f=}DiSp1$|v!}s6ng08Rd`x%~B z?%5M{aSy&G-~1Z2bS;*#e17dTOlaA`_XOsj=#*#?F%?169Lb$lHxOGXv!q z5}6zo8D$q6xNaqTV^nl+a{0orKhCVGHvoGdn8AC21r-B%{`zo?MwAy zINN0Q2C6*?uqCpDyho0(<#rcVwJf=$Ib=z;d@^wZYRo{kGRw?Gd2+m^#-tsw*AC0N@t)Y` z>KJ8m6h|vdC7qo*#$GeP`32obmY$+a^-w0R@O#&b@$?yu@6h~=Jsh}5%;T1!SmSxK z8#?b*PieHXu)IyDdW!4d7vr%1Z2E0%6;)^L*iV3Cr7|(KDG{pdZak zn|!*6uErb%q}vZOHKyshOt+j}bAGXH-5KC;;;G1dR9HudwV7*R+iM#hm*dMv0`xMEsD?b7j{3rr7OjLrIle|=tq%vq`jsMZM90Dko_ vE=pE*H|^s8qhu2MKL_VAN+#QGGq(%whY@nMZCQglZluP*%Xh2ZfBWkX?|`}& literal 4278 zcmds)J!soN6vr=CX=#TNC|y(Ah!R0NxS2e3un?3C9o)^(B|-!|hE6eENnZn=S@P79>E?&j&4`bI)7<&~}^$(xEoXvmlw}0-v-1{Ev92^|HI6gjp&9BE7 z7Z*?8Kl`QZe?EIh!^QJavclih+P^CQ7u#woeP`~iSXd3cm2>5v8qM;W%j(2xn&av^ zQ$l@YG|KTe*^>?nGTdk;7EC&2=xMfpLfqN1G90vxrqTS?t`BF+s6BMM#py}*q+JaK zg9RCa=AT6C2?vKo=`q{Tu5Sd{TUo8V(bggLt6E-vwk>AYhme`b?vXw9Ra;8vNf!^(wARp{i&c((MOQAelA+Pn>?={L46{;ZTZnC5iZP1R2sH03BAaROKxi3# zTt@{m9I+iK4}1?AiDko@!1GUq4ED4P+m^P2Z2Cxy*T*y-YpS1%y3!MU{Zef3Fy8Ho z@hn7yyGNo{U85GwTS_Nb>uf1y)w~sWvn544?~xJX$@t2|ZLao7-$n!RjyT~_lFC>t zKtxUC8dzu)icDgwHE5jVvs&;yA-{bU#i}-w>w%||HMT!v_8{`=*uQj{$6*6b&|p&* zQdZbbMNiCDYw)$|nmjvcUp4Y_9yiDexf)K-mSn3Myu7-GtVuT3AU(T~uEi`VHedI( z^n)zhl=|^jQLGk_I>U}E>Ys{vlX6%3!m35BnANvvfwzDSpNB6$G+V8?UH1qwJUkm* zaR+<0)JOWLJ}h>c*sx_vCox&A!MBkKl6fro&F1?(*`;z7&6%_%wTWj!C+AAPY(4?$ zZg##8S}p&i(s#R?Rw$~IZGK`j;u8T2{FCHb%dwq>(o=eVm!i{XP=3F)vfQ08Ls2$j zo(Pm15xLd%Y++s^Tk!oUIw{>9^U3w4ebB^Ss^PR!mnRBCbLL`h`-;XDl^go@R_53o z^OLwdt03dkc}cAe+Jepe`?1r00v6AW+($*RT9O@5y`0=>Hs52poOA-9y~SDF%{Iq8 zOQj0*?aW1MY%T01ET|9NE?FCAGr`Z78}e+w)0xYPPR;R`w3p|x)BX^TNx#LqnY5W7 z_c@E?|G7=Mt(HA?=6U7%h6T<-^w`tXPOm9FF~+>4T)$1<)cz39O}QU}NessR0?oTi A{r~^~ diff --git a/assets/temperature-icon.svg b/assets/temperature-icon.svg index 2ada07b..57b2e69 100644 --- a/assets/temperature-icon.svg +++ b/assets/temperature-icon.svg @@ -26,8 +26,8 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="10.216728" - inkscape:cx="24.861188" - inkscape:cy="27.748609" + inkscape:cx="24.910128" + inkscape:cy="27.74861" inkscape:window-width="1916" inkscape:window-height="1032" inkscape:window-x="0" @@ -49,7 +49,7 @@ div[style*="background-color: rgb(135, 135, 135)"] { inkscape:label="Layer 1" transform="matrix(2.0390253,0,0,2.0390253,-3.3768322,-2.3378069)"> lw0m>idgJ2*R0)8+sJYZ*FkcV=e+=Ghy4}H}L z+#x3_*ZP{#*w|P&EiFwAh}mY&oXKg+b)8uwrr!vr9YnJrb4J<2E(E%Q-dB?XIh-It zaNH7ASEd(c&)6l%> piwI2Y>ha66*;)HlzLC7IJ${r3O000+Zy3GIp literal 4278 zcmZ?r-6p^Q23lw0m>idgJ2*R0)8+sJYZ*FkcV=e+=Ghy4}H}L z+#x3_*ZP{#*w|P&EiFwAh}mY&oXKg+b)8uwrr!vr9YnJrbAW6WMOwj8voS-K_FeFw+Ym3yT9yBr%xmYAoT68P}AE zpoTXkCSmFwH5*r$td4@kFoM>Emc?C2X$Mw5dtyl&Af2$h3b6+g-q`9(-)xAA_tl8J zxDJ{>`;hV{LN@XovGwIpFdJL>2MgO(NM#=^FCv$Ru(A(CL&`r`td6E{SV$n~;Sna8 zUa0LP4kw5YJ<-HbRblcV=?OJV(Cf=wEO`?-AL3#YQ(q1hvw6_^53qcAItsOaK!~RU V?mwW0HzI9PgEeY4W+>7&OaL189LfLy diff --git a/assets/voc-icon.svg b/assets/voc-icon.svg index 8aec3ee..1c73917 100644 --- a/assets/voc-icon.svg +++ b/assets/voc-icon.svg @@ -24,7 +24,7 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="12.529602" - inkscape:cx="23.624054" + inkscape:cx="23.66396" inkscape:cy="30.687327" inkscape:window-width="1916" inkscape:window-height="1032" @@ -33,7 +33,7 @@ inkscape:window-maximized="0" inkscape:current-layer="svg1" /> f32 { - ((x - x_min) / (x_max - x_min)) * (y_max - y_min) + y_min -} - -const DEFAULT_LUT: [Rgb565; 5] = [ - Rgb565::new(0, 16, 11), - Rgb565::new(11, 20, 17), - Rgb565::new(23, 20, 18), - Rgb565::new(31, 24, 12), - Rgb565::new(31, 41, 0), -]; - -fn rgb565_interpolate(a: Rgb565, b: Rgb565, x: f32) -> Rgb565 { - Rgb565::new( - (a.r() as f32 * (1. - x)) as u8 + (b.r() as f32 * x) as u8, - (a.g() as f32 * (1. - x)) as u8 + (b.g() as f32 * x) as u8, - (a.b() as f32 * (1. - x)) as u8 + (b.b() as f32 * x) as u8, - ) -} - -fn color_lut(mut x: f32, colors: &[Rgb565]) -> Rgb565 { - if x == 1. { - return colors[colors.len() - 1]; - } - - if x == 0. { - return colors[0]; - } - - x *= (colors.len() - 1) as f32; - - let index = libm::floorf(x); - let interp = x - index; - - rgb565_interpolate(colors[index as usize], colors[index as usize + 1], interp) -} - -pub fn graph_data>(data: &[f32], target: &mut T) { - let min = data.iter().copied().reduce(f32::min).unwrap_or(0.); - let max = data.iter().copied().reduce(f32::max).unwrap_or(0.); - data.iter().map(|x| x); - let size = Size::new( - target.bounding_box().size.width, - target.bounding_box().size.height, - ); - - let mut start = Point::new( - 0, - map_float(data[0], min, max, size.height as f32, 0.) as i32, - ); - let point_count = size.width / 2; - - for y in 0..size.height { - let value = map_float(y as f32, size.height as f32, 0., min, max); - let lut_color = color_lut( - map_float(y as f32, size.height as f32, 0., 0., 1.), - &DEFAULT_LUT, - ); - - let color = rgb565_interpolate(Rgb565::BLACK, lut_color, 0.3); - for x in 0..size.width { - let pos = map_float( - x as f32, - 0., - size.width as f32 - 1., - 0., - (data.len() - 1) as f32, - ); - - // Sample - let index = libm::floorf(pos).min((data.len() - 2) as f32); - let interpolation = pos - index; - let curve_value = data[index as usize] * (1. - interpolation) - + data[index as usize + 1] * interpolation; - // if value <= curve_value && (x + y) % 2 == 0 { - // let _ = Pixel(Point::new(x as i32, y as i32), color).draw(target); - // } - if (x as i32 - y as i32) % 6 == 0 { - if value <= curve_value { - let mut pixel_color = Rgb565::new(1, 2, 1); - pixel_color = color; - let _ = Pixel(Point::new(x as i32 - 2, y as i32), pixel_color).draw(target); - } - } - } - } - - for (i, x) in data.iter().skip(1).enumerate() { - let point = Point::new( - map_float(i as f32, 0., data.len() as f32 - 1., 0., size.width as f32) as i32, - map_float(*x, min, max, size.height as f32, 0.) as i32, - ); - let factor = map_float(*x, min, max, 0., 1.); - let _ = Line::new(start, point) - .into_styled(PrimitiveStyle::with_stroke( - color_lut(factor, &DEFAULT_LUT), - 2, - )) - .draw(target); - start = point; - } - - // for i in 0..point_count { - // // Sample data - // let index = map_float( - // i as f32, - // 0., - // point_count as f32, - // 0 as f32, - // data.len() as f32, - // ) as usize; - // let value = data[index]; - // let point = Point::new( - // map_float(i as f32, 0., point_count as f32, 0., size.width as f32) as i32, - // map_float(value, min, max, 0., size.height as f32) as i32, - // ); - // - // let _ = Line::new(start, point) - // .into_styled(PrimitiveStyle::with_stroke(Rgb565::WHITE, 2)) - // .draw(target); - // start = point; - // } -} diff --git a/src/bin/main.rs b/src/bin/main.rs deleted file mode 100644 index d756fc2..0000000 --- a/src/bin/main.rs +++ /dev/null @@ -1,199 +0,0 @@ -#![no_std] -#![no_main] -#![deny( - clippy::mem_forget, - reason = "mem::forget is generally not safe to do with esp_hal types, especially those \ - holding buffers for the duration of a data transfer." -)] -#![allow(unreachable_code)] - -mod colors; -mod graph; -mod images; -mod sampler; -mod views; - -use buoyant::primitives::Size; -use buoyant::view::AsDrawable; -use buoyant::view::Image; -use buoyant::view::ViewExt; -use core::default::Default; -use core::iter::Iterator; -use defmt::info; -use embedded_graphics::Drawable; -use embedded_graphics::framebuffer::Framebuffer; -use embedded_graphics::framebuffer::buffer_size; -use embedded_graphics::image::ImageRaw; -use embedded_graphics::pixelcolor::raw::LittleEndian; -use embedded_graphics::prelude::Point; -use embedded_graphics::prelude::RgbColor; -use embedded_hal_bus::spi::ExclusiveDevice; -use esp_hal::clock::CpuClock; -use esp_hal::delay::Delay; -use esp_hal::gpio::Level; -use esp_hal::gpio::Output; -use esp_hal::gpio::OutputConfig; -use esp_hal::time::Rate; -use heapless::format; -use mipidsi::interface::SpiInterface; -use mipidsi::models::ST7789; -use mipidsi::options::Orientation; -use mipidsi::options::Rotation; - -use buoyant::view::HStack; -use buoyant::view::Spacer; -use buoyant::view::View; -use core::env; -use embedded_graphics::pixelcolor::Rgb565; -use esp_backtrace as _; -use esp_hal::main; -use esp_println as _; -use tinybmp::Bmp; - -use crate::graph::graph_data; -use crate::images::StaticImage; - -extern crate alloc; - -// This creates a default app-descriptor required by the esp-idf bootloader. -// For more information see: -esp_bootloader_esp_idf::esp_app_desc!(); - -#[main] -fn main() -> ! { - // generator version: 1.0.1 - images::prepare_images(); - - esp_alloc::heap_allocator!(size: 32 * 1024); - let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); - let peripherals = esp_hal::init(config); - let mut timer = Delay::new(); - - let spi = esp_hal::spi::master::Spi::new( - peripherals.SPI2, - esp_hal::spi::master::Config::default() - .with_mode(esp_hal::spi::Mode::_0) - .with_frequency(Rate::from_mhz(80)), - ) - .unwrap() - .with_sck(peripherals.GPIO4) - .with_mosi(peripherals.GPIO6); - - let mut cs_output = Output::new(peripherals.GPIO1, Level::High, OutputConfig::default()); - cs_output.set_high(); - let spi_device = ExclusiveDevice::new_no_delay(spi, cs_output).unwrap(); - let _ = Output::new(peripherals.GPIO0, Level::High, OutputConfig::default()); - - let mut buffer = [0_u8; 512]; - - // Define the display interface with no chip select - let di = SpiInterface::new( - spi_device, - Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default()), - &mut buffer, - ); - - let mut display = mipidsi::Builder::new(ST7789, di) - .reset_pin(Output::new( - peripherals.GPIO3, - Level::Low, - OutputConfig::default(), - )) - .invert_colors(mipidsi::options::ColorInversion::Inverted) - .init(&mut timer) - .unwrap(); - - let mut fb = - Framebuffer::(240, 240) }>::new( - ); - //fb.clear(Rgb565::BLACK).unwrap(); - - // views::menu::menu_view() - // .as_drawable(Size::new(240, 240), Rgb565::WHITE) - // .draw(&mut fb) - // .unwrap(); - - let mut x = [0.; 100]; - for (i, x) in x.iter_mut().enumerate() { - *x = libm::sinf(i as f32 / 10.); - } - graph_data(&x, &mut fb); - - let img_raw = ImageRaw::::new(fb.data(), 240); - let image = embedded_graphics::image::Image::new(&img_raw, Point::zero()); - image.draw(&mut display).unwrap(); - // embedded_graphics::image::Image::new(get_image!(images::HUMIDITY_ICON), Point::zero()) - // .draw(&mut display) - // .unwrap(); - - info!("Finished !"); - loop { - core::hint::spin_loop(); - } - - // for inspiration have a look at the examples at https://github.com/esp-rs/esp-hal/tree/esp-hal-v1.0.0/examples/src/bin -} - -pub enum MenuIndicatorType { - Temperature(f32), - Humidity(f32), - Co2(u32), - Voc(u32), -} - -impl MenuIndicatorType { - pub fn get_corresponding_icon(&self) -> &'static StaticImage { - match self { - MenuIndicatorType::Temperature(_) => &images::TEMPERATURE_ICON, - MenuIndicatorType::Humidity(_) => &images::HUMIDITY_ICON, - MenuIndicatorType::Co2(_) => &images::CO2_ICON, - MenuIndicatorType::Voc(_) => &images::VOC_ICON, - } - } - - pub fn get_corresponding_unit_string(&self) -> &'static str { - match self { - MenuIndicatorType::Temperature(_) => "C", - MenuIndicatorType::Humidity(_) => "%", - MenuIndicatorType::Co2(_) => "ppm", - MenuIndicatorType::Voc(_) => "ppb", - } - } - - pub fn get_value_str(&self) -> heapless::String<16> { - match self { - MenuIndicatorType::Temperature(temp) => format!(16; "{:.1}", temp).unwrap(), - MenuIndicatorType::Humidity(hum) => format!(16; "{:.1}", hum).unwrap(), - MenuIndicatorType::Co2(co2) => format!(16; "{}", co2).unwrap(), - MenuIndicatorType::Voc(voc) => format!(16; "{}", voc).unwrap(), - } - } -} - -pub enum Tendency { - Rising, - Steady, - Falling, -} - -impl Tendency { - pub fn get_corresponding_icon(&self) -> &'static StaticImage { - match self { - Self::Rising => &images::TENDENCY_RISING, - Self::Steady => &images::TENDENCY_STEADY, - Self::Falling => &images::TENDENCY_FALLING, - } - } -} - -fn tendency_indicator(tendency: Tendency) -> impl View { - HStack::new(( - Image::new(get_image!(tendency.get_corresponding_icon())) - .flex_frame() - .with_min_size(10, 20) - .with_max_size(10, 20), - Spacer::default(), - )) - .flex_frame() - .with_max_width(15) -} diff --git a/src/bin/sampler.rs b/src/bin/sampler.rs deleted file mode 100644 index f4426bb..0000000 --- a/src/bin/sampler.rs +++ /dev/null @@ -1,53 +0,0 @@ -use core::cell::RefCell; - -use aht20_driver::AHT20; -use alloc::rc::Rc; -use alloc::vec::Vec; -use core::default::Default; -use embedded_hal_bus::i2c::RcDevice; -use ens160::Ens160; -use esp_hal::Blocking; -use esp_hal::DriverMode; -use esp_hal::delay::Delay; -use esp_hal::gpio::interconnect::PeripheralOutput; -use esp_hal::i2c; -use esp_hal::i2c::master::I2c; -use esp_hal::i2c::master::Instance; -use esp_hal::peripherals; -use esp_hal::peripherals::Peripherals; - -pub struct Sampler<'a> { - ens160: Ens160>>, - aht20: aht20_driver::AHT20Initialized>>, -} - -impl<'a> Sampler<'a> { - pub fn new( - i2c: impl Instance + 'a, - sda: impl PeripheralOutput<'a>, - scl: impl PeripheralOutput<'a>, - mut timer: Delay, - ) -> Self { - let i2c = I2c::new(i2c, Default::default()) - .unwrap() - .with_sda(sda) - .with_scl(scl); - - let i2c = Rc::new(RefCell::new(i2c)); - - let mut ens160 = Ens160::new(embedded_hal_bus::i2c::RcDevice::new(i2c.clone()), 0x53); - timer.delay_millis(500); - ens160.reset().unwrap(); - timer.delay_millis(500); - ens160.operational().unwrap(); - - let aht20_uninit = AHT20::new( - embedded_hal_bus::i2c::RcDevice::new(i2c.clone()), - aht20_driver::SENSOR_ADDRESS, - ); - - let aht20 = aht20_uninit.init(&mut timer).unwrap(); - - Sampler { ens160, aht20 } - } -} diff --git a/src/bin/colors.rs b/src/colors.rs similarity index 93% rename from src/bin/colors.rs rename to src/colors.rs index f37e25d..3fbe474 100644 --- a/src/bin/colors.rs +++ b/src/colors.rs @@ -10,4 +10,4 @@ pub const FRAME_STROKE_COLOR: Rgb565 = Rgb565::new(4, 9, 4); pub const MAIN_TEXT_COLOR: Rgb565 = Rgb565::WHITE; pub const SUB_TEXT_COLOR: Rgb565 = Rgb565::CSS_DARK_GRAY; -pub const FRAME_STROKE: u32 = 3; +pub const FRAME_STROKE: u32 = 1; diff --git a/src/graph.rs b/src/graph.rs new file mode 100644 index 0000000..56f18d1 --- /dev/null +++ b/src/graph.rs @@ -0,0 +1,264 @@ +use embedded_graphics::Pixel; +use embedded_graphics::image::GetPixel; +use embedded_graphics::mono_font::MonoTextStyle; +use embedded_graphics::pixelcolor::Rgb565; +use embedded_graphics::prelude::DrawTarget; +use embedded_graphics::prelude::Drawable; +use embedded_graphics::prelude::Point; +use embedded_graphics::prelude::Primitive; +use embedded_graphics::prelude::RgbColor; +use embedded_graphics::prelude::Size; +use embedded_graphics::primitives::Line; +use embedded_graphics::primitives::PrimitiveStyle; +use embedded_graphics::text::Text; +use embedded_graphics::text::renderer::TextRenderer; +use heapless::format; +use profont::PROFONT_10_POINT; + +fn map_float(x: f32, x_min: f32, x_max: f32, y_min: f32, y_max: f32) -> f32 { + ((x - x_min) / (x_max - x_min)) * (y_max - y_min) + y_min +} + +// const DEFAULT_LUT: [Rgb565; 5] = [ +// Rgb565::new(0, 16, 11), +// Rgb565::new(11, 20, 17), +// Rgb565::new(23, 20, 18), +// Rgb565::new(31, 24, 12), +// Rgb565::new(31, 41, 0), +// ]; +const DEFAULT_LUT: [Rgb565; 4] = [Rgb565::GREEN, Rgb565::YELLOW, Rgb565::RED, Rgb565::RED]; + +fn rgb565_interpolate(a: Rgb565, b: Rgb565, x: f32) -> Rgb565 { + Rgb565::new( + (a.r() as f32 * (1. - x)) as u8 + (b.r() as f32 * x) as u8, + (a.g() as f32 * (1. - x)) as u8 + (b.g() as f32 * x) as u8, + (a.b() as f32 * (1. - x)) as u8 + (b.b() as f32 * x) as u8, + ) +} + +fn color_lut(mut x: f32, colors: &[Rgb565]) -> Rgb565 { + if x == 1. { + return colors[colors.len() - 1]; + } + + if x == 0. { + return colors[0]; + } + + x *= (colors.len() - 1) as f32; + + let index = libm::floorf(x); + let interp = x - index; + + rgb565_interpolate(colors[index as usize], colors[index as usize + 1], interp) +} + +pub fn graph_data(data: I, data_count: usize, target: &mut T) +where + I: Iterator + Clone, + T: DrawTarget + GetPixel, +{ + let min = data.clone().reduce(f32::min).unwrap_or(0.); + let max = data.clone().reduce(f32::max).unwrap_or(0.); + let size = Size::new( + target.bounding_box().size.width, + target.bounding_box().size.height, + ); + + // Draw data as WHITE line + let mut start = Point::new( + 0, + map_float( + data.clone().next().unwrap(), + min, + max, + size.height as f32, + 0., + ) as i32, + ); + for (i, x) in data.clone().skip(1).enumerate() { + let point = Point::new( + map_float(i as f32, 0., data_count as f32 - 2., 0., size.width as f32) as i32, + map_float(x, min, max, size.height as f32, 0.) as i32, + ); + let _ = Line::new(start, point) + .into_styled(PrimitiveStyle::with_stroke(Rgb565::WHITE, 2)) + .draw(target); + start = point; + } + + for x in 0..size.width { + // Start coloring from up to bottom + let mut met_curve = false; + + for y in 0..size.height { + let position = Point::new(x as i32, y as i32); + let pixel = target.pixel(position).unwrap(); + let height_factor = map_float(y as f32, 0., size.height as f32, 1., 0.); + let height_color = color_lut(height_factor, &DEFAULT_LUT); + + if pixel == Rgb565::WHITE { + let _ = Pixel(position, height_color).draw(target); + met_curve = true; + } else if met_curve && (x as i32 - y as i32) % 7 == 0 { + let _ = Pixel( + position, + rgb565_interpolate(height_color, Rgb565::BLACK, 1. - height_factor), + ) + .draw(target); + } + } + } +} + +pub fn min_indicator< + I: Iterator + Clone, + T: DrawTarget + GetPixel, +>( + data: I, + data_count: usize, + target: &mut T, +) { + let size = target.bounding_box().size; + let (min_index, min) = data + .clone() + .enumerate() + .reduce(|a, b| if a.1 < b.1 { a } else { b }) + .unwrap_or((0, 0.)); + + let min_x = map_float( + min_index as f32, + 0., + data_count as f32, + 0., + size.width as f32, + ) as i32; + + // let _ = Line::new(Point::new(min_x, 0), Point::new(min_x, size.height as i32)) + // .into_styled(PrimitiveStyle::with_stroke(Rgb565::RED, 1)) + // .draw(target); + for y in 0..size.height { + if (y / 2) % 2 == 0 { + let position = Point::new(min_x, y as i32); + let _ = Pixel( + position, + rgb565_interpolate(Rgb565::RED, Rgb565::BLACK, 0.6), + ) + .draw(target); + + // let position = Point::new(min_x + 1, y as i32); + // let _ = Pixel( + // position, + // rgb565_interpolate(Rgb565::RED, Rgb565::BLACK, 0.6), + // ) + // .draw(target); + } + } + + let minimum_text = "Minimum"; + let font = &PROFONT_10_POINT; + + let text_start = if min_x < (size.width / 2) as i32 { + 5 + } else { + -((minimum_text.len() + 1) as i32 * font.character_size.width as i32 + 3) + }; + + let style = MonoTextStyle::new( + &PROFONT_10_POINT, + rgb565_interpolate(Rgb565::WHITE, Rgb565::BLACK, 0.8), + ); + let _ = Text::new( + minimum_text, + Point::new(min_x + text_start as i32, 10), + style, + ) + .draw(target); + + let value = format!(16; "{:.1}", min).unwrap(); + let _ = Text::new( + value.as_str(), + Point::new( + min_x + text_start as i32, + 10 + font.character_size.height as i32, + ), + style, + ) + .draw(target); +} + +pub fn max_indicator< + T: DrawTarget + GetPixel, + I: Iterator + Clone, +>( + data: I, + data_count: usize, + target: &mut T, +) { + let size = target.bounding_box().size; + let (max_index, max) = data + .clone() + .enumerate() + .reduce(|a, b| if a.1 > b.1 { a } else { b }) + .unwrap_or((0, 0.)); + + let max_x = map_float( + max_index as f32, + 0., + data_count as f32, + 0., + size.width as f32, + ) as i32; + + for y in 0..size.height { + if (y / 2) % 2 == 0 { + let position = Point::new(max_x, y as i32); + let _ = Pixel( + position, + rgb565_interpolate(Rgb565::GREEN, Rgb565::BLACK, 0.6), + ) + .draw(target); + + // let position = Point::new(max_x + 1, y as i32); + // let _ = Pixel( + // position, + // rgb565_interpolate(Rgb565::GREEN, Rgb565::BLACK, 0.6), + // ) + // .draw(target); + } + } + + let maximum_text = "Maximum"; + let font = &PROFONT_10_POINT; + + let text_start = if max_x < (size.width / 2) as i32 { + 5 + } else { + -((maximum_text.len() + 1) as i32 * font.character_size.width as i32 + 3) + }; + + let style = MonoTextStyle::new( + &PROFONT_10_POINT, + rgb565_interpolate(Rgb565::WHITE, Rgb565::BLACK, 0.8), + ); + let _ = Text::new( + maximum_text, + Point::new( + max_x + text_start as i32, + size.height as i32 - font.character_size.height as i32 * 2, + ), + style, + ) + .draw(target); + + let value = format!(16; "{:.1}", max).unwrap(); + let _ = Text::new( + value.as_str(), + Point::new( + max_x + text_start as i32, + size.height as i32 - font.character_size.height as i32, + ), + style, + ) + .draw(target); +} diff --git a/src/bin/images.rs b/src/images.rs similarity index 76% rename from src/bin/images.rs rename to src/images.rs index 4e0785e..3c3da9c 100644 --- a/src/bin/images.rs +++ b/src/images.rs @@ -43,13 +43,13 @@ macro_rules! get_image { pub fn prepare_images() { unsafe { - load_image!(HUMIDITY_ICON, "../../assets/humidity-icon.bmp"); - load_image!(TEMPERATURE_ICON, "../../assets/temperature-icon.bmp"); - load_image!(VOC_ICON, "../../assets/voc-icon.bmp"); - load_image!(CO2_ICON, "../../assets/co2-icon.bmp"); + load_image!(HUMIDITY_ICON, "../assets/humidity-icon.bmp"); + load_image!(TEMPERATURE_ICON, "../assets/temperature-icon.bmp"); + load_image!(VOC_ICON, "../assets/voc-icon.bmp"); + load_image!(CO2_ICON, "../assets/co2-icon.bmp"); - load_image!(TENDENCY_RISING, "../../assets/indic-rising.bmp"); - load_image!(TENDENCY_STEADY, "../../assets/indic-steady.bmp"); - load_image!(TENDENCY_FALLING, "../../assets/indic-falling.bmp"); + load_image!(TENDENCY_RISING, "../assets/indic-rising.bmp"); + load_image!(TENDENCY_STEADY, "../assets/indic-steady.bmp"); + load_image!(TENDENCY_FALLING, "../assets/indic-falling.bmp"); } } diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 0c9ac1a..0000000 --- a/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -#![no_std] diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..269dc2f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,395 @@ +#![no_std] +#![no_main] +#![deny( + clippy::mem_forget, + reason = "mem::forget is generally not safe to do with esp_hal types, especially those \ + holding buffers for the duration of a data transfer." +)] +#![allow(unreachable_code)] + +mod colors; +mod graph; +mod images; +mod sampler; +mod views; + +use buoyant::primitives::Pixel; +use buoyant::primitives::Size; +use buoyant::primitives::geometry::Rectangle; +use buoyant::view::AsDrawable; +use buoyant::view::Image; +use buoyant::view::ViewExt; +use core::default::Default; +use core::iter::Iterator; +use core::ops::Sub; +use defmt::info; +use embedded_graphics::Drawable; +use embedded_graphics::framebuffer::Framebuffer; +use embedded_graphics::framebuffer::buffer_size; +use embedded_graphics::image::GetPixel; +use embedded_graphics::image::ImageRaw; +use embedded_graphics::pixelcolor::raw::LittleEndian; +use embedded_graphics::prelude::Dimensions; +use embedded_graphics::prelude::DrawTarget; +use embedded_graphics::prelude::Point; +use embedded_graphics::prelude::Primitive; +use embedded_graphics::prelude::RgbColor; +use embedded_graphics::primitives::PrimitiveStyle; +use embedded_hal_bus::spi::ExclusiveDevice; +use esp_hal::clock::CpuClock; +use esp_hal::delay::Delay; +use esp_hal::gpio::Input; +use esp_hal::gpio::InputConfig; +use esp_hal::gpio::Level; +use esp_hal::gpio::Output; +use esp_hal::gpio::OutputConfig; +use esp_hal::gpio::Pull; +use esp_hal::interrupt; +use esp_hal::riscv::asm::delay; +use esp_hal::time::Rate; +use heapless::deque; +use heapless::format; +use heapless::history_buf; +use mipidsi::interface::SpiInterface; +use mipidsi::models::ST7789; + +use buoyant::view::HStack; +use buoyant::view::Spacer; +use buoyant::view::View; +use core::env; +use embedded_graphics::pixelcolor::Rgb565; +use esp_backtrace as _; +use esp_hal::main; +use esp_println as _; + +use crate::colors::BACKGROUND_COLOR; +use crate::graph::graph_data; +use crate::graph::max_indicator; +use crate::graph::min_indicator; +use crate::images::StaticImage; +use crate::sampler::History; +use crate::sampler::Sample; +use crate::sampler::Sampler; + +extern crate alloc; + +// This creates a default app-descriptor required by the esp-idf bootloader. +// For more information see: +esp_bootloader_esp_idf::esp_app_desc!(); + +#[main] +fn main() -> ! { + // generator version: 1.0.1 + images::prepare_images(); + + esp_alloc::heap_allocator!(size: 32 * 1024); + let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); + let peripherals = esp_hal::init(config); + let mut timer = Delay::new(); + + let spi = esp_hal::spi::master::Spi::new( + peripherals.SPI2, + esp_hal::spi::master::Config::default() + .with_mode(esp_hal::spi::Mode::_0) + .with_frequency(Rate::from_mhz(80)), + ) + .unwrap() + .with_sck(peripherals.GPIO4) + .with_mosi(peripherals.GPIO6); + + let mut cs_output = Output::new(peripherals.GPIO0, Level::High, OutputConfig::default()); + cs_output.set_high(); + let spi_device = ExclusiveDevice::new_no_delay(spi, cs_output).unwrap(); + + let mut buffer = [0_u8; 512]; + + // Define the display interface with no chip select + let di = SpiInterface::new( + spi_device, + Output::new(peripherals.GPIO1, Level::Low, OutputConfig::default()), + &mut buffer, + ); + + let mut display = mipidsi::Builder::new(ST7789, di) + .invert_colors(mipidsi::options::ColorInversion::Inverted) + .init(&mut timer) + .unwrap(); + + let mut fb = + Framebuffer::(240, 240) }>::new( + ); + + // views::menu::menu_view() + // .as_drawable(Size::new(240, 240), Rgb565::WHITE) + // .draw(&mut fb) + // .unwrap(); + + // embedded_graphics::image::Image::new(get_image!(images::HUMIDITY_ICON), Point::zero()) + // .draw(&mut display) + // .unwrap(); + + let mut sampler = Sampler::new( + peripherals.I2C0, + peripherals.GPIO8, + peripherals.GPIO9, + &mut timer, + ); + timer.delay_millis(2000); + let _ = sampler.sample(&mut timer); + + let input_button = Input::new( + peripherals.GPIO10, + InputConfig::default().with_pull(Pull::Down), + ); + let mut last_button_state = Level::Low; + + let mut history = History::new(&mut sampler, &mut timer); + let mut view_state = ViewState::Main; + loop { + // input state + let mut button_pressed = false; + if last_button_state == Level::Low && input_button.is_high() { + button_pressed = true; + } + + last_button_state = input_button.level(); + + if button_pressed { + view_state = view_state.circulate(); + } + + if history.update(&mut sampler, &mut timer) || button_pressed { + // let iter = history.min5.oldest_ordered().map(|x| x.eco2); + // embedded_graphics::primitives::Rectangle::new(Point::zero(), fb.bounding_box().size) + // .into_styled(PrimitiveStyle::with_fill(Rgb565::BLACK)) + // .draw(&mut fb) + // .unwrap(); + // graph_data(iter.clone(), history.min5.len(), &mut fb); + // min_indicator(iter.clone(), history.min5.len(), &mut fb); + // max_indicator(iter.clone(), history.min5.len(), &mut fb); + // + // let img_raw = ImageRaw::::new(fb.data(), 240); + // let image = embedded_graphics::image::Image::new(&img_raw, Point::zero()); + // image.draw(&mut display).unwrap(); + + let app_state = AppState { + sample: *history.min5.last().unwrap(), + history: &history, + tendencies: Tendencies::from_history(&history), + }; + display + .bounding_box() + .into_styled(PrimitiveStyle::with_fill(BACKGROUND_COLOR)) + .draw(&mut fb) + .unwrap(); + view_state.draw_view(&mut fb, app_state); + let img_raw = ImageRaw::::new(fb.data(), 240); + let image = embedded_graphics::image::Image::new(&img_raw, Point::zero()); + image.draw(&mut display).unwrap(); + } + } + + // for inspiration have a look at the examples at https://github.com/esp-rs/esp-hal/tree/esp-hal-v1.0.0/examples/src/bin +} + +pub struct Tendencies { + temperature: Tendency, + humidity: Tendency, + eco2: Tendency, + tvoc: Tendency, +} + +impl Tendencies { + pub fn from_history(history: &History) -> Self { + let mut iter = history.min5.oldest_ordered().rev().copied().take(5); + let len = history.min5.len().min(5); + + if len <= 1 { + return Tendencies { + temperature: Tendency::Steady, + humidity: Tendency::Steady, + eco2: Tendency::Steady, + tvoc: Tendency::Steady, + }; + } + + let mut last = iter.next().unwrap(); + let mut avg_slope = Sample::zero(); + for x in iter { + avg_slope = avg_slope + (last - x); + last = x; + } + + avg_slope = avg_slope * (1. / len as f32); + + const TEMPERATURE_TENDENCY_TRESHOLD: f32 = 0.3; + const HUMIDITY_TENDENCY_TRESHOLD: f32 = 0.3; + const ECO2_TENDENCY_TRESHOLD: f32 = 50.; + const TVOC_TENDENCY_TRESHOLD: f32 = 50.; + Tendencies { + temperature: if avg_slope.temperature > TEMPERATURE_TENDENCY_TRESHOLD { + Tendency::Rising + } else if avg_slope.temperature < -TEMPERATURE_TENDENCY_TRESHOLD { + Tendency::Falling + } else { + Tendency::Steady + }, + humidity: if avg_slope.humidity > HUMIDITY_TENDENCY_TRESHOLD { + Tendency::Rising + } else if avg_slope.humidity < -HUMIDITY_TENDENCY_TRESHOLD { + Tendency::Falling + } else { + Tendency::Steady + }, + eco2: if avg_slope.eco2 > ECO2_TENDENCY_TRESHOLD { + Tendency::Rising + } else if avg_slope.eco2 < -ECO2_TENDENCY_TRESHOLD { + Tendency::Falling + } else { + Tendency::Steady + }, + tvoc: if avg_slope.tvoc > TVOC_TENDENCY_TRESHOLD { + Tendency::Rising + } else if avg_slope.tvoc < -TVOC_TENDENCY_TRESHOLD { + Tendency::Falling + } else { + Tendency::Steady + }, + } + } +} + +pub struct AppState<'a> { + sample: Sample, + tendencies: Tendencies, + history: &'a History, +} + +#[derive(Clone, Copy)] +pub enum ViewState { + Main, + GraphTemperature, + GraphHumidity, + GraphECo2, + GraphTvoc, +} + +impl ViewState { + pub fn circulate(&self) -> Self { + match self { + ViewState::Main => ViewState::GraphTemperature, + ViewState::GraphTemperature => ViewState::GraphHumidity, + ViewState::GraphHumidity => ViewState::GraphECo2, + ViewState::GraphECo2 => ViewState::GraphTvoc, + ViewState::GraphTvoc => ViewState::Main, + } + } + + pub fn draw_view + GetPixel>( + &self, + target: &mut T, + app_state: AppState<'_>, + ) { + match self { + ViewState::Main => { + let _ = views::menu::menu_view(app_state) + .as_drawable(Size::new(240, 240), Rgb565::WHITE) + .draw(target); + } + ViewState::GraphTemperature => { + let _ = views::detail::detailed_view( + MenuIndicatorType::Temperature(10.), + Tendency::Steady, + ) + .as_drawable(Size::new(240, 240), Rgb565::WHITE) + .draw(target); + + let mut graph_fb = Framebuffer::< + Rgb565, + _, + LittleEndian, + 240, + { 240 - 53 }, + { buffer_size::(240, 240) }, + >::new(); + let iter = app_state.history.min5.oldest_ordered().map(|x| x.eco2); + graph_data(iter.clone(), app_state.history.min5.len(), &mut graph_fb); + min_indicator(iter.clone(), app_state.history.min5.len(), &mut graph_fb); + max_indicator(iter.clone(), app_state.history.min5.len(), &mut graph_fb); + + let img_raw = ImageRaw::::new(graph_fb.data(), 240); + let image = embedded_graphics::image::Image::new(&img_raw, Point::new(0, 53)); + let _ = image.draw(target); + } + _ => { + let _ = views::menu::menu_view(app_state) + .as_drawable(Size::new(240, 240), Rgb565::WHITE) + .draw(target); + } + } + } +} + +pub enum MenuIndicatorType { + Temperature(f32), + Humidity(f32), + Co2(u32), + Voc(u32), +} + +impl MenuIndicatorType { + pub fn get_corresponding_icon(&self) -> &'static StaticImage { + match self { + MenuIndicatorType::Temperature(_) => &images::TEMPERATURE_ICON, + MenuIndicatorType::Humidity(_) => &images::HUMIDITY_ICON, + MenuIndicatorType::Co2(_) => &images::CO2_ICON, + MenuIndicatorType::Voc(_) => &images::VOC_ICON, + } + } + + pub fn get_corresponding_unit_string(&self) -> &'static str { + match self { + MenuIndicatorType::Temperature(_) => "C", + MenuIndicatorType::Humidity(_) => "%", + MenuIndicatorType::Co2(_) => "ppm", + MenuIndicatorType::Voc(_) => "ppb", + } + } + + pub fn get_value_str(&self) -> heapless::String<16> { + match self { + MenuIndicatorType::Temperature(temp) => format!(16; "{:.1}", temp).unwrap(), + MenuIndicatorType::Humidity(hum) => format!(16; "{:.1}", hum).unwrap(), + MenuIndicatorType::Co2(co2) => format!(16; "{}", co2).unwrap(), + MenuIndicatorType::Voc(voc) => format!(16; "{}", voc).unwrap(), + } + } +} + +pub enum Tendency { + Rising, + Steady, + Falling, +} + +impl Tendency { + pub fn get_corresponding_icon(&self) -> &'static StaticImage { + match self { + Self::Rising => &images::TENDENCY_RISING, + Self::Steady => &images::TENDENCY_STEADY, + Self::Falling => &images::TENDENCY_FALLING, + } + } +} + +fn tendency_indicator(tendency: Tendency) -> impl View { + HStack::new(( + Image::new(get_image!(tendency.get_corresponding_icon())) + .flex_frame() + .with_min_size(10, 20) + .with_max_size(10, 20), + Spacer::default(), + )) + .flex_frame() + .with_max_width(15) +} diff --git a/src/sampler.rs b/src/sampler.rs new file mode 100644 index 0000000..ffe7619 --- /dev/null +++ b/src/sampler.rs @@ -0,0 +1,209 @@ +use core::cell::RefCell; +use core::ops::Add; +use core::ops::Mul; +use core::ops::Sub; + +use aht20_driver::AHT20; +use alloc::rc::Rc; +use alloc::vec::Vec; +use core::default::Default; +use embedded_hal_bus::i2c::RcDevice; +use ens160::Ens160; +use esp_hal::Blocking; +use esp_hal::DriverMode; +use esp_hal::delay::Delay; +use esp_hal::gpio::interconnect::PeripheralOutput; +use esp_hal::i2c; +use esp_hal::i2c::master::I2c; +use esp_hal::i2c::master::Instance; +use esp_hal::peripherals; +use esp_hal::peripherals::Peripherals; +use esp_hal::time::Duration; +use esp_hal::time::Instant; +use heapless::Deque; +use heapless::HistoryBuf; + +use crate::sampler; + +pub struct Sampler<'a> { + ens160: Ens160>>, + aht20: aht20_driver::AHT20Initialized>>, +} + +#[derive(Clone, Copy, Debug)] +pub struct Sample { + pub temperature: f32, + pub humidity: f32, + pub eco2: f32, + pub tvoc: f32, +} + +impl Sample { + pub fn zero() -> Self { + Sample { + temperature: 0., + humidity: 0., + eco2: 0., + tvoc: 0., + } + } +} + +impl Add for Sample { + type Output = Sample; + + fn add(self, rhs: Sample) -> Self::Output { + Sample { + temperature: self.temperature + rhs.temperature, + humidity: self.humidity + rhs.humidity, + eco2: self.eco2 + rhs.eco2, + tvoc: self.tvoc + rhs.tvoc, + } + } +} + +impl Sub for Sample { + type Output = Sample; + + fn sub(self, rhs: Sample) -> Self::Output { + Sample { + temperature: self.temperature - rhs.temperature, + humidity: self.humidity - rhs.humidity, + eco2: self.eco2 - rhs.eco2, + tvoc: self.tvoc - rhs.tvoc, + } + } +} + +impl Mul for Sample { + type Output = Sample; + + fn mul(self, rhs: Sample) -> Self::Output { + Sample { + temperature: self.temperature * rhs.temperature, + humidity: self.humidity * rhs.humidity, + eco2: self.eco2 * rhs.eco2, + tvoc: self.tvoc * rhs.tvoc, + } + } +} + +impl Mul for Sample { + type Output = Sample; + + fn mul(self, rhs: f32) -> Self::Output { + Sample { + temperature: self.temperature * rhs, + humidity: self.humidity * rhs, + eco2: self.eco2 * rhs, + tvoc: self.tvoc * rhs, + } + } +} + +impl<'a> Sampler<'a> { + pub fn new( + i2c: impl Instance + 'a, + sda: impl PeripheralOutput<'a>, + scl: impl PeripheralOutput<'a>, + timer: &mut Delay, + ) -> Self { + let i2c = I2c::new(i2c, Default::default()) + .unwrap() + .with_sda(sda) + .with_scl(scl); + + let i2c = Rc::new(RefCell::new(i2c)); + + let mut ens160 = Ens160::new(embedded_hal_bus::i2c::RcDevice::new(i2c.clone()), 0x53); + timer.delay_millis(500); + ens160.reset().unwrap(); + timer.delay_millis(500); + ens160.operational().unwrap(); + + let aht20_uninit = AHT20::new( + embedded_hal_bus::i2c::RcDevice::new(i2c.clone()), + aht20_driver::SENSOR_ADDRESS, + ); + + let aht20 = aht20_uninit.init(timer).unwrap(); + + Sampler { ens160, aht20 } + } + + pub fn sample(&mut self, timer: &mut Delay) -> Sample { + let aht20_measurement = self.aht20.measure(timer).unwrap(); + Sample { + temperature: aht20_measurement.temperature, + humidity: aht20_measurement.humidity, + eco2: *self.ens160.eco2().unwrap() as f32, + tvoc: self.ens160.tvoc().unwrap() as f32, + } + } +} + +pub const SECONDS_PER_SAMPLES: usize = 1; +pub const MIN_5_LENGTH: usize = (5 * 60) / SECONDS_PER_SAMPLES; + +pub struct History { + // 5 minutes, every 5 seconds + pub min5: heapless::history_buf::HistoryBuf, + + // 2 hours every 5 seconds + pub hour2: heapless::history_buf::HistoryBuf, + + // 24 hours every 5 minutes + pub day: heapless::history_buf::HistoryBuf, + + samples_since_day: u32, + + last_sample: Instant, +} + +impl History { + pub fn new(sampler: &mut Sampler, timer: &mut Delay) -> Self { + let mut min5 = HistoryBuf::new(); + let mut hour2 = HistoryBuf::new(); + let mut day = HistoryBuf::new(); + + // First sampler + let sample = sampler.sample(timer); + min5.write(sample); + hour2.write(sample); + day.write(sample); + + History { + min5, + hour2, + day, + samples_since_day: 0, + + last_sample: Instant::now(), + } + } + + pub fn update(&mut self, sampler: &mut Sampler, timer: &mut Delay) -> bool { + let now = Instant::now(); + + if now - self.last_sample > Duration::from_secs(SECONDS_PER_SAMPLES as u64) { + let sample = sampler.sample(timer); + self.last_sample = Instant::now(); + self.samples_since_day += 1; + + if self.samples_since_day as usize == MIN_5_LENGTH { + // Compute average + let avg = self.min5.iter().fold(Sample::zero(), |a, b| a + *b) + * (1. / self.min5.len() as f32); + self.day.write(avg); + + self.samples_since_day = 0; + } + + self.min5.write(sample); + self.hour2.write(sample); + true + } else { + false + } + } +} diff --git a/src/bin/views.rs b/src/views.rs similarity index 100% rename from src/bin/views.rs rename to src/views.rs diff --git a/src/bin/views/detail.rs b/src/views/detail.rs similarity index 100% rename from src/bin/views/detail.rs rename to src/views/detail.rs diff --git a/src/bin/views/icon.rs b/src/views/icon.rs similarity index 77% rename from src/bin/views/icon.rs rename to src/views/icon.rs index 8d0f5c1..9fbaf0a 100644 --- a/src/bin/views/icon.rs +++ b/src/views/icon.rs @@ -4,12 +4,15 @@ use buoyant::view::ZStack; use buoyant::view::shape::Rectangle; use embedded_graphics::pixelcolor::Rgb565; +use crate::colors::BACKGROUND_COLOR; use crate::get_image; use crate::images::StaticImage; pub fn icon_box_view(box_color: Rgb565, icon: &'static StaticImage) -> impl View { ZStack::new(( - Rectangle.corner_radius(10).foreground_color(box_color), + Rectangle + .corner_radius(10) + .foreground_color(BACKGROUND_COLOR), buoyant::view::Image::new(get_image!(icon)), )) .flex_frame() diff --git a/src/bin/views/menu.rs b/src/views/menu.rs similarity index 75% rename from src/bin/views/menu.rs rename to src/views/menu.rs index 23d7a7a..080944d 100644 --- a/src/bin/views/menu.rs +++ b/src/views/menu.rs @@ -7,6 +7,7 @@ use embedded_graphics::prelude::*; use profont::PROFONT_18_POINT; use profont::PROFONT_24_POINT; +use crate::AppState; use crate::MenuIndicatorType; use crate::Tendency; use crate::colors::FRAME_BACKGROUD_COLOR; @@ -17,25 +18,37 @@ use crate::colors::SUB_TEXT_COLOR; use crate::get_image; use crate::views::icon::icon_box_view; -pub fn menu_view() -> impl View { +pub fn menu_view(app_state: AppState) -> impl View { VStack::new(( HStack::new(( - main_menu_indicator(MenuIndicatorType::Temperature(31.5), Tendency::Falling), - main_menu_indicator(MenuIndicatorType::Humidity(36.2), Tendency::Steady), + main_menu_indicator( + MenuIndicatorType::Temperature(app_state.sample.temperature), + app_state.tendencies.temperature, + ), + main_menu_indicator( + MenuIndicatorType::Humidity(app_state.sample.humidity), + app_state.tendencies.humidity, + ), )) - .with_spacing(5), + .with_spacing(2), HStack::new(( - main_menu_indicator(MenuIndicatorType::Co2(1329), Tendency::Rising), - main_menu_indicator(MenuIndicatorType::Voc(29), Tendency::Falling), + main_menu_indicator( + MenuIndicatorType::Co2(app_state.sample.eco2 as u32), + app_state.tendencies.eco2, + ), + main_menu_indicator( + MenuIndicatorType::Voc(app_state.sample.tvoc as u32), + app_state.tendencies.tvoc, + ), )) - .with_spacing(5), + .with_spacing(2), )) - .with_spacing(5) + .with_spacing(2) } fn main_menu_indicator(indicator_type: MenuIndicatorType, tendency: Tendency) -> impl View { Rectangle - .corner_radius(10) + .corner_radius(5) .stroked(FRAME_STROKE) .foreground_color(FRAME_STROKE_COLOR) .background(Alignment::Center, || {