{"id":259,"date":"2026-02-17T00:08:34","date_gmt":"2026-02-17T00:08:34","guid":{"rendered":"https:\/\/farnsworth.engineering\/?p=259"},"modified":"2026-02-22T22:36:41","modified_gmt":"2026-02-22T22:36:41","slug":"notifier","status":"publish","type":"post","link":"https:\/\/farnsworth.engineering\/index.php\/2026\/02\/17\/notifier\/","title":{"rendered":"! Notifier"},"content":{"rendered":"\n<p>What started out as a dedicated piece of hardware for a driveway and mailbox sensor has since turned into a quick message board for me to pass love notes to my wife during the day. <\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"480\" height=\"640\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/InShot_20260216_140244987.gif\" alt=\"\" class=\"wp-image-263\"\/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Demo Video<\/h2>\n\n\n\n<figure class=\"wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-9-16 wp-has-aspect-ratio\"><div class=\"wp-block-embed__wrapper\">\n<iframe loading=\"lazy\" title=\"! Notifier Demo\" width=\"443\" height=\"788\" src=\"https:\/\/www.youtube.com\/embed\/C5UuRmCwZ78?feature=oembed\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen><\/iframe>\n<\/div><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Backstory<\/h2>\n\n\n\n<p>A few years ago I purchased a set of 4x of these 8&#215;16 LED displays after watching a <a href=\"https:\/\/www.youtube.com\/watch?v=If-hiuwsQh0\" data-type=\"link\" data-id=\"https:\/\/www.youtube.com\/watch?v=If-hiuwsQh0\">video by Upir.<\/a> <\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.aliexpress.us\/item\/3256806035044737.html?spm=a2g0o.productlist.main.14.ac89fpkjfpkj9K&amp;algo_pvid=af7f307f-d4f8-4a83-a2e6-4485c079409a&amp;algo_exp_id=af7f307f-d4f8-4a83-a2e6-4485c079409a-13&amp;pdp_ext_f=%7B%22order%22%3A%2210%22%2C%22eval%22%3A%221%22%2C%22fromPage%22%3A%22search%22%7D&amp;pdp_npi=6%40dis%21USD%215.24%215.00%21%21%2135.98%2134.30%21%40210337c117712873245796611ebda3%2112000036341127029%21sea%21US%212417307347%21X%211%210%21n_tag%3A-29919%3Bd%3Ac9b11a36%3Bm03_new_user%3A-29895&amp;curPageLogUid=Yq5AkYzoy2yT&amp;utparam-url=scene%3Asearch%7Cquery_from%3A%7Cx_object_id%3A1005006221359489%7C_p_origin_prod%3A\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"448\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-1024x448.png\" alt=\"\" class=\"wp-image-264\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-1024x448.png 1024w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-300x131.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-768x336.png 768w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image.png 1257w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><\/a><figcaption class=\"wp-element-caption\">Aliexpress hyperlinked in this image. <\/figcaption><\/figure>\n\n\n\n<p>I was inspired to make a binary clock with a custom aperture made on my bambulab printer (super exciting, this multiple-filament business). It was a fun start and honestly a good excuse to vibe code up some real-world displays. I wanted a desktop toy that I could power with an ESP32 and display the time, weather, and some other data. <\/p>\n\n\n\n<figure class=\"wp-block-video\"><video height=\"1080\" style=\"aspect-ratio: 1920 \/ 1080;\" width=\"1920\" controls src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/Snapchat-1168882157.mp4\"><\/video><figcaption class=\"wp-element-caption\">It was stunning in real life. Something with the color blue and these phone camera sensors just gets blown out. <\/figcaption><\/figure>\n\n\n\n<p>This was shortly before I decided on the <a href=\"https:\/\/farnsworth.engineering\/index.php\/2025\/03\/17\/retro-crt-weather-display\/\">CRT Weather Display<\/a> as my primary project for 2025. These LED panels quickly got shelved. <\/p>\n\n\n\n<p>Fast forward to 2025 and my wife and I had a second kid, bought a house, and are now dealing with a long walk to the mailbox and no way to tell if someone is coming up our driveway. <\/p>\n\n\n\n<p>Like Chekhov&#8217;s Gun these little panels sat in my box full of random project parts for a little over two years only to meet their fate last night. <\/p>\n\n\n\n<figure class=\"wp-block-image size-full is-resized\"><img loading=\"lazy\" decoding=\"async\" width=\"522\" height=\"902\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-1.png\" alt=\"\" class=\"wp-image-267\" style=\"width:457px;height:auto\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-1.png 522w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-1-174x300.png 174w\" sizes=\"auto, (max-width: 522px) 100vw, 522px\" \/><figcaption class=\"wp-element-caption\">Not the best fate being direct-soldered to a piece of perf board but hey, they&#8217;re no longer in the box of unused shit I&#8217;ve bought from China. <\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Beginning<\/h2>\n\n\n\n<p>The idea came to me as I was exploring options for notifying the household of mail or a vehicle coming up my driveway. Why not have a dedicated art piece that contains a bell, and a set of icons for discerning between us getting mail and a car driving up the road? Light up the mail icon for mail and the truck icon for road. This would let me use those panels I bought so long ago (and make custom light masks on my 3D printer again!) I&#8217;m losing money if I don&#8217;t buy the bell for this project!<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"324\" height=\"248\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-2.png\" alt=\"\" class=\"wp-image-268\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-2.png 324w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-2-300x230.png 300w\" sizes=\"auto, (max-width: 324px) 100vw, 324px\" \/><figcaption class=\"wp-element-caption\">Much prettier in my mind&#8217;s eye. But you get the drift. <\/figcaption><\/figure>\n\n\n\n<p>I got <a href=\"https:\/\/www.aliexpress.us\/item\/3256808101638594.html?spm=a2g0o.order_detail.order_detail_item.3.7202f19crZjI82&amp;gatewayAdapt=glo2usa\">the bell<\/a> on order from AliExpress as the Amazon options were quite limited and honestly too large for my need. I would have chosen a smaller bell, but lacked the search terms to find something smaller and cheaper. In hindsight an unpowered children&#8217;s bicycle bell would have probably worked just fine!<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"437\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-3-1024x437.png\" alt=\"\" class=\"wp-image-269\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-3-1024x437.png 1024w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-3-300x128.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-3-768x328.png 768w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-3.png 1255w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><\/figure>\n\n\n\n<p>Once the bell arrived I tested it out and it quickly found a spot on my shelf next to all the other project parts I&#8217;ll definitely get to some day. I didn&#8217;t quite like the way it clanged like a fire alarm so I needed to brainstorm solutions for that. <\/p>\n\n\n\n<p>The bell then sat dormant for the next four months. <\/p>\n\n\n\n<h2 class=\"wp-block-heading\">I&#8217;m on a Roll!<\/h2>\n\n\n\n<p>Since it&#8217;s winter, a lot of my outdoor home improvement tasks have been put on hold while I wait out the rain here in the Pacific Northwest. I had a few mission critical tasks I needed to sort out before I could get to the notifier however. Through some miracle, my 5 backburner projects have all been completed or in a state where I was out of excuses for not working on this one. <\/p>\n\n\n\n<p>With the long President&#8217;s day weekend ahead of me I began drafting my idea in Solidworks. I designed a simple, bi-color proof of concept unit that I could hang on a wall and test fit around the house. <\/p>\n\n\n\n<p>The first problem this design highlighted was how freaking large it was! I could not get the whole thing to fit on the bed of my printer so I had to cut it into pieces and glue them together later. <\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"392\" height=\"957\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-4.png\" alt=\"\" class=\"wp-image-270\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-4.png 392w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-4-123x300.png 123w\" sizes=\"auto, (max-width: 392px) 100vw, 392px\" \/><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Quick Side Bar<\/h2>\n\n\n\n<p>In order to cut the model into pieces I used the Split command in solidworks. This turned my singular part into multiple bodies. To make 3D printing easier (one filament per body) I also unchecked &#8220;Merge result&#8221; in my boss extrudes.  I was then left with four bodies to export as STEP files. To do this manually (at least in SW2014) is SUPER TEDIOUS. The user has to delete the unused bodies, save the single body as a STEP, undo the delete, and re-delete a new set of bodies for the next save. This quickly got old so I turned to the <a href=\"https:\/\/chatgpt.com\/\">internet plagiarism machine<\/a> to help me write my first macro. With a little debugging I actually got functional code and a macro that exports all bodies in a part within seconds! I also stole\/made a macro for multi-body STL files, but don&#8217;t like how STL handles curves. Both macros are posted below. The STL macro is tailored to put the output files into C:\\Users\\solidworks\\Desktop\\Conrads Projects\\Multi_Body_STL_Exports be sure to change this for your setup!<\/p>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>MULTI-BODY STEP EXPORTER MACRO<\/summary>\n<pre class=\"wp-block-code\"><code>Option Explicit\n\n' --- numeric constants ---\nConst swDocPART = 1\nConst swExportSTEP = 6\nConst swSaveAsCurrentVersion = 0\nConst swSaveAsOptions_Silent = 2\nConst swSaveAsOptions_Copy = 8\nConst TEMPLATE_PATH = \"C:\\ProgramData\\SolidWorks\\SolidWorks 2014\\templates\\Part.prtdot\"\n\n\nDim swApp As Object\nDim src As Object               ' ModelDoc2 (source part)\nDim BodyArr As Variant\n\nSub main()\n    Set swApp = Application.SldWorks\n    Set src = swApp.ActiveDoc\n    If src Is Nothing Then Exit Sub\n    If src.GetType &lt;&gt; swDocPART Then\n        MsgBox \"Open a PART document.\", vbExclamation\n        Exit Sub\n    End If\n    ' If the part isn't saved, we'll export to Desktop\n    ' (no need to exit)\n\n\n    Dim saveDir As String\n    If src.GetPathName &lt;&gt; \"\" Then\n        saveDir = Left(src.GetPathName, InStrRev(src.GetPathName, \"\\\") - 1)\n    Else\n        Dim wsh As Object: Set wsh = CreateObject(\"WScript.Shell\")\n        saveDir = wsh.SpecialFolders(\"Desktop\")\n    End If\n    If Dir(saveDir, vbDirectory) = vbNullString Then MkDir saveDir\n\n\n    ' get all bodies in the source part\n    BodyArr = src.GetBodies2(0, False)\n    If IsEmpty(BodyArr) Then\n        MsgBox \"No bodies found.\", vbExclamation\n        Exit Sub\n    End If\n\n    Dim i As Long, swBody As Object\n    Dim exported As Long, failed As Long\n\n    For i = 0 To UBound(BodyArr)\n        Set swBody = BodyArr(i)\n        If swBody Is Nothing Then GoTo NextI\n\n        ' --- create a brand-new part and INSERT the body without selection ---\n        Dim partTplt As String: partTplt = TEMPLATE_PATH\n        If partTplt = \"\" Then\n            MsgBox \"Set a default Part template in SW Options &gt; Default Templates.\", vbExclamation\n            Exit Sub\n        End If\n\n        Dim tmp As Object\n        Set tmp = swApp.NewDocument(partTplt, 0, 0, 0)\n        If tmp Is Nothing Then\n            failed = failed + 1: GoTo NextI\n        End If\n\n        ' Copy the body and insert via CreateFeatureFromBody3 (bulletproof in SW2014)\n        Dim freeBody As Object\n        On Error Resume Next\n        Set freeBody = swBody.Copy\n        On Error GoTo 0\n        If freeBody Is Nothing Then\n            ' fall back: try using original body reference\n            Set freeBody = swBody\n        End If\n\n        Dim partIf As Object\n        Set partIf = tmp ' IPartDoc late-bound\n        Dim feat As Object\n        On Error Resume Next\n        Set feat = partIf.CreateFeatureFromBody3(freeBody, False, 0)\n        On Error GoTo 0\n\n        If feat Is Nothing Then\n            ' nothing inserted; close and mark failed\n            On Error Resume Next: swApp.CloseDoc tmp.GetTitle: On Error GoTo 0\n            failed = failed + 1: GoTo NextI\n        End If\n\n        SafeRebuild tmp\n        If Not HasBodies(tmp) Then\n            On Error Resume Next: swApp.CloseDoc tmp.GetTitle: On Error GoTo 0\n            failed = failed + 1: GoTo NextI\n        End If\n\n        ' STEP export options (late bound)\n        Dim expData As Object\n        Set expData = swApp.GetExportFileData(swExportSTEP)\n        On Error Resume Next\n        expData.AP214 = True\n        expData.ExportSketchEntities = False\n        expData.ExportAnnotations = False\n        On Error GoTo 0\n\n        ' filename and export\n        Dim base As String, outPath As String\n        base = Format(i, \"000\") &amp; \"_\" &amp; SafeName(swBody.Name)\n        outPath = saveDir &amp; \"\\\" &amp; base &amp; \".step\"\n\n        Dim longstatus As Long, longwarnings As Long, ok As Boolean\n        longstatus = 0: longwarnings = 0\n        ok = tmp.Extension.SaveAs( _\n                outPath, _\n                swSaveAsCurrentVersion, _\n                swSaveAsOptions_Silent Or swSaveAsOptions_Copy, _\n                expData, _\n                longstatus, _\n                longwarnings)\n\n        ' close temp part regardless\n        On Error Resume Next\n        swApp.CloseDoc tmp.GetTitle\n        On Error GoTo 0\n\n        If ok Then exported = exported + 1 Else failed = failed + 1\n\nNextI:\n    Next\n\n    src.ClearSelection2 True\n    MsgBox \"STEP export complete. Exported: \" &amp; exported &amp; \", Failed: \" &amp; failed &amp; vbCrLf &amp; _\n          \"Folder: \" &amp; saveDir, vbInformation\nEnd Sub\n\nPrivate Sub SafeRebuild(ByVal mdl As Object)\n    On Error Resume Next\n    mdl.EditRebuild3\n    On Error GoTo 0\nEnd Sub\n\nPrivate Function HasBodies(ByVal mdl As Object) As Boolean\n    On Error Resume Next\n    Dim arr As Variant\n    arr = mdl.GetBodies2(0, False)\n    HasBodies = Not IsEmpty(arr)\n    On Error GoTo 0\nEnd Function\n\nPrivate Function SafeName(ByVal s As String) As String\n    Dim bad As Variant, r As Variant\n    bad = Array(\"\\\", \"\/\", \":\", \"*\", \"?\", Chr$(34), \"&lt;\", \"&gt;\", \"|\")\n    For Each r In bad\n        s = Replace$(s, CStr(r), \"_\")\n    Next\n    If Len(Trim$(s)) = 0 Then s = \"Body\"\n    SafeName = s\nEnd Function\n\n\nPrivate Function GetDefaultPartTemplate(app As Object) As String\n    ' Try SolidWorks default template setting first\n    On Error Resume Next\n    Dim p As String\n    p = app.GetUserPreferenceStringValue(2) ' swDefaultTemplatePart\n    On Error GoTo 0\n    If Len(p) &gt; 0 And Len(Dir$(p)) &gt; 0 Then\n        GetDefaultPartTemplate = p\n        Exit Function\n    End If\n\n    ' Try a few common install paths (adjust if needed)\n    Dim c As Variant\n    Dim candidates As Variant\n    candidates = Array( _\n        \"C:\\ProgramData\\SolidWorks\\SOLIDWORKS 2014\\templates\\Part.prtdot\", _\n        \"C:\\ProgramData\\SolidWorks\\SolidWorks 2014\\templates\\Part.prtdot\", _\n        \"C:\\ProgramData\\SolidWorks\\SolidWorks 2013\\templates\\Part.prtdot\", _\n        \"C:\\ProgramData\\SolidWorks\\SOLIDWORKS 2015\\templates\\Part.prtdot\")\n    For Each c In candidates\n        If Len(Dir$(c)) &gt; 0 Then\n            GetDefaultPartTemplate = CStr(c)\n            Exit Function\n        End If\n    Next\n\n    ' Last resort: prompt user to paste a template path\n    Dim userPath As String\n    userPath = InputBox(\"Enter the full path to a Part template (*.prtdot)\", _\n                        \"Select Part Template\", _\n                        \"C:\\ProgramData\\SolidWorks\\SOLIDWORKS 2014\\templates\\Part.prtdot\")\n    If Len(userPath) &gt; 0 And Len(Dir$(userPath)) &gt; 0 Then\n        GetDefaultPartTemplate = userPath\n    Else\n        GetDefaultPartTemplate = \"\"\n    End If\nEnd Function<\/code><\/pre>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>MULTI-BODY STL EXPORTER MACRO<\/summary>\n<p>Dim swApp As Object<br><br>Dim Part As Object<br><br>Dim boolstatus As Boolean<br><br>Dim longstatus As Long, longwarnings As Long<br><br>Dim MyPath As String<br><br>Dim MyDate As String<br><br>Dim MyFilename As String<br><br>Dim MySaveasDir As String<br><br>Dim Cnt As Integer<br><br>Dim Body_Vis_States() As Boolean<br><br>Dim BodyArr As Variant<br><br>Sub main()<br><br>Set swApp = Application.SldWorks<br><br>Set Part = swApp.ActiveDoc<br><br>Dim myModelView As Object<br><br>Set myModelView = Part.ActiveView<br><br>myModelView.FrameState = swWindowState_e.swWindowMaximized<br><br>&#8216; Gets folder path of current part<br><br>MyPath = Left(Part.GetPathName, InStrRev(Part.GetPathName, &#8220;\\&#8221;) &#8211; 1)<br><br>&#8216; Used to create a directory for the STL file with a date code<br><br>MyDate = Format(Now(), &#8220;yyyy-mm-dd&#8221;)<br><br>&#8216; Uncomment this line for STL files to use same file name as the part file<br><br>&#8216; MyFilename = Left(Part.GetTitle(), InStrRev(Part.GetTitle(), &#8220;.&#8221;) &#8211; 1)<br><br>&#8216; Uncomment this line to have the user set the name of the STL files<br><br>&#8216; MyFilename = InputBox(&#8220;Set the name for the STL file(s)&#8221;)<br><br>&#8216; Sets the directory to store the STL files, I like this format but it can be changed<br><br>&#8216; MySaveasDir = &#8220;C:\\Users\\solidworks\\Desktop\\Conrads Projects\\Multi_Body_STL_Exports&#8221; &#8216;<br>MySaveasDir = MyPath<br><br>&#8216; checks if MySaveAsDir directory exists, if not it will create one, otherwise an error will occur<br><br>If Dir(MySaveasDir, vbDirectory) = vbNullString Then<br><br>MkDir (MySaveasDir)<br><br>&#8216; MsgBox &#8220;Folder &#8221; &amp; MySaveasDir &amp; &#8221; Created&#8221;<br><br>End If<br><br>&#8216; creates an array of all the bodies in the current part<br><br>BodyArr = Part.GetBodies2(0, False)<br><br>&#8216; Get current visibility state of all bodies, put into an array<br><br>For Cnt = 0 To UBound(BodyArr)<br><br>Set swBody = BodyArr(Cnt)<br><br>If Not swBody Is Nothing Then<br><br>ReDim Preserve Body_Vis_States(0 To Cnt)<br><br>Body_Vis_States(Cnt) = swBody.Visible<br><br>&#8216; MsgBox (&#8220;Body &#8221; &amp; Cnt &amp; &#8221; Visibility: &#8221; &amp; Body_Vis_States(Cnt))<br><br>End If<br><br>Next Cnt<br><br>&#8216; Hide all bodies<br><br>For Cnt = 0 To UBound(BodyArr)<br><br>Set swBody = BodyArr(Cnt)<br><br>If Not swBody Is Nothing Then<br><br>swBody.HideBody (True)<br><br>&#8216; MsgBox (&#8220;Body &#8221; &amp; Cnt &amp; &#8221; Hidden&#8221;)<br><br>End If<br><br>Next Cnt<br><br>&#8216; Show each body one by one, save as STL, then hide again<br><br>For Cnt = 0 To UBound(BodyArr)<br><br>Set swBody = BodyArr(Cnt)<br><br>If Not swBody Is Nothing Then<br><br>swBody.HideBody (False)<br><br>longstatus = Part.SaveAs3(MySaveasDir &amp; &#8220;\\&#8221; &amp; swBody.Name &amp; &#8220;.stl&#8221;, 0, 2) &#8216;<br><br>&#8216; MsgBox (VarType(BodyArr(Cnt)))<br><br>swBody.HideBody (True)<br><br>End If<br><br>Next Cnt<br><br>&#8216; Put bodies back in the original visibility state<br><br>For Cnt = 0 To UBound(BodyArr)<br><br>Set swBody = BodyArr(Cnt)<br><br>If Not swBody Is Nothing Then<br><br>swBody.HideBody (Not Body_Vis_States(Cnt))<br><br>End If<br><br>Next Cnt<br><br>&#8216; Open the window containing the STLs<br><br>&#8216; Shell &#8220;explorer.exe &#8221; &amp; MySaveasDir &amp; &#8220;\\&#8221;, vbNormalFocus<br><br>End Sub<br><\/p>\n<\/details>\n\n\n\n<h2 class=\"wp-block-heading\">Mechanical Design<\/h2>\n\n\n\n<p>I&#8217;m sure this is a lack of user experience, but I first tried to make a new part for each 3D print in the assembly. I started out with the base and included cutouts for the white prints. I then added a new part directly to the assembly and referenced the base for the new part&#8217;s dimensions. I quickly discovered that changing the dimensions of the base did NOT change the dimensions of the new part. This was going to get out of hand quick. I could have the whole model built up and a single dimension change could wreck fitment for all subsequent models. Instead I took a different approach, one that I&#8217;ve seen Fusion users do a lot of: I modeled all 3D prints in a single solidpart file and made sure to make them separate bodies. Since all the parts refence geometry in the same solidpart file any underlying geometry changes will influence the other parts. 10\/10 strategy. For clarity, I still had an assembly with non-3D printed parts that I could use for dimensioning off of so I&#8217;m not 100% out of the external reference woods. <\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"430\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-5-1024x430.png\" alt=\"\" class=\"wp-image-272\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-5-1024x430.png 1024w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-5-300x126.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-5-768x322.png 768w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-5-1536x644.png 1536w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-5-2048x859.png 2048w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><figcaption class=\"wp-element-caption\">6 parts for the price of one. I think I might use this method for future multi-print assemblies.<\/figcaption><\/figure>\n\n\n\n<p>The design was centered around that freaking massive bell I ordered. Luckily I was able to split the prints around each major component (the bell and the LED panels). <\/p>\n\n\n\n<p>3D printing went smooth. The black slender part of the exclamation mark did suffer from warping so I re-printed it with 65C bed instead of 55C and that solved all my warping issues. <\/p>\n\n\n\n<p>Real Quick, I&#8217;ll go through all of the 3D Prints and their purpose. <\/p>\n\n\n\n<h3 class=\"wp-block-heading\">BELL_BASE<\/h3>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"957\" height=\"927\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-7.png\" alt=\"\" class=\"wp-image-274\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-7.png 957w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-7-300x291.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-7-768x744.png 768w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><\/figure>\n\n\n\n<p>This part contains the USB C bulkhead, and clearance holes for attaching the BELL_Holder. It also contains 4x holes for heat-set inserts. This let&#8217;s us attach the BELL_BASE to the LED_BASE. <\/p>\n\n\n\n<h3 class=\"wp-block-heading\">LED_BASE<\/h3>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"576\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-8-1024x576.png\" alt=\"\" class=\"wp-image-275\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-8-1024x576.png 1024w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-8-300x169.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-8-768x432.png 768w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-8.png 1289w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><figcaption class=\"wp-element-caption\">Cross section view.<\/figcaption><\/figure>\n\n\n\n<p>The LED_BASE contains clearance holes for attaching to the BELL_BASE and for the LED_Holder. It also contains a feature for wall-mounting that&#8217;s 3D printer friendly. <\/p>\n\n\n\n<h3 class=\"wp-block-heading\">LED_HOLDER_BLOCKER and LED_Holder<\/h3>\n\n\n\n<p>This is a multi-color print. I wanted to get around the light bleed so I could have nice crisp pixels but honestly I don&#8217;t think it mattered as much and the black light shield shows up through the print like Cousin Eddie&#8217;s dickey. <\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"640\" height=\"360\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-9.png\" alt=\"\" class=\"wp-image-276\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-9.png 640w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-9-300x169.png 300w\" sizes=\"auto, (max-width: 640px) 100vw, 640px\" \/><\/figure>\n\n\n\n<p>This gets printed as one piece, you can just set the color of the blocker to white as well and you won&#8217;t get such an ugly print. I&#8217;m not going to do that because there&#8217;s approximately 15 heat-set inserts I would need to throw out. <\/p>\n\n\n\n<p>To test fitment I did 3D print the whole piece and a cutaway view so I could see any tolerancing issues I had with the LED panels (there were none!)<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"686\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-10-1024x686.png\" alt=\"\" class=\"wp-image-277\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-10-1024x686.png 1024w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-10-300x201.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-10-768x514.png 768w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-10.png 1193w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><figcaption class=\"wp-element-caption\">Model<\/figcaption><\/figure>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"565\" height=\"832\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-11.png\" alt=\"\" class=\"wp-image-278\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-11.png 565w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-11-204x300.png 204w\" sizes=\"auto, (max-width: 565px) 100vw, 565px\" \/><figcaption class=\"wp-element-caption\">IRL Phantom of the Opera style. <\/figcaption><\/figure>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"854\" height=\"406\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-12.png\" alt=\"\" class=\"wp-image-279\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-12.png 854w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-12-300x143.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-12-768x365.png 768w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><figcaption class=\"wp-element-caption\">Full-scale print. <\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">BELL_Holder<\/h3>\n\n\n\n<p>The bell holder does what the name describes. It holds the bell. There&#8217;s two slots for positioning a 12V solenoid, a hole for fishing the solenoid up through, a heat-set insert hole for the bell bolt and 4x 2mm heat-set inserts on the bottom for attaching to the BELL_BASE. The only unpopular thing about this part is there&#8217;s no good orientation to print it. There&#8217;s a lip at the bottom to center it in the BELL_BASE. I suppose this lip could be removed but with it in, you need to enable supports and it looks kind of crappy coming off the printer. <\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"628\" height=\"487\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-13.png\" alt=\"\" class=\"wp-image-280\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-13.png 628w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-13-300x233.png 300w\" sizes=\"auto, (max-width: 628px) 100vw, 628px\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">LED_CLAMP<\/h3>\n\n\n\n<p>The LED_CLAMP does what the name implies, it clamps the LED panels to the LED_Holder. I didn&#8217;t want to glue the panels in. I also wanted hooks in place for zip-tying my proto board to the unit as a whole as that bit was an after-though. The squares on the print are for feeding through the wires. I really should have left them as cutouts so you can install this and then fish the wires around. It&#8217;s a bit fiddley. <\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"449\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-14-1024x449.png\" alt=\"\" class=\"wp-image-281\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-14-1024x449.png 1024w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-14-300x131.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-14-768x336.png 768w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-14-1536x673.png 1536w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-14.png 1607w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><\/figure>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"916\" height=\"690\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-15.png\" alt=\"\" class=\"wp-image-282\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-15.png 916w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-15-300x226.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-15-768x579.png 768w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><figcaption class=\"wp-element-caption\">a bit fiddly to get in but it does the job of clamping down on my panels!<\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Electrical Design<\/h2>\n\n\n\n<p>I had a lot of fun just throwing shit on to a proto board. Normally I would do a PCB but at the time of doing this project I have five concurrent PCB projects going at work and at home and am a bit burned out. I&#8217;m also learning Altium for work so my brain is just&#8230;full. The best you&#8217;ll get is a Powerpoint drawing of everything. <\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"757\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-18-1024x757.png\" alt=\"\" class=\"wp-image-285\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-18-1024x757.png 1024w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-18-300x222.png 300w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-18-768x568.png 768w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-18.png 1198w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><\/figure>\n\n\n\n<p>Nothing super special here. The solenoid draws like 200mA always-on. The ESP32 does have a current limit so we&#8217;re actually close to not being in saturation. Practically, I don&#8217;t see any issues with this. <\/p>\n\n\n\n<p>The LED displays run on 3.3V power from the ESP32 and I was worried about the linear regulator providing power for 3 full panels and the on-board WiFi. I put the panels into a test mode (full on) and left them powered for about an hour. No noticeable heating to the voltage regulator. <\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Other Parts (Amazon Links and AliExpress Links)<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><td class=\"has-text-align-center\" data-align=\"center\">Item<\/td><td class=\"has-text-align-center\" data-align=\"center\">Quantity<\/td><td class=\"has-text-align-center\" data-align=\"center\">Note<\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/a.co\/d\/044bfjgv\">12V Solenoid<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\"><\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/www.aliexpress.us\/item\/3256806035044737.html?spm=a2g0o.productlist.main.14.ac89fpkjfpkj9K&amp;algo_pvid=af7f307f-d4f8-4a83-a2e6-4485c079409a&amp;algo_exp_id=af7f307f-d4f8-4a83-a2e6-4485c079409a-13&amp;pdp_ext_f=%7B%22order%22%3A%2210%22%2C%22eval%22%3A%221%22%2C%22fromPage%22%3A%22search%22%7D&amp;pdp_npi=6%40dis%21USD%215.24%215.00%21%21%2135.98%2134.30%21%40210337c117712873245796611ebda3%2112000036341127029%21sea%21US%212417307347%21X%211%210%21n_tag%3A-29919%3Bd%3Ac9b11a36%3Bm03_new_user%3A-29895&amp;curPageLogUid=Yq5AkYzoy2yT&amp;utparam-url=scene%3Asearch%7Cquery_from%3A%7Cx_object_id%3A1005006221359489%7C_p_origin_prod%3A\">LED Panel<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">4<\/td><td class=\"has-text-align-center\" data-align=\"center\"><\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/www.aliexpress.us\/item\/3256808101638594.html?spm=a2g0o.order_detail.order_detail_item.2.7532f19c7V50Nx&amp;gatewayAdapt=glo2usa\">Bell<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\"><\/td><td class=\"has-text-align-center\" data-align=\"center\"><\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/a.co\/d\/0a4Br64u\">M2 Screws<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">A Few<\/td><td class=\"has-text-align-center\" data-align=\"center\">Various sizes, test fitment.<\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/a.co\/d\/0iHPXf0c\">M2 Heat Set Insert<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">A Few<\/td><td class=\"has-text-align-center\" data-align=\"center\"><\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/a.co\/d\/0iHPXf0c\">M6 Heat Set Insert<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\">bell mount<\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/a.co\/d\/0eu1Mnc1\">Proto Board<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\"><\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/a.co\/d\/0irmYCVJ\">ESP32 Dev Board<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\"><\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\">2N2222 NPN<\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\">Some random 2N2222 I had in a kit<\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\">Diode<\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\">Some random diode I had laying around. No link here.<\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/a.co\/d\/05dhpCEO\">Silicone Wire<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\">28GA is what I use.<\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/a.co\/d\/0fuYQTdD\">Boost Converter<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\">20 Pack<\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\"><a href=\"https:\/\/a.co\/d\/0jl8acgq\">USB Bulkhead<\/a><\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\">Tight fit, but the screws seat it firmly in place<\/td><\/tr><tr><td class=\"has-text-align-center\" data-align=\"center\">80-grit sand paper<\/td><td class=\"has-text-align-center\" data-align=\"center\">1<\/td><td class=\"has-text-align-center\" data-align=\"center\"><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Other Notes<\/h2>\n\n\n\n<p>The only real work\/modifications that need done are the proto board needed trimmed down to fit inside the &#8220;!&#8221; and the LED panels needed sanded down to fit in a nice-uninterrupted row. <\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"653\" height=\"527\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-19.png\" alt=\"\" class=\"wp-image-286\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-19.png 653w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-19-300x242.png 300w\" sizes=\"auto, (max-width: 653px) 100vw, 653px\" \/><figcaption class=\"wp-element-caption\">80-grit makes short work of shortening these PCBs.<\/figcaption><\/figure>\n\n\n\n<p>The LED board can safely be sanded down along it&#8217;s length. Unfortunately given the pitch of the LEDs you need to get quite close to them. Take sanding slow and it&#8217;ll all go well. I&#8217;ve also included extra length for the top and bottom panels so you only have to sand 4x sides instead of 6x sides. <\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Code<\/h2>\n\n\n\n<p>Disclaimer: This was 100% vibe coded but based loosely on <a href=\"https:\/\/github.com\/upiir\/led_matrix_display_16x8\">UPIRs Code<\/a>. <\/p>\n\n\n\n<p>The code requirements are as follows: <\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>OTA Firmware Updates for remote upgrades\/tweaks<\/li>\n\n\n\n<li>WIFI web server for debugging<\/li>\n\n\n\n<li>MQTT connection<\/li>\n\n\n\n<li>MQTT advertisement of variables<\/li>\n\n\n\n<li>GPIO Control (solenoid)<\/li>\n\n\n\n<li>3x LED Panel Support<\/li>\n<\/ol>\n\n\n\n<p>With these requirements and some example code I fed to chatGPT I got the following &#8220;finished&#8221; code. <\/p>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>Arduino Code, change variables to suit your needs. <\/strong><\/summary>\n<pre class=\"wp-block-code\"><code>\/*\n  ESP32 Display + Dinger Combo (FULL)\n\n  - 3x Keyestudio 8x16 LED matrix panels stacked vertically =&gt; 8w x 48h\n      TOP:    SCL=22 SDA=23\n      MID:    SCL=18 SDA=19\n      BOTTOM: SCL=2  SDA=4\n\n  - Solenoid bell striker on GPIO15 (PULSE_PIN)\n  - MQTT discovery (SMALL payloads) for:\n      * Button: Ding\n      * Number: Ding count (1..3)\n      * Number: Pulse width (ms)\n      * Number: Gap (ms)\n      * Text: Display message (Home Assistant text entity)\n  - MQTT command topics (also usable directly):\n      * home\/dinger\/&lt;deviceId&gt;\/ding\/press          payload: PRESS\n      * home\/dinger\/&lt;deviceId&gt;\/ding_count\/set      payload: 1..3\n      * home\/dinger\/&lt;deviceId&gt;\/pulse_width_ms\/set  payload: 1..2000\n      * home\/dinger\/&lt;deviceId&gt;\/gap_ms\/set          payload: 0..5000\n      * home\/dinger\/&lt;deviceId&gt;\/display\/set         payload: string\n      * home\/dinger\/&lt;deviceId&gt;\/max_current\/set     payload: 0\/1\n\n  - Web UI:\n      * Set count\/width\/gap + Ding\n      * Set display text\n      * Max-current test toggle (all LEDs on all panels)\n  - OTA enabled\n\n  Notes:\n  - GPIO15 is a strapping pin on some ESP32 boards; move if boot issues.\n  - Display scroll direction is TOP -&gt; BOTTOM when message length &gt; 6 chars (48\/8).\n*\/\n\n#include &lt;Arduino.h&gt;\n#include &lt;WiFi.h&gt;\n#include &lt;PubSubClient.h&gt;\n#include &lt;ArduinoOTA.h&gt;\n#include &lt;WebServer.h&gt;\n\n\/\/ ---------- WIFI ----------\nconst char* WIFI_SSIDS&#91;] = { \"SSID1\", \"SSID2\" };\nconst int WIFI_SSID_COUNT = sizeof(WIFI_SSIDS) \/ sizeof(WIFI_SSIDS&#91;0]);\nconst char* WIFI_PASSWORD  = \"PASSWORD\";\n\n\/\/ ---------- MQTT ----------\nconst char* MQTT_HOST      = \"homeassistant.local\";\nconst uint16_t MQTT_PORT   = 1883;\nconst char* MQTT_USER      = \"MQTTUSER\";\nconst char* MQTT_PASS      = \"MQTTPASS\";\n\n\/\/ OTA password\nconst char* OTA_PASSWORD   = \"OTAPASS\";\n\n#ifndef LED_BUILTIN\n#define LED_BUILTIN 2\n#endif\n\n\/\/ ---------- HARDWARE ----------\nstatic constexpr uint8_t PULSE_PIN = 15;\nstatic constexpr uint8_t LED_PIN   = LED_BUILTIN;\n\n\/\/ 3 panels (top-&gt;bottom)\n#define SCL_TOP  22\n#define SDA_TOP  23\n#define SCL_MID  18\n#define SDA_MID  19\n#define SCL_BOT  2\n#define SDA_BOT  4\n\n\/\/ ---------- DISPLAY GEOMETRY ----------\nstatic const int PANEL_H = 16;\nstatic const int PANELS  = 3;\nstatic const int DISP_H  = PANEL_H * PANELS; \/\/ 48\n\n\/\/ ---------- DING LIMITS ----------\nstatic constexpr int      DING_MIN = 1;\nstatic constexpr int      DING_MAX = 5;\nstatic constexpr uint32_t PULSE_MIN_MS = 1;\nstatic constexpr uint32_t PULSE_MAX_MS = 2000;\nstatic constexpr uint32_t GAP_MIN_MS   = 0;\nstatic constexpr uint32_t GAP_MAX_MS   = 5000;\n\n\/\/ ---------- SCROLL ----------\nstatic const uint16_t SCROLL_STEP_MS = 80;\nstatic const uint16_t HOLD_MS        = 800;\n\n\/\/ ---------- GLOBALS ----------\nWiFiClient    espClient;\nPubSubClient  mqtt(espClient);\nWebServer     server(80);\n\nint currentSsidIndex = 0;\n\n\/\/ Dinger params\nvolatile int dingCount = 1;\nvolatile uint32_t pulseWidthMs = 20;\nvolatile uint32_t gapMs = 80;\n\n\/\/ Dinger state machine (non-blocking, FIXED)\nenum DingPhase : uint8_t { DING_IDLE, DING_PULSE_ON, DING_GAP_WAIT };\nstatic DingPhase dingPhase = DING_IDLE;\nstatic int dingRemaining = 0;\nstatic uint32_t phaseUntilMs = 0;\n\n\/\/ Display state\nstatic const int MAX_MSG = 96;\nchar displayText&#91;MAX_MSG] = \"I LOVE YOU\";\nbool maxCurrentTest = false;\n\n\/\/ Display buffers\nstatic uint8_t fb&#91;DISP_H];          \/\/ visible 48 rows (each row is 8 bits)\nstatic uint8_t* vrows = nullptr;    \/\/ virtual rows for scrolling\nstatic int vheightAlloc = 0;\nint scrollTopY = 0;\nbool scrollHold = false;\nuint32_t scrollLastStepMs = 0;\nuint32_t scrollHoldStartMs = 0;\nuint32_t displayEpoch = 0;          \/\/ increment when text changes\nuint32_t renderedEpoch = 0;         \/\/ last rendered epoch\nbool isScrolling = false;\n\n\/\/ MQTT topics\/IDs\nchar deviceId&#91;32];\nchar baseTopic&#91;96];\nchar availTopic&#91;128];\n\nchar dingCmdTopic&#91;128];\n\nchar countStateTopic&#91;128];\nchar countCmdTopic&#91;128];\n\nchar widthStateTopic&#91;128];\nchar widthCmdTopic&#91;128];\n\nchar gapStateTopic&#91;128];\nchar gapCmdTopic&#91;128];\n\n\/\/ Display topics\nchar dispStateTopic&#91;128];\nchar dispCmdTopic&#91;128];\n\n\/\/ Max current topics\nchar maxTestStateTopic&#91;128];\nchar maxTestCmdTopic&#91;128];\n\n\/\/ Telemetry\nuint32_t bootMs = 0;\nuint32_t mqttReconnects = 0;\nuint32_t lastMqttRxMs = 0;\nchar lastMqttTopic&#91;128] = {0};\nchar lastMqttPayload&#91;256] = {0};\n\n\/\/ ---------- UTILS ----------\nstatic int clampInt(int v, int lo, int hi) {\n  if (v &lt; lo) return lo;\n  if (v &gt; hi) return hi;\n  return v;\n}\nstatic uint32_t clampU32(uint32_t v, uint32_t lo, uint32_t hi) {\n  if (v &lt; lo) return lo;\n  if (v &gt; hi) return hi;\n  return v;\n}\n\nstatic void formatDeviceId() {\n  uint32_t macLo = (uint32_t)ESP.getEfuseMac();\n  snprintf(deviceId, sizeof(deviceId), \"DINGER_%08X\", macLo);\n}\n\nstatic void buildTopics() {\n  snprintf(baseTopic, sizeof(baseTopic), \"home\/dinger\/%s\", deviceId);\n\n  snprintf(availTopic, sizeof(availTopic), \"%s\/availability\", baseTopic);\n\n  snprintf(dingCmdTopic, sizeof(dingCmdTopic), \"%s\/ding\/press\", baseTopic);\n\n  snprintf(countStateTopic, sizeof(countStateTopic), \"%s\/ding_count\/state\", baseTopic);\n  snprintf(countCmdTopic,   sizeof(countCmdTopic),   \"%s\/ding_count\/set\",   baseTopic);\n\n  snprintf(widthStateTopic, sizeof(widthStateTopic), \"%s\/pulse_width_ms\/state\", baseTopic);\n  snprintf(widthCmdTopic,   sizeof(widthCmdTopic),   \"%s\/pulse_width_ms\/set\",   baseTopic);\n\n  snprintf(gapStateTopic,   sizeof(gapStateTopic),   \"%s\/gap_ms\/state\", baseTopic);\n  snprintf(gapCmdTopic,     sizeof(gapCmdTopic),     \"%s\/gap_ms\/set\",   baseTopic);\n\n  \/\/ Display\n  snprintf(dispStateTopic, sizeof(dispStateTopic), \"%s\/display\/state\", baseTopic);\n  snprintf(dispCmdTopic,   sizeof(dispCmdTopic),   \"%s\/display\/set\",   baseTopic);\n\n  \/\/ Max current test\n  snprintf(maxTestStateTopic, sizeof(maxTestStateTopic), \"%s\/max_current\/state\", baseTopic);\n  snprintf(maxTestCmdTopic,   sizeof(maxTestCmdTopic),   \"%s\/max_current\/set\",   baseTopic);\n}\n\nstatic void publishRetainedU32(const char* topic, uint32_t v) {\n  if (!mqtt.connected()) return;\n  char buf&#91;16];\n  snprintf(buf, sizeof(buf), \"%lu\", (unsigned long)v);\n  mqtt.publish(topic, buf, true);\n}\nstatic void publishRetainedI32(const char* topic, int v) {\n  if (!mqtt.connected()) return;\n  char buf&#91;16];\n  snprintf(buf, sizeof(buf), \"%d\", v);\n  mqtt.publish(topic, buf, true);\n}\nstatic void publishRetainedStr(const char* topic, const char* s) {\n  if (!mqtt.connected()) return;\n  mqtt.publish(topic, s, true);\n}\nstatic void publishRetainedBool(const char* topic, bool v) {\n  if (!mqtt.connected()) return;\n  mqtt.publish(topic, v ? \"1\" : \"0\", true);\n}\n\n\/\/ ---------- SOFTWARE I2C (bit-banged) ----------\nvoid IIC_start(uint8_t scl, uint8_t sda) {\n  digitalWrite(scl, LOW);  delayMicroseconds(3);\n  digitalWrite(sda, HIGH); delayMicroseconds(3);\n  digitalWrite(scl, HIGH); delayMicroseconds(3);\n  digitalWrite(sda, LOW);  delayMicroseconds(3);\n}\nvoid IIC_send(uint8_t scl, uint8_t sda, uint8_t data) {\n  for (uint8_t i = 0; i &lt; 8; i++) {\n    digitalWrite(scl, LOW); delayMicroseconds(3);\n    digitalWrite(sda, (data &amp; 0x01) ? HIGH : LOW);\n    delayMicroseconds(3);\n    digitalWrite(scl, HIGH); delayMicroseconds(3);\n    data &gt;&gt;= 1;\n  }\n}\nvoid IIC_end(uint8_t scl, uint8_t sda) {\n  digitalWrite(scl, LOW);  delayMicroseconds(3);\n  digitalWrite(sda, LOW);  delayMicroseconds(3);\n  digitalWrite(scl, HIGH); delayMicroseconds(3);\n  digitalWrite(sda, HIGH); delayMicroseconds(3);\n}\n\nstatic void setBrightness(uint8_t scl, uint8_t sda, uint8_t brightnessCmd) {\n  IIC_start(scl, sda);\n  IIC_send(scl, sda, brightnessCmd); \/\/ 0x88..0x8F\n  IIC_end(scl, sda);\n}\n\nstatic void updateDisp(uint8_t scl, uint8_t sda, uint8_t frame16&#91;16]) {\n  IIC_start(scl, sda);\n  IIC_send(scl, sda, 0x40); \/\/ auto-increment\n  IIC_end(scl, sda);\n\n  IIC_start(scl, sda);\n  IIC_send(scl, sda, 0xC0); \/\/ start addr 0\n  for (uint8_t i = 0; i &lt; 16; i++) IIC_send(scl, sda, frame16&#91;i]);\n  IIC_end(scl, sda);\n}\n\n\/\/ ---------- DISPLAY RENDERING ----------\nstatic inline void fbClear() { memset(fb, 0, sizeof(fb)); }\n\n\/\/ 8x8 font A-Z, 0-9, space\nstruct Glyph { char c; uint8_t rows&#91;8]; };\nstatic const Glyph font&#91;] = {\n  { ' ', {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00} },\n\n  { '0', {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00} },\n  { '1', {0x18,0x38,0x18,0x18,0x18,0x18,0x3C,0x00} },\n  { '2', {0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00} },\n  { '3', {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00} },\n  { '4', {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00} },\n  { '5', {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00} },\n  { '6', {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00} },\n  { '7', {0x7E,0x66,0x06,0x0C,0x18,0x18,0x18,0x00} },\n  { '8', {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00} },\n  { '9', {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00} },\n\n  { 'A', {0x18,0x24,0x42,0x7E,0x42,0x42,0x42,0x00} },\n  { 'B', {0x7C,0x42,0x42,0x7C,0x42,0x42,0x7C,0x00} },\n  { 'C', {0x3C,0x42,0x40,0x40,0x40,0x42,0x3C,0x00} },\n  { 'D', {0x78,0x44,0x42,0x42,0x42,0x44,0x78,0x00} },\n  { 'E', {0x7E,0x40,0x40,0x7C,0x40,0x40,0x7E,0x00} },\n  { 'F', {0x7E,0x40,0x40,0x7C,0x40,0x40,0x40,0x00} },\n  { 'G', {0x3C,0x42,0x40,0x4E,0x42,0x42,0x3C,0x00} },\n  { 'H', {0x42,0x42,0x42,0x7E,0x42,0x42,0x42,0x00} },\n  { 'I', {0x7E,0x18,0x18,0x18,0x18,0x18,0x7E,0x00} },\n  { 'J', {0x0E,0x04,0x04,0x04,0x44,0x44,0x38,0x00} },\n  { 'K', {0x42,0x44,0x48,0x70,0x48,0x44,0x42,0x00} },\n  { 'L', {0x40,0x40,0x40,0x40,0x40,0x40,0x7E,0x00} },\n  { 'M', {0x42,0x66,0x5A,0x5A,0x42,0x42,0x42,0x00} },\n  { 'N', {0x42,0x62,0x52,0x4A,0x46,0x42,0x42,0x00} },\n  { 'O', {0x3C,0x42,0x42,0x42,0x42,0x42,0x3C,0x00} },\n  { 'P', {0x7C,0x42,0x42,0x7C,0x40,0x40,0x40,0x00} },\n  { 'Q', {0x3C,0x42,0x42,0x42,0x4A,0x44,0x3A,0x00} },\n  { 'R', {0x7C,0x42,0x42,0x7C,0x48,0x44,0x42,0x00} },\n  { 'S', {0x3C,0x42,0x40,0x3C,0x02,0x42,0x3C,0x00} },\n  { 'T', {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00} },\n  { 'U', {0x42,0x42,0x42,0x42,0x42,0x42,0x3C,0x00} },\n  { 'V', {0x42,0x42,0x42,0x42,0x24,0x24,0x18,0x00} },\n  { 'W', {0x42,0x42,0x42,0x5A,0x5A,0x66,0x42,0x00} },\n  { 'X', {0x42,0x24,0x18,0x18,0x18,0x24,0x42,0x00} },\n  { 'Y', {0x42,0x24,0x18,0x18,0x18,0x18,0x18,0x00} },\n  { 'Z', {0x7E,0x02,0x04,0x18,0x20,0x40,0x7E,0x00} },\n};\n\nstatic const uint8_t* glyphRows(char c) {\n  if (c &gt;= 'a' &amp;&amp; c &lt;= 'z') c = char(c - 'a' + 'A');\n  for (size_t i=0; i&lt;sizeof(font)\/sizeof(font&#91;0]); i++) {\n    if (font&#91;i].c == c) return font&#91;i].rows;\n  }\n  return font&#91;0].rows;\n}\n\nstatic int msgLen(const char* s) { int n=0; while (*s++) n++; return n; }\n\nstatic void ensureVirtualHeight(int vheight) {\n  if (vheightAlloc == vheight &amp;&amp; vrows) return;\n  if (vrows) free(vrows);\n  vrows = (uint8_t*)malloc(vheight);\n  vheightAlloc = vrows ? vheight : 0;\n}\n\nstatic void renderToVirtual(const char* msg) {\n  const int n = msgLen(msg);\n  const int messageHeight = n * 8;             \/\/ 8px per char, no gap\n  const int vheight = messageHeight + DISP_H;  \/\/ pad for entry\/exit\n  ensureVirtualHeight(vheight);\n  if (!vrows) return;\n\n  memset(vrows, 0, vheight);\n  int y = 0;\n  for (const char* p = msg; *p; p++) {\n    const uint8_t* r = glyphRows(*p);\n    for (int dy=0; dy&lt;8; dy++) {\n      int yy = y + dy;\n      if (yy &gt;= 0 &amp;&amp; yy &lt; vheight) vrows&#91;yy] = r&#91;dy];\n    }\n    y += 8;\n    if (y &gt;= vheight) break;\n  }\n\n  isScrolling = (n &gt; (DISP_H \/ 8)); \/\/ &gt;6 chars\n  scrollTopY = -DISP_H;             \/\/ start above view\n  scrollHold = false;\n  scrollLastStepMs = millis();\n}\n\nstatic void windowFromVirtual(int topY) {\n  if (!vrows || vheightAlloc &lt;= 0) { fbClear(); return; }\n  for (int y=0; y&lt;DISP_H; y++) {\n    int srcY = topY + y;\n    fb&#91;y] = (srcY &gt;= 0 &amp;&amp; srcY &lt; vheightAlloc) ? vrows&#91;srcY] : 0x00;\n  }\n}\n\n\/\/ If mirrored\/flipped, fix ONLY here.\nstatic void pushFbToPanels() {\n  uint8_t fTop&#91;16], fMid&#91;16], fBot&#91;16];\n  for (int i=0; i&lt;16; i++) fTop&#91;i] = fb&#91;0  + i];\n  for (int i=0; i&lt;16; i++) fMid&#91;i] = fb&#91;16 + i];\n  for (int i=0; i&lt;16; i++) fBot&#91;i] = fb&#91;32 + i];\n\n  updateDisp(SCL_TOP, SDA_TOP, fTop);\n  updateDisp(SCL_MID, SDA_MID, fMid);\n  updateDisp(SCL_BOT, SDA_BOT, fBot);\n}\n\nstatic void allLedsOnAllPanels() {\n  uint8_t on16&#91;16];\n  for (int i=0; i&lt;16; i++) on16&#91;i] = 0xFF;\n  updateDisp(SCL_TOP, SDA_TOP, on16);\n  updateDisp(SCL_MID, SDA_MID, on16);\n  updateDisp(SCL_BOT, SDA_BOT, on16);\n}\n\nstatic void setDisplayText(const char* s) {\n  \/\/ sanitize to A-Z 0-9 space (unknown -&gt; space)\n  size_t n = strnlen(s, MAX_MSG - 1);\n  for (size_t i = 0; i &lt; n; i++) {\n    char c = s&#91;i];\n    if (c &gt;= 'a' &amp;&amp; c &lt;= 'z') c = char(c - 'a' + 'A');\n    bool ok = (c == ' ') || (c &gt;= 'A' &amp;&amp; c &lt;= 'Z') || (c &gt;= '0' &amp;&amp; c &lt;= '9');\n    displayText&#91;i] = ok ? c : ' ';\n  }\n  displayText&#91;n] = '\\0';\n\n  displayEpoch++;\n  publishRetainedStr(dispStateTopic, displayText);\n}\n\n\/\/ ---------- DINGER (non-blocking, FIXED) ----------\nstatic void startDings(int n) {\n  if (dingPhase != DING_IDLE) return;\n  n = clampInt(n, DING_MIN, DING_MAX);\n  dingRemaining = n;\n  dingPhase = DING_PULSE_ON;\n  phaseUntilMs = 0; \/\/ start immediately\n}\n\nstatic void serviceDinger() {\n  const uint32_t now = millis();\n  if (dingPhase == DING_IDLE) return;\n\n  if (phaseUntilMs &amp;&amp; (int32_t)(now - phaseUntilMs) &lt; 0) return;\n\n  const uint32_t w = clampU32(pulseWidthMs, PULSE_MIN_MS, PULSE_MAX_MS);\n  const uint32_t g = clampU32(gapMs, GAP_MIN_MS, GAP_MAX_MS);\n\n  switch (dingPhase) {\n    case DING_PULSE_ON:\n      \/\/ start pulse\n      digitalWrite(PULSE_PIN, HIGH);\n      digitalWrite(LED_PIN, HIGH);\n      dingPhase = DING_GAP_WAIT;\n      phaseUntilMs = now + w;\n      break;\n\n    case DING_GAP_WAIT:\n      \/\/ end pulse\n      digitalWrite(PULSE_PIN, LOW);\n      digitalWrite(LED_PIN, LOW);\n\n      dingRemaining--;\n      if (dingRemaining &lt;= 0) {\n        dingPhase = DING_IDLE;\n        phaseUntilMs = 0;\n      } else {\n        \/\/ wait gap before next pulse\n        dingPhase = DING_PULSE_ON;\n        phaseUntilMs = now + g;\n      }\n      break;\n\n    default:\n      dingPhase = DING_IDLE;\n      phaseUntilMs = 0;\n      break;\n  }\n}\n\n\/\/ ---------- WIFI ----------\nvoid connectWiFi() {\n  if (WiFi.status() == WL_CONNECTED) return;\n\n  WiFi.mode(WIFI_STA);\n\n  while (WiFi.status() != WL_CONNECTED) {\n    const char* ssid = WIFI_SSIDS&#91;currentSsidIndex];\n    Serial.printf(\"Connecting to WiFi SSID: %s\\n\", ssid);\n    WiFi.begin(ssid, WIFI_PASSWORD);\n\n    int attempts = 0;\n    while (WiFi.status() != WL_CONNECTED &amp;&amp; attempts &lt; 20) {\n      delay(500);\n      Serial.print(\".\");\n      attempts++;\n    }\n\n    if (WiFi.status() == WL_CONNECTED) {\n      Serial.print(\"\\nConnected to \");\n      Serial.print(ssid);\n      Serial.print(\", IP=\");\n      Serial.println(WiFi.localIP());\n      break;\n    } else {\n      Serial.printf(\"\\nFailed to connect to %s\\n\", ssid);\n      currentSsidIndex = (currentSsidIndex + 1) % WIFI_SSID_COUNT;\n      delay(1000);\n    }\n  }\n}\n\n\/\/ ---------- MQTT DISCOVERY ----------\nstatic void publishDiscoverySmall() {\n  if (!mqtt.connected()) return;\n\n  char cfgTopic&#91;128];\n  char payload&#91;256];\n\n  \/\/ Button (ding)\n  snprintf(cfgTopic, sizeof(cfgTopic), \"homeassistant\/button\/%s\/ding\/config\", deviceId);\n  snprintf(payload, sizeof(payload),\n           \"{\\\"name\\\":\\\"%s Ding\\\",\\\"unique_id\\\":\\\"%s_ding\\\",\"\n           \"\\\"command_topic\\\":\\\"%s\\\",\\\"payload_press\\\":\\\"PRESS\\\"}\",\n           deviceId, deviceId, dingCmdTopic);\n  mqtt.publish(cfgTopic, payload, true);\n\n  \/\/ Number: Ding Count\n  snprintf(cfgTopic, sizeof(cfgTopic), \"homeassistant\/number\/%s\/ding_count\/config\", deviceId);\n  snprintf(payload, sizeof(payload),\n           \"{\\\"name\\\":\\\"%s Ding Count\\\",\\\"unique_id\\\":\\\"%s_ding_count\\\",\"\n           \"\\\"state_topic\\\":\\\"%s\\\",\\\"command_topic\\\":\\\"%s\\\",\"\n           \"\\\"min\\\":1,\\\"max\\\":5,\\\"step\\\":1,\\\"mode\\\":\\\"slider\\\"}\",\n           deviceId, deviceId, countStateTopic, countCmdTopic);\n  mqtt.publish(cfgTopic, payload, true);\n\n  \/\/ Number: Pulse Width (ms)\n  snprintf(cfgTopic, sizeof(cfgTopic), \"homeassistant\/number\/%s\/pulse_width_ms\/config\", deviceId);\n  snprintf(payload, sizeof(payload),\n           \"{\\\"name\\\":\\\"%s Pulse Width (ms)\\\",\\\"unique_id\\\":\\\"%s_pulse_width_ms\\\",\"\n           \"\\\"state_topic\\\":\\\"%s\\\",\\\"command_topic\\\":\\\"%s\\\",\"\n           \"\\\"min\\\":%lu,\\\"max\\\":%lu,\\\"step\\\":1,\\\"mode\\\":\\\"slider\\\"}\",\n           deviceId, deviceId, widthStateTopic, widthCmdTopic,\n           (unsigned long)PULSE_MIN_MS, (unsigned long)PULSE_MAX_MS);\n  mqtt.publish(cfgTopic, payload, true);\n\n  \/\/ Number: Gap (ms)\n  snprintf(cfgTopic, sizeof(cfgTopic), \"homeassistant\/number\/%s\/gap_ms\/config\", deviceId);\n  snprintf(payload, sizeof(payload),\n           \"{\\\"name\\\":\\\"%s Gap (ms)\\\",\\\"unique_id\\\":\\\"%s_gap_ms\\\",\"\n           \"\\\"state_topic\\\":\\\"%s\\\",\\\"command_topic\\\":\\\"%s\\\",\"\n           \"\\\"min\\\":50,\\\"max\\\":500,\\\"step\\\":50,\\\"mode\\\":\\\"slider\\\"}\",\n           deviceId, deviceId, gapStateTopic, gapCmdTopic,\n           (unsigned long)GAP_MIN_MS, (unsigned long)GAP_MAX_MS);\n  mqtt.publish(cfgTopic, payload, true);\n\n  \/\/ Text: Display Message (THIS MAKES text.&lt;device&gt;_display appear)\n  snprintf(cfgTopic, sizeof(cfgTopic), \"homeassistant\/text\/%s\/display\/config\", deviceId);\n  snprintf(payload, sizeof(payload),\n           \"{\\\"name\\\":\\\"%s Display\\\",\\\"unique_id\\\":\\\"%s_display\\\",\"\n           \"\\\"state_topic\\\":\\\"%s\\\",\\\"command_topic\\\":\\\"%s\\\",\"\n           \"\\\"max\\\":95,\\\"mode\\\":\\\"text\\\"}\",\n           deviceId, deviceId, dispStateTopic, dispCmdTopic);\n  mqtt.publish(cfgTopic, payload, true);\n}\n\n\/\/ ---------- MQTT CALLBACK ----------\nvoid mqttCallback(char* topic, byte* payload, unsigned int length) {\n  lastMqttRxMs = millis();\n  strncpy(lastMqttTopic, topic, sizeof(lastMqttTopic) - 1);\n  lastMqttTopic&#91;sizeof(lastMqttTopic) - 1] = '\\0';\n\n  unsigned int n = (length &lt; (sizeof(lastMqttPayload) - 1)) ? length : (sizeof(lastMqttPayload) - 1);\n  for (unsigned int i = 0; i &lt; n; i++) lastMqttPayload&#91;i] = (char)payload&#91;i];\n  lastMqttPayload&#91;n] = '\\0';\n\n  if (strcmp(topic, countCmdTopic) == 0) {\n    dingCount = clampInt(atoi(lastMqttPayload), DING_MIN, DING_MAX);\n    publishRetainedI32(countStateTopic, dingCount);\n    return;\n  }\n  if (strcmp(topic, widthCmdTopic) == 0) {\n    pulseWidthMs = clampU32((uint32_t)atoi(lastMqttPayload), PULSE_MIN_MS, PULSE_MAX_MS);\n    publishRetainedU32(widthStateTopic, pulseWidthMs);\n    return;\n  }\n  if (strcmp(topic, gapCmdTopic) == 0) {\n    gapMs = clampU32((uint32_t)atoi(lastMqttPayload), GAP_MIN_MS, GAP_MAX_MS);\n    publishRetainedU32(gapStateTopic, gapMs);\n    return;\n  }\n  if (strcmp(topic, dingCmdTopic) == 0) {\n    startDings(dingCount);\n    return;\n  }\n\n  if (strcmp(topic, dispCmdTopic) == 0) {\n    setDisplayText(lastMqttPayload);\n    return;\n  }\n\n  if (strcmp(topic, maxTestCmdTopic) == 0) {\n    int v = atoi(lastMqttPayload);\n    maxCurrentTest = (v != 0);\n    publishRetainedBool(maxTestStateTopic, maxCurrentTest);\n    return;\n  }\n}\n\nvoid connectMQTT() {\n  mqtt.setServer(MQTT_HOST, MQTT_PORT);\n  mqtt.setCallback(mqttCallback);\n  mqtt.setBufferSize(512);\n\n  while (!mqtt.connected()) {\n    mqttReconnects++;\n\n    bool ok = mqtt.connect(\n      deviceId,\n      MQTT_USER,\n      MQTT_PASS,\n      availTopic,\n      0,\n      true,\n      \"offline\"\n    );\n\n    if (ok) {\n      mqtt.publish(availTopic, \"online\", true);\n\n      mqtt.subscribe(dingCmdTopic);\n      mqtt.subscribe(countCmdTopic);\n      mqtt.subscribe(widthCmdTopic);\n      mqtt.subscribe(gapCmdTopic);\n      mqtt.subscribe(dispCmdTopic);\n      mqtt.subscribe(maxTestCmdTopic);\n\n      publishDiscoverySmall();\n\n      publishRetainedI32(countStateTopic, dingCount);\n      publishRetainedU32(widthStateTopic, pulseWidthMs);\n      publishRetainedU32(gapStateTopic, gapMs);\n      publishRetainedStr(dispStateTopic, displayText);\n      publishRetainedBool(maxTestStateTopic, maxCurrentTest);\n\n    } else {\n      Serial.print(\"MQTT failed rc=\");\n      Serial.println(mqtt.state());\n      delay(2000);\n    }\n  }\n}\n\n\/\/ ---------- WEB ----------\nstatic String statusJson() {\n  bool wifiOk = (WiFi.status() == WL_CONNECTED);\n  bool mqttOk = mqtt.connected();\n  int rssi = wifiOk ? WiFi.RSSI() : -999;\n\n  uint32_t upS = (millis() - bootMs) \/ 1000;\n  uint32_t lastAgeMs = (lastMqttRxMs == 0) ? 0 : (millis() - lastMqttRxMs);\n\n  String ip = wifiOk ? WiFi.localIP().toString() : \"0.0.0.0\";\n\n  String j;\n  j.reserve(1000);\n  j += \"{\";\n  j += \"\\\"device_id\\\":\\\"\"; j += deviceId; j += \"\\\",\";\n  j += \"\\\"ip\\\":\\\"\"; j += ip; j += \"\\\",\";\n  j += \"\\\"rssi\\\":\"; j += String(rssi); j += \",\";\n  j += \"\\\"mqtt_connected\\\":\"; j += (mqttOk ? \"true\" : \"false\"); j += \",\";\n  j += \"\\\"mqtt_state\\\":\"; j += String(mqtt.state()); j += \",\";\n  j += \"\\\"mqtt_reconnects\\\":\"; j += String(mqttReconnects); j += \",\";\n  j += \"\\\"ding_count\\\":\"; j += String(dingCount); j += \",\";\n  j += \"\\\"pulse_width_ms\\\":\"; j += String(pulseWidthMs); j += \",\";\n  j += \"\\\"gap_ms\\\":\"; j += String(gapMs); j += \",\";\n  j += \"\\\"ding_phase\\\":\"; j += String((int)dingPhase); j += \",\";\n  j += \"\\\"display_text\\\":\\\"\"; j += displayText; j += \"\\\",\";\n  j += \"\\\"max_current_test\\\":\"; j += (maxCurrentTest ? \"true\" : \"false\"); j += \",\";\n  j += \"\\\"uptime_s\\\":\"; j += String(upS); j += \",\";\n  j += \"\\\"last_mqtt_topic\\\":\\\"\"; j += lastMqttTopic; j += \"\\\",\";\n  j += \"\\\"last_mqtt_payload\\\":\\\"\"; j += lastMqttPayload; j += \"\\\",\";\n  j += \"\\\"last_mqtt_age_ms\\\":\"; j += String(lastAgeMs);\n  j += \"}\";\n  return j;\n}\n\nvoid handleRoot() {\n  server.send(200, \"text\/html\",\n    \"&lt;!doctype html&gt;&lt;html&gt;&lt;head&gt;&lt;meta name='viewport' content='width=device-width,initial-scale=1'&gt;\"\n    \"&lt;title&gt;Dinger + Display&lt;\/title&gt;\"\n    \"&lt;style&gt;body{font-family:system-ui;margin:20px;max-width:860px}\"\n    \"input{font-size:18px;padding:6px 8px} \"\n    \"button{font-size:18px;padding:10px 16px;margin:6px 8px 6px 0} \"\n    \"pre{background:#111;color:#eee;padding:12px;border-radius:10px;overflow:auto}\"\n    \".row{margin:10px 0}&lt;\/style&gt;&lt;\/head&gt;&lt;body&gt;\"\n    \"&lt;h2&gt;Dinger + Display&lt;\/h2&gt;\"\n\n    \"&lt;div class='row'&gt;Ding count (1..3): &lt;input id='count' type='number' min='1' max='3' step='1'&gt;&lt;\/div&gt;\"\n    \"&lt;div class='row'&gt;Pulse width (ms): &lt;input id='width' type='number' min='1' step='1'&gt;&lt;\/div&gt;\"\n    \"&lt;div class='row'&gt;Gap (ms): &lt;input id='gap' type='number' min='0' step='1'&gt;&lt;\/div&gt;\"\n    \"&lt;button onclick='applyDing()'&gt;Apply&lt;\/button&gt;\"\n    \"&lt;button onclick='fire()'&gt;Ding&lt;\/button&gt;\"\n\n    \"&lt;hr&gt;\"\n\n    \"&lt;div class='row'&gt;Display text: &lt;input id='msg' type='text' style='width:420px' maxlength='95'&gt;&lt;\/div&gt;\"\n    \"&lt;button onclick='applyMsg()'&gt;Set Display&lt;\/button&gt;\"\n    \"&lt;button onclick='maxOn()'&gt;Max Current ON&lt;\/button&gt;\"\n    \"&lt;button onclick='maxOff()'&gt;Max Current OFF&lt;\/button&gt;\"\n\n    \"&lt;h3&gt;Status&lt;\/h3&gt;&lt;pre id='st'&gt;loading...&lt;\/pre&gt;\"\n\n    \"&lt;script&gt;\"\n    \"async function getStatus(){\"\n    \" const r=await fetch('\/status'); const j=await r.json();\"\n    \" document.getElementById('st').textContent=JSON.stringify(j,null,2);\"\n    \" if(document.activeElement.id!=='count') document.getElementById('count').value=j.ding_count;\"\n    \" if(document.activeElement.id!=='width') document.getElementById('width').value=j.pulse_width_ms;\"\n    \" if(document.activeElement.id!=='gap') document.getElementById('gap').value=j.gap_ms;\"\n    \" if(document.activeElement.id!=='msg') document.getElementById('msg').value=j.display_text;\"\n    \"}\"\n    \"async function applyDing(){\"\n    \" const c=document.getElementById('count').value|0;\"\n    \" const w=document.getElementById('width').value|0;\"\n    \" const g=document.getElementById('gap').value|0;\"\n    \" await fetch(`\/set_ding?count=${c}&amp;width=${w}&amp;gap=${g}`); await getStatus();\"\n    \"}\"\n    \"async function fire(){ await fetch('\/fire'); await getStatus(); }\"\n    \"async function applyMsg(){\"\n    \" const m=encodeURIComponent(document.getElementById('msg').value);\"\n    \" await fetch(`\/set_msg?m=${m}`); await getStatus();\"\n    \"}\"\n    \"async function maxOn(){ await fetch('\/max?on=1'); await getStatus(); }\"\n    \"async function maxOff(){ await fetch('\/max?on=0'); await getStatus(); }\"\n    \"setInterval(getStatus,1000); getStatus();\"\n    \"&lt;\/script&gt;&lt;\/body&gt;&lt;\/html&gt;\"\n  );\n}\n\nvoid handleStatus() { server.send(200, \"application\/json\", statusJson()); }\n\nvoid handleSetDing() {\n  if (server.hasArg(\"count\")) dingCount = clampInt(server.arg(\"count\").toInt(), DING_MIN, DING_MAX);\n  if (server.hasArg(\"width\")) pulseWidthMs = clampU32((uint32_t)server.arg(\"width\").toInt(), PULSE_MIN_MS, PULSE_MAX_MS);\n  if (server.hasArg(\"gap\"))   gapMs       = clampU32((uint32_t)server.arg(\"gap\").toInt(),   GAP_MIN_MS,   GAP_MAX_MS);\n\n  publishRetainedI32(countStateTopic, dingCount);\n  publishRetainedU32(widthStateTopic, pulseWidthMs);\n  publishRetainedU32(gapStateTopic, gapMs);\n\n  server.send(200, \"text\/plain\", \"OK\");\n}\n\nvoid handleFire() {\n  startDings(dingCount);\n  server.send(200, \"text\/plain\", \"OK\");\n}\n\nvoid handleSetMsg() {\n  if (server.hasArg(\"m\")) {\n    String m = server.arg(\"m\"); \/\/ decoded\n    setDisplayText(m.c_str());\n  }\n  server.send(200, \"text\/plain\", \"OK\");\n}\n\nvoid handleMax() {\n  int on = server.hasArg(\"on\") ? server.arg(\"on\").toInt() : 0;\n  maxCurrentTest = (on != 0);\n  publishRetainedBool(maxTestStateTopic, maxCurrentTest);\n  server.send(200, \"text\/plain\", \"OK\");\n}\n\n\/\/ ---------- DISPLAY SERVICE (non-blocking) ----------\nstatic void serviceDisplay() {\n  if (maxCurrentTest) {\n    allLedsOnAllPanels();\n    return;\n  }\n\n  if (renderedEpoch != displayEpoch) {\n    renderToVirtual(displayText);\n    renderedEpoch = displayEpoch;\n  }\n\n  const uint32_t now = millis();\n\n  if (!isScrolling) {\n    fbClear();\n    int n = msgLen(displayText);\n    int maxStatic = DISP_H \/ 8; \/\/ 6\n    for (int i=0; i&lt;n &amp;&amp; i&lt;maxStatic; i++) {\n      const uint8_t* r = glyphRows(displayText&#91;i]);\n      for (int dy=0; dy&lt;8; dy++) {\n        fb&#91;i*8 + dy] = r&#91;dy];\n      }\n    }\n    pushFbToPanels();\n    return;\n  }\n\n  if (scrollHold) {\n    if (now - scrollHoldStartMs &gt;= HOLD_MS) {\n      scrollHold = false;\n      scrollLastStepMs = now;\n    } else {\n      windowFromVirtual(scrollTopY);\n      pushFbToPanels();\n      return;\n    }\n  }\n\n  if (now - scrollLastStepMs &gt;= SCROLL_STEP_MS) {\n    scrollLastStepMs = now;\n    scrollTopY++; \/\/ TOP -&gt; BOTTOM\n    if (vrows &amp;&amp; vheightAlloc &gt; 0) {\n      if (scrollTopY &gt; (vheightAlloc - DISP_H)) {\n        scrollTopY = -DISP_H;\n        scrollHold = true;\n        scrollHoldStartMs = now;\n      }\n    }\n  }\n\n  windowFromVirtual(scrollTopY);\n  pushFbToPanels();\n}\n\n\/\/ ---------- OTA ----------\nstatic void setupOTA() {\n  ArduinoOTA.setHostname(deviceId);\n  if (OTA_PASSWORD &amp;&amp; OTA_PASSWORD&#91;0] != '\\0') ArduinoOTA.setPassword(OTA_PASSWORD);\n  ArduinoOTA.begin();\n}\n\n\/\/ ---------- SETUP \/ LOOP ----------\nvoid setup() {\n  Serial.begin(115200);\n  delay(300);\n  bootMs = millis();\n\n  \/\/ Dinger pins\n  pinMode(PULSE_PIN, OUTPUT);\n  digitalWrite(PULSE_PIN, LOW);\n  pinMode(LED_PIN, OUTPUT);\n  digitalWrite(LED_PIN, LOW);\n\n  \/\/ Display pins\n  pinMode(SCL_TOP, OUTPUT); pinMode(SDA_TOP, OUTPUT);\n  pinMode(SCL_MID, OUTPUT); pinMode(SDA_MID, OUTPUT);\n  pinMode(SCL_BOT, OUTPUT); pinMode(SDA_BOT, OUTPUT);\n\n  digitalWrite(SCL_TOP, LOW); digitalWrite(SDA_TOP, LOW);\n  digitalWrite(SCL_MID, LOW); digitalWrite(SDA_MID, LOW);\n  digitalWrite(SCL_BOT, LOW); digitalWrite(SDA_BOT, LOW);\n\n  \/\/ Brightness\n  setBrightness(SCL_TOP, SDA_TOP, 0x8E);\n  setBrightness(SCL_MID, SDA_MID, 0x8E);\n  setBrightness(SCL_BOT, SDA_BOT, 0x8E);\n\n  \/\/ IDs\/topics\n  formatDeviceId();\n  buildTopics();\n\n  \/\/ WiFi\n  connectWiFi();\n\n  \/\/ OTA\n  setupOTA();\n\n  \/\/ MQTT\n  mqtt.setCallback(mqttCallback);\n  connectMQTT();\n\n  \/\/ Web\n  server.on(\"\/\", handleRoot);\n  server.on(\"\/status\", handleStatus);\n  server.on(\"\/set_ding\", handleSetDing);\n  server.on(\"\/fire\", handleFire);\n  server.on(\"\/set_msg\", handleSetMsg);\n  server.on(\"\/max\", handleMax);\n  server.begin();\n\n  \/\/ Init display\n  setDisplayText(displayText);\n  renderToVirtual(displayText);\n  renderedEpoch = displayEpoch;\n}\n\nvoid loop() {\n  ArduinoOTA.handle();\n\n  if (WiFi.status() != WL_CONNECTED) connectWiFi();\n  if (!mqtt.connected()) connectMQTT();\n  mqtt.loop();\n\n  server.handleClient();\n\n  serviceDinger();\n  serviceDisplay();\n}\n<\/code><\/pre>\n<\/details>\n\n\n\n<p>Wifi supports multiple networks. So you can test and deploy between multiple locations (like if you&#8217;re giving this to a friend). <\/p>\n\n\n\n<p>The board to use for these generic ESP-32&#8217;s is <strong>DOIT ESP32 DEVKIT V1<\/strong><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Home Assistant Integration<\/h2>\n\n\n\n<p>Since the code above contains auto-discovery you can easily write your own dashboard for this unit. The main breakouts are:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Ding<\/li>\n\n\n\n<li>Ding Count<\/li>\n\n\n\n<li>Pulse Width (how long the solenoid stays on for)<\/li>\n\n\n\n<li>Gap (gap between dings)<\/li>\n\n\n\n<li>Text (display message)<\/li>\n<\/ol>\n\n\n\n<p>Once the MQTT &#8220;sensors&#8221; are discovered you can make a quick dashboard to test out basic functionality. Keep in mind that the &#8220;dinger&#8221; sensor is followed by the ESP&#8217;s MAC address so you can have multiple of these units. The below HomeAssistant integration code won&#8217;t work for you, but you should be able to use your own mac address (advertised) and be up and running. <\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"573\" height=\"327\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-20.png\" alt=\"\" class=\"wp-image-287\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-20.png 573w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-20-300x171.png 300w\" sizes=\"auto, (max-width: 573px) 100vw, 573px\" \/><\/figure>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Bell Control Dashboard Code<\/summary>\n<pre class=\"wp-block-code\"><code>type: entities\ntitle: Bell Control\nentities:\n  - entity: number.dinger_8ad8cbb0_ding_count\n    name: Number of dings\n  - entity: button.dinger_8ad8cbb0_ding\n    name: Ring bell\n  - entity: text.dinger_8ad8cbb0_display\n  - entity: number.dinger_8ad8cbb0_gap_ms\n    name: Delay Between Dings\ngrid_options:\n  columns: 12\n  rows: auto\n<\/code><\/pre>\n<\/details>\n\n\n\n<p>Once you&#8217;re happy with the manual control, you&#8217;ll want to get some automation hooks in. This allows for 1-press canned messages and for integration with other sensors (like a mailbox or road sensor). <\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"588\" height=\"168\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-21.png\" alt=\"\" class=\"wp-image-288\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-21.png 588w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-21-300x86.png 300w\" sizes=\"auto, (max-width: 588px) 100vw, 588px\" \/><\/figure>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Canned Messages Dashboard Code<\/summary>\n<pre class=\"wp-block-code\"><code>type: horizontal-stack\ncards:\n  - show_name: true\n    show_icon: true\n    type: button\n    name: I LOVE YOU\n    icon: mdi:heart\n    tap_action:\n      action: perform-action\n      target:\n        entity_id: automation.dinger_love_you\n      perform_action: automation.trigger\n      data:\n        skip_condition: true\n  - type: button\n    name: Clear\n    icon: mdi:close-circle\n    tap_action:\n      action: call-service\n      service: automation.trigger\n      target:\n        entity_id: automation.dinger_clear_display\n  - type: button\n    name: Road\n    icon: mdi:road\n    tap_action:\n      action: call-service\n      service: automation.trigger\n      target:\n        entitity_id: automation.road_notification_dinger\n  - type: button\n    name: Mail\n    icon: mdi:mailbox\n    tap_action:\n      action: call-service\n      service: automation.trigger\n      target:\n        entitity_id: automation.mail\n<\/code><\/pre>\n<\/details>\n\n\n\n<p>For the canned messages to work, you need to have automations written on the back end for each of the buttons to call. Here are a couple automations you can use:<\/p>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Clear Automation (Clears the LED Display)<\/summary>\n<pre class=\"wp-block-code\"><code>alias: dinger_clear_display\ndescription: \"\"\ntriggers:\n  - trigger: event\n    event_type: \"\"\nconditions: &#91;]\nactions:\n  - action: text.set_value\n    metadata: {}\n    target:\n      entity_id: text.dinger_8ad8cbb0_display\n    data:\n      value: \"\"\nmode: single\n<\/code><\/pre>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>I LOVE YOU Automation (Dings the bell 3 times, and displays &#8220;I LOVE YOU&#8221; on the LED Display)<\/summary>\n<pre class=\"wp-block-code\"><code>alias: dinger_love_you\ndescription: \"\"\ntriggers:\n  - trigger: event\n    event_type: \"\"\nconditions: &#91;]\nactions:\n  - action: text.set_value\n    metadata: {}\n    target:\n      entity_id: text.dinger_8ad8cbb0_display\n    data:\n      value: I LOVE YOU\n  - action: number.set_value\n    metadata: {}\n    target:\n      entity_id: number.dinger_8ad8cbb0_ding_count\n    data:\n      value: \"3\"\n  - action: button.press\n    metadata: {}\n    target:\n      entity_id: button.dinger_8ad8cbb0_ding\n    data: {}\nmode: single\n<\/code><\/pre>\n<\/details>\n\n\n\n<h2 class=\"wp-block-heading\">Project Files<\/h2>\n\n\n\n<p>My code is pasted above in the Code section. The 3D files are available for download, modification, or printing on <a href=\"https:\/\/www.printables.com\/model\/1604548-home-assistant-bell-notifier\">Printables<\/a> and <a href=\"https:\/\/makerworld.com\/en\/models\/2408580-homeassistant-bell-notifier#profileId-2640598\">MakerWorld<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Final Thoughts<\/h2>\n\n\n\n<p>The ! Notifier was fun to design and I hope to see others use or remix the design for their own use! <\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Errata<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"680\" height=\"479\" src=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-23.png\" alt=\"\" class=\"wp-image-299\" srcset=\"https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-23.png 680w, https:\/\/farnsworth.engineering\/wp-content\/uploads\/2026\/02\/image-23-300x211.png 300w\" sizes=\"auto, (max-width: 680px) 100vw, 680px\" \/><\/figure>\n\n\n\n<p>The ESP32 I use works just great when called a &#8220;DOIT ESP32 DEVKIT V1&#8221; that&#8217;s not what it&#8217;s called on amazon. <\/p>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>What started out as a dedicated piece of hardware for a driveway and mailbox sensor has since turned into a quick message board for me to pass love notes to my wife during the day. Demo Video Backstory A few years ago I purchased a set of 4x of these 8&#215;16 LED displays after watching &hellip; <\/p>\n<p class=\"link-more\"><a href=\"https:\/\/farnsworth.engineering\/index.php\/2026\/02\/17\/notifier\/\" class=\"more-link\">Continue reading<span class=\"screen-reader-text\"> &#8220;! Notifier&#8221;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-259","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/posts\/259","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/comments?post=259"}],"version-history":[{"count":10,"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/posts\/259\/revisions"}],"predecessor-version":[{"id":301,"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/posts\/259\/revisions\/301"}],"wp:attachment":[{"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/media?parent=259"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/categories?post=259"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/farnsworth.engineering\/index.php\/wp-json\/wp\/v2\/tags?post=259"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}