From 1ead799ba7e7014b17111c6924ac6442789e40c0 Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 20:09:52 +0000 Subject: [PATCH 1/6] Cyberpunk edition: NetRunner face, Matrix app, neon theme - Add NetRunner watch face with chromatic-aberration time, random glitch bursts, HUD lines, seconds sweep, vitals and hex epoch - Add Matrix digital rain app, tap to cycle color palettes - Retheme UI with neon cyan/magenta palette and sharp outlined buttons - Recolor Terminal watch face as a root shell - Drop Paint, Paddle, 2048, Dice, Metronome and non-cyberpunk watch faces from the default build - Make NetRunner the default watch face - Document the fork in the README with simulator screenshots --- README.md | 20 ++ doc/cyberpunk/launcher.png | Bin 0 -> 2614 bytes doc/cyberpunk/matrix.png | Bin 0 -> 5545 bytes doc/cyberpunk/netrunner-glitch.png | Bin 0 -> 6004 bytes doc/cyberpunk/netrunner.png | Bin 0 -> 5774 bytes doc/cyberpunk/settings.png | Bin 0 -> 3752 bytes doc/cyberpunk/terminal.png | Bin 0 -> 7630 bytes src/CMakeLists.txt | 3 + src/components/settings/Settings.h | 2 +- src/displayapp/InfiniTimeTheme.cpp | 23 +- src/displayapp/InfiniTimeTheme.h | 20 +- src/displayapp/UserApps.h | 2 + src/displayapp/apps/Apps.h.in | 2 + src/displayapp/apps/CMakeLists.txt | 28 +- src/displayapp/fonts/fonts.json | 2 +- src/displayapp/screens/List.cpp | 7 +- src/displayapp/screens/Matrix.cpp | 142 +++++++++ src/displayapp/screens/Matrix.h | 79 +++++ src/displayapp/screens/Symbols.h | 2 + src/displayapp/screens/SystemInfo.cpp | 2 +- src/displayapp/screens/Tile.cpp | 7 +- src/displayapp/screens/WatchFaceNetRunner.cpp | 301 ++++++++++++++++++ src/displayapp/screens/WatchFaceNetRunner.h | 122 +++++++ src/displayapp/screens/WatchFaceTerminal.cpp | 22 +- 24 files changed, 742 insertions(+), 44 deletions(-) create mode 100644 doc/cyberpunk/launcher.png create mode 100644 doc/cyberpunk/matrix.png create mode 100644 doc/cyberpunk/netrunner-glitch.png create mode 100644 doc/cyberpunk/netrunner.png create mode 100644 doc/cyberpunk/settings.png create mode 100644 doc/cyberpunk/terminal.png create mode 100644 src/displayapp/screens/Matrix.cpp create mode 100644 src/displayapp/screens/Matrix.h create mode 100644 src/displayapp/screens/WatchFaceNetRunner.cpp create mode 100644 src/displayapp/screens/WatchFaceNetRunner.h diff --git a/README.md b/README.md index 68d8779d0e..42fd8e49f0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,26 @@ +## // CYBERPUNK EDITION + +This fork reworks InfiniTime into a neon, hacker-con-ready build for the PineTime. + +| NetRunner | Glitch | Matrix | +|:-:|:-:|:-:| +| ![NetRunner](doc/cyberpunk/netrunner.png) | ![Glitch](doc/cyberpunk/netrunner-glitch.png) | ![Matrix](doc/cyberpunk/matrix.png) | +| **Launcher** | **Settings** | **Terminal** | +| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.png) | + +What's different from upstream: + +- **NetRunner watch face** (new default): chromatic-aberration time with random glitch bursts plus a guaranteed glitch every minute, HUD frame lines, seconds sweep bar, link/power status, HR + steps, weather, and the Unix epoch in hex. The handle in the bottom-left corner is set by `WatchFaceNetRunner::handle`. +- **Matrix app**: digital rain built from ASCII and Cyrillic glyphs. Tap to cycle green, cyan, and magenta palettes. +- **Neon theme**: cyan and magenta outlined buttons, sharp corners, dark purple surfaces, and recolored launcher tiles, lists, sliders, and switches. +- **Terminal face** recolored as a root shell. +- **Trimmed build**: Paint, Paddle, 2048, Dice, and Metronome apps plus the Analog, PineTimeStyle, Infineat, Casio, and Pride Flag faces are no longer built by default, which frees about 49 KB of flash. Re-enable any of them in `src/displayapp/apps/CMakeLists.txt`. + +> If your watch already has saved settings, pick NetRunner in *Settings → Watch face*. + ## New to InfiniTime? - [Getting started with InfiniTime](doc/gettingStarted/gettingStarted-1.0.md) diff --git a/doc/cyberpunk/launcher.png b/doc/cyberpunk/launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..627b4a11191895a8ac682a08f6b45e1271b81944 GIT binary patch literal 2614 zcmbuBc`zGjAIIt8O40Te>Zn#6TXD4NDy8ZwR)Z!%T_J)^BZ(taRMG7^O3_zUv?8%- zy)211D|MB+T6LwYqJ%ms;%ZS%-K(9>&b~8!cV_4P zlM@pV5D>SswSw;Yhc8QH&#smk#17II7jqd|JMA3=stty15 z4V^cSi@myLB5jpC=&Mi9_SCI(cY@{j_&8VB*Qag0ZQNz{Zi4mgo!go z##i36DpUgOn5=_xKl%{$Iww9-LY}lb)%9gViO6b(UC&MjdtvJn?p6!_-n;vnGd+aR zuu!su#bu-6sLvB>%yAt%9Cv&r?0d?CZOJjaU@{A{ac3U*1QRh!36&NK=0@7~NDnf=gME`Q-r(sRrWMu!#$8zQSOe9$D@5AUr0-64v_gEut_f1|KxQ$5XS~hO-!(8FSjA)T33nVbp{2!@9qbfqtBxS;)K4+H~VhB z0X)yAG?@;}1(^lbJk~0}c>1`mkV8(*MQ;#gKLaGypn)LBmcux6GWyzxfzJF3E#mrG znDM}4z4SMS3O5~)n1n^V*-&WFv0vzh16h5MKAwZMX;DwA`JJ3uyji3v8J(sAC4(Tv zG;jQ(n9_k%PZ1r8a1isP^KrRbzvtV+;zvV@mZhfCAj=aG(y66Z7+4-7^oH|sz2kZU z7JI+SH~RZ`~TxkBXOYhsXvMkCv8B-S^7r z(e4g0PT}y3Or(d@Dxr~G6lT9Xf+Z7+^Tt!Xa)gWXF&|&B{p#4`@XBt7#6WZ`+ZBR$ za{~^zg7J6Q2o`JFp+j2SUeo^KgLaU57n-bpNos7-oE5?!!$FlXrqDh}aTpbVKwYg^700K_2-3ka#D`B@6|QU}+Y>|WmC zM4GdF{J{9R%i3XQfI8bukM~;TV=l>MZt+uTZB=;fMlf6rh(#2FT3!y_G?kSj_nVvv z0b}tzRenM!M{qJ8;YcRL^QaJpW6yr>l>$w&?zur9Psd;zRC;w;R~LH`o#V>(z3D|_ z0|Rd6v~+%@ahVp#+;*cM=#(&Pk zL#W^WRFUtY8^Yhg#OO){)CV0qxi@zFnH2_-XOd%;N3sI5p2Bp)A~xDdu&pDW_0>;v zy7x{tk?zQyBd}_Q9T7ch*~&>@pa`7FBG+1oSSrg{T;x6@&)EZS34tD~_rhrrIYlP% z)u@cR&lGEA&Ga`?>U0Gk;9rLNz2SYC_}`pQzm;0~>86V=aU0Z;zBtr!G!<9RF{iKT zUW`I{0%%+2W>Fafni%X*M&|RKrMUzmM=_NA!$)_W+A~=vF{^D&)OpEuU$)_;xnKJa z*l^R@cIipe7)b>HN{`b5R}-+)m5sl13j5`+4(3Gc(+%`Y{tWgAevNrKeS2(QEIT5% zK}0^NJ?~WU-R{fC5=(^PqqJn1OAQZg0ah#O%JvcwdL*SaUWF>4$WAt&6Mb?cN8b;CvrDx@am3|&r=${pLJNbH6 zd@TrxzTl|n_ktd*8vmNTejjjO=iczt6~UTEWjq*i$g7!B#kiGBl~S7q1Kjmcbb6c? zEc+VHlbbTEP?AITzBgz~&N z$%i+q&{|I-1{~!0vy{}Q3d%vRoSKj|ue(SIaXuuF}!_AB| RcK?F{cGeD7Obefze*m5&&UpX; literal 0 HcmV?d00001 diff --git a/doc/cyberpunk/matrix.png b/doc/cyberpunk/matrix.png new file mode 100644 index 0000000000000000000000000000000000000000..87fe9c6f823073c0966dbc4b3270f9aa1416ad02 GIT binary patch literal 5545 zcmZ8l2T&70ltqdt0zpJt2vvHo0i+|LBfSeKEp$SaE((HlDI%e_(0c~~5dkqGp(PLq zO@u%w(xoVecXKy)_ha58yiNX)=R|Z?k0fadViuc4mQp+niYlT z8{`HzUZ{}J@U^HeN?Ri@>dJN=nC}72PQG5$C83RP?aS!OhaM%yN(EJ89_5(^4?}iA zsX8E9q|f0+EX@JN3$}58-B_eFvJZ25ANtNHUn1mBNJ)f=p@G31T;L05r-r(cqmnOyA6q?H|G*a7`IH@PPdu~#dEqZ)VZYRnR z#xZ0Y8kvf`G2_9RqQhc#5`+z_QZJ#_7C;NZsmiUsIU_dh`=*f=L50t+>^NYO)4-OT zYljmka)0)zg=F(%%mL^O;|fvZxC4V~>_`(F5}@9!&%O7cKe1>hgEqK21?o~_Z=z@~ zM1>yNO8YV-{6{Ch?{cy7C}`_v%_pa7pLqK5rxCgSL{$1)<`$#co=tpsGDW;g|0!>j z=;E?{6#yBmeZ0?Kk$N0vCc*_XP3gcJnf;lKRUHy=;XrQJAOQYz# z2ICRo0aff9czh4r0A&48=YwzPu)Z^Br?0ekYGW139l*f@de;G+;n1!-Q%{F~Z+MgI zS?&X~>YGT8uD_%i`=Gsa^c`onfqY`G2w+DIXCVOk#@-Q@89yw;i^#p7XFjfSE6wMugM#bX};&rxMBKXb-<|15ONpN`Cd5=@@1Z)+CXCtu-Jg~8XjI3VG963^Qef5U< zy_dU!uFHZn0a~27h^WRJgClIQa*@&nz&sU(Ud(F7P}DR^fGGYNhco2jTP;qYP$KlTq3G;YOmH_LH%`Axx$0IYI_?xb~ zc?REk+nT0_IikKmqad<5^dH?)i^~%4lIWtAf7T=ds_Zflr`r2W&x|H{owEcMt!!5f zKs~Ya`_RzRNs@sOH-sBBwvYoJhqBYgbjZIRZ6~1~g{+haM*#j>d9&LLiXH!&Gh`oN z)1!dmpS}+=miTvv|A`o>rRD8AA}&oQGL`*)D+Jk65*YrYEVg-zML<6sL{7YujSK3S z-|b6PaNX35&Afu-X!oT^%Aea=hS)0ac7zcc%yVZ7f2Vf&_cpUtrYWbDN65H2Iz&*| z6k=xZZRtsG-Ab?ib<_b=)g2~&jrYPq6>nAw z2$N1`Qdte+-R^Q_snqX~189wjZ|u51!j<-Z{83uW;LFCczCMTX8JC=BgbKe&-ClWM zuJ*pyuQNz>&&(~Pj5W(rD}>j~NItjwyY)Rd0b6L(P`zf*P5*Y z-1#jo@hCnD&G|-C5EZqmT-HAKs`We(5!%!j zCP(HRB$q#hHhgqz*5@>pFd`7#*tZA3zTo`o) zJE}IfQ0DRDTwactCx}!5s+(<7idHRMrL@)r12&gdv4R2%YY<%$uAO$1Ad53Ot&)aB z^SJS(rhyAEZs*2E&;PiEaD zkvqnUN%aIPdlek9ja9hk8fKyy694U&JoyEZ1$<6B9#VemDw+(4H zn(+}qwew|eMzqPRw1eK$(Ljl!Z~j-NU+Y^b6QZ?Fgi5dB=8d?ZcINs8cysI$0_oyx z($Z~>eXk4eS_zk{Ombw~;|J+OV?pQN-FfKR{XvynY@Ci+Jpn=Nl}d5v+`q(`&vbIq zlizksnwi!kD`XHMbf6sL_U6^v>N>Gk zy@+_5=2+wopVGk>eQFO^cJh?!tr6yrWNMgIRbDi&&;9f_LTv3yo4@Hd*G7qOdBJjEg zzA!gmh7W2CR)>CL?zWWYwx%L_{O~*`!N_y|_dLw>IV&nDfS~^B+~vNFyZ)1+GwpMN z3`X3`P{2Ab_R@(kvk`Lkbu;BtELU)2HQxIr@snZ~7OAub=x7_gtHsJP#HQ*`VD91u zN7!NecM9r6eiItJP-J))PSUaI&CO?zhbALhTRs`Tv!Hekr|%Xt3+AiaE8pM24W8=M zJuO{wD`&9VFY>>*RT@Z9H*>ZSy^LC8UMP<$@8}=|V8dty)yWCTRF%10v0DkEl|JGX zNOdG1hNG@@Z)hVAtM2!sz{B8aPC-yu-uUDZG>KkORZr75xuk;w1B7(O>LkQ3!p8+TVh^{o(;3QS6= zf)M|}+A4EN{f5p6D;}A{q(B1|dzM4b)@;;%6HjA6yUGSL_!vJCA&vFUy-j+Pc#Js~ zst5$UP#I!QU?L*Q;1UZqw6E{6-r1lm*D;yk(0>|^RWK^=Yuhyh@8>eBp^e63Y)T$6y%QF+o4XmaBUJJt1C?3Q_|^HruIbgQHFnfg=K8arx^cR@p@Q)MucY_Vx_TZe zQyM`8D48`7mR1$#Xp@C%7I<(8oN2h5_0_rQ{^w1>`{=H{MIKE31>RrIuT7w?=lD5O z76YQ@BYgg1U?$GEL3`W-=ZuNCeGeTi|+^Ktu=&Ms-dww`uJi-w1jn!MB(FMDy7mq&ZbpQHISi?*z0S5d`h zFD=TtpvI@^@#IvB&cSKBlMiE?;l(0Z;hT=$zP8hkC&Q-Z`F z-)$<<4rt+co*vwOC#dDLAoSWhil+}HPqwQ`&bv+)F1_3A_D>>vHUg;k(!E-T4+~jp zvS{ghcq-LPM3Wd8C|DRqb?Yx4dK=3-(8BDqJG1vY7-m>F_=PZT8NN=|u@QbHnC;2LM2uC@ z?&tAia(?1@aEKZfMfumwd>LqBdMx`Y+)_MaV!QTzqPuJ!bBhr7lK$SW1>rh-vRux4 zZN(d0B2|;I?-x|TggN(zWJXzz`Qmd6(wV`{vqVk{#KLSP32tS{A98%R8VY0{53|ht z30m?S70crQ+QC`MqI%M*xa)90E)41($VmMx_;qyOBnB&Kx%v1er-n4uqKyL{M*f7t_>_jJEiv*(UV{R@9PA`<}!7r!rYU#!$KNR{i^ zs)*esB=iA!GsCsp2T(9DOp2i9XVVy2?E(=+<>(;39I5HtZQ~y-v-1PFHQ$)=XK-|% zhr*zEkG#xI-TC=8mRct-F|d1Z;}oiF(F=?3crwmU=S#7oHuH>aZ}gk5U}LO_EMEb| zPc}?yhS9j!IoogUH#75;G69EtaZrYK!SncgMdM6ue=@TP=)|X^dV*iyvh_}^Yb%z! z1{Cf(C>K-W`dOS%|4Q`#B_Bzlo!z-Utjn`uIC2F=b9?4YfRQ}CTWf~X$Sv12hE4CW ztc=EF^bu9_PJ;T4pAznJHuL$VUabijepUxMM%|@0f4Np$8RLX1d0VZ0jtk7xmb&)2 zg8v~-9O1y1;4dk!!^LkN4x`q761~1ibQy0E7lY{k^04Y0tRO;Cc!om`b4D+A3iLtX z{rl2U`lHOfKSxH;QasOMWGl;ZgZW;*wuM0Pav9z)wzM zloW{9pnWhR)UyX1;^M$fAO1A#>M04)=g`$7D#>6zx|0L{-3TUrt#8+?ID09EI7Lep zA%nW#vv5E{_%FAePd?C~ny1r5vXT_SnC(5>v~8q&y&+&RxoHk@R>GDv(=4DzilF&hE}W{a zf`+?Ep#6?VK$z*}t^bAZzhO9M6mGKL6Ju)~bV6pfcd4K`lu1M|pdnUhoD0#Cyz-j+ zK79MHmEWWE=W}zB51ogZMAfDy zhJ~7s&&;#y;l9y+nBQGEYAr5HX4AAi_EI@BM~yxG)SUz5--k?{wRfkl=SC+w!*2(` zr)9D;*JV6YfHUQ=gq=S!(rWCAD+z&-AM@rv$T1)51{32b&tbMYjW*N{pEqZT*`g@{QjZ3k&GqFc)BpEJ!I5ld3uvd>S+0x zzuQ|z0xX+Nq9KSvm70!wx-bIs1vY)X-Nd9b)MpVKD+y{v*B@hX#kt3P zKnCRCNYa62+V(qTYdy8TjblU~Bzn45J67mEzJlel__V}iiHF6AmG;YlOaC720l8yn z+l%5f13-FMW@{$qZksQm*QqB)7VYB5d~&ms>4IT7a3FHJB~+hXiUv>3LFnrLj^b4y z{lCEweWZK8LO1<+d)3!;D{Z+|rD%kIj9c#S7cQ#gsB1&E%Q5`&Pw>I%)m7L%T>Mqf zUA0U7zX?~DL;MtRY{#P2_2r$KapCbd&lZL~MT?W^1A+ABLq=~(jT>&e@-#JZ>=~Lf z(i;pCw3}0Pgvu|0wpa0RVnqc*HDPZF`h<&hmYe<2rLhQlGe!(|N*53h#=y zi}Xu%zPO=CRgtC#`QbvcN808Ft*q}$XZ|JJlM3?9|1 HIlTN2t?IA~ literal 0 HcmV?d00001 diff --git a/doc/cyberpunk/netrunner-glitch.png b/doc/cyberpunk/netrunner-glitch.png new file mode 100644 index 0000000000000000000000000000000000000000..12ac0fcdfda66f095eca66cb00891a7384d039a6 GIT binary patch literal 6004 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 H%MbqraW`7t literal 0 HcmV?d00001 diff --git a/doc/cyberpunk/settings.png b/doc/cyberpunk/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..8b474d1c48247b190bb110cb1e2af77042e5ef61 GIT binary patch literal 3752 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 diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index 93196ed6a0..b7c9311729 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -1,20 +1,22 @@ if(DEFINED ENABLE_USERAPPS) set(USERAPP_TYPES ${ENABLE_USERAPPS} CACHE STRING "List of user apps to build into the firmware") else () - set(DEFAULT_USER_APP_TYPES "Apps::StopWatch") + set(DEFAULT_USER_APP_TYPES "Apps::Matrix") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::StopWatch") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Alarm") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Timer") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Steps") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::HeartRate") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Music") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paint") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paddle") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Twos") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Dice") - set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Metronome") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Navigation") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Calculator") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Weather") + # Trimmed from the cyberpunk build, re-enable by uncommenting + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paint") + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Paddle") + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Twos") + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Dice") + #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Metronome") #set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Motion") set(USERAPP_TYPES "${DEFAULT_USER_APP_TYPES}" CACHE STRING "List of user apps to build into the firmware") endif () @@ -22,13 +24,15 @@ endif () if(DEFINED ENABLE_WATCHFACES) set(WATCHFACE_TYPES ${ENABLE_WATCHFACES} CACHE STRING "List of watch faces to build into the firmware") else() - set(DEFAULT_WATCHFACE_TYPES "WatchFace::Digital") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Analog") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::PineTimeStyle") + set(DEFAULT_WATCHFACE_TYPES "WatchFace::NetRunner") set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Terminal") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Infineat") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::CasioStyleG7710") - set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::PrideFlag") + set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Digital") + # Trimmed from the cyberpunk build, re-enable by uncommenting + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Analog") + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::PineTimeStyle") + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::Infineat") + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::CasioStyleG7710") + #set(DEFAULT_WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}, WatchFace::PrideFlag") set(WATCHFACE_TYPES "${DEFAULT_WATCHFACE_TYPES}" CACHE STRING "List of watch faces to build into the firmware") endif() diff --git a/src/displayapp/fonts/fonts.json b/src/displayapp/fonts/fonts.json index 3223b76b56..f1837995c9 100644 --- a/src/displayapp/fonts/fonts.json +++ b/src/displayapp/fonts/fonts.json @@ -7,7 +7,7 @@ }, { "file": "FontAwesome5-Solid+Brands+Regular.woff", - "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed" + "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed, 0xf120, 0xf54c" } ], "bpp": 2, diff --git a/src/displayapp/screens/List.cpp b/src/displayapp/screens/List.cpp index 264b4fc98b..029bffb403 100644 --- a/src/displayapp/screens/List.cpp +++ b/src/displayapp/screens/List.cpp @@ -44,8 +44,11 @@ List::List(uint8_t screenID, static constexpr int btnHeight = (LV_HOR_RES_MAX - ((MAXLISTITEMS - 1) * innerPad)) / MAXLISTITEMS; itemApps[i] = lv_btn_create(container, nullptr); - lv_obj_set_style_local_radius(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, btnHeight / 3); + lv_obj_set_style_local_radius(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, 3); lv_obj_set_style_local_bg_color(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, Colors::bgAlt); + lv_obj_set_style_local_border_side(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, LV_BORDER_SIDE_LEFT); + lv_obj_set_style_local_border_width(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, 4); + lv_obj_set_style_local_border_color(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); lv_obj_set_width(itemApps[i], LV_HOR_RES - 8); lv_obj_set_height(itemApps[i], btnHeight); lv_obj_set_event_cb(itemApps[i], ButtonEventHandler); @@ -54,7 +57,7 @@ List::List(uint8_t screenID, lv_obj_set_style_local_clip_corner(itemApps[i], LV_BTN_PART_MAIN, LV_STATE_DEFAULT, true); lv_obj_t* icon = lv_label_create(itemApps[i], nullptr); - lv_obj_set_style_local_text_color(icon, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_YELLOW); + lv_obj_set_style_local_text_color(icon, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); lv_label_set_text_static(icon, applications[i].icon); lv_label_set_long_mode(icon, LV_LABEL_LONG_CROP); lv_label_set_align(icon, LV_LABEL_ALIGN_CENTER); diff --git a/src/displayapp/screens/Matrix.cpp b/src/displayapp/screens/Matrix.cpp new file mode 100644 index 0000000000..7a9a972d75 --- /dev/null +++ b/src/displayapp/screens/Matrix.cpp @@ -0,0 +1,142 @@ +#include "displayapp/screens/Matrix.h" + +#include +#include + +using namespace Pinetime::Applications::Screens; + +namespace { + // Printable ASCII plus the Cyrillic block, both of which exist in jetbrains_mono_bold_20. + // '#' is excluded because it is the recolor escape character. + constexpr char asciiGlyphs[] = "0123456789ABCDEFXZ$%&*+<>=?@"; + + char* AppendGlyph(char* out, uint16_t glyph) { + if (glyph < 0x80) { + *out++ = static_cast(glyph); + } else { + *out++ = static_cast(0xC0 | (glyph >> 6)); + *out++ = static_cast(0x80 | (glyph & 0x3F)); + } + return out; + } + + char* AppendColor(char* out, const char* color) { + *out++ = '#'; + for (uint8_t i = 0; i < 6; i++) { + *out++ = color[i]; + } + *out++ = ' '; + return out; + } +} + +Matrix::Matrix(Controllers::DateTime& dateTimeController) { + rngState = static_cast(dateTimeController.CurrentDateTime().time_since_epoch().count()) | 1; + + lv_obj_set_style_local_bg_color(lv_scr_act(), LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, LV_COLOR_BLACK); + + for (uint8_t i = 0; i < nbColumns; i++) { + Column& column = columns[i]; + ResetColumn(column, true); + for (auto& glyph : column.glyphs) { + glyph = RandomGlyph(); + } + column.label = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_recolor(column.label, true); + lv_obj_set_style_local_text_line_space(column.label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, 0); + lv_obj_set_pos(column.label, i * columnWidth, 0); + RenderColumn(column); + } + + taskRefresh = lv_task_create(RefreshTaskCallback, 60, LV_TASK_PRIO_MID, this); +} + +Matrix::~Matrix() { + lv_task_del(taskRefresh); + lv_obj_clean(lv_scr_act()); +} + +uint32_t Matrix::NextRandom() { + // xorshift32 + rngState ^= rngState << 13; + rngState ^= rngState >> 17; + rngState ^= rngState << 5; + return rngState; +} + +uint16_t Matrix::RandomGlyph() { + const uint32_t r = NextRandom(); + if (r % 3 == 0) { + return static_cast(asciiGlyphs[(r >> 2) % (sizeof(asciiGlyphs) - 1)]); + } + return 0x410 + ((r >> 2) % 0x40); +} + +void Matrix::ResetColumn(Column& column, bool initial) { + const uint32_t r = NextRandom(); + column.trail = 4 + r % 7; + column.speed = 1 + (r >> 4) % 3; + // Start above the screen so drops enter at staggered times + column.head = initial ? -static_cast((r >> 8) % 16) : -static_cast((r >> 8) % 8); +} + +void Matrix::RenderColumn(Column& column) { + const Palette& palette = palettes[paletteIndex]; + char* out = column.text.data(); + for (int8_t row = 0; row < nbRows; row++) { + const int8_t distance = column.head - row; + if (distance < 0 || distance >= column.trail) { + *out++ = ' '; + } else { + const char* color = distance == 0 ? palette.head : (distance < 3 ? palette.bright : palette.dim); + out = AppendColor(out, color); + out = AppendGlyph(out, column.glyphs[row]); + *out++ = '#'; + } + if (row < nbRows - 1) { + *out++ = '\n'; + } + } + *out = '\0'; + lv_label_set_text_static(column.label, column.text.data()); +} + +void Matrix::Refresh() { + tick++; + for (auto& column : columns) { + bool changed = false; + + // Glyphs inside the trail flicker every now and then + if (NextRandom() % 4 == 0) { + column.glyphs[NextRandom() % nbRows] = RandomGlyph(); + changed = true; + } + + if (tick % column.speed == 0) { + column.head++; + if (column.head - column.trail >= nbRows) { + ResetColumn(column, false); + } + // The new head always gets a fresh glyph + if (column.head >= 0 && column.head < nbRows) { + column.glyphs[column.head] = RandomGlyph(); + } + changed = true; + } + + if (changed) { + RenderColumn(column); + } + } +} + +bool Matrix::OnTouchEvent(TouchEvents event) { + if (event == TouchEvents::Tap) { + paletteIndex = (paletteIndex + 1) % palettes.size(); + for (auto& column : columns) { + RenderColumn(column); + } + return true; + } + return false; +} diff --git a/src/displayapp/screens/Matrix.h b/src/displayapp/screens/Matrix.h new file mode 100644 index 0000000000..ce0e68cf36 --- /dev/null +++ b/src/displayapp/screens/Matrix.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "components/datetime/DateTimeController.h" +#include "Symbols.h" + +namespace Pinetime { + namespace Applications { + namespace Screens { + + class Matrix : public Screen { + public: + explicit Matrix(Controllers::DateTime& dateTimeController); + ~Matrix() override; + + void Refresh() override; + bool OnTouchEvent(TouchEvents event) override; + + private: + static constexpr uint8_t nbColumns = 20; + static constexpr uint8_t nbRows = 10; + static constexpr uint8_t columnWidth = 12; + static constexpr uint8_t rowHeight = 24; + // Worst case per row: "#rrggbb " + 2 byte glyph + "#\n" + static constexpr uint16_t bufferSize = nbRows * 12 + 1; + + struct Column { + int8_t head; + uint8_t trail; + uint8_t speed; + std::array glyphs; + std::array text; + lv_obj_t* label; + }; + + struct Palette { + const char* head; + const char* bright; + const char* dim; + }; + + static constexpr std::array palettes {{ + {"ffffff", "39ff14", "0b6b1a"}, + {"ffffff", "05d9e8", "0a4a55"}, + {"fcee0a", "ff2a6d", "5a1030"}, + }}; + + uint32_t NextRandom(); + uint16_t RandomGlyph(); + void ResetColumn(Column& column, bool initial); + void RenderColumn(Column& column); + + std::array columns; + uint32_t rngState; + uint8_t tick = 0; + uint8_t paletteIndex = 0; + lv_task_t* taskRefresh; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::Matrix; + static constexpr const char* icon = Screens::Symbols::terminal; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::Matrix(controllers.dateTimeController); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + }; + }; + } +} diff --git a/src/displayapp/screens/Symbols.h b/src/displayapp/screens/Symbols.h index 058b2d06e9..01c454bbe6 100644 --- a/src/displayapp/screens/Symbols.h +++ b/src/displayapp/screens/Symbols.h @@ -42,6 +42,8 @@ namespace Pinetime { static constexpr const char* sleep = "\xEE\xBD\x84"; static constexpr const char* calculator = "\xEF\x87\xAC"; static constexpr const char* backspace = "\xEF\x95\x9A"; + static constexpr const char* terminal = "\xEF\x84\xA0"; // 0xf120 + static constexpr const char* skull = "\xEF\x95\x8C"; // 0xf54c // fontawesome_weathericons.c // static constexpr const char* sun = "\xEF\x86\x85"; diff --git a/src/displayapp/screens/SystemInfo.cpp b/src/displayapp/screens/SystemInfo.cpp index 2392f3be3e..b6ab02862a 100644 --- a/src/displayapp/screens/SystemInfo.cpp +++ b/src/displayapp/screens/SystemInfo.cpp @@ -80,7 +80,7 @@ std::unique_ptr SystemInfo::CreateScreen1() { lv_obj_t* label = lv_label_create(lv_scr_act(), nullptr); lv_label_set_recolor(label, true); lv_label_set_text_fmt(label, - "#FFFF00 InfiniTime#\n\n" + "#ff2a6d InfiniTime#\n#05d9e8 // CYBERPUNK#\n" "#808080 Version# %ld.%ld.%ld\n" "#808080 Short Ref# %s\n" "#808080 Build date#\n" diff --git a/src/displayapp/screens/Tile.cpp b/src/displayapp/screens/Tile.cpp index 45f715b55a..8cdede5e07 100644 --- a/src/displayapp/screens/Tile.cpp +++ b/src/displayapp/screens/Tile.cpp @@ -69,7 +69,12 @@ Tile::Tile(uint8_t screenID, lv_obj_set_size(btnm1, LV_HOR_RES - 16, LV_VER_RES - 60); lv_obj_align(btnm1, nullptr, LV_ALIGN_CENTER, 0, 10); - lv_obj_set_style_local_radius(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, 20); + lv_obj_set_style_local_radius(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, 4); + lv_obj_set_style_local_text_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, Colors::neonCyan); + lv_obj_set_style_local_text_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_PRESSED, Colors::neonYellow); + lv_obj_set_style_local_border_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_obj_set_style_local_border_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_PRESSED, Colors::neonYellow); + lv_obj_set_style_local_border_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DISABLED, Colors::bgAlt); lv_obj_set_style_local_bg_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DEFAULT, Colors::bgAlt); lv_obj_set_style_local_bg_opa(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DISABLED, LV_OPA_50); lv_obj_set_style_local_bg_color(btnm1, LV_BTNMATRIX_PART_BTN, LV_STATE_DISABLED, Colors::bgDark); diff --git a/src/displayapp/screens/WatchFaceNetRunner.cpp b/src/displayapp/screens/WatchFaceNetRunner.cpp new file mode 100644 index 0000000000..f20c756896 --- /dev/null +++ b/src/displayapp/screens/WatchFaceNetRunner.cpp @@ -0,0 +1,301 @@ +#include "displayapp/screens/WatchFaceNetRunner.h" + +#include +#include "components/battery/BatteryController.h" +#include "components/ble/BleController.h" +#include "components/ble/NotificationManager.h" +#include "components/heartrate/HeartRateController.h" +#include "components/motion/MotionController.h" +#include "components/settings/Settings.h" +#include "displayapp/screens/WeatherSymbols.h" +#include "displayapp/InfiniTimeTheme.h" + +using namespace Pinetime::Applications::Screens; + +namespace { + lv_obj_t* CreateLabel(lv_obj_t* parent, lv_color_t color) { + lv_obj_t* label = lv_label_create(parent, nullptr); + lv_obj_set_style_local_text_color(label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, color); + return label; + } + + lv_color_t PowerColor(int percent) { + if (percent > 50) { + return Colors::neonGreen; + } + if (percent > 20) { + return Colors::neonYellow; + } + return Colors::neonMagenta; + } +} + +WatchFaceNetRunner::WatchFaceNetRunner(Controllers::DateTime& dateTimeController, + const Controllers::Battery& batteryController, + const Controllers::Ble& bleController, + Controllers::NotificationManager& notificationManager, + Controllers::Settings& settingsController, + Controllers::HeartRateController& heartRateController, + Controllers::MotionController& motionController, + Controllers::SimpleWeatherService& weatherService) + : currentDateTime {{}}, + dateTimeController {dateTimeController}, + batteryController {batteryController}, + bleController {bleController}, + notificationManager {notificationManager}, + settingsController {settingsController}, + heartRateController {heartRateController}, + motionController {motionController}, + weatherService {weatherService} { + + // HUD frame lines + topLine = lv_line_create(lv_scr_act(), nullptr); + lv_line_set_points(topLine, topLinePoints, 4); + lv_obj_set_style_local_line_color(topLine, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_obj_set_style_local_line_width(topLine, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, 2); + + bottomLine = lv_line_create(lv_scr_act(), nullptr); + lv_line_set_points(bottomLine, bottomLinePoints, 4); + lv_obj_set_style_local_line_color(bottomLine, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); + lv_obj_set_style_local_line_width(bottomLine, LV_LINE_PART_MAIN, LV_STATE_DEFAULT, 2); + + // Status row + labelLink = CreateLabel(lv_scr_act(), Colors::gray); + lv_obj_set_pos(labelLink, 4, 4); + + labelPower = CreateLabel(lv_scr_act(), Colors::neonGreen); + lv_obj_align(labelPower, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 4); + + // Date row + labelDate = CreateLabel(lv_scr_act(), Colors::neonYellow); + lv_obj_set_pos(labelDate, 2, 38); + + labelNotification = CreateLabel(lv_scr_act(), Colors::neonMagenta); + lv_label_set_text_static(labelNotification, "[MSG]"); + lv_obj_align(labelNotification, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 44); + lv_obj_set_hidden(labelNotification, true); + + // Time: a yellow ghost and a magenta shadow sit behind the cyan digits for the + // chromatic-aberration look. The ghost only shows up while glitching. + labelTimeGhost = CreateLabel(lv_scr_act(), Colors::neonYellow); + labelTimeShadow = CreateLabel(lv_scr_act(), Colors::neonMagenta); + labelTime = CreateLabel(lv_scr_act(), Colors::neonCyan); + for (lv_obj_t* label : {labelTimeGhost, labelTimeShadow, labelTime}) { + lv_obj_set_style_local_text_font(label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, &jetbrains_mono_extrabold_compressed); + lv_obj_set_style_local_text_letter_space(label, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, -6); + lv_label_set_text_static(label, "00:00"); + } + lv_obj_set_hidden(labelTimeGhost, true); + + glitchSlice = lv_obj_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_bg_color(glitchSlice, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_obj_set_style_local_radius(glitchSlice, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, 0); + lv_obj_set_style_local_border_width(glitchSlice, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, 0); + lv_obj_set_hidden(glitchSlice, true); + + // Seconds sweep + barSeconds = lv_bar_create(lv_scr_act(), nullptr); + lv_obj_set_size(barSeconds, 150, 6); + lv_obj_set_pos(barSeconds, 6, 138); + lv_bar_set_range(barSeconds, 0, 59); + lv_obj_set_style_local_bg_color(barSeconds, LV_BAR_PART_BG, LV_STATE_DEFAULT, Colors::dimCyan); + lv_obj_set_style_local_bg_opa(barSeconds, LV_BAR_PART_BG, LV_STATE_DEFAULT, LV_OPA_COVER); + lv_obj_set_style_local_radius(barSeconds, LV_BAR_PART_BG, LV_STATE_DEFAULT, 0); + lv_obj_set_style_local_bg_color(barSeconds, LV_BAR_PART_INDIC, LV_STATE_DEFAULT, Colors::neonYellow); + lv_obj_set_style_local_radius(barSeconds, LV_BAR_PART_INDIC, LV_STATE_DEFAULT, 0); + + labelSeconds = CreateLabel(lv_scr_act(), Colors::neonYellow); + lv_obj_set_pos(labelSeconds, 164, 129); + + // Biometrics and environment + labelVitals = CreateLabel(lv_scr_act(), LV_COLOR_WHITE); + lv_label_set_recolor(labelVitals, true); + lv_obj_set_pos(labelVitals, 4, 148); + + labelEnv = CreateLabel(lv_scr_act(), LV_COLOR_WHITE); + lv_label_set_recolor(labelEnv, true); + lv_obj_set_pos(labelEnv, 4, 170); + + // Footer + labelHandle = CreateLabel(lv_scr_act(), Colors::neonMagenta); + lv_label_set_text_static(labelHandle, handle); + lv_obj_set_pos(labelHandle, 4, 212); + + labelHex = CreateLabel(lv_scr_act(), Colors::neonPurple); + lv_obj_align(labelHex, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 212); + + taskRefresh = lv_task_create(RefreshTaskCallback, LV_DISP_DEF_REFR_PERIOD, LV_TASK_PRIO_MID, this); + Refresh(); +} + +WatchFaceNetRunner::~WatchFaceNetRunner() { + lv_task_del(taskRefresh); + lv_obj_clean(lv_scr_act()); +} + +uint32_t WatchFaceNetRunner::NextRandom() { + // xorshift32 + rngState ^= rngState << 13; + rngState ^= rngState >> 17; + rngState ^= rngState << 5; + return rngState; +} + +void WatchFaceNetRunner::StartGlitch() { + glitchFrames = 4 + NextRandom() % 5; + lv_obj_set_hidden(labelTimeGhost, false); + lv_obj_set_hidden(glitchSlice, false); +} + +void WatchFaceNetRunner::UpdateGlitch() { + if (glitchFrames == 0) { + return; + } + glitchFrames--; + + const lv_coord_t timeX = (LV_HOR_RES - lv_obj_get_width(labelTime)) / 2; + if (glitchFrames == 0) { + lv_obj_set_hidden(labelTimeGhost, true); + lv_obj_set_hidden(glitchSlice, true); + lv_obj_set_pos(labelTime, timeX, timeY); + lv_obj_set_pos(labelTimeShadow, timeX + shadowOffset, timeY + shadowOffset); + return; + } + + auto jitter = [this](int range) { + return static_cast(static_cast(NextRandom() % (2 * range + 1)) - range); + }; + lv_obj_set_pos(labelTime, timeX + jitter(3), timeY); + lv_obj_set_pos(labelTimeShadow, timeX + shadowOffset + jitter(8), timeY + shadowOffset); + lv_obj_set_pos(labelTimeGhost, timeX + jitter(10), timeY + jitter(3)); + + lv_obj_set_style_local_bg_color(glitchSlice, + LV_OBJ_PART_MAIN, + LV_STATE_DEFAULT, + (NextRandom() & 1) != 0 ? Colors::neonMagenta : Colors::neonCyan); + lv_obj_set_size(glitchSlice, 40 + NextRandom() % 160, 2 + NextRandom() % 5); + lv_obj_set_pos(glitchSlice, NextRandom() % 120, timeY + NextRandom() % 60); +} + +void WatchFaceNetRunner::Refresh() { + UpdateGlitch(); + + notificationState = notificationManager.AreNewNotificationsAvailable(); + + currentDateTime = std::chrono::time_point_cast(dateTimeController.CurrentDateTime()); + if (currentDateTime.IsUpdated()) { + const uint8_t second = dateTimeController.Seconds(); + lv_bar_set_value(barSeconds, second, LV_ANIM_OFF); + + const auto epoch = std::chrono::duration_cast(dateTimeController.UTCDateTime().time_since_epoch()).count(); + lv_label_set_text_fmt(labelHex, "0x%08lX", static_cast(epoch)); + lv_obj_align(labelHex, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 212); + + // Blink the notification tag once per second + lv_obj_set_hidden(labelNotification, !notificationState.Get() || (second % 2) != 0); + + // Stir the RNG with the clock so every boot glitches differently, then roll for a glitch + rngState ^= static_cast(epoch); + if (rngState == 0) { + rngState = 0x1337c0de; + } + if (glitchFrames == 0 && NextRandom() % 7 == 0) { + StartGlitch(); + } + + currentMinute = std::chrono::time_point_cast(currentDateTime.Get()); + if (currentMinute.IsUpdated()) { + uint8_t hour = dateTimeController.Hours(); + const uint8_t minute = dateTimeController.Minutes(); + + if (settingsController.GetClockType() == Controllers::Settings::ClockType::H12) { + amPm = hour < 12 ? "AM" : "PM"; + if (hour == 0) { + hour = 12; + } else if (hour > 12) { + hour -= 12; + } + } else { + amPm = ""; + } + + for (lv_obj_t* label : {labelTimeGhost, labelTimeShadow, labelTime}) { + lv_label_set_text_fmt(label, "%02d:%02d", hour, minute); + } + const lv_coord_t timeX = (LV_HOR_RES - lv_obj_get_width(labelTime)) / 2; + lv_obj_set_pos(labelTime, timeX, timeY); + lv_obj_set_pos(labelTimeShadow, timeX + shadowOffset, timeY + shadowOffset); + lv_obj_set_pos(labelTimeGhost, timeX, timeY); + + // A guaranteed glitch at the top of every minute + StartGlitch(); + } + + lv_label_set_text_fmt(labelSeconds, ":%02d%s", second, amPm); + + currentDate = std::chrono::time_point_cast(currentDateTime.Get()); + if (currentDate.IsUpdated()) { + lv_label_set_text_fmt(labelDate, + "%s %04d.%02d.%02d", + dateTimeController.DayOfWeekShortToString(), + dateTimeController.Year(), + static_cast(dateTimeController.Month()), + dateTimeController.Day()); + } + } + + powerPresent = batteryController.IsPowerPresent(); + batteryPercentRemaining = batteryController.PercentRemaining(); + if (batteryPercentRemaining.IsUpdated() || powerPresent.IsUpdated()) { + const int percent = batteryPercentRemaining.Get(); + lv_obj_set_style_local_text_color(labelPower, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, PowerColor(percent)); + if (batteryController.IsCharging()) { + lv_label_set_text_fmt(labelPower, "CHG %d%%", percent); + } else { + lv_label_set_text_fmt(labelPower, "PWR %d%%", percent); + } + lv_obj_align(labelPower, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 4); + } + + bleState = bleController.IsConnected(); + bleRadioEnabled = bleController.IsRadioEnabled(); + if (bleState.IsUpdated() || bleRadioEnabled.IsUpdated()) { + if (!bleRadioEnabled.Get()) { + lv_label_set_text_static(labelLink, "LNK:OFF"); + lv_obj_set_style_local_text_color(labelLink, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::gray); + } else if (bleState.Get()) { + lv_label_set_text_static(labelLink, "LNK:UP"); + lv_obj_set_style_local_text_color(labelLink, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); + } else { + lv_label_set_text_static(labelLink, "LNK:--"); + lv_obj_set_style_local_text_color(labelLink, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); + } + } + + stepCount = motionController.NbSteps(); + heartbeat = heartRateController.HeartRate(); + heartbeatRunning = heartRateController.State() != Controllers::HeartRateController::States::Stopped; + if (stepCount.IsUpdated() || heartbeat.IsUpdated() || heartbeatRunning.IsUpdated()) { + if (heartbeatRunning.Get()) { + lv_label_set_text_fmt(labelVitals, "#ff2a6d HR# %03d #05d9e8 STP# %lu", heartbeat.Get(), stepCount.Get()); + } else { + lv_label_set_text_fmt(labelVitals, "#ff2a6d HR# --- #05d9e8 STP# %lu", stepCount.Get()); + } + } + + currentWeather = weatherService.Current(); + if (currentWeather.IsUpdated()) { + auto optCurrentWeather = currentWeather.Get(); + if (optCurrentWeather) { + int16_t temp = optCurrentWeather->temperature.Celsius(); + char tempUnit = 'C'; + if (settingsController.GetWeatherFormat() == Controllers::Settings::WeatherFormat::Imperial) { + temp = optCurrentWeather->temperature.Fahrenheit(); + tempUnit = 'F'; + } + lv_label_set_text_fmt(labelEnv, "#9d4edd ENV# %d°%c %s", temp, tempUnit, Symbols::GetSimpleCondition(optCurrentWeather->iconId)); + } else { + lv_label_set_text_static(labelEnv, "#9d4edd ENV# NO SIGNAL"); + } + } +} diff --git a/src/displayapp/screens/WatchFaceNetRunner.h b/src/displayapp/screens/WatchFaceNetRunner.h new file mode 100644 index 0000000000..3829710c42 --- /dev/null +++ b/src/displayapp/screens/WatchFaceNetRunner.h @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "displayapp/screens/Screen.h" +#include "components/datetime/DateTimeController.h" +#include "components/ble/SimpleWeatherService.h" +#include "utility/DirtyValue.h" + +namespace Pinetime { + namespace Controllers { + class Settings; + class Battery; + class Ble; + class NotificationManager; + class HeartRateController; + class MotionController; + } + + namespace Applications { + namespace Screens { + + class WatchFaceNetRunner : public Screen { + public: + // Handle shown in the bottom-left corner of the HUD. Change it to your own. + static constexpr const char* handle = "VOIDSEC"; + + WatchFaceNetRunner(Controllers::DateTime& dateTimeController, + const Controllers::Battery& batteryController, + const Controllers::Ble& bleController, + Controllers::NotificationManager& notificationManager, + Controllers::Settings& settingsController, + Controllers::HeartRateController& heartRateController, + Controllers::MotionController& motionController, + Controllers::SimpleWeatherService& weatherService); + ~WatchFaceNetRunner() override; + + void Refresh() override; + + private: + void UpdateGlitch(); + void StartGlitch(); + uint32_t NextRandom(); + + Utility::DirtyValue batteryPercentRemaining {}; + Utility::DirtyValue powerPresent {}; + Utility::DirtyValue bleState {}; + Utility::DirtyValue bleRadioEnabled {}; + Utility::DirtyValue> currentDateTime {}; + Utility::DirtyValue> currentMinute {}; + Utility::DirtyValue> currentDate {}; + Utility::DirtyValue stepCount {}; + Utility::DirtyValue heartbeat {}; + Utility::DirtyValue heartbeatRunning {}; + Utility::DirtyValue notificationState {}; + Utility::DirtyValue> currentWeather {}; + + static constexpr lv_coord_t timeY = 62; + static constexpr lv_coord_t shadowOffset = 3; + + lv_point_t topLinePoints[4] {{0, 30}, {170, 30}, {180, 38}, {240, 38}}; + lv_point_t bottomLinePoints[4] {{0, 204}, {98, 204}, {108, 196}, {240, 196}}; + + lv_obj_t* topLine; + lv_obj_t* bottomLine; + lv_obj_t* labelLink; + lv_obj_t* labelPower; + lv_obj_t* labelDate; + lv_obj_t* labelNotification; + lv_obj_t* labelTimeGhost; + lv_obj_t* labelTimeShadow; + lv_obj_t* labelTime; + lv_obj_t* barSeconds; + lv_obj_t* labelSeconds; + lv_obj_t* labelVitals; + lv_obj_t* labelEnv; + lv_obj_t* labelHandle; + lv_obj_t* labelHex; + lv_obj_t* glitchSlice; + + uint32_t rngState = 0x1337c0de; + uint8_t glitchFrames = 0; + const char* amPm = ""; + + Controllers::DateTime& dateTimeController; + const Controllers::Battery& batteryController; + const Controllers::Ble& bleController; + Controllers::NotificationManager& notificationManager; + Controllers::Settings& settingsController; + Controllers::HeartRateController& heartRateController; + Controllers::MotionController& motionController; + Controllers::SimpleWeatherService& weatherService; + + lv_task_t* taskRefresh; + }; + } + + template <> + struct WatchFaceTraits { + static constexpr WatchFace watchFace = WatchFace::NetRunner; + static constexpr const char* name = "NetRunner"; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::WatchFaceNetRunner(controllers.dateTimeController, + controllers.batteryController, + controllers.bleController, + controllers.notificationManager, + controllers.settingsController, + controllers.heartRateController, + controllers.motionController, + *controllers.weatherController); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + } + }; + } +} diff --git a/src/displayapp/screens/WatchFaceTerminal.cpp b/src/displayapp/screens/WatchFaceTerminal.cpp index 505f79500c..d2c1d99010 100644 --- a/src/displayapp/screens/WatchFaceTerminal.cpp +++ b/src/displayapp/screens/WatchFaceTerminal.cpp @@ -40,8 +40,8 @@ WatchFaceTerminal::WatchFaceTerminal(Controllers::DateTime& dateTimeController, notificationIcon = lv_label_create(container, nullptr); labelPrompt1 = lv_label_create(container, nullptr); - lv_obj_set_style_local_text_color(labelPrompt1, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); - lv_label_set_text_static(labelPrompt1, "user@watch:~ $ now"); + lv_obj_set_style_local_text_color(labelPrompt1, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_label_set_text_static(labelPrompt1, "root@voidsec:~# now"); labelTime = lv_label_create(container, nullptr); lv_label_set_recolor(labelTime, true); @@ -54,7 +54,7 @@ WatchFaceTerminal::WatchFaceTerminal(Controllers::DateTime& dateTimeController, stepValue = lv_label_create(container, nullptr); lv_label_set_recolor(stepValue, true); - lv_obj_set_style_local_text_color(stepValue, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::orange); + lv_obj_set_style_local_text_color(stepValue, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonYellow); heartbeatValue = lv_label_create(container, nullptr); lv_label_set_recolor(heartbeatValue, true); @@ -66,8 +66,8 @@ WatchFaceTerminal::WatchFaceTerminal(Controllers::DateTime& dateTimeController, lv_label_set_recolor(connectState, true); labelPrompt2 = lv_label_create(container, nullptr); - lv_obj_set_style_local_text_color(labelPrompt2, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); - lv_label_set_text_static(labelPrompt2, "user@watch:~ $"); + lv_obj_set_style_local_text_color(labelPrompt2, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_label_set_text_static(labelPrompt2, "root@voidsec:~#"); lv_obj_align(container, nullptr, LV_ALIGN_IN_TOP_LEFT, 0, 7); @@ -106,9 +106,9 @@ void WatchFaceTerminal::Refresh() { hour = hour - 12; ampmChar[0] = 'P'; } - lv_label_set_text_fmt(labelTime, "#ffffff [TIME]# #11cc55 %02d:%02d:%02d %s#", hour, minute, second, ampmChar); + lv_label_set_text_fmt(labelTime, "#ffffff [TIME]# #39ff14 %02d:%02d:%02d %s#", hour, minute, second, ampmChar); } else { - lv_label_set_text_fmt(labelTime, "#ffffff [TIME]# #11cc55 %02d:%02d:%02d#", hour, minute, second); + lv_label_set_text_fmt(labelTime, "#ffffff [TIME]# #39ff14 %02d:%02d:%02d#", hour, minute, second); } currentDate = std::chrono::time_point_cast(currentDateTime.Get()); @@ -116,7 +116,7 @@ void WatchFaceTerminal::Refresh() { uint16_t year = dateTimeController.Year(); Controllers::DateTime::Months month = dateTimeController.Month(); uint8_t day = dateTimeController.Day(); - lv_label_set_text_fmt(labelDate, "#ffffff [DATE]# #007fff %04d-%02d-%02d#", year, month, day); + lv_label_set_text_fmt(labelDate, "#ffffff [DATE]# #05d9e8 %04d-%02d-%02d#", year, month, day); } } @@ -161,12 +161,12 @@ void WatchFaceTerminal::Refresh() { tempUnit = 'F'; } lv_label_set_text_fmt(weather, - "#ffffff [WTHR]# #ffdd00 %d°%c %s#", + "#ffffff [WTHR]# #fcee0a %d°%c %s#", temp, tempUnit, Symbols::GetSimpleCondition(optCurrentWeather->iconId)); } else { - lv_label_set_text(weather, "#ffffff [WTHR]# #ffdd00 ---"); + lv_label_set_text(weather, "#ffffff [WTHR]# #fcee0a ---"); } } @@ -179,7 +179,7 @@ void WatchFaceTerminal::Refresh() { } else { if (bleState.Get()) { lv_label_set_text_static(connectState, "#ffffff [STAT]# Connected"); - lv_obj_set_style_local_text_color(connectState, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::blue); + lv_obj_set_style_local_text_color(connectState, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); } else { lv_label_set_text_static(connectState, "#ffffff [STAT]# Disconnected"); lv_obj_set_style_local_text_color(connectState, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::gray); From ca89070269a59d3d3886a1cfb8fe70f5e5ef6bbd Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 20:23:55 +0000 Subject: [PATCH 2/6] Add Badge app, VOIDSEC Bluetooth name and auto heart rate - Badge app shows the handle and a QR code linking to GitHub, keeps the screen on while open. QR image is generated by tools/badge/generate_badge_qr.py - Move the handle to src/Identity.h and use it as the BLE device name - Start heart rate measurement at boot and default the background interval to 5 minutes - Back off background measurement to every 5 minutes when no pulse is found, so continuous mode doesn't run the sensor while off the wrist - Enable heart rate measurement when a background interval is picked in settings - NetRunner shows --- instead of 000 when there is no pulse --- README.md | 15 +- doc/cyberpunk/badge.png | Bin 0 -> 3122 bytes doc/cyberpunk/launcher.png | Bin 2614 -> 3230 bytes src/CMakeLists.txt | 4 + src/Identity.h | 9 + src/components/ble/NimbleController.h | 3 +- src/components/settings/Settings.h | 2 +- src/displayapp/DisplayApp.cpp | 2 +- src/displayapp/UserApps.h | 1 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/apps/CMakeLists.txt | 1 + src/displayapp/screens/Badge.cpp | 41 ++++ src/displayapp/screens/Badge.h | 38 ++++ src/displayapp/screens/BadgeQr.h | 191 ++++++++++++++++++ src/displayapp/screens/WatchFaceNetRunner.cpp | 5 +- src/displayapp/screens/WatchFaceNetRunner.h | 3 - .../screens/settings/SettingHeartRate.cpp | 9 +- .../screens/settings/SettingHeartRate.h | 4 +- src/heartratetask/HeartRateTask.cpp | 18 +- src/heartratetask/HeartRateTask.h | 4 + src/systemtask/SystemTask.cpp | 4 + 21 files changed, 338 insertions(+), 17 deletions(-) create mode 100644 doc/cyberpunk/badge.png create mode 100644 src/Identity.h create mode 100644 src/displayapp/screens/Badge.cpp create mode 100644 src/displayapp/screens/Badge.h create mode 100644 src/displayapp/screens/BadgeQr.h diff --git a/README.md b/README.md index 42fd8e49f0..b4ae123368 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,19 @@ This fork reworks InfiniTime into a neon, hacker-con-ready build for the PineTime. -| NetRunner | Glitch | Matrix | -|:-:|:-:|:-:| -| ![NetRunner](doc/cyberpunk/netrunner.png) | ![Glitch](doc/cyberpunk/netrunner-glitch.png) | ![Matrix](doc/cyberpunk/matrix.png) | -| **Launcher** | **Settings** | **Terminal** | -| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.png) | +| NetRunner | Glitch | Matrix | Badge | +|:-:|:-:|:-:|:-:| +| ![NetRunner](doc/cyberpunk/netrunner.png) | ![Glitch](doc/cyberpunk/netrunner-glitch.png) | ![Matrix](doc/cyberpunk/matrix.png) | ![Badge](doc/cyberpunk/badge.png) | +| **Launcher** | **Settings** | **Terminal** | | +| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.png) | | What's different from upstream: -- **NetRunner watch face** (new default): chromatic-aberration time with random glitch bursts plus a guaranteed glitch every minute, HUD frame lines, seconds sweep bar, link/power status, HR + steps, weather, and the Unix epoch in hex. The handle in the bottom-left corner is set by `WatchFaceNetRunner::handle`. +- **NetRunner watch face** (new default): chromatic-aberration time with random glitch bursts plus a guaranteed glitch every minute, HUD frame lines, seconds sweep bar, link/power status, HR + steps, weather, and the Unix epoch in hex. The handle in the bottom-left corner comes from `src/Identity.h`. - **Matrix app**: digital rain built from ASCII and Cyrillic glyphs. Tap to cycle green, cyan, and magenta palettes. +- **Badge app** (skull icon): your handle and a scannable QR code linking to your GitHub. The screen stays on while it is open. To point it somewhere else, run `python3 tools/badge/generate_badge_qr.py ` (needs `pip install segno`). +- **Bluetooth name** is your handle (`VOIDSEC`), set in `src/Identity.h`. Gadgetbridge only auto-detects watches whose name starts with `InfiniTime`, so pair it before flashing this firmware, or temporarily change the name back to pair. +- **Heart rate in the background**: measurement starts at boot and runs every 5 minutes by default, so you never have to open the heart rate app. If no pulse is found for 30 seconds (the watch is off your wrist or on the charger), it backs off and retries every 5 minutes, so even *Cont* mode doesn't run the sensor all day on the nightstand. Change the interval in *Settings → Heart rate*. - **Neon theme**: cyan and magenta outlined buttons, sharp corners, dark purple surfaces, and recolored launcher tiles, lists, sliders, and switches. - **Terminal face** recolored as a root shell. - **Trimmed build**: Paint, Paddle, 2048, Dice, and Metronome apps plus the Analog, PineTimeStyle, Infineat, Casio, and Pride Flag faces are no longer built by default, which frees about 49 KB of flash. Re-enable any of them in `src/displayapp/apps/CMakeLists.txt`. diff --git a/doc/cyberpunk/badge.png b/doc/cyberpunk/badge.png new file mode 100644 index 0000000000000000000000000000000000000000..4949b3e139d9879ebd98a36d8872b5fdb4a52bbe GIT binary patch literal 3122 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 RcK?F{cGeD7Obefze*m5&&UpX; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6cf0447b06..6af48ec5e5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -394,6 +394,7 @@ list(APPEND SOURCE_FILES displayapp/screens/Timer.cpp displayapp/screens/Dice.cpp displayapp/screens/Matrix.cpp + displayapp/screens/Badge.cpp displayapp/screens/PassKey.cpp displayapp/screens/Error.cpp displayapp/screens/Alarm.cpp @@ -593,6 +594,7 @@ set(INCLUDE_FILES ${CMAKE_CURRENT_BINARY_DIR}/src/Version.h BootloaderVersion.h + Identity.h logging/Logger.h logging/NrfLogger.h displayapp/DisplayApp.h @@ -621,6 +623,8 @@ set(INCLUDE_FILES displayapp/screens/Timer.h displayapp/screens/Dice.h displayapp/screens/Matrix.h + displayapp/screens/Badge.h + displayapp/screens/BadgeQr.h displayapp/screens/Alarm.h displayapp/Colors.h displayapp/widgets/Counter.h diff --git a/src/Identity.h b/src/Identity.h new file mode 100644 index 0000000000..c2aaa923af --- /dev/null +++ b/src/Identity.h @@ -0,0 +1,9 @@ +#pragma once + +namespace Pinetime { + namespace Identity { + // Your handle. Shown on the NetRunner watch face and the Badge app, and used as the Bluetooth name. + // Note: companion apps such as Gadgetbridge look for a name starting with "InfiniTime" when pairing a new watch. + static constexpr const char* handle = "VOIDSEC"; + } +} diff --git a/src/components/ble/NimbleController.h b/src/components/ble/NimbleController.h index 597ef0cc34..77e0c68b04 100644 --- a/src/components/ble/NimbleController.h +++ b/src/components/ble/NimbleController.h @@ -1,6 +1,7 @@ #pragma once #include +#include "Identity.h" #define min // workaround: nimble's min/max macros conflict with libstdc++ #define max @@ -85,7 +86,7 @@ namespace Pinetime { void PersistBond(struct ble_gap_conn_desc& desc); void RestoreBond(); - static constexpr const char* deviceName = "InfiniTime"; + static constexpr const char* deviceName = Pinetime::Identity::handle; Pinetime::System::SystemTask& systemTask; Ble& bleController; DateTime& dateTimeController; diff --git a/src/components/settings/Settings.h b/src/components/settings/Settings.h index 96f867a272..2e7fcd9a55 100644 --- a/src/components/settings/Settings.h +++ b/src/components/settings/Settings.h @@ -382,7 +382,7 @@ namespace Pinetime { Controllers::BrightnessController::Levels brightLevel = Controllers::BrightnessController::Levels::Medium; bool dfuAndFsEnabledOnBoot = false; - uint16_t heartRateBackgroundPeriod = std::numeric_limits::max(); // Disabled by default + uint16_t heartRateBackgroundPeriod = 5 * 60; // Measure every 5 minutes by default }; SettingsData settings; diff --git a/src/displayapp/DisplayApp.cpp b/src/displayapp/DisplayApp.cpp index 84fa603622..043c78dbd8 100644 --- a/src/displayapp/DisplayApp.cpp +++ b/src/displayapp/DisplayApp.cpp @@ -611,7 +611,7 @@ void DisplayApp::LoadScreen(Apps app, DisplayApp::FullRefreshDirections directio currentScreen = std::make_unique(settingsController); break; case Apps::SettingHeartRate: - currentScreen = std::make_unique(settingsController); + currentScreen = std::make_unique(settingsController, heartRateController); break; case Apps::SettingDisplay: currentScreen = std::make_unique(settingsController); diff --git a/src/displayapp/UserApps.h b/src/displayapp/UserApps.h index 94c6f289f1..19fdc0e6d0 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -5,6 +5,7 @@ #include "displayapp/screens/Alarm.h" #include "displayapp/screens/Dice.h" #include "displayapp/screens/Matrix.h" +#include "displayapp/screens/Badge.h" #include "displayapp/screens/Timer.h" #include "displayapp/screens/Twos.h" #include "displayapp/screens/Tile.h" diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index 3d95fea18b..2731bda3e3 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -31,6 +31,7 @@ namespace Pinetime { Dice, Weather, Matrix, + Badge, PassKey, QuickSettings, Settings, diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index b7c9311729..4228b55465 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -2,6 +2,7 @@ if(DEFINED ENABLE_USERAPPS) set(USERAPP_TYPES ${ENABLE_USERAPPS} CACHE STRING "List of user apps to build into the firmware") else () set(DEFAULT_USER_APP_TYPES "Apps::Matrix") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Badge") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::StopWatch") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Alarm") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Timer") diff --git a/src/displayapp/screens/Badge.cpp b/src/displayapp/screens/Badge.cpp new file mode 100644 index 0000000000..92503c882a --- /dev/null +++ b/src/displayapp/screens/Badge.cpp @@ -0,0 +1,41 @@ +#include "displayapp/screens/Badge.h" + +#include +#include "Identity.h" +#include "displayapp/InfiniTimeTheme.h" +#include "displayapp/screens/BadgeQr.h" + +using namespace Pinetime::Applications::Screens; + +Badge::Badge(System::SystemTask& systemTask) : wakeLock(systemTask) { + // Keep the screen on while someone is scanning the code + wakeLock.Lock(); + + lv_obj_t* title = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(title, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_label_set_text_fmt(title, "// %s", Pinetime::Identity::handle); + lv_obj_align(title, nullptr, LV_ALIGN_IN_TOP_MID, 0, 4); + + const lv_coord_t qrSize = BadgeQr::image.header.w; + + lv_obj_t* frame = lv_obj_create(lv_scr_act(), nullptr); + lv_obj_set_size(frame, qrSize + 8, qrSize + 8); + lv_obj_align(frame, nullptr, LV_ALIGN_CENTER, 0, 2); + lv_obj_set_style_local_bg_opa(frame, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, LV_OPA_TRANSP); + lv_obj_set_style_local_radius(frame, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, 0); + lv_obj_set_style_local_border_width(frame, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, 2); + lv_obj_set_style_local_border_color(frame, LV_OBJ_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + + lv_obj_t* qr = lv_img_create(lv_scr_act(), nullptr); + lv_img_set_src(qr, &BadgeQr::image); + lv_obj_align(qr, frame, LV_ALIGN_CENTER, 0, 0); + + lv_obj_t* caption = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(caption, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonCyan); + lv_label_set_text_static(caption, BadgeQr::caption); + lv_obj_align(caption, nullptr, LV_ALIGN_IN_BOTTOM_MID, 0, -4); +} + +Badge::~Badge() { + lv_obj_clean(lv_scr_act()); +} diff --git a/src/displayapp/screens/Badge.h b/src/displayapp/screens/Badge.h new file mode 100644 index 0000000000..024c1d6c23 --- /dev/null +++ b/src/displayapp/screens/Badge.h @@ -0,0 +1,38 @@ +#pragma once + +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "systemtask/SystemTask.h" +#include "systemtask/WakeLock.h" +#include "Symbols.h" + +namespace Pinetime { + namespace Applications { + namespace Screens { + + class Badge : public Screen { + public: + explicit Badge(System::SystemTask& systemTask); + ~Badge() override; + + private: + System::WakeLock wakeLock; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::Badge; + static constexpr const char* icon = Screens::Symbols::skull; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::Badge(*controllers.systemTask); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + }; + }; + } +} diff --git a/src/displayapp/screens/BadgeQr.h b/src/displayapp/screens/BadgeQr.h new file mode 100644 index 0000000000..0c11f8fc98 --- /dev/null +++ b/src/displayapp/screens/BadgeQr.h @@ -0,0 +1,191 @@ +// Generated by tools/badge/generate_badge_qr.py, do not edit by hand +// URL: https://github.com/CypherNova1337 +#pragma once + +#include + +namespace Pinetime { + namespace Applications { + namespace Screens { + namespace BadgeQr { + static constexpr const char* url = "https://github.com/CypherNova1337"; + static constexpr const char* caption = "gh/CypherNova1337"; + + static const uint8_t map[] = { + 0xe8, 0xd9, 0x05, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x0f, 0xff, 0xe0, 0x07, 0xfe, 0x0f, + 0x80, 0x00, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, + 0x80, 0x00, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7c, 0x1f, 0x07, 0xc0, 0x0f, + 0x80, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x3e, 0x00, 0x7f, 0xe0, 0x00, 0x3f, 0xf0, + 0x00, 0x00, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0x83, 0xe0, 0x07, 0xfe, 0x0f, + 0xff, 0xe0, 0xf8, 0x3f, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x0f, 0x80, 0x00, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0xf8, 0x3e, 0x0f, + 0x83, 0xe0, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x00, 0xff, 0xfe, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xf0, + 0x7f, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0x00, 0x3e, 0x00, + 0x7c, 0x1f, 0x07, 0xfe, 0x0f, 0xfc, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x7c, 0x00, 0xf8, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0x83, 0xe0, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0x00, 0x00, 0x0f, + 0xfc, 0x1f, 0x00, 0x01, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xff, 0xc0, 0x00, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x80, 0x1f, 0x07, 0xc0, 0x0f, 0xff, 0xe0, 0x00, 0x01, 0xff, + 0x83, 0xe0, 0xff, 0xfe, 0x00, 0x7f, 0xe0, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x0f, 0x83, 0xe0, 0x07, 0xff, 0xf0, + 0x03, 0xff, 0x00, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x03, 0xe0, 0x07, 0xc1, 0xff, 0x83, 0xe0, 0x07, 0xc0, 0x0f, + 0xfc, 0x00, 0x07, 0xfe, 0x0f, 0x83, 0xe0, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x01, 0xf0, 0x03, 0xe0, 0xf8, 0x3e, 0x0f, 0x80, 0x00, 0x07, 0xff, 0xf0, + 0x7c, 0x1f, 0x07, 0xc1, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xfc, 0x1f, 0x00, 0x00, 0x00, 0x7c, 0x1f, 0xff, 0xc1, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3f, 0xf0, 0x00, 0x1f, 0xff, 0xc0, 0x00, 0x7f, 0xe0, 0x07, 0xc0, 0x00, + 0x7c, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0x83, 0xe0, 0x00, 0x01, 0xff, 0xff, 0xff, 0x07, 0xc0, 0x00, + 0x03, 0xe0, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xfc, 0x1f, 0xf8, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xff, 0xf0, + 0x7f, 0xff, 0xff, 0xfe, 0x0f, 0xff, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xf0, 0x03, 0xe0, 0xff, 0xfe, 0x00, + 0x03, 0xe0, 0x00, 0x3f, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xff, 0xf0, + 0x7f, 0xe0, 0xf8, 0x3f, 0xff, 0x80, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x01, 0xf0, 0x00, 0x00, 0xff, 0xc0, 0x0f, + 0xff, 0xe0, 0x00, 0x3e, 0x00, 0x7c, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0xfc, 0x1f, 0xff, 0xc0, 0x00, + 0x7f, 0xff, 0xff, 0xff, 0xf0, 0x7f, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x00, 0x0f, 0x83, 0xff, 0xff, 0xc0, 0x0f, + 0xff, 0xff, 0x00, 0x01, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x0f, 0xff, 0xe0, 0xf8, 0x01, 0xff, 0xfc, 0x00, 0x07, 0xfe, 0x0f, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x1f, 0xf8, 0x3e, 0x00, + 0x00, 0x00, 0xff, 0xc1, 0xff, 0x83, 0xe0, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xf8, 0x3f, 0xf0, 0x7c, 0x1f, 0x07, 0xff, 0xff, + 0xfc, 0x00, 0xff, 0xfe, 0x0f, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + + static const lv_img_dsc_t image = { + {LV_IMG_CF_INDEXED_1BIT, 0, 0, 165, 165}, + 3473, + map, + }; + } + } + } +} diff --git a/src/displayapp/screens/WatchFaceNetRunner.cpp b/src/displayapp/screens/WatchFaceNetRunner.cpp index f20c756896..160daf1876 100644 --- a/src/displayapp/screens/WatchFaceNetRunner.cpp +++ b/src/displayapp/screens/WatchFaceNetRunner.cpp @@ -1,6 +1,7 @@ #include "displayapp/screens/WatchFaceNetRunner.h" #include +#include "Identity.h" #include "components/battery/BatteryController.h" #include "components/ble/BleController.h" #include "components/ble/NotificationManager.h" @@ -118,7 +119,7 @@ WatchFaceNetRunner::WatchFaceNetRunner(Controllers::DateTime& dateTimeController // Footer labelHandle = CreateLabel(lv_scr_act(), Colors::neonMagenta); - lv_label_set_text_static(labelHandle, handle); + lv_label_set_text_static(labelHandle, Pinetime::Identity::handle); lv_obj_set_pos(labelHandle, 4, 212); labelHex = CreateLabel(lv_scr_act(), Colors::neonPurple); @@ -276,7 +277,7 @@ void WatchFaceNetRunner::Refresh() { heartbeat = heartRateController.HeartRate(); heartbeatRunning = heartRateController.State() != Controllers::HeartRateController::States::Stopped; if (stepCount.IsUpdated() || heartbeat.IsUpdated() || heartbeatRunning.IsUpdated()) { - if (heartbeatRunning.Get()) { + if (heartbeatRunning.Get() && heartbeat.Get() != 0) { lv_label_set_text_fmt(labelVitals, "#ff2a6d HR# %03d #05d9e8 STP# %lu", heartbeat.Get(), stepCount.Get()); } else { lv_label_set_text_fmt(labelVitals, "#ff2a6d HR# --- #05d9e8 STP# %lu", stepCount.Get()); diff --git a/src/displayapp/screens/WatchFaceNetRunner.h b/src/displayapp/screens/WatchFaceNetRunner.h index 3829710c42..05a4520474 100644 --- a/src/displayapp/screens/WatchFaceNetRunner.h +++ b/src/displayapp/screens/WatchFaceNetRunner.h @@ -25,9 +25,6 @@ namespace Pinetime { class WatchFaceNetRunner : public Screen { public: - // Handle shown in the bottom-left corner of the HUD. Change it to your own. - static constexpr const char* handle = "VOIDSEC"; - WatchFaceNetRunner(Controllers::DateTime& dateTimeController, const Controllers::Battery& batteryController, const Controllers::Ble& bleController, diff --git a/src/displayapp/screens/settings/SettingHeartRate.cpp b/src/displayapp/screens/settings/SettingHeartRate.cpp index a45dc835ae..31596f0ebf 100644 --- a/src/displayapp/screens/settings/SettingHeartRate.cpp +++ b/src/displayapp/screens/settings/SettingHeartRate.cpp @@ -12,7 +12,9 @@ namespace { } } -SettingHeartRate::SettingHeartRate(Pinetime::Controllers::Settings& settingsController) : settingsController {settingsController} { +SettingHeartRate::SettingHeartRate(Pinetime::Controllers::Settings& settingsController, + Pinetime::Controllers::HeartRateController& heartRateController) + : settingsController {settingsController}, heartRateController {heartRateController} { lv_obj_t* container = lv_cont_create(lv_scr_act(), nullptr); lv_obj_set_style_local_bg_opa(container, LV_CONT_PART_MAIN, LV_STATE_DEFAULT, LV_OPA_TRANSP); @@ -63,6 +65,11 @@ void SettingHeartRate::UpdateSelected(lv_obj_t* object, lv_event_t event) { if (object == cbOption[i]) { lv_checkbox_set_checked(cbOption[i], true); settingsController.SetHeartRateBackgroundMeasurementInterval(options[i].intervalInSeconds); + // Start measuring right away instead of waiting for the heart rate app to be opened + if (options[i].intervalInSeconds.has_value() && + heartRateController.State() == Pinetime::Controllers::HeartRateController::States::Stopped) { + heartRateController.Enable(); + } } else { lv_checkbox_set_checked(cbOption[i], false); } diff --git a/src/displayapp/screens/settings/SettingHeartRate.h b/src/displayapp/screens/settings/SettingHeartRate.h index 736f2b10d7..dfc10a1fc5 100644 --- a/src/displayapp/screens/settings/SettingHeartRate.h +++ b/src/displayapp/screens/settings/SettingHeartRate.h @@ -6,6 +6,7 @@ #include #include "components/settings/Settings.h" +#include "components/heartrate/HeartRateController.h" #include "displayapp/screens/Screen.h" namespace Pinetime { @@ -14,7 +15,7 @@ namespace Pinetime { namespace Screens { class SettingHeartRate : public Screen { public: - explicit SettingHeartRate(Pinetime::Controllers::Settings& settings); + SettingHeartRate(Pinetime::Controllers::Settings& settings, Pinetime::Controllers::HeartRateController& heartRateController); ~SettingHeartRate() override; void UpdateSelected(lv_obj_t* object, lv_event_t event); @@ -26,6 +27,7 @@ namespace Pinetime { }; Pinetime::Controllers::Settings& settingsController; + Pinetime::Controllers::HeartRateController& heartRateController; static constexpr std::array options = {{ {.intervalInSeconds = std::nullopt, .name = " Off"}, diff --git a/src/heartratetask/HeartRateTask.cpp b/src/heartratetask/HeartRateTask.cpp index fa352772f7..5cbe803202 100644 --- a/src/heartratetask/HeartRateTask.cpp +++ b/src/heartratetask/HeartRateTask.cpp @@ -9,6 +9,9 @@ using namespace Pinetime::Applications; namespace { constexpr TickType_t backgroundMeasurementTimeLimit = 30 * configTICK_RATE_HZ; + // When no pulse is found in the background, retry at most this often. + // Stops continuous mode from running the sensor all day while the watch is off the wrist. + constexpr TickType_t noPulseRetryInterval = 5 * 60 * configTICK_RATE_HZ; } std::optional HeartRateTask::BackgroundMeasurementInterval() const { @@ -16,7 +19,11 @@ std::optional HeartRateTask::BackgroundMeasurementInterval() const { if (!interval.has_value()) { return std::nullopt; } - return interval.value() * configTICK_RATE_HZ; + TickType_t ticks = interval.value() * configTICK_RATE_HZ; + if (noPulse && ticks < noPulseRetryInterval) { + return noPulseRetryInterval; + } + return ticks; } bool HeartRateTask::BackgroundMeasurementNeeded() const { @@ -185,6 +192,7 @@ void HeartRateTask::StartMeasurement() { measurementSucceeded = false; count = 0; measurementStartTime = xTaskGetTickCount(); + lastPulseTime = measurementStartTime; } void HeartRateTask::StopMeasurement() { @@ -235,6 +243,8 @@ void HeartRateTask::HandleSensorData() { } measurementSucceeded = true; valueCurrentlyShown = true; + lastPulseTime = xTaskGetTickCount(); + noPulse = false; controller.Update(Controllers::HeartRateController::States::Running, bpm); return; } @@ -253,6 +263,12 @@ void HeartRateTask::HandleSensorData() { valueCurrentlyShown = false; } if (state == States::BackgroundMeasuring) { + if (xTaskGetTickCount() - lastPulseTime > backgroundMeasurementTimeLimit) { + // No pulse for a while: back off until the watch is worn again + noPulse = true; + controller.Update(Controllers::HeartRateController::States::Running, 0); + valueCurrentlyShown = false; + } lastMeasurementTime = xTaskGetTickCount() - backgroundMeasurementTimeLimit; } else { lastMeasurementTime = xTaskGetTickCount(); diff --git a/src/heartratetask/HeartRateTask.h b/src/heartratetask/HeartRateTask.h index e00bc4d638..399cab7bff 100644 --- a/src/heartratetask/HeartRateTask.h +++ b/src/heartratetask/HeartRateTask.h @@ -51,6 +51,10 @@ namespace Pinetime { Controllers::Ppg ppg; TickType_t lastMeasurementTime; TickType_t measurementStartTime; + // Time of the last valid reading (or the start of the measurement if none yet) + TickType_t lastPulseTime; + // Set when a background measurement found no pulse, e.g. the watch is off the wrist + bool noPulse = false; }; } diff --git a/src/systemtask/SystemTask.cpp b/src/systemtask/SystemTask.cpp index 826474a068..c60f4cb0e6 100644 --- a/src/systemtask/SystemTask.cpp +++ b/src/systemtask/SystemTask.cpp @@ -13,6 +13,7 @@ #include "drivers/SpiNorFlash.h" #include "drivers/TwiMaster.h" #include "drivers/Hrs3300.h" +#include "components/heartrate/HeartRateController.h" #include "drivers/PinMap.h" #include "main.h" #include "BootErrors.h" @@ -148,6 +149,9 @@ void SystemTask::Work() { heartRateSensor.Init(); heartRateSensor.Disable(); heartRateApp.Start(); + if (settingsController.GetHeartRateBackgroundMeasurementInterval().has_value()) { + heartRateController.Enable(); + } buttonHandler.Init(this); From a866b7ff303b4eb503c158e9ad1339500b88caa0 Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 20:44:43 +0000 Subject: [PATCH 3/6] Add Bluetooth intrusion log with alerts - Log every connection that doesn't encrypt with the bonded phone: time, address, duration, pairing attempts and failures - Raise an UNKNOWN LINK notification with vibration when a bond exists or pairing fails, 10 seconds after connecting or on disconnect - Persist the log to /intrusion.dat, keep the last 16 entries - Add Intrusion Log app with alert toggle and wipe button - Build CI artifacts for voidsec-hub branches - Document Gadgetbridge install steps --- .github/workflows/main.yml | 2 +- README.md | 12 +- doc/cyberpunk/intrusions.png | Bin 0 -> 6654 bytes src/CMakeLists.txt | 5 + src/components/ble/IntrusionLog.cpp | 202 ++++++++++++++++++++++++ src/components/ble/IntrusionLog.h | 110 +++++++++++++ src/components/ble/NimbleController.cpp | 35 +++- src/components/ble/NimbleController.h | 10 ++ src/displayapp/UserApps.h | 1 + src/displayapp/apps/Apps.h.in | 1 + src/displayapp/apps/CMakeLists.txt | 1 + src/displayapp/screens/IntrusionLog.cpp | 164 +++++++++++++++++++ src/displayapp/screens/IntrusionLog.h | 58 +++++++ src/systemtask/Messages.h | 4 +- src/systemtask/SystemTask.cpp | 48 ++++++ src/systemtask/SystemTask.h | 4 + 16 files changed, 652 insertions(+), 5 deletions(-) create mode 100644 doc/cyberpunk/intrusions.png create mode 100644 src/components/ble/IntrusionLog.cpp create mode 100644 src/components/ble/IntrusionLog.h create mode 100644 src/displayapp/screens/IntrusionLog.cpp create mode 100644 src/displayapp/screens/IntrusionLog.h diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 00ea938210..e2f6876b44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,7 +3,7 @@ name: CI # Run this workflow whenever the build may be affected on: push: - branches: [ main ] + branches: [ main, "voidsec-hub/**" ] paths-ignore: - 'doc/**' - '**.md' diff --git a/README.md b/README.md index b4ae123368..ca6f219892 100644 --- a/README.md +++ b/README.md @@ -27,20 +27,28 @@ This fork reworks InfiniTime into a neon, hacker-con-ready build for the PineTim | NetRunner | Glitch | Matrix | Badge | |:-:|:-:|:-:|:-:| | ![NetRunner](doc/cyberpunk/netrunner.png) | ![Glitch](doc/cyberpunk/netrunner-glitch.png) | ![Matrix](doc/cyberpunk/matrix.png) | ![Badge](doc/cyberpunk/badge.png) | -| **Launcher** | **Settings** | **Terminal** | | -| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.png) | | +| **Launcher** | **Settings** | **Terminal** | **Intrusions** | +| ![Launcher](doc/cyberpunk/launcher.png) | ![Settings](doc/cyberpunk/settings.png) | ![Terminal](doc/cyberpunk/terminal.png) | ![Intrusions](doc/cyberpunk/intrusions.png) | What's different from upstream: - **NetRunner watch face** (new default): chromatic-aberration time with random glitch bursts plus a guaranteed glitch every minute, HUD frame lines, seconds sweep bar, link/power status, HR + steps, weather, and the Unix epoch in hex. The handle in the bottom-left corner comes from `src/Identity.h`. - **Matrix app**: digital rain built from ASCII and Cyrillic glyphs. Tap to cycle green, cyan, and magenta palettes. - **Badge app** (skull icon): your handle and a scannable QR code linking to your GitHub. The screen stays on while it is open. To point it somewhere else, run `python3 tools/badge/generate_badge_qr.py ` (needs `pip install segno`). +- **Intrusion log** (shield icon): every Bluetooth connection that isn't your bonded phone is logged with time, address, duration and what it tried (pairing, failed pairing). If you have a bonded phone, an unknown connection vibrates and pops up an `UNKNOWN LINK` alert. Toggle alerts on/off or wipe the log from the app. Your phone counts as trusted only when it is paired with the PIN (bonded), so pair it that way. - **Bluetooth name** is your handle (`VOIDSEC`), set in `src/Identity.h`. Gadgetbridge only auto-detects watches whose name starts with `InfiniTime`, so pair it before flashing this firmware, or temporarily change the name back to pair. - **Heart rate in the background**: measurement starts at boot and runs every 5 minutes by default, so you never have to open the heart rate app. If no pulse is found for 30 seconds (the watch is off your wrist or on the charger), it backs off and retries every 5 minutes, so even *Cont* mode doesn't run the sensor all day on the nightstand. Change the interval in *Settings → Heart rate*. - **Neon theme**: cyan and magenta outlined buttons, sharp corners, dark purple surfaces, and recolored launcher tiles, lists, sliders, and switches. - **Terminal face** recolored as a root shell. - **Trimmed build**: Paint, Paddle, 2048, Dice, and Metronome apps plus the Analog, PineTimeStyle, Infineat, Casio, and Pride Flag faces are no longer built by default, which frees about 49 KB of flash. Re-enable any of them in `src/displayapp/apps/CMakeLists.txt`. +### Installing with Gadgetbridge + +1. Grab `pinetime-mcuboot-app-dfu-.zip` (and `infinitime-resources-.zip` for the navigation icons) from the CI artifacts of this branch. +2. On the watch, enable firmware updates in *Settings → Firmware update* if they are turned off. +3. In Gadgetbridge, open the zip with *File installer* (or share it to Gadgetbridge) and install. Then install the resources zip the same way. +4. After the watch reboots, go to *Settings → Firmware* and tap *Validate*, otherwise the old firmware comes back on the next reboot. + > If your watch already has saved settings, pick NetRunner in *Settings → Watch face*. ## New to InfiniTime? diff --git a/doc/cyberpunk/intrusions.png b/doc/cyberpunk/intrusions.png new file mode 100644 index 0000000000000000000000000000000000000000..e47919dc58754f8a0107799156597519a4cb42e1 GIT binary patch literal 6654 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 literal 0 HcmV?d00001 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6af48ec5e5..2ee7eb5253 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -395,6 +395,7 @@ list(APPEND SOURCE_FILES displayapp/screens/Dice.cpp displayapp/screens/Matrix.cpp displayapp/screens/Badge.cpp + displayapp/screens/IntrusionLog.cpp displayapp/screens/PassKey.cpp displayapp/screens/Error.cpp displayapp/screens/Alarm.cpp @@ -466,6 +467,7 @@ list(APPEND SOURCE_FILES components/ble/BatteryInformationService.cpp components/ble/FSService.cpp components/ble/ImmediateAlertService.cpp + components/ble/IntrusionLog.cpp components/ble/ServiceDiscovery.cpp components/ble/HeartRateService.cpp components/ble/MotionService.cpp @@ -536,6 +538,7 @@ list(APPEND RECOVERY_SOURCE_FILES components/ble/BatteryInformationService.cpp components/ble/FSService.cpp components/ble/ImmediateAlertService.cpp + components/ble/IntrusionLog.cpp components/ble/ServiceDiscovery.cpp components/ble/NavigationService.cpp components/ble/HeartRateService.cpp @@ -625,6 +628,7 @@ set(INCLUDE_FILES displayapp/screens/Matrix.h displayapp/screens/Badge.h displayapp/screens/BadgeQr.h + displayapp/screens/IntrusionLog.h displayapp/screens/Alarm.h displayapp/Colors.h displayapp/widgets/Counter.h @@ -660,6 +664,7 @@ set(INCLUDE_FILES components/ble/BatteryInformationService.h components/ble/FSService.h components/ble/ImmediateAlertService.h + components/ble/IntrusionLog.h components/ble/ServiceDiscovery.h components/ble/BleClient.h components/ble/HeartRateService.h diff --git a/src/components/ble/IntrusionLog.cpp b/src/components/ble/IntrusionLog.cpp new file mode 100644 index 0000000000..468b987d6b --- /dev/null +++ b/src/components/ble/IntrusionLog.cpp @@ -0,0 +1,202 @@ +#include "components/ble/IntrusionLog.h" + +#include +#include +#include "components/fs/FS.h" + +using namespace Pinetime::Controllers; + +IntrusionLog::IntrusionLog(FS& fs) : fs {fs} { +} + +void IntrusionLog::Load() { + FileData data; + lfs_file_t file; + if (fs.FileOpen(&file, fileName, LFS_O_RDONLY) != LFS_ERR_OK) { + return; + } + const int read = fs.FileRead(&file, reinterpret_cast(&data), sizeof(data)); + fs.FileClose(&file); + if (read != sizeof(data) || data.magic != magic || data.version != fileVersion || data.count > MaxEntries || data.head >= MaxEntries) { + return; + } + enabled = data.enabled != 0; + count = data.count; + head = data.head; + entries = data.entries; +} + +void IntrusionLog::Save() { + FileData data; + taskENTER_CRITICAL(); + data.magic = magic; + data.version = fileVersion; + data.enabled = enabled ? 1 : 0; + data.count = count; + data.head = head; + data.entries = entries; + dirty = false; + taskEXIT_CRITICAL(); + + lfs_file_t file; + if (fs.FileOpen(&file, fileName, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC) != LFS_ERR_OK) { + dirty = true; + return; + } + fs.FileWrite(&file, reinterpret_cast(&data), sizeof(data)); + fs.FileClose(&file); +} + +void IntrusionLog::SetEnabled(bool enable) { + enabled = enable; + dirty = true; +} + +void IntrusionLog::Clear() { + taskENTER_CRITICAL(); + count = 0; + committed = false; + dirty = true; + taskEXIT_CRITICAL(); +} + +IntrusionLog::Entry IntrusionLog::Get(size_t index) const { + Entry entry {}; + taskENTER_CRITICAL(); + if (index < count) { + entry = entries[(head + MaxEntries - index) % MaxEntries]; + } + taskEXIT_CRITICAL(); + return entry; +} + +void IntrusionLog::FormatAddress(const Entry& entry, char* buffer) { + static constexpr char hex[] = "0123456789ABCDEF"; + // NimBLE stores addresses little-endian, print them the usual way round + for (int i = 0; i < 6; i++) { + const uint8_t byte = entry.address[5 - i]; + *buffer++ = hex[byte >> 4]; + *buffer++ = hex[byte & 0x0F]; + *buffer++ = i < 5 ? ':' : '\0'; + } +} + +const char* IntrusionLog::Describe(const Entry& entry) { + if ((entry.flags & Trusted) != 0) { + return "BONDED"; + } + if ((entry.flags & EncryptFailed) != 0) { + return "PAIR FAILED"; + } + if ((entry.flags & PairAttempt) != 0) { + return "PAIR ATTEMPT"; + } + if ((entry.flags & NoBond) != 0) { + return "UNVERIFIED"; + } + return "UNKNOWN"; +} + +void IntrusionLog::Commit() { + taskENTER_CRITICAL(); + head = (head + 1) % MaxEntries; + count = std::min(count + 1, MaxEntries); + entries[head] = current; + committed = true; + dirty = true; + taskEXIT_CRITICAL(); +} + +void IntrusionLog::OnConnect(const uint8_t* address, uint8_t addressType, bool knownBond, uint32_t time) { + // Nothing to log when disabled, or when the bonded phone reconnects with its identity address + connected = enabled && !knownBond; + pending = connected; + committed = false; + if (!connected) { + return; + } + current.time = time; + current.duration = 0; + std::memcpy(current.address, address, sizeof(current.address)); + current.addressType = addressType; + current.flags = 0; + connectTick = xTaskGetTickCount(); +} + +bool IntrusionLog::OnPairingRequest() { + if (connected) { + Active().flags |= PairAttempt; + } + return false; +} + +bool IntrusionLog::OnEncryptionChange(bool success, bool bonded) { + if (!connected) { + return false; + } + if (success && bonded) { + if (pending) { + // Our own phone: forget about it + connected = false; + pending = false; + } else { + // Encrypted after it was already reported, keep the record but mark it + Active().flags |= Trusted; + dirty = true; + } + return false; + } + if (!success) { + Active().flags |= EncryptFailed; + if (pending) { + pending = false; + current.flags |= Alerted; + Commit(); + return true; + } + dirty = true; + } + return false; +} + +bool IntrusionLog::ClassifyPending(bool hasBond) { + if (!pending) { + return false; + } + pending = false; + if (!hasBond) { + current.flags |= NoBond; + } + const bool alert = hasBond || (current.flags & (EncryptFailed | PairAttempt)) != 0; + if (alert) { + current.flags |= Alerted; + } + Commit(); + return alert; +} + +bool IntrusionLog::Classify(bool hasBond) { + // Give someone typing the passkey on their phone time to finish pairing + if (pending && (current.flags & PairAttempt) != 0) { + return false; + } + return ClassifyPending(hasBond); +} + +bool IntrusionLog::OnDisconnect(bool hasBond) { + if (!connected) { + return false; + } + const uint32_t seconds = (xTaskGetTickCount() - connectTick) / configTICK_RATE_HZ; + const uint16_t duration = static_cast(std::min(std::max(seconds, 1), UINT16_MAX)); + current.duration = duration; + const bool alert = ClassifyPending(hasBond); + if (committed) { + taskENTER_CRITICAL(); + entries[head].duration = duration; + taskEXIT_CRITICAL(); + dirty = true; + } + connected = false; + return alert; +} diff --git a/src/components/ble/IntrusionLog.h b/src/components/ble/IntrusionLog.h new file mode 100644 index 0000000000..bd18a77781 --- /dev/null +++ b/src/components/ble/IntrusionLog.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Pinetime { + namespace Controllers { + class FS; + + // Records Bluetooth connections that don't come from the bonded phone. + // + // A connection is considered trusted when it encrypts with the stored bond. + // Anything else is logged, and raises an alert when a bond exists (so there is + // a known phone to compare against) or when pairing/encryption was attempted. + class IntrusionLog { + public: + static constexpr uint8_t MaxEntries = 16; + + enum Flags : uint8_t { + Trusted = 1 << 0, // Encrypted with the stored bond + EncryptFailed = 1 << 1, + PairAttempt = 1 << 2, // Asked the watch to show a pairing passkey + Alerted = 1 << 3, + NoBond = 1 << 4, // No bonded phone to compare against, can't tell friend from foe + }; + + struct Entry { + uint32_t time; // Local time, seconds since epoch + uint16_t duration; // Seconds connected, 0 while still connected + uint8_t address[6]; + uint8_t addressType; + uint8_t flags; + }; + + explicit IntrusionLog(FS& fs); + + void Load(); + void Save(); + + bool IsEnabled() const { + return enabled; + } + + void SetEnabled(bool enable); + void Clear(); + + bool IsDirty() const { + return dirty; + } + + size_t Count() const { + return count; + } + + // 0 is the newest entry + Entry Get(size_t index) const; + + // "AA:BB:CC:DD:EE:FF", buffer must hold at least 18 bytes + static void FormatAddress(const Entry& entry, char* buffer); + // Short description of what the connection did + static const char* Describe(const Entry& entry); + + // Called from the BLE stack. Return true when an alert should be raised. + void OnConnect(const uint8_t* address, uint8_t addressType, bool knownBond, uint32_t time); + bool OnPairingRequest(); + bool OnEncryptionChange(bool success, bool bonded); + bool OnDisconnect(bool hasBond); + + // Called a few seconds after the connection is established + bool Classify(bool hasBond); + + private: + static constexpr uint32_t magic = 0x4c495356; // "VSIL" + static constexpr uint8_t fileVersion = 1; + static constexpr const char* fileName = "/intrusion.dat"; + + struct FileData { + uint32_t magic; + uint8_t version; + uint8_t enabled; + uint8_t count; + uint8_t head; + std::array entries; + }; + + // Entry for the current connection: kept aside until it's classified, then committed to the log + Entry& Active() { + return committed ? entries[head] : current; + } + + bool ClassifyPending(bool hasBond); + void Commit(); + + FS& fs; + std::array entries {}; + uint8_t head = MaxEntries - 1; + uint8_t count = 0; + bool enabled = true; + bool dirty = false; + Entry current {}; + bool connected = false; // A connection is being tracked + bool pending = false; // The tracked connection isn't classified yet + bool committed = false; // The tracked connection was written to the log + TickType_t connectTick = 0; + }; + } +} diff --git a/src/components/ble/NimbleController.cpp b/src/components/ble/NimbleController.cpp index 5059007ab9..447849fb7f 100644 --- a/src/components/ble/NimbleController.cpp +++ b/src/components/ble/NimbleController.cpp @@ -49,7 +49,8 @@ NimbleController::NimbleController(Pinetime::System::SystemTask& systemTask, heartRateService {*this, heartRateController}, motionService {*this, motionController}, fsService {systemTask, fs}, - serviceDiscovery({¤tTimeClient, &alertNotificationClient}) { + serviceDiscovery({¤tTimeClient, &alertNotificationClient}), + intrusionLogger {fs} { } void nimble_on_reset(int reason) { @@ -132,6 +133,7 @@ void NimbleController::Init() { ASSERT(rc == 0); RestoreBond(); + intrusionLogger.Load(); StartAdvertising(); } @@ -207,6 +209,17 @@ int NimbleController::OnGAPEvent(ble_gap_event* event) { } else { connectionHandle = event->connect.conn_handle; bleController.Connect(); + + struct ble_gap_conn_desc desc; + if (ble_gap_conn_find(connectionHandle, &desc) == 0) { + union ble_store_key key; + union ble_store_value value; + memset(&key, 0, sizeof key); + key.sec.peer_addr = desc.peer_id_addr; + const bool knownBond = ble_store_read_peer_sec(&key.sec, &value.sec) == 0; + const auto now = std::chrono::duration_cast(dateTimeController.CurrentDateTime().time_since_epoch()); + intrusionLogger.OnConnect(desc.peer_ota_addr.val, desc.peer_ota_addr.type, knownBond, static_cast(now.count())); + } systemTask.PushMessage(Pinetime::System::Messages::BleConnected); // Service discovery is deferred via systemtask } @@ -221,6 +234,12 @@ int NimbleController::OnGAPEvent(ble_gap_event* event) { PersistBond(event->disconnect.conn); } + if (intrusionLogger.OnDisconnect(HasBond())) { + systemTask.PushMessage(Pinetime::System::Messages::OnIntrusion); + } else if (intrusionLogger.IsDirty()) { + systemTask.PushMessage(Pinetime::System::Messages::IntrusionLogChanged); + } + currentTimeClient.Reset(); alertNotificationClient.Reset(); connectionHandle = BLE_HS_CONN_HANDLE_NONE; @@ -258,12 +277,15 @@ int NimbleController::OnGAPEvent(ble_gap_event* event) { if (desc.sec_state.bonded) { PersistBond(desc); } + intrusionLogger.OnEncryptionChange(true, desc.sec_state.bonded); NRF_LOG_INFO("new state: encrypted=%d authenticated=%d bonded=%d key_size=%d", desc.sec_state.encrypted, desc.sec_state.authenticated, desc.sec_state.bonded, desc.sec_state.key_size); + } else if (intrusionLogger.OnEncryptionChange(false, false)) { + systemTask.PushMessage(Pinetime::System::Messages::OnIntrusion); } break; @@ -281,6 +303,7 @@ int NimbleController::OnGAPEvent(ble_gap_event* event) { * Use the tinycrypt prng here since rand() is predictable. */ NRF_LOG_INFO("Security event : BLE_GAP_EVENT_PASSKEY_ACTION"); + intrusionLogger.OnPairingRequest(); if (event->passkey.params.action == BLE_SM_IOACT_DISP) { struct ble_sm_io pkey = {0}; pkey.action = event->passkey.params.action; @@ -480,6 +503,16 @@ void NimbleController::PersistBond(struct ble_gap_conn_desc& desc) { } } +bool NimbleController::HasBond() { + int count = 0; + ble_store_util_count(BLE_STORE_OBJ_TYPE_PEER_SEC, &count); + return count > 0; +} + +bool NimbleController::CheckIntrusion() { + return intrusionLogger.Classify(HasBond()); +} + void NimbleController::RestoreBond() { lfs_file_t file_p; union ble_store_value sec, cccd; diff --git a/src/components/ble/NimbleController.h b/src/components/ble/NimbleController.h index 77e0c68b04..39e73bf9c3 100644 --- a/src/components/ble/NimbleController.h +++ b/src/components/ble/NimbleController.h @@ -18,6 +18,7 @@ #include "components/ble/FSService.h" #include "components/ble/HeartRateService.h" #include "components/ble/ImmediateAlertService.h" +#include "components/ble/IntrusionLog.h" #include "components/ble/MusicService.h" #include "components/ble/NavigationService.h" #include "components/ble/ServiceDiscovery.h" @@ -72,6 +73,13 @@ namespace Pinetime { return weatherService; }; + Pinetime::Controllers::IntrusionLog& intrusionLog() { + return intrusionLogger; + }; + + // Classifies the current connection for the intrusion log, returns true if it should raise an alert + bool CheckIntrusion(); + uint16_t connHandle(); void NotifyBatteryLevel(uint8_t level); @@ -85,6 +93,7 @@ namespace Pinetime { private: void PersistBond(struct ble_gap_conn_desc& desc); void RestoreBond(); + bool HasBond(); static constexpr const char* deviceName = Pinetime::Identity::handle; Pinetime::System::SystemTask& systemTask; @@ -108,6 +117,7 @@ namespace Pinetime { MotionService motionService; FSService fsService; ServiceDiscovery serviceDiscovery; + IntrusionLog intrusionLogger; uint8_t addrType; uint16_t connectionHandle = BLE_HS_CONN_HANDLE_NONE; diff --git a/src/displayapp/UserApps.h b/src/displayapp/UserApps.h index 19fdc0e6d0..0b3240aebb 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -6,6 +6,7 @@ #include "displayapp/screens/Dice.h" #include "displayapp/screens/Matrix.h" #include "displayapp/screens/Badge.h" +#include "displayapp/screens/IntrusionLog.h" #include "displayapp/screens/Timer.h" #include "displayapp/screens/Twos.h" #include "displayapp/screens/Tile.h" diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index 2731bda3e3..44431f95bd 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -32,6 +32,7 @@ namespace Pinetime { Weather, Matrix, Badge, + IntrusionLog, PassKey, QuickSettings, Settings, diff --git a/src/displayapp/apps/CMakeLists.txt b/src/displayapp/apps/CMakeLists.txt index 4228b55465..8d6fbdeaf9 100644 --- a/src/displayapp/apps/CMakeLists.txt +++ b/src/displayapp/apps/CMakeLists.txt @@ -3,6 +3,7 @@ if(DEFINED ENABLE_USERAPPS) else () set(DEFAULT_USER_APP_TYPES "Apps::Matrix") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Badge") + set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::IntrusionLog") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::StopWatch") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Alarm") set(DEFAULT_USER_APP_TYPES "${DEFAULT_USER_APP_TYPES}, Apps::Timer") diff --git a/src/displayapp/screens/IntrusionLog.cpp b/src/displayapp/screens/IntrusionLog.cpp new file mode 100644 index 0000000000..8910976bd4 --- /dev/null +++ b/src/displayapp/screens/IntrusionLog.cpp @@ -0,0 +1,164 @@ +#include "displayapp/screens/IntrusionLog.h" + +#include +#include +#include "displayapp/InfiniTimeTheme.h" + +using namespace Pinetime::Applications::Screens; + +namespace { + void ButtonEventHandler(lv_obj_t* object, lv_event_t event) { + auto* screen = static_cast(object->user_data); + screen->OnButtonEvent(object, event); + } + + lv_obj_t* CreateButton(lv_obj_t** label, const char* text, void* userData) { + lv_obj_t* button = lv_btn_create(lv_scr_act(), nullptr); + button->user_data = userData; + lv_obj_set_event_cb(button, ButtonEventHandler); + lv_obj_set_style_local_pad_all(button, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, 0); + *label = lv_label_create(button, nullptr); + lv_label_set_text_static(*label, text); + return button; + } +} + +IntrusionLog::IntrusionLog(Controllers::IntrusionLog& log) : log {log} { + // The display task keeps the SPI flash awake, so this is a safe moment to persist pending changes + if (log.IsDirty()) { + log.Save(); + } + + lv_obj_t* title = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(title, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonMagenta); + lv_label_set_text_static(title, "// INTRUSIONS"); + lv_obj_set_pos(title, 4, 2); + + labelPage = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(labelPage, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::neonPurple); + + buttonToggle = CreateButton(&labelToggle, "", this); + lv_obj_set_size(buttonToggle, 148, 40); + lv_obj_set_pos(buttonToggle, 2, 30); + + lv_obj_t* labelWipe; + buttonWipe = CreateButton(&labelWipe, "WIPE", this); + lv_obj_set_size(buttonWipe, 82, 40); + lv_obj_set_pos(buttonWipe, 156, 30); + + labelEmpty = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_style_local_text_color(labelEmpty, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, Colors::lightGray); + lv_label_set_long_mode(labelEmpty, LV_LABEL_LONG_BREAK); + lv_obj_set_width(labelEmpty, 232); + lv_label_set_text_static(labelEmpty, "No intrusions\nlogged.\n\nPair your phone\nwith the PIN to\nenable alerts."); + lv_obj_set_pos(labelEmpty, 4, 84); + + for (uint8_t i = 0; i < entriesPerPage; i++) { + const lv_coord_t y = 80 + i * 80; + labelStatus[i] = lv_label_create(lv_scr_act(), nullptr); + lv_obj_set_pos(labelStatus[i], 4, y); + labelDetails[i] = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_recolor(labelDetails[i], true); + lv_obj_set_pos(labelDetails[i], 4, y + 24); + } + + UpdateToggle(); + ShowPage(); +} + +IntrusionLog::~IntrusionLog() { + if (log.IsDirty()) { + log.Save(); + } + lv_obj_clean(lv_scr_act()); +} + +uint8_t IntrusionLog::PageCount() const { + const size_t count = log.Count(); + return count == 0 ? 1 : (count + entriesPerPage - 1) / entriesPerPage; +} + +void IntrusionLog::UpdateToggle() { + const bool enabled = log.IsEnabled(); + lv_label_set_text_static(labelToggle, enabled ? "ALERTS ON" : "ALERTS OFF"); + lv_obj_set_style_local_text_color(labelToggle, LV_LABEL_PART_MAIN, LV_STATE_DEFAULT, enabled ? Colors::neonCyan : Colors::gray); + lv_obj_set_style_local_border_color(buttonToggle, LV_BTN_PART_MAIN, LV_STATE_DEFAULT, enabled ? Colors::neonCyan : Colors::gray); +} + +void IntrusionLog::ShowPage() { + lv_label_set_text_fmt(labelPage, "%d/%d", page + 1, PageCount()); + lv_obj_align(labelPage, nullptr, LV_ALIGN_IN_TOP_RIGHT, -4, 2); + lv_obj_set_hidden(labelEmpty, log.Count() != 0); + + for (uint8_t i = 0; i < entriesPerPage; i++) { + const size_t index = page * entriesPerPage + i; + const bool visible = index < log.Count(); + lv_obj_set_hidden(labelStatus[i], !visible); + lv_obj_set_hidden(labelDetails[i], !visible); + if (!visible) { + continue; + } + + const auto entry = log.Get(index); + const bool alerted = (entry.flags & Controllers::IntrusionLog::Alerted) != 0; + lv_obj_set_style_local_text_color(labelStatus[i], + LV_LABEL_PART_MAIN, + LV_STATE_DEFAULT, + alerted ? Colors::neonMagenta : Colors::lightGray); + + char duration[8]; + if (entry.duration == 0) { + snprintf(duration, sizeof(duration), "LIVE"); + } else if (entry.duration < 60) { + snprintf(duration, sizeof(duration), "%ds", entry.duration); + } else if (entry.duration < 3600) { + snprintf(duration, sizeof(duration), "%dm", entry.duration / 60); + } else { + snprintf(duration, sizeof(duration), "%dh", entry.duration / 3600); + } + lv_label_set_text_fmt(labelStatus[i], "%-13s%5s", Controllers::IntrusionLog::Describe(entry), duration); + + const std::chrono::sys_seconds time {std::chrono::seconds {entry.time}}; + const auto days = std::chrono::floor(time); + const std::chrono::year_month_day date {days}; + const std::chrono::hh_mm_ss clock {time - days}; + + char address[18]; + Controllers::IntrusionLog::FormatAddress(entry, address); + lv_label_set_text_fmt(labelDetails[i], + "#fcee0a %02u-%02u %02d:%02d#\n#05d9e8 %s#", + static_cast(date.month()), + static_cast(date.day()), + static_cast(clock.hours().count()), + static_cast(clock.minutes().count()), + address); + } +} + +void IntrusionLog::OnButtonEvent(lv_obj_t* object, lv_event_t event) { + if (event != LV_EVENT_CLICKED) { + return; + } + if (object == buttonToggle) { + log.SetEnabled(!log.IsEnabled()); + UpdateToggle(); + } else if (object == buttonWipe) { + log.Clear(); + page = 0; + ShowPage(); + } +} + +bool IntrusionLog::OnTouchEvent(TouchEvents event) { + if (event == TouchEvents::SwipeUp && page + 1 < PageCount()) { + page++; + ShowPage(); + return true; + } + if (event == TouchEvents::SwipeDown && page > 0) { + page--; + ShowPage(); + return true; + } + return false; +} diff --git a/src/displayapp/screens/IntrusionLog.h b/src/displayapp/screens/IntrusionLog.h new file mode 100644 index 0000000000..f8ecdba18f --- /dev/null +++ b/src/displayapp/screens/IntrusionLog.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "components/ble/IntrusionLog.h" +#include "systemtask/SystemTask.h" +#include "Symbols.h" + +namespace Pinetime { + namespace Applications { + namespace Screens { + + class IntrusionLog : public Screen { + public: + explicit IntrusionLog(Controllers::IntrusionLog& log); + ~IntrusionLog() override; + + bool OnTouchEvent(TouchEvents event) override; + void OnButtonEvent(lv_obj_t* object, lv_event_t event); + + private: + static constexpr uint8_t entriesPerPage = 2; + + void UpdateToggle(); + void ShowPage(); + uint8_t PageCount() const; + + Controllers::IntrusionLog& log; + uint8_t page = 0; + + lv_obj_t* labelPage; + lv_obj_t* buttonToggle; + lv_obj_t* labelToggle; + lv_obj_t* buttonWipe; + lv_obj_t* labelEmpty; + std::array labelStatus; + std::array labelDetails; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::IntrusionLog; + static constexpr const char* icon = Screens::Symbols::shieldAlt; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::IntrusionLog(controllers.systemTask->nimble().intrusionLog()); + }; + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + }; + }; + } +} diff --git a/src/systemtask/Messages.h b/src/systemtask/Messages.h index fee94bb747..2bf7d8df65 100644 --- a/src/systemtask/Messages.h +++ b/src/systemtask/Messages.h @@ -29,7 +29,9 @@ namespace Pinetime { BatteryPercentageUpdated, StartFileTransfer, StopFileTransfer, - BleRadioEnableToggle + BleRadioEnableToggle, + OnIntrusion, + IntrusionLogChanged }; } } diff --git a/src/systemtask/SystemTask.cpp b/src/systemtask/SystemTask.cpp index c60f4cb0e6..2c433c7e2b 100644 --- a/src/systemtask/SystemTask.cpp +++ b/src/systemtask/SystemTask.cpp @@ -18,6 +18,8 @@ #include "main.h" #include "BootErrors.h" +#include +#include #include using namespace Pinetime::System; @@ -243,6 +245,8 @@ void SystemTask::Work() { displayApp.PushMessage(Pinetime::Applications::Display::Messages::NotifyDeviceActivity); isBleDiscoveryTimerRunning = true; bleDiscoveryTimer = 5; + // Give a bonded phone 10 seconds to encrypt the link before calling it an intrusion + intrusionCheckTimer = 100; break; case Messages::BleFirmwareUpdateStarted: GoToRunning(); @@ -370,6 +374,12 @@ void SystemTask::Work() { GoToRunning(); displayApp.PushMessage(Pinetime::Applications::Display::Messages::ShowPairingKey); break; + case Messages::OnIntrusion: + RaiseIntrusionAlert(); + break; + case Messages::IntrusionLogChanged: + SaveIntrusionLog(); + break; case Messages::BleRadioEnableToggle: if (settingsController.GetBleRadioEnabled()) { nimbleController.EnableRadio(); @@ -394,6 +404,13 @@ void SystemTask::Work() { bleDiscoveryTimer--; } } + if (intrusionCheckTimer > 0 && --intrusionCheckTimer == 0) { + if (nimbleController.CheckIntrusion()) { + RaiseIntrusionAlert(); + } else { + SaveIntrusionLog(); + } + } monitor.Process(); NoInit_BackUpTime = dateTimeController.CurrentDateTime(); if (nrf_gpio_pin_read(PinMap::Button) == 0) { @@ -405,6 +422,37 @@ void SystemTask::Work() { #pragma clang diagnostic pop } +void SystemTask::SaveIntrusionLog() { + auto& log = nimbleController.intrusionLog(); + // The SPI flash is powered down while sleeping, the log gets saved on the next alert or when the app is opened + if (log.IsDirty() && state == SystemTaskState::Running) { + log.Save(); + } +} + +void SystemTask::RaiseIntrusionAlert() { + GoToRunning(); + SaveIntrusionLog(); + + auto& log = nimbleController.intrusionLog(); + if (log.Count() == 0) { + return; + } + const auto entry = log.Get(0); + char address[18]; + Controllers::IntrusionLog::FormatAddress(entry, address); + + Controllers::NotificationManager::Notification notif; + const int size = + snprintf(notif.message.data(), notif.message.size(), "UNKNOWN LINK%c%s\n%s", '\0', address, Controllers::IntrusionLog::Describe(entry)); + notif.size = std::min(size + 1, notif.message.size()); + notif.category = Controllers::NotificationManager::Categories::SimpleAlert; + notificationManager.Push(std::move(notif)); + if (settingsController.GetNotificationStatus() == Pinetime::Controllers::Settings::Notification::On) { + displayApp.PushMessage(Pinetime::Applications::Display::Messages::NewNotification); + } +} + void SystemTask::GoToRunning() { if (state == SystemTaskState::Running) { return; diff --git a/src/systemtask/SystemTask.h b/src/systemtask/SystemTask.h index 606ddd3492..a083182df2 100644 --- a/src/systemtask/SystemTask.h +++ b/src/systemtask/SystemTask.h @@ -132,6 +132,10 @@ namespace Pinetime { void Work(); bool isBleDiscoveryTimerRunning = false; uint8_t bleDiscoveryTimer = 0; + // Counts down in state update periods after a connection, then the connection is classified + uint8_t intrusionCheckTimer = 0; + void RaiseIntrusionAlert(); + void SaveIntrusionLog(); TimerHandle_t measureBatteryTimer; uint8_t wakeLocksHeld = 0; SystemTaskState state = SystemTaskState::Running; From 03fce1e9aeb8a7039cc7acc9041cc18dbe7366bb Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 20:45:00 +0000 Subject: [PATCH 4/6] Fix menu names in Gadgetbridge install steps --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ca6f219892..881c109c16 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ What's different from upstream: ### Installing with Gadgetbridge 1. Grab `pinetime-mcuboot-app-dfu-.zip` (and `infinitime-resources-.zip` for the navigation icons) from the CI artifacts of this branch. -2. On the watch, enable firmware updates in *Settings → Firmware update* if they are turned off. +2. On the watch, go to *Settings → Over-the-air* and pick *Till reboot*. Updates and file uploads are refused while it is *Disabled*, and *Till reboot* shuts the door again after the update. 3. In Gadgetbridge, open the zip with *File installer* (or share it to Gadgetbridge) and install. Then install the resources zip the same way. 4. After the watch reboots, go to *Settings → Firmware* and tap *Validate*, otherwise the old firmware comes back on the next reboot. From 19f68450591440bcaf6d0711a247d979941a3cd5 Mon Sep 17 00:00:00 2001 From: CypherNova1337 Date: Fri, 25 Sep 2026 22:14:28 +0000 Subject: [PATCH 5/6] Fix calculator equals and clarify missing weather - Show the answer on the main line after pressing =, clear the operator so the next digit starts a new number and the next operator continues from the answer - Ignore presses that land between buttons, which replayed the previous key because LVGL keeps it as the active button - Watch face shows ENV NO WEATHER instead of NO SIGNAL when the phone hasn't sent weather in the last 24 hours --- src/displayapp/screens/Calculator.cpp | 20 +++++++++++++------ src/displayapp/screens/WatchFaceNetRunner.cpp | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/displayapp/screens/Calculator.cpp b/src/displayapp/screens/Calculator.cpp index a1f093830c..c8c152357e 100644 --- a/src/displayapp/screens/Calculator.cpp +++ b/src/displayapp/screens/Calculator.cpp @@ -59,6 +59,13 @@ void Calculator::OnButtonEvent(lv_obj_t* obj, lv_event_t event) { } void Calculator::HandleInput() { + // A press between or outside the buttons leaves LVGL's "active" button on the previous key, + // which would replay it. Only accept presses that actually landed on a button. + const auto* ext = static_cast(lv_obj_get_ext_attr(buttonMatrix)); + if (ext->btn_id_pr == LV_BTNMATRIX_BTN_NONE) { + return; + } + const char* buttonText = lv_btnmatrix_get_active_btn_text(buttonMatrix); if (buttonText == nullptr) { @@ -188,14 +195,15 @@ void Calculator::HandleInput() { break; case '=': - equalSignPressedBefore = true; Eval(); - // If the operation is ' ' then we move the value to the result. - // We reset the input after this. - // This seems more convenient. - if (operation == ' ') { - ResetInput(); + if (error == Error::None) { + // Show the answer on the main line, like a regular calculator. + // The next digit starts a new number, the next operator keeps working on the answer. + value = result; + operation = ' '; + UpdateOperation(); } + equalSignPressedBefore = true; break; } diff --git a/src/displayapp/screens/WatchFaceNetRunner.cpp b/src/displayapp/screens/WatchFaceNetRunner.cpp index 160daf1876..a0b0a0c6b2 100644 --- a/src/displayapp/screens/WatchFaceNetRunner.cpp +++ b/src/displayapp/screens/WatchFaceNetRunner.cpp @@ -296,7 +296,7 @@ void WatchFaceNetRunner::Refresh() { } lv_label_set_text_fmt(labelEnv, "#9d4edd ENV# %d°%c %s", temp, tempUnit, Symbols::GetSimpleCondition(optCurrentWeather->iconId)); } else { - lv_label_set_text_static(labelEnv, "#9d4edd ENV# NO SIGNAL"); + lv_label_set_text_static(labelEnv, "#9d4edd ENV# NO WEATHER"); } } } From 0dff8a4d02affbd3101048067378d0f906aac59b Mon Sep 17 00:00:00 2001 From: CypherNova1337 <131998711+CypherNova1337@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:28:29 -0400 Subject: [PATCH 6/6] Revert "Fix calculator equals and clarify missing weather" --- src/displayapp/screens/Calculator.cpp | 20 ++++++------------- src/displayapp/screens/WatchFaceNetRunner.cpp | 2 +- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/displayapp/screens/Calculator.cpp b/src/displayapp/screens/Calculator.cpp index c8c152357e..a1f093830c 100644 --- a/src/displayapp/screens/Calculator.cpp +++ b/src/displayapp/screens/Calculator.cpp @@ -59,13 +59,6 @@ void Calculator::OnButtonEvent(lv_obj_t* obj, lv_event_t event) { } void Calculator::HandleInput() { - // A press between or outside the buttons leaves LVGL's "active" button on the previous key, - // which would replay it. Only accept presses that actually landed on a button. - const auto* ext = static_cast(lv_obj_get_ext_attr(buttonMatrix)); - if (ext->btn_id_pr == LV_BTNMATRIX_BTN_NONE) { - return; - } - const char* buttonText = lv_btnmatrix_get_active_btn_text(buttonMatrix); if (buttonText == nullptr) { @@ -195,15 +188,14 @@ void Calculator::HandleInput() { break; case '=': + equalSignPressedBefore = true; Eval(); - if (error == Error::None) { - // Show the answer on the main line, like a regular calculator. - // The next digit starts a new number, the next operator keeps working on the answer. - value = result; - operation = ' '; - UpdateOperation(); + // If the operation is ' ' then we move the value to the result. + // We reset the input after this. + // This seems more convenient. + if (operation == ' ') { + ResetInput(); } - equalSignPressedBefore = true; break; } diff --git a/src/displayapp/screens/WatchFaceNetRunner.cpp b/src/displayapp/screens/WatchFaceNetRunner.cpp index a0b0a0c6b2..160daf1876 100644 --- a/src/displayapp/screens/WatchFaceNetRunner.cpp +++ b/src/displayapp/screens/WatchFaceNetRunner.cpp @@ -296,7 +296,7 @@ void WatchFaceNetRunner::Refresh() { } lv_label_set_text_fmt(labelEnv, "#9d4edd ENV# %d°%c %s", temp, tempUnit, Symbols::GetSimpleCondition(optCurrentWeather->iconId)); } else { - lv_label_set_text_static(labelEnv, "#9d4edd ENV# NO WEATHER"); + lv_label_set_text_static(labelEnv, "#9d4edd ENV# NO SIGNAL"); } } }