From 4173087c771feaeba9c12fa34a36d0537d132613 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Mon, 14 Apr 2025 16:34:51 +0200 Subject: [PATCH] Initial commit --- .gitignore | 6 + backplate.stl | Bin 0 -> 26684 bytes stand.scad | 85 +++ transigione/.gitignore | 5 + transigione/.vscode/extensions.json | 10 + transigione/include/README | 39 ++ transigione/lib/README | 46 ++ transigione/platformio.ini | 29 ++ transigione/src/main.cpp | 783 ++++++++++++++++++++++++++++ transigione/test/README | 11 + wallmount.stl | Bin 0 -> 61284 bytes 11 files changed, 1014 insertions(+) create mode 100644 .gitignore create mode 100644 backplate.stl create mode 100644 stand.scad create mode 100644 transigione/.gitignore create mode 100644 transigione/.vscode/extensions.json create mode 100644 transigione/include/README create mode 100644 transigione/lib/README create mode 100644 transigione/platformio.ini create mode 100644 transigione/src/main.cpp create mode 100644 transigione/test/README create mode 100644 wallmount.stl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90552b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.pioenvs +.piolibdeps +.clang_complete +.gcc-flags.json +.pio + diff --git a/backplate.stl b/backplate.stl new file mode 100644 index 0000000000000000000000000000000000000000..d905e03ed88ecb78404a0fd0813fa4b319818935 GIT binary patch literal 26684 zcmbuHU#M-@RmPX8sbD1fU?nx_^%nJ3FecTTqQ!Fe*(ZTQX>HKrKYWt3CQ_Os_Q67g z^$>JhW15Nv`yyM?rfrgfFXDyPyR*@UfJp@tu;wkc(N>>guMdI&=Qqdv*7uFM);b0} zaL=7*&GDOK%rVEDYpl8ceDLhUANz&7xA$E9z=QXF_@m!F{C|JG{|2-$Ztx4GVYqf0 z|L=}bzI696?5&LBjR?U?xD*bPMj5nz#?2?5yW{lJfBS=_-QDqPzx*@Xmv8vDqO%g) zVR)?k3mvp*-2C@n-L-w_!oSX>GF^0xp=aFO_C0rQw-3BJBXUbl-2Sb1Y+t?pjTwek;voHYEk}tFh}~%G^5rj;GA;3#&Q^)I z^`8&^YMK{*%x@4*XB|#F z{K_AlVhsV|^g40r^+!&zPJwWGofw8kPCxURcUQvcb>ih4{&3Ra^g8j;+h5w!SD6+w zZoI~chc0|_@`B_M?Ox-=_JKc`Je>)zapKkMyJ<8t;nDENzwzD@mABLw+dlC5X+BnB z3~t=Gl+zw#Sx1ghkMN%>adq3`dL=2)55DJZP3nyhu;d%Ra-kBLmR(pAr2~C>@I60N z38y9BuRdRS?ez3tUI@bJbz&H^(Ya!*J5JSR#E^Aetg@0cPSwBmS;aH)H4{>dqhC67pS zOJE_-wd+EpkhgIRq9%xWb{4hc2Du@}z=?TFB<9*f$GjyHAN#Yrrd7>#tjE<1WIWcQ#6oFFMo@uSeRd%euOSB^&WR~GM$}F>-2;_ryjZ@|zevp@V zOXbjrIvvpORfVsC+1sEIsPUHGYvsuizK`zP~mvUP2 z(BV=}EuzJbJ!dvpfFhC!U?gDHFN3tjjAZkK+Gv zgKGeehiX8q`D$7F?zr3%b?90~*B@vpT}L$o@^uuv!0WR&M!?hDnQ*!`KZcU|n3E`R zRmOa61SN%bLTWWi4vNuz*-zqrqVI)wJKKp+-qzF62mBrrp}cKxS>Jg&l(+56ZWQy! z^9bc4`?4Fw{IMsrPqjZf;T};h&=2iXWx`wP3Fe|8xJL#{m^1cPX=f`-Wg2b`P4KR_ z#a*@iQCViMLpxr*l<3?KWqoK1*8}*$m~#iR*Wpsi+ukUlybV#ptnWM>%G(en%pcDq zl(!*Dm_MFJC~u3=j8*A8LT3g@F@IbpkOAzE+LMD6ch!1AnauvE{k@)!QNr)>;DoZt zOiR8elpF4kPOlT%pMef$wS7Ol#tG%9MvSmZJ1MAPHo3QZY(~_cE63R%l{xK?%5Zu@ zdoNi>){Vx?zw(1Qm@SE%dVf@2+UxM~P?otrYDcdp+;;Di(_v5i9uLaAa&MJ)^*WSC z?T;>{EUG7z7445Ml_OtvjR$1_SbdmbHzL!r9OK~xvW?jB z*dLu6q0tLVK1yf$`w{sKSl7JY5^~gz|QepS@it^n`NiOw3xE`k`z) z6SI~kLK%G~mRmw3`n>a=FSi6j_pqS@D^8qubMC9Q#shNB*b&Y|ZpjJcoUtFBiQJMC z$UkGxI}^DjCv*=x6S*ZPbl*7>xg{q&7jas57txY_#)A_srCqA?2$#~HQ%@kfi>od6 z0~SP{7}q{Mk@It%WpYcUkr6E+N<4SQIv>}DIKy$bw%394M_egpDW{|H`n%}xww#6+ z(pnNgA#he>&t1yttiz?8)?RPl4i#4C~QS#8?R=E_U)48@`08;an|R`xt=rlsQGAmURqlnqwVqF zHCVTJPSn15-w#;9^Po#PoprdB)7nYzb%b?s)^{nVLx%luDW`{lJM?oMeU0>K#gG<` zM@VH$c$a*x1AGv=mmc{peJBkY^B|BfQVLlu$7Y>yDP*QjYB=DE@V~kq-E;^dMnJz6&$4g%}MRd&PaM1J*V>fi5 zhP(4iQnX4vuY|3+_#0Aa7e8v(%k2GQrj>d~(j9irEXVfdX& z6p5qLi5SDX#iMnkL`=tRJlKx~sX|l7GgogpCLIHP^4Yh%ztl7@RDJaBy&XZHJahFV z5xJ$%5?0YCU%T#OFhu-_WTSQ+(25K0I><444jo^6i_18`yg~)(B ziFr#TzVPT*PauWJfINwLOC+wo^w%elLS#Um#JnXE!|<0URjNcr1zJDmEj1#9AXP_A zG>Q3`Pwf`MrD#=*Q%Etc@E3K^qlgTRO~}hCSrRp*TT8Rpr57WvnpClH-71b$#IA^W zOR~ys7cpp4_mKlB*tnPk>SyoTM92m z?8a&tk>Nyc$%$BZIkvJYx8y{e$s1;Lod=`cb*ng1I@bol@lb@yDnTIxU^#98oPx7>Gl^0&TO zGkD*1(tgse-ci=ok`pfFw91%WKl0BH9e(Yd7po563v(%zk-a*h8noqiz*)xeV%4Vf z;|fMbdjPwmU#qL!6RN>IT0P6BGOYU3J1<_jz4Rq?_*E*eOn3DTFW)!Ggm%?j3hk0c z`O=N=Iz0QG@2WcF-MGunH(&D3ofE3bqo$V7^G0mY4-oNmq~H4wI-Y1Wt15xE_^hMT zDnm!{)wSZQM)>`H^fsOs)!qJ{(EW2*vP8qG7A1Ae5hd|{EowY6Sndgza$03r_1O|r z*Gp^_SB<{orM;{eO+KZYh+$bO-@BcBb-;LdG-$sSmM}`pS0Kh^sd&ml`zURG)H$fV z4A`#y3zeb63DxZDjN*S(9y=D`pS1}wAZt% zcg8taGO;Wb7Rpye$F(-*-TYuA?(C@-^vMaut4sAyy|h)d#;#_RKe4mxp8avcW7KJt z&8q4vMM+*ubi5w-Cp9W!Z=kMC2cFl{%-H4PxDepMJfb)0O+= zc6~RY5hIAX6!H@8WiWsIb~^{+e`ZDfu8Lbb42>A^KX(UW=jQ&>e%Q`zDX!bmWAvkV zVBdaZhK3NK#qMc z<#f#XVTf@U#u!QL`$P>RKd$I}Kd2FP?B`NWQ=hFRn>=gpuMv)(=XvggdvQ*O-(r4a z*22q-63h>_ixr3TZ1w5ZtGGS`%}9)yhOwp&Mg!VnEJ2FVGbdciX~tMsMekysi5LS~g1N#qF=Dq7SmV=-n$sBbld>{k?m-89MoSP& z-Y)wQG1iDWqvlc(ORle;aq8M$tU9CXYD+NCkJ>( zQFq*T-P4~=xRlc>BU}3MvZlXSuGbJ-{!Ft=sfp|~$)A*MzS_`Ny5qV#`aN2edqOq)qthzG zs!x}FhM$OQ=h2^U)$^%nmru>2Y{alE)j#*zqNMg5-sKZ!@>}*otZXF`JMTvHkycBv z>WLD!o7SsN+?K5zA8X#INaye$=@R ztN1K1Z0FnEI&S@_#IjWXtaF+x8EW@*>j!33XO&Aijks#Q0x_cJ><9c4Be=6? zoy}KO$FkJEdY9v0#~k}52h4MM*PlWO9pT;PtBKH4C@$qR`tiBgTdF$zNtYZ;d`<>B zj2M@tVm$Z+Oq@Ts?rPS0EJe@tZ3vt{NGRg=wld^ZVzR1wS5blnZQGfnFEaaR|M2+^CQ7+{)bYN~^UBvwGC&u+G zIKHvvPnbKQn)pTrvjMdP@4mB~&wsI<&;2=emr@y4IiVVSQA}@ustg^-dGHVYkX7-1 zCFgd2THOiNqi}PSjBjCOH{UgR6;fWJrRG)wCUK;S9%tBSKbEW_Y&}~6w5uK z8oW!UZ)B(pt4jMkoj84cO=al79B*sM0IPVeh3#BFblm!ZC>fTe@>|B!9IS-e^0%V1r!(Px zcUooZdDY=jlHVlY8wx$)QckPfd^KeN?p?%doDFDhC8it^VM`d_xD)z7R0W5hqw4@B3#O8mCe)v1CE~PT8 zazZt`qthxw$D7Kv`PbX|TvqLmdedTOW$19irJPn7I?6Te0hu5A0jrQ>;r@_YqB3+i zp_*NK>sKv7$J@)8|9RWv)w>Jv#S`Sx(5JT`dO|h$GJ(FNqB5*{d)W`YG@Z}+)scPo zJrN$SIz$Y5dl5C1a@us%X!o~7^o~hkuvc-+Bk{n$VUtirVvYG6CABG9gmTR$qXEaf!%(Pq?4hkI1zo=}Zj621y+ zVO8rnbd2aZ`=R%C`>*vFQN0`E>{5ICA`q-9zPiFOmv66gX_c)XU6i1P zD?|T9sMe24EK50U{iyTY#~GGz4OSVxYW=8$YJ8miD^Sq!!(~1EWV?SS0#}e+Me~Z9 z372wOW$0*eRbv;?5c4i(DkEISEv~As6z%@yC#Rvq&(8jp8@(HhoZ8dRwPEg~_kod%@=MwAEt5F6_k>G1tum~_z7qDA%0e>wS9$bB546j# z^`LA-xjyGNOI)h|mPsQHSgrCq;Ezn-J=by5Q3+^|`xD4__5QhzTkO_Zi&5hl**&Uq zPpBp$oU~ekRenDAZ%}!}=y*qz>vMfcUQ+SBC9d-|5;u%py+Mv^K!2y6W#}leI}tAB zw94qYpU+_x&x863RLl>qE16i9>c0ZjW>j5$T!(v9Wmx5eYTOe4HkI|f>OjwFFM7)n zRJkWqgS{TnDmPzEXJ_}WBGJdDjrl|@OU3+%Gc|OS`Ed>R?=U|+sud+@SF<1y<@%i8 zQgf*o59uf?^>nqZl>t|#{v8FD(UKD`<+RGyk2)TT64Yq@&~fN!J+H*Fl+!3TtLi-0 z2*#SoRTNf5+4@ll)!_P^Q4(HY#MG5~!)rAWxOV1u@BQmp&{5X*iEt^WRYpJje2ysL W`CQQ#s~z*}M3g;C#p^N+9sdhYg=dif literal 0 HcmV?d00001 diff --git a/stand.scad b/stand.scad new file mode 100644 index 0000000..59cff95 --- /dev/null +++ b/stand.scad @@ -0,0 +1,85 @@ +panel_width = 95; +panel_height = 200; + +thickness = 3; + +screw_right_right = 17.7; +screw_right_bottom = 8.8; + +screw_left_left = 13.8; +screw_left_bottom = 9; + +screw_top_right_right = 10.5; +screw_top_right_bottom = 108; + +screw_top_left_left = 10.5; +screw_top_left_bottom = 84; + +backside_hold_height = 50; + +bottom_margin_height = 30; + +total_height = backside_hold_height + bottom_margin_height; + +backside_standoff_height = 17; + +long_thingy_height = 116; +long_thingy_width = 18; +/* +difference() { + union() { + translate([0, -bottom_margin_height, 0]) cube([panel_width, total_height, thickness]); + + translate([25, 10, -backside_standoff_height])cylinder(h=backside_standoff_height, d=8, $fn=20); + translate([panel_width - 25, 10, -backside_standoff_height])cylinder(h=backside_standoff_height, d=8, $fn=20); + + translate([10, long_thingy_height, -backside_standoff_height])cylinder(h=backside_standoff_height, d=8, $fn=20); + translate([panel_width - 10, long_thingy_height, -backside_standoff_height])cylinder(h=backside_standoff_height, d=8, $fn=20); + + cube([long_thingy_width, long_thingy_height, thickness]); + translate([panel_width - long_thingy_width, 0, 0]) cube([long_thingy_width, long_thingy_height, thickness]); + + translate([0, long_thingy_height, 0]) cube([panel_width, 8, thickness]); + + translate([4, 12.7, -thickness+2])cube([panel_width/8, 2, 1]); + translate([4, 18.3, -thickness+2])cube([panel_width/8, 2, 1]); + } + + // knobs + translate([panel_width - bottom_margin_height/2, -bottom_margin_height/2, -1]) cylinder(h = 20, d = 7.2, $fn=20); + translate([panel_width - bottom_margin_height/2 - 25, -bottom_margin_height/2, -1]) cylinder(h = 20, d = 7.2, $fn=20); + + + // screws + translate([panel_width - screw_right_right, screw_right_bottom, -1]) cylinder(h = 20, d = 3.2, $fn=20); + translate([screw_left_left, screw_left_bottom, -1]) cylinder(h = 20, d = 3.2, $fn=20); + + translate([panel_width - screw_top_right_right, screw_top_right_bottom, -1]) cylinder(h = 20, d = 3.2, $fn=20); + translate([screw_top_left_left, screw_top_left_bottom, -1]) cylinder(h = 20, d = 3.2, $fn=20); + + translate([10, long_thingy_height, -1-backside_standoff_height]) cylinder(h = 30, d = 3, $fn=20); + translate([panel_width - 10, long_thingy_height, -1-backside_standoff_height]) cylinder(h = 30, d = 3, $fn=20); + + translate([25, 10, -1-backside_standoff_height]) cylinder(h = 30, d = 3, $fn=20); + translate([panel_width - 25, 10, -1-backside_standoff_height]) cylinder(h = 30, d = 3, $fn=20); +}*/ + +color("blue") translate([0, 0, -backside_standoff_height-thickness]) difference() { + union() { + cube([panel_width, long_thingy_height+50, thickness]); + + translate([0, 15, 0]) cube([panel_width/4, 3, backside_standoff_height+thickness-.2]); + + + + } + translate([panel_width / 2, long_thingy_height+40, -1]) cylinder(h=20, d=8, $fn=20); + + translate([10.5, 20, 12]) rotate([90, 0, 0]) cylinder(h = 20, d = 10, $fn=20); + + translate([10, long_thingy_height, -1-backside_standoff_height]) cylinder(h = 30, d = 3.2, $fn=20); + translate([panel_width - 10, long_thingy_height, -1-backside_standoff_height]) cylinder(h = 30, d = 3.2, $fn=20); + + translate([25, 10, -1-backside_standoff_height]) cylinder(h = 30, d = 3.2, $fn=20); + translate([panel_width - 25, 10, -1-backside_standoff_height]) cylinder(h = 30, d = 3.2, $fn=20); +} \ No newline at end of file diff --git a/transigione/.gitignore b/transigione/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/transigione/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/transigione/.vscode/extensions.json b/transigione/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/transigione/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/transigione/include/README b/transigione/include/README new file mode 100644 index 0000000..194dcd4 --- /dev/null +++ b/transigione/include/README @@ -0,0 +1,39 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the usual convention is to give header files names that end with `.h'. +It is most portable to use only letters, digits, dashes, and underscores in +header file names, and at most one dot. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/transigione/lib/README b/transigione/lib/README new file mode 100644 index 0000000..2593a33 --- /dev/null +++ b/transigione/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into executable file. + +The source code of each library should be placed in an own separate directory +("lib/your_library_name/[here are source files]"). + +For example, see a structure of the following two libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional, custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +and a contents of `src/main.c`: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +PlatformIO Library Dependency Finder will find automatically dependent +libraries scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/transigione/platformio.ini b/transigione/platformio.ini new file mode 100644 index 0000000..3485e00 --- /dev/null +++ b/transigione/platformio.ini @@ -0,0 +1,29 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 +build_flags = + -DCORE_DEBUG_LEVEL=3 +lib_deps = + mrfaptastic/ESP32 HUB75 LED MATRIX PANEL DMA Display@^3.0.12 + adafruit/Adafruit GFX Library@^1.12.0 + madhephaestus/ESP32Encoder@^0.11.7 + arduinogetstarted/ezButton@^1.0.6 + johboh/HomeAssistantEntities@^8.0.6 + johboh/MQTTRemote@^5.0.2 + bblanchon/ArduinoJson@^7.4.1 + juerd/ESP-WiFiSettings@^3.9.2 + +upload_protocol = espota +upload_port = transigione-902e41.local diff --git a/transigione/src/main.cpp b/transigione/src/main.cpp new file mode 100644 index 0000000..989945d --- /dev/null +++ b/transigione/src/main.cpp @@ -0,0 +1,783 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Information about this device. +// All these keys will be added to a "device" key in the Home Assistant configuration for each entity. +// Only a flat layout structure is supported, no nesting. +// We call the setupJsonForThisDevice() from the ardunio setup() function to populate the Json document. +// IJsonDocument can be replaced with nlohmann-json::json or ArduinoJson::JsonDocument +IJsonDocument _json_this_device_doc; +void setupJsonForThisDevice(String name) { + _json_this_device_doc["identifiers"] = name; + _json_this_device_doc["name"] = "Transigione"; + _json_this_device_doc["sw_version"] = "1.0.0"; + _json_this_device_doc["model"] = "transgione_matrix"; + _json_this_device_doc["manufacturer"] = "jhbruhn"; +} + +MQTTRemote* _mqtt_remote; + +// Create the Home Assistant bridge. This is shared across all entities. +// We only have one per device/hardware. In our example, the name of our device is "livingroom". +// See constructor of HaBridge for more documentation. +HaBridge* ha_bridge; + +// Create the two lights with the "Human readable" strings. This what will show up in Home Assistant. +// As we have two entities of the same type (light) for the same device, we need to add a child object +// id to separate them. +HaEntityLight* _ha_entity_light; + +ESP32Encoder encoder1; +ezButton encoder1Button(21); +ESP32Encoder encoder2; +ezButton encoder2Button(22); + +MatrixPanel_I2S_DMA *dma_display = nullptr; + +uint16_t myBLACK = dma_display->color565(0, 0, 0); +uint16_t myWHITE = dma_display->color565(255, 255, 255); +uint16_t myRED = dma_display->color565(255, 0, 0); +uint16_t myGREEN = dma_display->color565(0, 255, 0); +uint16_t myBLUE = dma_display->color565(0, 0, 255); + + +#define XMAX 64 +#define YMAX 32 + +#define BRIGHTNESS 127 + +void displaySetup() { + HUB75_I2S_CFG mxconfig( + 64, // module width + 32, // module height + 1 // Chain length + ); + + // If you are using a 64x64 matrix you need to pass a value for the E pin + // The trinity connects GPIO 18 to E. + // This can be commented out for any smaller displays (but should work fine with it) + //mxconfig.gpio.e = 18; + + + // May or may not be needed depending on your matrix + // Example of what needing it looks like: + // https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA/issues/134#issuecomment-866367216 + mxconfig.clkphase = false; + + // Some matrix panels use different ICs for driving them and some of them have strange quirks. + // If the display is not working right, try this. + //mxconfig.driver = HUB75_I2S_CFG::FM6126A; + + dma_display = new MatrixPanel_I2S_DMA(mxconfig); + dma_display->begin(); +} + +void setup_ota() { + ArduinoOTA.setHostname(WiFiSettings.hostname.c_str()); + + ArduinoOTA.begin(); +} + +bool power = true; +uint8_t brightness = BRIGHTNESS; + +void setup() { + Serial.begin(115200); + Serial.setDebugOutput(true); + Serial.println("Moini"); + displaySetup(); + + Serial.println("Moin"); + + encoder1Button.setDebounceTime(50); + encoder2Button.setDebounceTime(50); + + ESP32Encoder::useInternalWeakPullResistors = puType::none; + + encoder1.attachSingleEdge(34, 35); + encoder2.attachSingleEdge(32, 33); + encoder1.setFilter(1023); + encoder2.setFilter(1023); + + SPIFFS.begin(true); // On first run, will format after failing to mount + WiFiSettings.hostname = "transigione-"; + setupJsonForThisDevice(WiFiSettings.hostname); + String mqtt_host = WiFiSettings.string( "mqtt_host", "default.example.org"); + int mqtt_port = WiFiSettings.integer("mqtt_port", 0, 65535, 1883); + String mqtt_username = WiFiSettings.string("mqtt_username", "username"); + String mqtt_password = WiFiSettings.string("mqtt_password", "password"); + + + + // Set callbacks to start OTA when the portal is active + WiFiSettings.onPortal = []() { + setup_ota(); + }; + WiFiSettings.onPortalWaitLoop = []() { + ArduinoOTA.handle(); + }; + + WiFiSettings.connect(); + + _mqtt_remote = new MQTTRemote(WiFiSettings.hostname.c_str(), mqtt_host.c_str(), 1883, mqtt_username.c_str(), mqtt_password.c_str()); + ha_bridge = new HaBridge(*_mqtt_remote, WiFiSettings.hostname.c_str(), _json_this_device_doc); + _ha_entity_light = new HaEntityLight(*ha_bridge, "Light", "light", {.with_brightness = true}); + + ArduinoOTA + .onStart([]() { + String type; + if (ArduinoOTA.getCommand() == U_FLASH) { + type = "sketch"; + } else { // U_SPIFFS + type = "filesystem"; + } + + // NOTE: if updating SPIFFS this would be the place to unmount SPIFFS using SPIFFS.end() + Serial.println("Start updating " + type); + }) + .onEnd([]() { + Serial.println("\nEnd"); + }) + .onProgress([](unsigned int progress, unsigned int total) { + Serial.printf("Progress: %u%%\r", (progress / (total / 100))); + }) + .onError([](ota_error_t error) { + Serial.printf("Error[%u]: ", error); + if (error == OTA_AUTH_ERROR) { + Serial.println("Auth Failed"); + } else if (error == OTA_BEGIN_ERROR) { + Serial.println("Begin Failed"); + } else if (error == OTA_CONNECT_ERROR) { + Serial.println("Connect Failed"); + } else if (error == OTA_RECEIVE_ERROR) { + Serial.println("Receive Failed"); + } else if (error == OTA_END_ERROR) { + Serial.println("End Failed"); + } + }); + + + // Can be set between 0 and 255 + // WARNING: The birghter it is, the more power it uses + // Could take up to 3A on full brightness + dma_display->setBrightness8(BRIGHTNESS); //0-255 + + + dma_display->clearScreen(); + + Serial.println("Ready"); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + + _mqtt_remote->setOnConnectionChange([](bool connected) { + if (connected) { + Serial.println("We are in"); + // Publish Home Assistant Configuration for both lights once connected to MQTT. + _ha_entity_light->publishConfiguration(); + _ha_entity_light->publishIsOn(power); + _ha_entity_light->publishBrightness(brightness << 1); + + // Subscribe to new light "on" state pushed by Home Assistant. + _ha_entity_light->setOnOn([&](bool on) { + power = on; + _ha_entity_light->publishIsOn(power); + }); + _ha_entity_light->setOnBrightness( + [&](uint8_t b) { Serial.println("Got brightness " + String(b) + " for light"); brightness = b >> 1; + _ha_entity_light->publishBrightness(b); }); + } + }); + + setup_ota(); +} + +void transition1(); +void transition2(); +void transition3(); +void transition4(); +void transition5(); +void transition6(); + +uint8_t parameter = 0; + +void loop() { + ArduinoOTA.handle(); + _mqtt_remote->handle(); + + encoder1Button.loop(); + encoder2Button.loop(); + + if (encoder1Button.isPressed() || encoder2Button.isPressed()) { + power = !power; + _ha_entity_light->publishIsOn(power); + } + + dma_display->setBrightness8(power ? brightness : 0); + + uint8_t program = encoder2.getCount(); + parameter = encoder1.getCount(); + + switch (program % 6) { + case 0: + transition6(); + break; + case 1: + transition2(); + break; + case 2: + transition3(); + break; + case 3: + transition4(); + break; + case 4: + transition5(); + break; + case 5: + transition1(); + break; + } +} + +uint16_t interpolateRGB565(uint16_t color1, uint16_t color2, uint8_t t) { + // Extract the RGB components from color1 + uint8_t r1 = (color1 >> 11) & 0x1F; + uint8_t g1 = (color1 >> 5) & 0x3F; + uint8_t b1 = color1 & 0x1F; + + // Extract the RGB components from color2 + uint8_t r2 = (color2 >> 11) & 0x1F; + uint8_t g2 = (color2 >> 5) & 0x3F; + uint8_t b2 = color2 & 0x1F; + // Calculate the difference for each component + int16_t rdiff = r2 - r1; + int16_t gdiff = g2 - g1; + int16_t bdiff = b2 - b1; + + // Interpolate each component + // Using 32-bit integers for intermediate calculations to avoid overflow + uint8_t r = r1 + ((rdiff * t + 128) >> 8); + uint8_t g = g1 + ((gdiff * t + 128) >> 8); + uint8_t b = b1 + ((bdiff * t + 128) >> 8); + + + // Combine the components back into a 16-bit RGB565 color + return ((uint16_t)r << 11) | ((uint16_t)g << 5) | b; +} + + + +/** + * Interpolates between two RGB565 colors using HSV color space with fixed-point arithmetic. + * No floating point operations are used. Ensures smooth color transitions. + * + * @param color1 Starting color in RGB565 format (5 bits R, 6 bits G, 5 bits B) + * @param color2 Ending color in RGB565 format (5 bits R, 6 bits G, 5 bits B) + * @param t Interpolation factor from 0 to 255 (0 = color1, 255 = color2) + * @return Interpolated color in RGB565 format + */ +uint16_t interpolateHSV565(uint16_t color1, uint16_t color2, uint8_t t) { + // Extract RGB components from RGB565 colors + uint8_t r1 = (color1 >> 11) & 0x1F; + uint8_t g1 = (color1 >> 5) & 0x3F; + uint8_t b1 = color1 & 0x1F; + + uint8_t r2 = (color2 >> 11) & 0x1F; + uint8_t g2 = (color2 >> 5) & 0x3F; + uint8_t b2 = color2 & 0x1F; + + // Scale up to 8 bits for better precision + r1 = (r1 << 3) | (r1 >> 2); + g1 = (g1 << 2) | (g1 >> 4); + b1 = (b1 << 3) | (b1 >> 2); + + r2 = (r2 << 3) | (r2 >> 2); + g2 = (g2 << 2) | (g2 >> 4); + b2 = (b2 << 3) | (b2 >> 2); + + // Convert RGB to HSV for both colors (using fixed-point) + uint16_t h1, s1, v1; + uint16_t h2, s2, v2; + + // For color1 + uint8_t max1 = r1 > g1 ? (r1 > b1 ? r1 : b1) : (g1 > b1 ? g1 : b1); + uint8_t min1 = r1 < g1 ? (r1 < b1 ? r1 : b1) : (g1 < b1 ? g1 : b1); + uint16_t delta1 = max1 - min1; + + // Value is easy + v1 = max1; + + // Saturation (scaled to 0-255) + s1 = max1 == 0 ? 0 : ((uint32_t)delta1 * 255) / max1; + + // Hue (scaled to 0-1535 for fixed-point representation, 0-360 degrees * 4.26) + if (delta1 == 0) { + h1 = 0; // undefined, default to red + } else if (max1 == r1) { + int16_t diff = g1 - b1; + h1 = 0 + (((int32_t)diff * 256) / delta1); + if (h1 < 0) h1 += 1536; + } else if (max1 == g1) { + int16_t diff = b1 - r1; + h1 = 512 + (((int32_t)diff * 256) / delta1); + } else { // max1 == b1 + int16_t diff = r1 - g1; + h1 = 1024 + (((int32_t)diff * 256) / delta1); + } + + // For color2 (same calculations) + uint8_t max2 = r2 > g2 ? (r2 > b2 ? r2 : b2) : (g2 > b2 ? g2 : b2); + uint8_t min2 = r2 < g2 ? (r2 < b2 ? r2 : b2) : (g2 < b2 ? g2 : b2); + uint16_t delta2 = max2 - min2; + + v2 = max2; + s2 = max2 == 0 ? 0 : ((uint32_t)delta2 * 255) / max2; + + if (delta2 == 0) { + h2 = 0; + } else if (max2 == r2) { + int16_t diff = g2 - b2; + h2 = 0 + (((int32_t)diff * 256) / delta2); + if (h2 < 0) h2 += 1536; + } else if (max2 == g2) { + int16_t diff = b2 - r2; + h2 = 512 + (((int32_t)diff * 256) / delta2); + } else { // max2 == b2 + int16_t diff = r2 - g2; + h2 = 1024 + (((int32_t)diff * 256) / delta2); + } + + // Handle special case for grayscale colors (undefined hue) + if (s1 < 8) h1 = h2; + if (s2 < 8) h2 = h1; + + // Interpolate HSV components + uint16_t h_interp, s_interp, v_interp; + + // Hue needs special handling for the shortest path around the circle + int16_t h_diff = h2 - h1; + if (h_diff > 768) h_diff -= 1536; + else if (h_diff < -768) h_diff += 1536; + + // Use 32-bit arithmetic for the interpolation to avoid overflow issues + h_interp = h1 + (((int32_t)h_diff * t) >> 8); + if (h_interp >= 1536) h_interp -= 1536; + else if (h_interp < 0) h_interp += 1536; + + s_interp = s1 + (((int32_t)(s2 - s1) * t) >> 8); + v_interp = v1 + (((int32_t)(v2 - v1) * t) >> 8); + + // Convert interpolated HSV back to RGB using more precise fixed-point math + uint8_t r, g, b; + + uint8_t region = h_interp / 256; + uint16_t remainder = (h_interp % 256); + + // These calculations now use 32-bit fixed-point to avoid rounding errors + uint32_t p = ((uint32_t)v_interp * (255 - s_interp)) / 255; + uint32_t q = ((uint32_t)v_interp * (255 - ((s_interp * remainder) / 256))) / 255; + uint32_t t_val = ((uint32_t)v_interp * (255 - ((s_interp * (255 - remainder)) / 256))) / 255; + + switch (region) { + case 0: + r = v_interp; + g = t_val; + b = p; + break; + case 1: + r = q; + g = v_interp; + b = p; + break; + case 2: + r = p; + g = v_interp; + b = t_val; + break; + case 3: + r = p; + g = q; + b = v_interp; + break; + case 4: + r = t_val; + g = p; + b = v_interp; + break; + default: // case 5 + r = v_interp; + g = p; + b = q; + break; + } + + // Convert back to RGB565 format + r = (r >> 3); + g = (g >> 2); + b = (b >> 3); + + return (r << 11) | (g << 5) | b; +} + +uint16_t RGB888ToRGB565(uint32_t rgb888) { + // Extract 8-bit color components + uint8_t red = (rgb888 >> 16) & 0xFF; + uint8_t green = (rgb888 >> 8) & 0xFF; + uint8_t blue = rgb888 & 0xFF; + + // Convert 8-bit components to 5-6-5 bit format + uint16_t r = (red >> 3) & 0x1F; // 5 bits for red + uint16_t g = (green >> 2) & 0x3F; // 6 bits for green + uint16_t b = (blue >> 3) & 0x1F; // 5 bits for blue + + // Combine the components into a 16-bit value + return (r << 11) | (g << 5) | b; +} + +void transition1() { + + const int color1 = dma_display->color565(0xdf, 0xf5, 0x4f); + const int color2 = dma_display->color565(0x8d, 0x1f, 0xf2); + + static uint8_t state[YMAX]; + static unsigned long last_move_time = 0; + if (last_move_time == 0) { + for (int y = 0; y < YMAX; y++) { + state[y] = 127; + } + } + if (last_move_time + ((parameter + 50) % 255) < millis()) { + for (int y = 0; y < YMAX; y++) { + int move_length = rand() - RAND_MAX / 2; + int dir = state[y] < 64 || state[y] > 191 ? 4 : 1; + if (move_length > 0) { + dir = -dir; + } + state[y] += dir; + if (state[y] < 0) state[y] = 0; + if (state[y] > 255) state[y] = 255; + } + last_move_time = millis(); + } + + for (int x = 0; x < XMAX; x++) { + for(int y = 0; y < YMAX; y++) { + int t = x << 2; + + t += state[y] - 127; + + if (t <= 0) t = 1; + if (t >= 255) t = 254; + + int color = interpolateHSV565(color1, color2, t); + + dma_display->drawPixel(x, y, color); + } + } +} + +void transition2() { + const int color1 = dma_display->color565(0xdf, 0xf5, 0x4f); + const int color2 = dma_display->color565(0x8d, 0x1f, 0xf2); + const int color3 = dma_display->color565(0x6f, 0x55, 0xff); + const int color4 = dma_display->color565(0x1d, 0x9f, 0x23); + static unsigned long last_step = 0; + + static uint8_t t1 = 0; + static uint8_t t2 = 254; + static int8_t dir1 = 1; + static int8_t dir2 = 1; + + if (last_step + (255-parameter) < millis()) { + t1 += dir1; + t2 += dir2; + if (t1 == 255) { + t1 = 254; + dir1 = -1; + } + if (t1 == 0) { + t1 = 1; + dir1 = 1; + } + if (t2 == 255) { + t2 = 254; + dir2 = -1; + } + if (t2 == 0) { + t2 = 1; + dir2 = 1; + } + last_step = millis(); + + for (int x = 0; x < XMAX; x++) { + for(int y = 0; y < YMAX; y++) { + + int colorA = interpolateHSV565(color1, color2, t1); + int colorB = interpolateHSV565(color3, color4, t2); + + int color = interpolateRGB565(colorA, colorB, x << 2); + + dma_display->drawPixel(x, y, color); + } + } + } +} + +void transition3() { + //0x274001, 0x828a00, 0xf29f05, 0xf25c05, 0xd6568c, 0x4d8584, 0xa62f03, 0x400d01 + const int color1 = RGB888ToRGB565(0x274001); + const int color2 = RGB888ToRGB565(0x828a00); + const int color3 = RGB888ToRGB565(0xf29f05); + const int color4 = RGB888ToRGB565(0xf25c05); + const int color5 = RGB888ToRGB565(0xd6568c); + const int color6 = RGB888ToRGB565(0x4d8584); + const int color7 = RGB888ToRGB565(0xa62f03); + const int color8 = RGB888ToRGB565(0x400d01); + static unsigned long last_step = 0; + + static uint8_t t1 = 0; + static uint8_t t2 = 0; + static int8_t dir1 = 1; + static int8_t dir2 = 1; + + if (last_step + 100 < millis()) { + t1 += dir1; + t2 += dir2; + if (t1 == 255) { + t1 = 254; + dir1 = -1; + } + if (t1 == 0) { + t1 = 1; + dir1 = 1; + } + if (t2 == 255) { + t2 = 254; + dir2 = -1; + } + if (t2 == 0) { + t2 = 1; + dir2 = 1; + } + last_step = millis(); + + for (int x = 0; x < XMAX; x++) { + for(int y = 0; y < YMAX; y++) { + + int colorA = interpolateHSV565(color1, color5, t1); + int colorB = interpolateHSV565(color2, color6, t2); + int colorC = interpolateHSV565(color3, color7, t1); + int colorD = interpolateHSV565(color4, color8, t2); + + int colorX = interpolateRGB565(colorA, colorB, x << 2); + int colorY = interpolateRGB565(colorC, colorD, x << 2); + + int color = interpolateRGB565(colorX, colorY, y << 3); + + dma_display->drawPixel(x, y, color); + } + } + } +} + +void transition4() { + const uint8_t NUM_COLORS = 4; + const uint8_t NUM_PALETTES = 8; +// Color palettes (up to 4 colors per palette) +const uint16_t palettes[NUM_PALETTES][NUM_COLORS] = { + // 0: Soft purples and blues + { 0x780F, 0x401F, 0x001F, 0x4010 }, + // 1: Warm tones + { 0xF800, 0xFA60, 0xFFE0, 0xFD20 }, + // 2: Cool tones + { 0x001F, 0x07FF, 0x05BF, 0x041F }, + // 3: Fire + { 0xF800, 0xFC00, 0xFFE0, 0xFCA0 }, + // 4: Ocean + { 0x001F, 0x021F, 0x03FF, 0x05DF }, + // 5: Sunset + { 0xF800, 0xF81F, 0x780F, 0x001F }, + // 6: Neon + { 0xF81F, 0x07FF, 0xFFE0, 0x07E0 }, + // 7: Grayscale + { 0x0000, 0x528A, 0xAD55, 0xFFFF } +}; + +// Select the active palette here (index 0-7) +uint8_t currentPaletteIndex = parameter % 8; + const uint32_t TRANSITION_DURATION = 200000UL; + uint32_t t = millis(); + const uint16_t* currentPalette = palettes[currentPaletteIndex]; + + uint32_t totalCycle = NUM_COLORS * TRANSITION_DURATION; + uint32_t localTime = t % totalCycle; + + uint8_t index1 = (localTime / TRANSITION_DURATION) % NUM_COLORS; + uint8_t index2 = (index1 + 1) % NUM_COLORS; + + uint16_t timeInStep = localTime % TRANSITION_DURATION; + uint8_t blendT = (timeInStep * 255UL) / TRANSITION_DURATION; + + for (int x = 0; x < XMAX; x++) { + for (int y = 0; y < YMAX; y++) { + uint8_t offset = ((x + y) * 2 + (t >> 4)) & 0x3F; + uint8_t finalBlend = (blendT + offset) & 0xFF; + + uint16_t color = interpolateRGB565(currentPalette[index1], currentPalette[index2], finalBlend); + dma_display->drawPixel(x, y, color); + } + } +} + + +void transition5() { + static const uint8_t NUM_COLORS = 4; + static const uint8_t NUM_PALETTES = 8; + static const uint32_t COLOR_DURATION = 100000UL; // 100s per color blend + static const uint32_t PALETTE_BLEND_DURATION = 200000UL; // 200s per full palette blend + + // More vibrant and distinct palettes + static const uint16_t palettes[NUM_PALETTES][NUM_COLORS] = { + { 0xF81F, 0x07FF, 0xFFE0, 0x07E0 }, // Neon Glow + { 0xF800, 0xFC00, 0xFFE0, 0xFFFF }, // Lava + { 0x001F, 0x07FF, 0x05BF, 0x87FF }, // Ocean + { 0x07FF, 0x780F, 0xFD20, 0xFFFF }, // Retro + { 0x001F, 0xF81F, 0x780F, 0x07E0 }, // Cyberpunk + { 0x07E0, 0xF81F, 0x780F, 0x07FF }, // Aurora + { 0xF800, 0xFFE0, 0x07E0, 0x001F }, // Rainbow + { 0x7800, 0x780F, 0xFD20, 0xFBE0 } // Sunset + }; + + uint32_t t = millis(); + + // Time into palette blend cycle + uint32_t paletteTime = t % PALETTE_BLEND_DURATION; + uint8_t paletteIndex1 = (t / PALETTE_BLEND_DURATION) % NUM_PALETTES; + uint8_t paletteIndex2 = (paletteIndex1 + 1) % NUM_PALETTES; + + // Blend between two palettes + uint8_t paletteBlendT = (paletteTime * 255UL) / PALETTE_BLEND_DURATION; + + // Color transition timing + uint32_t localColorTime = t % (NUM_COLORS * COLOR_DURATION); + uint8_t colorIndex1 = (localColorTime / COLOR_DURATION) % NUM_COLORS; + uint8_t colorIndex2 = (colorIndex1 + 1) % NUM_COLORS; + + uint16_t colorStepTime = localColorTime % COLOR_DURATION; + uint8_t baseBlend = (colorStepTime * 255UL) / COLOR_DURATION; + + // Blend the two palettes into a temporary working palette + uint16_t blendedPalette[NUM_COLORS]; + for (uint8_t i = 0; i < NUM_COLORS; i++) { + blendedPalette[i] = interpolateRGB565( + palettes[paletteIndex1][i], + palettes[paletteIndex2][i], + paletteBlendT + ); + } + + // Center coordinates + int16_t centerX = XMAX / 2; + int16_t centerY = YMAX / 2; + + for (int x = 0; x < XMAX; x++) { + for (int y = 0; y < YMAX; y++) { + int16_t dx = x - centerX; + int16_t dy = y - centerY; + + uint16_t distance = (uint16_t)sqrt(dx * dx + dy * dy); + uint8_t wave = (distance * 8 + (t >> 5)) & 0xFF; + uint8_t finalBlend = (baseBlend + wave) & 0xFF; + + uint16_t color = interpolateRGB565( + blendedPalette[colorIndex1], + blendedPalette[colorIndex2], + finalBlend + ); + + dma_display->drawPixel(x, y, color); + } + } +} + + +void transition6() { + static const uint8_t NUM_COLORS = 4; + static const uint8_t NUM_SEGMENTS = NUM_COLORS; // for smooth wrap (4 segments: 0→1→2→3→0) + static const uint8_t NUM_PALETTES = 8; + + static const uint32_t COLOR_DURATION = 200000UL; // 200s scroll loop + static const uint32_t PALETTE_BLEND_DURATION = 800000UL; // 800s palette transition + + static const uint8_t palettes[NUM_PALETTES][NUM_COLORS][3] = { + { {255, 0, 0}, {255, 127, 0}, {255, 255, 0}, {0, 255, 0} }, // Rainbow + { {85, 207, 252}, {247, 168, 184}, {255, 255, 255}, {85, 207, 252} }, // Trans + { {214, 2, 111}, {155, 79, 150}, {0, 56, 168}, {214, 2, 111} }, // Bi + { {255, 33, 140}, {255, 181, 0}, {0, 173, 239}, {255, 33, 140} }, // Pan + { {0, 0, 0}, {164, 164, 164}, {255, 255, 255}, {128, 0, 128} }, // Asexual + { {244, 200, 0}, {255, 255, 255}, {165, 165, 165}, {155, 89, 182} }, // Non-binary + { {255, 117, 160}, {243, 60, 140}, {108, 30, 143}, {76, 109, 155} }, // Genderfluid + { {214, 46, 0}, {255, 90, 54}, {255, 154, 139}, {209, 98, 169} } // Lesbian + }; + + uint32_t t = millis(); + + // Palette transition + uint32_t paletteTime = t % PALETTE_BLEND_DURATION; + uint8_t paletteIndex1 = (t / PALETTE_BLEND_DURATION) % NUM_PALETTES; + uint8_t paletteIndex2 = (paletteIndex1 + 1) % NUM_PALETTES; + uint8_t blendT = (paletteTime * 255UL) / PALETTE_BLEND_DURATION; + + // Create blended palette with wrap + uint16_t blendedPalette[NUM_COLORS + 1]; // add one for wraparound + for (uint8_t i = 0; i < NUM_COLORS; i++) { + uint32_t rgb1 = (palettes[paletteIndex1][i][0] << 16) | + (palettes[paletteIndex1][i][1] << 8) | + palettes[paletteIndex1][i][2]; + uint32_t rgb2 = (palettes[paletteIndex2][i][0] << 16) | + (palettes[paletteIndex2][i][1] << 8) | + palettes[paletteIndex2][i][2]; + uint16_t c1 = RGB888ToRGB565(rgb1); + uint16_t c2 = RGB888ToRGB565(rgb2); + blendedPalette[i] = interpolateRGB565(c1, c2, blendT); + } + // Wrap the first color to the end + blendedPalette[NUM_COLORS] = blendedPalette[0]; + + // Gradient and scroll setup + uint16_t gradientWidth = XMAX; + uint16_t segmentWidth = gradientWidth / NUM_SEGMENTS; + uint16_t scrollOffset = (uint32_t)(t % COLOR_DURATION) * gradientWidth / COLOR_DURATION; + + for (int x = 0; x < XMAX; x++) { + uint16_t gradientX = (x + scrollOffset) % gradientWidth; + uint8_t segment = gradientX / segmentWidth; + if (segment >= NUM_SEGMENTS) segment = NUM_SEGMENTS - 1; + + uint8_t localX = gradientX - segment * segmentWidth; + uint8_t blendFactor = (localX * 255) / segmentWidth; + + uint16_t color = interpolateRGB565( + blendedPalette[segment], + blendedPalette[segment + 1], + blendFactor + ); + + for (int y = 0; y < YMAX; y++) { + dma_display->drawPixel(x, y, color); + } + } +} + diff --git a/transigione/test/README b/transigione/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/transigione/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html diff --git a/wallmount.stl b/wallmount.stl new file mode 100644 index 0000000000000000000000000000000000000000..79e5dd3f8070e7541cda2d6574a458a8cd908288 GIT binary patch literal 61284 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 JrIbea{{q!gy(0hs literal 0 HcmV?d00001