! Notifier

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×16 LED displays after watching a video by Upir.

Aliexpress hyperlinked in this image.

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.

It was stunning in real life. Something with the color blue and these phone camera sensors just gets blown out.

This was shortly before I decided on the CRT Weather Display as my primary project for 2025. These LED panels quickly got shelved.

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.

Like Chekhov’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.

Not the best fate being direct-soldered to a piece of perf board but hey, they’re no longer in the box of unused shit I’ve bought from China.

Beginning

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’m losing money if I don’t buy the bell for this project!

Much prettier in my mind’s eye. But you get the drift.

I got the bell 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’s bicycle bell would have probably worked just fine!

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’ll definitely get to some day. I didn’t quite like the way it clanged like a fire alarm so I needed to brainstorm solutions for that.

The bell then sat dormant for the next four months.

I’m on a Roll!

Since it’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.

With the long President’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.

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.

Quick Side Bar

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 “Merge result” 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 internet plagiarism machine 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’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!

MULTI-BODY STEP EXPORTER MACRO
Option Explicit

' --- numeric constants ---
Const swDocPART = 1
Const swExportSTEP = 6
Const swSaveAsCurrentVersion = 0
Const swSaveAsOptions_Silent = 2
Const swSaveAsOptions_Copy = 8
Const TEMPLATE_PATH = "C:\ProgramData\SolidWorks\SolidWorks 2014\templates\Part.prtdot"


Dim swApp As Object
Dim src As Object               ' ModelDoc2 (source part)
Dim BodyArr As Variant

Sub main()
    Set swApp = Application.SldWorks
    Set src = swApp.ActiveDoc
    If src Is Nothing Then Exit Sub
    If src.GetType <> swDocPART Then
        MsgBox "Open a PART document.", vbExclamation
        Exit Sub
    End If
    ' If the part isn't saved, we'll export to Desktop
    ' (no need to exit)


    Dim saveDir As String
    If src.GetPathName <> "" Then
        saveDir = Left(src.GetPathName, InStrRev(src.GetPathName, "\") - 1)
    Else
        Dim wsh As Object: Set wsh = CreateObject("WScript.Shell")
        saveDir = wsh.SpecialFolders("Desktop")
    End If
    If Dir(saveDir, vbDirectory) = vbNullString Then MkDir saveDir


    ' get all bodies in the source part
    BodyArr = src.GetBodies2(0, False)
    If IsEmpty(BodyArr) Then
        MsgBox "No bodies found.", vbExclamation
        Exit Sub
    End If

    Dim i As Long, swBody As Object
    Dim exported As Long, failed As Long

    For i = 0 To UBound(BodyArr)
        Set swBody = BodyArr(i)
        If swBody Is Nothing Then GoTo NextI

        ' --- create a brand-new part and INSERT the body without selection ---
        Dim partTplt As String: partTplt = TEMPLATE_PATH
        If partTplt = "" Then
            MsgBox "Set a default Part template in SW Options > Default Templates.", vbExclamation
            Exit Sub
        End If

        Dim tmp As Object
        Set tmp = swApp.NewDocument(partTplt, 0, 0, 0)
        If tmp Is Nothing Then
            failed = failed + 1: GoTo NextI
        End If

        ' Copy the body and insert via CreateFeatureFromBody3 (bulletproof in SW2014)
        Dim freeBody As Object
        On Error Resume Next
        Set freeBody = swBody.Copy
        On Error GoTo 0
        If freeBody Is Nothing Then
            ' fall back: try using original body reference
            Set freeBody = swBody
        End If

        Dim partIf As Object
        Set partIf = tmp ' IPartDoc late-bound
        Dim feat As Object
        On Error Resume Next
        Set feat = partIf.CreateFeatureFromBody3(freeBody, False, 0)
        On Error GoTo 0

        If feat Is Nothing Then
            ' nothing inserted; close and mark failed
            On Error Resume Next: swApp.CloseDoc tmp.GetTitle: On Error GoTo 0
            failed = failed + 1: GoTo NextI
        End If

        SafeRebuild tmp
        If Not HasBodies(tmp) Then
            On Error Resume Next: swApp.CloseDoc tmp.GetTitle: On Error GoTo 0
            failed = failed + 1: GoTo NextI
        End If

        ' STEP export options (late bound)
        Dim expData As Object
        Set expData = swApp.GetExportFileData(swExportSTEP)
        On Error Resume Next
        expData.AP214 = True
        expData.ExportSketchEntities = False
        expData.ExportAnnotations = False
        On Error GoTo 0

        ' filename and export
        Dim base As String, outPath As String
        base = Format(i, "000") & "_" & SafeName(swBody.Name)
        outPath = saveDir & "\" & base & ".step"

        Dim longstatus As Long, longwarnings As Long, ok As Boolean
        longstatus = 0: longwarnings = 0
        ok = tmp.Extension.SaveAs( _
                outPath, _
                swSaveAsCurrentVersion, _
                swSaveAsOptions_Silent Or swSaveAsOptions_Copy, _
                expData, _
                longstatus, _
                longwarnings)

        ' close temp part regardless
        On Error Resume Next
        swApp.CloseDoc tmp.GetTitle
        On Error GoTo 0

        If ok Then exported = exported + 1 Else failed = failed + 1

NextI:
    Next

    src.ClearSelection2 True
    MsgBox "STEP export complete. Exported: " & exported & ", Failed: " & failed & vbCrLf & _
          "Folder: " & saveDir, vbInformation
End Sub

Private Sub SafeRebuild(ByVal mdl As Object)
    On Error Resume Next
    mdl.EditRebuild3
    On Error GoTo 0
End Sub

Private Function HasBodies(ByVal mdl As Object) As Boolean
    On Error Resume Next
    Dim arr As Variant
    arr = mdl.GetBodies2(0, False)
    HasBodies = Not IsEmpty(arr)
    On Error GoTo 0
End Function

Private Function SafeName(ByVal s As String) As String
    Dim bad As Variant, r As Variant
    bad = Array("\", "/", ":", "*", "?", Chr$(34), "<", ">", "|")
    For Each r In bad
        s = Replace$(s, CStr(r), "_")
    Next
    If Len(Trim$(s)) = 0 Then s = "Body"
    SafeName = s
End Function


Private Function GetDefaultPartTemplate(app As Object) As String
    ' Try SolidWorks default template setting first
    On Error Resume Next
    Dim p As String
    p = app.GetUserPreferenceStringValue(2) ' swDefaultTemplatePart
    On Error GoTo 0
    If Len(p) > 0 And Len(Dir$(p)) > 0 Then
        GetDefaultPartTemplate = p
        Exit Function
    End If

    ' Try a few common install paths (adjust if needed)
    Dim c As Variant
    Dim candidates As Variant
    candidates = Array( _
        "C:\ProgramData\SolidWorks\SOLIDWORKS 2014\templates\Part.prtdot", _
        "C:\ProgramData\SolidWorks\SolidWorks 2014\templates\Part.prtdot", _
        "C:\ProgramData\SolidWorks\SolidWorks 2013\templates\Part.prtdot", _
        "C:\ProgramData\SolidWorks\SOLIDWORKS 2015\templates\Part.prtdot")
    For Each c In candidates
        If Len(Dir$(c)) > 0 Then
            GetDefaultPartTemplate = CStr(c)
            Exit Function
        End If
    Next

    ' Last resort: prompt user to paste a template path
    Dim userPath As String
    userPath = InputBox("Enter the full path to a Part template (*.prtdot)", _
                        "Select Part Template", _
                        "C:\ProgramData\SolidWorks\SOLIDWORKS 2014\templates\Part.prtdot")
    If Len(userPath) > 0 And Len(Dir$(userPath)) > 0 Then
        GetDefaultPartTemplate = userPath
    Else
        GetDefaultPartTemplate = ""
    End If
End Function
MULTI-BODY STL EXPORTER MACRO

Dim swApp As Object

Dim Part As Object

Dim boolstatus As Boolean

Dim longstatus As Long, longwarnings As Long

Dim MyPath As String

Dim MyDate As String

Dim MyFilename As String

Dim MySaveasDir As String

Dim Cnt As Integer

Dim Body_Vis_States() As Boolean

Dim BodyArr As Variant

Sub main()

Set swApp = Application.SldWorks

Set Part = swApp.ActiveDoc

Dim myModelView As Object

Set myModelView = Part.ActiveView

myModelView.FrameState = swWindowState_e.swWindowMaximized

‘ Gets folder path of current part

MyPath = Left(Part.GetPathName, InStrRev(Part.GetPathName, “\”) – 1)

‘ Used to create a directory for the STL file with a date code

MyDate = Format(Now(), “yyyy-mm-dd”)

‘ Uncomment this line for STL files to use same file name as the part file

‘ MyFilename = Left(Part.GetTitle(), InStrRev(Part.GetTitle(), “.”) – 1)

‘ Uncomment this line to have the user set the name of the STL files

‘ MyFilename = InputBox(“Set the name for the STL file(s)”)

‘ Sets the directory to store the STL files, I like this format but it can be changed

‘ MySaveasDir = “C:\Users\solidworks\Desktop\Conrads Projects\Multi_Body_STL_Exports” ‘
MySaveasDir = MyPath

‘ checks if MySaveAsDir directory exists, if not it will create one, otherwise an error will occur

If Dir(MySaveasDir, vbDirectory) = vbNullString Then

MkDir (MySaveasDir)

‘ MsgBox “Folder ” & MySaveasDir & ” Created”

End If

‘ creates an array of all the bodies in the current part

BodyArr = Part.GetBodies2(0, False)

‘ Get current visibility state of all bodies, put into an array

For Cnt = 0 To UBound(BodyArr)

Set swBody = BodyArr(Cnt)

If Not swBody Is Nothing Then

ReDim Preserve Body_Vis_States(0 To Cnt)

Body_Vis_States(Cnt) = swBody.Visible

‘ MsgBox (“Body ” & Cnt & ” Visibility: ” & Body_Vis_States(Cnt))

End If

Next Cnt

‘ Hide all bodies

For Cnt = 0 To UBound(BodyArr)

Set swBody = BodyArr(Cnt)

If Not swBody Is Nothing Then

swBody.HideBody (True)

‘ MsgBox (“Body ” & Cnt & ” Hidden”)

End If

Next Cnt

‘ Show each body one by one, save as STL, then hide again

For Cnt = 0 To UBound(BodyArr)

Set swBody = BodyArr(Cnt)

If Not swBody Is Nothing Then

swBody.HideBody (False)

longstatus = Part.SaveAs3(MySaveasDir & “\” & swBody.Name & “.stl”, 0, 2) ‘

‘ MsgBox (VarType(BodyArr(Cnt)))

swBody.HideBody (True)

End If

Next Cnt

‘ Put bodies back in the original visibility state

For Cnt = 0 To UBound(BodyArr)

Set swBody = BodyArr(Cnt)

If Not swBody Is Nothing Then

swBody.HideBody (Not Body_Vis_States(Cnt))

End If

Next Cnt

‘ Open the window containing the STLs

‘ Shell “explorer.exe ” & MySaveasDir & “\”, vbNormalFocus

End Sub

Mechanical Design

I’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’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’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’m not 100% out of the external reference woods.

6 parts for the price of one. I think I might use this method for future multi-print assemblies.

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).

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.

Real Quick, I’ll go through all of the 3D Prints and their purpose.

BELL_BASE

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’s us attach the BELL_BASE to the LED_BASE.

LED_BASE

Cross section view.

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’s 3D printer friendly.

LED_HOLDER_BLOCKER and LED_Holder

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’t think it mattered as much and the black light shield shows up through the print like Cousin Eddie’s dickey.

This gets printed as one piece, you can just set the color of the blocker to white as well and you won’t get such an ugly print. I’m not going to do that because there’s approximately 15 heat-set inserts I would need to throw out.

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!)

Model
IRL Phantom of the Opera style.
Full-scale print.

BELL_Holder

The bell holder does what the name describes. It holds the bell. There’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’s no good orientation to print it. There’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.

LED_CLAMP

The LED_CLAMP does what the name implies, it clamps the LED panels to the LED_Holder. I didn’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’s a bit fiddley.

a bit fiddly to get in but it does the job of clamping down on my panels!

Electrical Design

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’m also learning Altium for work so my brain is just…full. The best you’ll get is a Powerpoint drawing of everything.

Nothing super special here. The solenoid draws like 200mA always-on. The ESP32 does have a current limit so we’re actually close to not being in saturation. Practically, I don’t see any issues with this.

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.

Other Parts (Amazon Links and AliExpress Links)

ItemQuantityNote
12V Solenoid1
LED Panel4
Bell
M2 ScrewsA FewVarious sizes, test fitment.
M2 Heat Set InsertA Few
M6 Heat Set Insert1bell mount
Proto Board1
ESP32 Dev Board1
2N2222 NPN1Some random 2N2222 I had in a kit
Diode1Some random diode I had laying around. No link here.
Silicone Wire128GA is what I use.
Boost Converter120 Pack
USB Bulkhead1Tight fit, but the screws seat it firmly in place
80-grit sand paper1

Other Notes

The only real work/modifications that need done are the proto board needed trimmed down to fit inside the “!” and the LED panels needed sanded down to fit in a nice-uninterrupted row.

80-grit makes short work of shortening these PCBs.

The LED board can safely be sanded down along it’s length. Unfortunately given the pitch of the LEDs you need to get quite close to them. Take sanding slow and it’ll all go well. I’ve also included extra length for the top and bottom panels so you only have to sand 4x sides instead of 6x sides.

Code

Disclaimer: This was 100% vibe coded but based loosely on UPIRs Code.

The code requirements are as follows:

  1. OTA Firmware Updates for remote upgrades/tweaks
  2. WIFI web server for debugging
  3. MQTT connection
  4. MQTT advertisement of variables
  5. GPIO Control (solenoid)
  6. 3x LED Panel Support

With these requirements and some example code I fed to chatGPT I got the following “finished” code.

Arduino Code, change variables to suit your needs.
/*
  ESP32 Display + Dinger Combo (FULL)

  - 3x Keyestudio 8x16 LED matrix panels stacked vertically => 8w x 48h
      TOP:    SCL=22 SDA=23
      MID:    SCL=18 SDA=19
      BOTTOM: SCL=2  SDA=4

  - Solenoid bell striker on GPIO15 (PULSE_PIN)
  - MQTT discovery (SMALL payloads) for:
      * Button: Ding
      * Number: Ding count (1..3)
      * Number: Pulse width (ms)
      * Number: Gap (ms)
      * Text: Display message (Home Assistant text entity)
  - MQTT command topics (also usable directly):
      * home/dinger/<deviceId>/ding/press          payload: PRESS
      * home/dinger/<deviceId>/ding_count/set      payload: 1..3
      * home/dinger/<deviceId>/pulse_width_ms/set  payload: 1..2000
      * home/dinger/<deviceId>/gap_ms/set          payload: 0..5000
      * home/dinger/<deviceId>/display/set         payload: string
      * home/dinger/<deviceId>/max_current/set     payload: 0/1

  - Web UI:
      * Set count/width/gap + Ding
      * Set display text
      * Max-current test toggle (all LEDs on all panels)
  - OTA enabled

  Notes:
  - GPIO15 is a strapping pin on some ESP32 boards; move if boot issues.
  - Display scroll direction is TOP -> BOTTOM when message length > 6 chars (48/8).
*/

#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoOTA.h>
#include <WebServer.h>

// ---------- WIFI ----------
const char* WIFI_SSIDS[] = { "SSID1", "SSID2" };
const int WIFI_SSID_COUNT = sizeof(WIFI_SSIDS) / sizeof(WIFI_SSIDS[0]);
const char* WIFI_PASSWORD  = "PASSWORD";

// ---------- MQTT ----------
const char* MQTT_HOST      = "homeassistant.local";
const uint16_t MQTT_PORT   = 1883;
const char* MQTT_USER      = "MQTTUSER";
const char* MQTT_PASS      = "MQTTPASS";

// OTA password
const char* OTA_PASSWORD   = "OTAPASS";

#ifndef LED_BUILTIN
#define LED_BUILTIN 2
#endif

// ---------- HARDWARE ----------
static constexpr uint8_t PULSE_PIN = 15;
static constexpr uint8_t LED_PIN   = LED_BUILTIN;

// 3 panels (top->bottom)
#define SCL_TOP  22
#define SDA_TOP  23
#define SCL_MID  18
#define SDA_MID  19
#define SCL_BOT  2
#define SDA_BOT  4

// ---------- DISPLAY GEOMETRY ----------
static const int PANEL_H = 16;
static const int PANELS  = 3;
static const int DISP_H  = PANEL_H * PANELS; // 48

// ---------- DING LIMITS ----------
static constexpr int      DING_MIN = 1;
static constexpr int      DING_MAX = 5;
static constexpr uint32_t PULSE_MIN_MS = 1;
static constexpr uint32_t PULSE_MAX_MS = 2000;
static constexpr uint32_t GAP_MIN_MS   = 0;
static constexpr uint32_t GAP_MAX_MS   = 5000;

// ---------- SCROLL ----------
static const uint16_t SCROLL_STEP_MS = 80;
static const uint16_t HOLD_MS        = 800;

// ---------- GLOBALS ----------
WiFiClient    espClient;
PubSubClient  mqtt(espClient);
WebServer     server(80);

int currentSsidIndex = 0;

// Dinger params
volatile int dingCount = 1;
volatile uint32_t pulseWidthMs = 20;
volatile uint32_t gapMs = 80;

// Dinger state machine (non-blocking, FIXED)
enum DingPhase : uint8_t { DING_IDLE, DING_PULSE_ON, DING_GAP_WAIT };
static DingPhase dingPhase = DING_IDLE;
static int dingRemaining = 0;
static uint32_t phaseUntilMs = 0;

// Display state
static const int MAX_MSG = 96;
char displayText[MAX_MSG] = "I LOVE YOU";
bool maxCurrentTest = false;

// Display buffers
static uint8_t fb[DISP_H];          // visible 48 rows (each row is 8 bits)
static uint8_t* vrows = nullptr;    // virtual rows for scrolling
static int vheightAlloc = 0;
int scrollTopY = 0;
bool scrollHold = false;
uint32_t scrollLastStepMs = 0;
uint32_t scrollHoldStartMs = 0;
uint32_t displayEpoch = 0;          // increment when text changes
uint32_t renderedEpoch = 0;         // last rendered epoch
bool isScrolling = false;

// MQTT topics/IDs
char deviceId[32];
char baseTopic[96];
char availTopic[128];

char dingCmdTopic[128];

char countStateTopic[128];
char countCmdTopic[128];

char widthStateTopic[128];
char widthCmdTopic[128];

char gapStateTopic[128];
char gapCmdTopic[128];

// Display topics
char dispStateTopic[128];
char dispCmdTopic[128];

// Max current topics
char maxTestStateTopic[128];
char maxTestCmdTopic[128];

// Telemetry
uint32_t bootMs = 0;
uint32_t mqttReconnects = 0;
uint32_t lastMqttRxMs = 0;
char lastMqttTopic[128] = {0};
char lastMqttPayload[256] = {0};

// ---------- UTILS ----------
static int clampInt(int v, int lo, int hi) {
  if (v < lo) return lo;
  if (v > hi) return hi;
  return v;
}
static uint32_t clampU32(uint32_t v, uint32_t lo, uint32_t hi) {
  if (v < lo) return lo;
  if (v > hi) return hi;
  return v;
}

static void formatDeviceId() {
  uint32_t macLo = (uint32_t)ESP.getEfuseMac();
  snprintf(deviceId, sizeof(deviceId), "DINGER_%08X", macLo);
}

static void buildTopics() {
  snprintf(baseTopic, sizeof(baseTopic), "home/dinger/%s", deviceId);

  snprintf(availTopic, sizeof(availTopic), "%s/availability", baseTopic);

  snprintf(dingCmdTopic, sizeof(dingCmdTopic), "%s/ding/press", baseTopic);

  snprintf(countStateTopic, sizeof(countStateTopic), "%s/ding_count/state", baseTopic);
  snprintf(countCmdTopic,   sizeof(countCmdTopic),   "%s/ding_count/set",   baseTopic);

  snprintf(widthStateTopic, sizeof(widthStateTopic), "%s/pulse_width_ms/state", baseTopic);
  snprintf(widthCmdTopic,   sizeof(widthCmdTopic),   "%s/pulse_width_ms/set",   baseTopic);

  snprintf(gapStateTopic,   sizeof(gapStateTopic),   "%s/gap_ms/state", baseTopic);
  snprintf(gapCmdTopic,     sizeof(gapCmdTopic),     "%s/gap_ms/set",   baseTopic);

  // Display
  snprintf(dispStateTopic, sizeof(dispStateTopic), "%s/display/state", baseTopic);
  snprintf(dispCmdTopic,   sizeof(dispCmdTopic),   "%s/display/set",   baseTopic);

  // Max current test
  snprintf(maxTestStateTopic, sizeof(maxTestStateTopic), "%s/max_current/state", baseTopic);
  snprintf(maxTestCmdTopic,   sizeof(maxTestCmdTopic),   "%s/max_current/set",   baseTopic);
}

static void publishRetainedU32(const char* topic, uint32_t v) {
  if (!mqtt.connected()) return;
  char buf[16];
  snprintf(buf, sizeof(buf), "%lu", (unsigned long)v);
  mqtt.publish(topic, buf, true);
}
static void publishRetainedI32(const char* topic, int v) {
  if (!mqtt.connected()) return;
  char buf[16];
  snprintf(buf, sizeof(buf), "%d", v);
  mqtt.publish(topic, buf, true);
}
static void publishRetainedStr(const char* topic, const char* s) {
  if (!mqtt.connected()) return;
  mqtt.publish(topic, s, true);
}
static void publishRetainedBool(const char* topic, bool v) {
  if (!mqtt.connected()) return;
  mqtt.publish(topic, v ? "1" : "0", true);
}

// ---------- SOFTWARE I2C (bit-banged) ----------
void IIC_start(uint8_t scl, uint8_t sda) {
  digitalWrite(scl, LOW);  delayMicroseconds(3);
  digitalWrite(sda, HIGH); delayMicroseconds(3);
  digitalWrite(scl, HIGH); delayMicroseconds(3);
  digitalWrite(sda, LOW);  delayMicroseconds(3);
}
void IIC_send(uint8_t scl, uint8_t sda, uint8_t data) {
  for (uint8_t i = 0; i < 8; i++) {
    digitalWrite(scl, LOW); delayMicroseconds(3);
    digitalWrite(sda, (data & 0x01) ? HIGH : LOW);
    delayMicroseconds(3);
    digitalWrite(scl, HIGH); delayMicroseconds(3);
    data >>= 1;
  }
}
void IIC_end(uint8_t scl, uint8_t sda) {
  digitalWrite(scl, LOW);  delayMicroseconds(3);
  digitalWrite(sda, LOW);  delayMicroseconds(3);
  digitalWrite(scl, HIGH); delayMicroseconds(3);
  digitalWrite(sda, HIGH); delayMicroseconds(3);
}

static void setBrightness(uint8_t scl, uint8_t sda, uint8_t brightnessCmd) {
  IIC_start(scl, sda);
  IIC_send(scl, sda, brightnessCmd); // 0x88..0x8F
  IIC_end(scl, sda);
}

static void updateDisp(uint8_t scl, uint8_t sda, uint8_t frame16[16]) {
  IIC_start(scl, sda);
  IIC_send(scl, sda, 0x40); // auto-increment
  IIC_end(scl, sda);

  IIC_start(scl, sda);
  IIC_send(scl, sda, 0xC0); // start addr 0
  for (uint8_t i = 0; i < 16; i++) IIC_send(scl, sda, frame16[i]);
  IIC_end(scl, sda);
}

// ---------- DISPLAY RENDERING ----------
static inline void fbClear() { memset(fb, 0, sizeof(fb)); }

// 8x8 font A-Z, 0-9, space
struct Glyph { char c; uint8_t rows[8]; };
static const Glyph font[] = {
  { ' ', {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00} },

  { '0', {0x3C,0x66,0x6E,0x76,0x66,0x66,0x3C,0x00} },
  { '1', {0x18,0x38,0x18,0x18,0x18,0x18,0x3C,0x00} },
  { '2', {0x3C,0x66,0x06,0x0C,0x18,0x30,0x7E,0x00} },
  { '3', {0x3C,0x66,0x06,0x1C,0x06,0x66,0x3C,0x00} },
  { '4', {0x0C,0x1C,0x3C,0x6C,0x7E,0x0C,0x0C,0x00} },
  { '5', {0x7E,0x60,0x7C,0x06,0x06,0x66,0x3C,0x00} },
  { '6', {0x1C,0x30,0x60,0x7C,0x66,0x66,0x3C,0x00} },
  { '7', {0x7E,0x66,0x06,0x0C,0x18,0x18,0x18,0x00} },
  { '8', {0x3C,0x66,0x66,0x3C,0x66,0x66,0x3C,0x00} },
  { '9', {0x3C,0x66,0x66,0x3E,0x06,0x0C,0x38,0x00} },

  { 'A', {0x18,0x24,0x42,0x7E,0x42,0x42,0x42,0x00} },
  { 'B', {0x7C,0x42,0x42,0x7C,0x42,0x42,0x7C,0x00} },
  { 'C', {0x3C,0x42,0x40,0x40,0x40,0x42,0x3C,0x00} },
  { 'D', {0x78,0x44,0x42,0x42,0x42,0x44,0x78,0x00} },
  { 'E', {0x7E,0x40,0x40,0x7C,0x40,0x40,0x7E,0x00} },
  { 'F', {0x7E,0x40,0x40,0x7C,0x40,0x40,0x40,0x00} },
  { 'G', {0x3C,0x42,0x40,0x4E,0x42,0x42,0x3C,0x00} },
  { 'H', {0x42,0x42,0x42,0x7E,0x42,0x42,0x42,0x00} },
  { 'I', {0x7E,0x18,0x18,0x18,0x18,0x18,0x7E,0x00} },
  { 'J', {0x0E,0x04,0x04,0x04,0x44,0x44,0x38,0x00} },
  { 'K', {0x42,0x44,0x48,0x70,0x48,0x44,0x42,0x00} },
  { 'L', {0x40,0x40,0x40,0x40,0x40,0x40,0x7E,0x00} },
  { 'M', {0x42,0x66,0x5A,0x5A,0x42,0x42,0x42,0x00} },
  { 'N', {0x42,0x62,0x52,0x4A,0x46,0x42,0x42,0x00} },
  { 'O', {0x3C,0x42,0x42,0x42,0x42,0x42,0x3C,0x00} },
  { 'P', {0x7C,0x42,0x42,0x7C,0x40,0x40,0x40,0x00} },
  { 'Q', {0x3C,0x42,0x42,0x42,0x4A,0x44,0x3A,0x00} },
  { 'R', {0x7C,0x42,0x42,0x7C,0x48,0x44,0x42,0x00} },
  { 'S', {0x3C,0x42,0x40,0x3C,0x02,0x42,0x3C,0x00} },
  { 'T', {0x7E,0x18,0x18,0x18,0x18,0x18,0x18,0x00} },
  { 'U', {0x42,0x42,0x42,0x42,0x42,0x42,0x3C,0x00} },
  { 'V', {0x42,0x42,0x42,0x42,0x24,0x24,0x18,0x00} },
  { 'W', {0x42,0x42,0x42,0x5A,0x5A,0x66,0x42,0x00} },
  { 'X', {0x42,0x24,0x18,0x18,0x18,0x24,0x42,0x00} },
  { 'Y', {0x42,0x24,0x18,0x18,0x18,0x18,0x18,0x00} },
  { 'Z', {0x7E,0x02,0x04,0x18,0x20,0x40,0x7E,0x00} },
};

static const uint8_t* glyphRows(char c) {
  if (c >= 'a' && c <= 'z') c = char(c - 'a' + 'A');
  for (size_t i=0; i<sizeof(font)/sizeof(font[0]); i++) {
    if (font[i].c == c) return font[i].rows;
  }
  return font[0].rows;
}

static int msgLen(const char* s) { int n=0; while (*s++) n++; return n; }

static void ensureVirtualHeight(int vheight) {
  if (vheightAlloc == vheight && vrows) return;
  if (vrows) free(vrows);
  vrows = (uint8_t*)malloc(vheight);
  vheightAlloc = vrows ? vheight : 0;
}

static void renderToVirtual(const char* msg) {
  const int n = msgLen(msg);
  const int messageHeight = n * 8;             // 8px per char, no gap
  const int vheight = messageHeight + DISP_H;  // pad for entry/exit
  ensureVirtualHeight(vheight);
  if (!vrows) return;

  memset(vrows, 0, vheight);
  int y = 0;
  for (const char* p = msg; *p; p++) {
    const uint8_t* r = glyphRows(*p);
    for (int dy=0; dy<8; dy++) {
      int yy = y + dy;
      if (yy >= 0 && yy < vheight) vrows[yy] = r[dy];
    }
    y += 8;
    if (y >= vheight) break;
  }

  isScrolling = (n > (DISP_H / 8)); // >6 chars
  scrollTopY = -DISP_H;             // start above view
  scrollHold = false;
  scrollLastStepMs = millis();
}

static void windowFromVirtual(int topY) {
  if (!vrows || vheightAlloc <= 0) { fbClear(); return; }
  for (int y=0; y<DISP_H; y++) {
    int srcY = topY + y;
    fb[y] = (srcY >= 0 && srcY < vheightAlloc) ? vrows[srcY] : 0x00;
  }
}

// If mirrored/flipped, fix ONLY here.
static void pushFbToPanels() {
  uint8_t fTop[16], fMid[16], fBot[16];
  for (int i=0; i<16; i++) fTop[i] = fb[0  + i];
  for (int i=0; i<16; i++) fMid[i] = fb[16 + i];
  for (int i=0; i<16; i++) fBot[i] = fb[32 + i];

  updateDisp(SCL_TOP, SDA_TOP, fTop);
  updateDisp(SCL_MID, SDA_MID, fMid);
  updateDisp(SCL_BOT, SDA_BOT, fBot);
}

static void allLedsOnAllPanels() {
  uint8_t on16[16];
  for (int i=0; i<16; i++) on16[i] = 0xFF;
  updateDisp(SCL_TOP, SDA_TOP, on16);
  updateDisp(SCL_MID, SDA_MID, on16);
  updateDisp(SCL_BOT, SDA_BOT, on16);
}

static void setDisplayText(const char* s) {
  // sanitize to A-Z 0-9 space (unknown -> space)
  size_t n = strnlen(s, MAX_MSG - 1);
  for (size_t i = 0; i < n; i++) {
    char c = s[i];
    if (c >= 'a' && c <= 'z') c = char(c - 'a' + 'A');
    bool ok = (c == ' ') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
    displayText[i] = ok ? c : ' ';
  }
  displayText[n] = '\0';

  displayEpoch++;
  publishRetainedStr(dispStateTopic, displayText);
}

// ---------- DINGER (non-blocking, FIXED) ----------
static void startDings(int n) {
  if (dingPhase != DING_IDLE) return;
  n = clampInt(n, DING_MIN, DING_MAX);
  dingRemaining = n;
  dingPhase = DING_PULSE_ON;
  phaseUntilMs = 0; // start immediately
}

static void serviceDinger() {
  const uint32_t now = millis();
  if (dingPhase == DING_IDLE) return;

  if (phaseUntilMs && (int32_t)(now - phaseUntilMs) < 0) return;

  const uint32_t w = clampU32(pulseWidthMs, PULSE_MIN_MS, PULSE_MAX_MS);
  const uint32_t g = clampU32(gapMs, GAP_MIN_MS, GAP_MAX_MS);

  switch (dingPhase) {
    case DING_PULSE_ON:
      // start pulse
      digitalWrite(PULSE_PIN, HIGH);
      digitalWrite(LED_PIN, HIGH);
      dingPhase = DING_GAP_WAIT;
      phaseUntilMs = now + w;
      break;

    case DING_GAP_WAIT:
      // end pulse
      digitalWrite(PULSE_PIN, LOW);
      digitalWrite(LED_PIN, LOW);

      dingRemaining--;
      if (dingRemaining <= 0) {
        dingPhase = DING_IDLE;
        phaseUntilMs = 0;
      } else {
        // wait gap before next pulse
        dingPhase = DING_PULSE_ON;
        phaseUntilMs = now + g;
      }
      break;

    default:
      dingPhase = DING_IDLE;
      phaseUntilMs = 0;
      break;
  }
}

// ---------- WIFI ----------
void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  WiFi.mode(WIFI_STA);

  while (WiFi.status() != WL_CONNECTED) {
    const char* ssid = WIFI_SSIDS[currentSsidIndex];
    Serial.printf("Connecting to WiFi SSID: %s\n", ssid);
    WiFi.begin(ssid, WIFI_PASSWORD);

    int attempts = 0;
    while (WiFi.status() != WL_CONNECTED && attempts < 20) {
      delay(500);
      Serial.print(".");
      attempts++;
    }

    if (WiFi.status() == WL_CONNECTED) {
      Serial.print("\nConnected to ");
      Serial.print(ssid);
      Serial.print(", IP=");
      Serial.println(WiFi.localIP());
      break;
    } else {
      Serial.printf("\nFailed to connect to %s\n", ssid);
      currentSsidIndex = (currentSsidIndex + 1) % WIFI_SSID_COUNT;
      delay(1000);
    }
  }
}

// ---------- MQTT DISCOVERY ----------
static void publishDiscoverySmall() {
  if (!mqtt.connected()) return;

  char cfgTopic[128];
  char payload[256];

  // Button (ding)
  snprintf(cfgTopic, sizeof(cfgTopic), "homeassistant/button/%s/ding/config", deviceId);
  snprintf(payload, sizeof(payload),
           "{\"name\":\"%s Ding\",\"unique_id\":\"%s_ding\","
           "\"command_topic\":\"%s\",\"payload_press\":\"PRESS\"}",
           deviceId, deviceId, dingCmdTopic);
  mqtt.publish(cfgTopic, payload, true);

  // Number: Ding Count
  snprintf(cfgTopic, sizeof(cfgTopic), "homeassistant/number/%s/ding_count/config", deviceId);
  snprintf(payload, sizeof(payload),
           "{\"name\":\"%s Ding Count\",\"unique_id\":\"%s_ding_count\","
           "\"state_topic\":\"%s\",\"command_topic\":\"%s\","
           "\"min\":1,\"max\":5,\"step\":1,\"mode\":\"slider\"}",
           deviceId, deviceId, countStateTopic, countCmdTopic);
  mqtt.publish(cfgTopic, payload, true);

  // Number: Pulse Width (ms)
  snprintf(cfgTopic, sizeof(cfgTopic), "homeassistant/number/%s/pulse_width_ms/config", deviceId);
  snprintf(payload, sizeof(payload),
           "{\"name\":\"%s Pulse Width (ms)\",\"unique_id\":\"%s_pulse_width_ms\","
           "\"state_topic\":\"%s\",\"command_topic\":\"%s\","
           "\"min\":%lu,\"max\":%lu,\"step\":1,\"mode\":\"slider\"}",
           deviceId, deviceId, widthStateTopic, widthCmdTopic,
           (unsigned long)PULSE_MIN_MS, (unsigned long)PULSE_MAX_MS);
  mqtt.publish(cfgTopic, payload, true);

  // Number: Gap (ms)
  snprintf(cfgTopic, sizeof(cfgTopic), "homeassistant/number/%s/gap_ms/config", deviceId);
  snprintf(payload, sizeof(payload),
           "{\"name\":\"%s Gap (ms)\",\"unique_id\":\"%s_gap_ms\","
           "\"state_topic\":\"%s\",\"command_topic\":\"%s\","
           "\"min\":50,\"max\":500,\"step\":50,\"mode\":\"slider\"}",
           deviceId, deviceId, gapStateTopic, gapCmdTopic,
           (unsigned long)GAP_MIN_MS, (unsigned long)GAP_MAX_MS);
  mqtt.publish(cfgTopic, payload, true);

  // Text: Display Message (THIS MAKES text.<device>_display appear)
  snprintf(cfgTopic, sizeof(cfgTopic), "homeassistant/text/%s/display/config", deviceId);
  snprintf(payload, sizeof(payload),
           "{\"name\":\"%s Display\",\"unique_id\":\"%s_display\","
           "\"state_topic\":\"%s\",\"command_topic\":\"%s\","
           "\"max\":95,\"mode\":\"text\"}",
           deviceId, deviceId, dispStateTopic, dispCmdTopic);
  mqtt.publish(cfgTopic, payload, true);
}

// ---------- MQTT CALLBACK ----------
void mqttCallback(char* topic, byte* payload, unsigned int length) {
  lastMqttRxMs = millis();
  strncpy(lastMqttTopic, topic, sizeof(lastMqttTopic) - 1);
  lastMqttTopic[sizeof(lastMqttTopic) - 1] = '\0';

  unsigned int n = (length < (sizeof(lastMqttPayload) - 1)) ? length : (sizeof(lastMqttPayload) - 1);
  for (unsigned int i = 0; i < n; i++) lastMqttPayload[i] = (char)payload[i];
  lastMqttPayload[n] = '\0';

  if (strcmp(topic, countCmdTopic) == 0) {
    dingCount = clampInt(atoi(lastMqttPayload), DING_MIN, DING_MAX);
    publishRetainedI32(countStateTopic, dingCount);
    return;
  }
  if (strcmp(topic, widthCmdTopic) == 0) {
    pulseWidthMs = clampU32((uint32_t)atoi(lastMqttPayload), PULSE_MIN_MS, PULSE_MAX_MS);
    publishRetainedU32(widthStateTopic, pulseWidthMs);
    return;
  }
  if (strcmp(topic, gapCmdTopic) == 0) {
    gapMs = clampU32((uint32_t)atoi(lastMqttPayload), GAP_MIN_MS, GAP_MAX_MS);
    publishRetainedU32(gapStateTopic, gapMs);
    return;
  }
  if (strcmp(topic, dingCmdTopic) == 0) {
    startDings(dingCount);
    return;
  }

  if (strcmp(topic, dispCmdTopic) == 0) {
    setDisplayText(lastMqttPayload);
    return;
  }

  if (strcmp(topic, maxTestCmdTopic) == 0) {
    int v = atoi(lastMqttPayload);
    maxCurrentTest = (v != 0);
    publishRetainedBool(maxTestStateTopic, maxCurrentTest);
    return;
  }
}

void connectMQTT() {
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setCallback(mqttCallback);
  mqtt.setBufferSize(512);

  while (!mqtt.connected()) {
    mqttReconnects++;

    bool ok = mqtt.connect(
      deviceId,
      MQTT_USER,
      MQTT_PASS,
      availTopic,
      0,
      true,
      "offline"
    );

    if (ok) {
      mqtt.publish(availTopic, "online", true);

      mqtt.subscribe(dingCmdTopic);
      mqtt.subscribe(countCmdTopic);
      mqtt.subscribe(widthCmdTopic);
      mqtt.subscribe(gapCmdTopic);
      mqtt.subscribe(dispCmdTopic);
      mqtt.subscribe(maxTestCmdTopic);

      publishDiscoverySmall();

      publishRetainedI32(countStateTopic, dingCount);
      publishRetainedU32(widthStateTopic, pulseWidthMs);
      publishRetainedU32(gapStateTopic, gapMs);
      publishRetainedStr(dispStateTopic, displayText);
      publishRetainedBool(maxTestStateTopic, maxCurrentTest);

    } else {
      Serial.print("MQTT failed rc=");
      Serial.println(mqtt.state());
      delay(2000);
    }
  }
}

// ---------- WEB ----------
static String statusJson() {
  bool wifiOk = (WiFi.status() == WL_CONNECTED);
  bool mqttOk = mqtt.connected();
  int rssi = wifiOk ? WiFi.RSSI() : -999;

  uint32_t upS = (millis() - bootMs) / 1000;
  uint32_t lastAgeMs = (lastMqttRxMs == 0) ? 0 : (millis() - lastMqttRxMs);

  String ip = wifiOk ? WiFi.localIP().toString() : "0.0.0.0";

  String j;
  j.reserve(1000);
  j += "{";
  j += "\"device_id\":\""; j += deviceId; j += "\",";
  j += "\"ip\":\""; j += ip; j += "\",";
  j += "\"rssi\":"; j += String(rssi); j += ",";
  j += "\"mqtt_connected\":"; j += (mqttOk ? "true" : "false"); j += ",";
  j += "\"mqtt_state\":"; j += String(mqtt.state()); j += ",";
  j += "\"mqtt_reconnects\":"; j += String(mqttReconnects); j += ",";
  j += "\"ding_count\":"; j += String(dingCount); j += ",";
  j += "\"pulse_width_ms\":"; j += String(pulseWidthMs); j += ",";
  j += "\"gap_ms\":"; j += String(gapMs); j += ",";
  j += "\"ding_phase\":"; j += String((int)dingPhase); j += ",";
  j += "\"display_text\":\""; j += displayText; j += "\",";
  j += "\"max_current_test\":"; j += (maxCurrentTest ? "true" : "false"); j += ",";
  j += "\"uptime_s\":"; j += String(upS); j += ",";
  j += "\"last_mqtt_topic\":\""; j += lastMqttTopic; j += "\",";
  j += "\"last_mqtt_payload\":\""; j += lastMqttPayload; j += "\",";
  j += "\"last_mqtt_age_ms\":"; j += String(lastAgeMs);
  j += "}";
  return j;
}

void handleRoot() {
  server.send(200, "text/html",
    "<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>Dinger + Display</title>"
    "<style>body{font-family:system-ui;margin:20px;max-width:860px}"
    "input{font-size:18px;padding:6px 8px} "
    "button{font-size:18px;padding:10px 16px;margin:6px 8px 6px 0} "
    "pre{background:#111;color:#eee;padding:12px;border-radius:10px;overflow:auto}"
    ".row{margin:10px 0}</style></head><body>"
    "<h2>Dinger + Display</h2>"

    "<div class='row'>Ding count (1..3): <input id='count' type='number' min='1' max='3' step='1'></div>"
    "<div class='row'>Pulse width (ms): <input id='width' type='number' min='1' step='1'></div>"
    "<div class='row'>Gap (ms): <input id='gap' type='number' min='0' step='1'></div>"
    "<button onclick='applyDing()'>Apply</button>"
    "<button onclick='fire()'>Ding</button>"

    "<hr>"

    "<div class='row'>Display text: <input id='msg' type='text' style='width:420px' maxlength='95'></div>"
    "<button onclick='applyMsg()'>Set Display</button>"
    "<button onclick='maxOn()'>Max Current ON</button>"
    "<button onclick='maxOff()'>Max Current OFF</button>"

    "<h3>Status</h3><pre id='st'>loading...</pre>"

    "<script>"
    "async function getStatus(){"
    " const r=await fetch('/status'); const j=await r.json();"
    " document.getElementById('st').textContent=JSON.stringify(j,null,2);"
    " if(document.activeElement.id!=='count') document.getElementById('count').value=j.ding_count;"
    " if(document.activeElement.id!=='width') document.getElementById('width').value=j.pulse_width_ms;"
    " if(document.activeElement.id!=='gap') document.getElementById('gap').value=j.gap_ms;"
    " if(document.activeElement.id!=='msg') document.getElementById('msg').value=j.display_text;"
    "}"
    "async function applyDing(){"
    " const c=document.getElementById('count').value|0;"
    " const w=document.getElementById('width').value|0;"
    " const g=document.getElementById('gap').value|0;"
    " await fetch(`/set_ding?count=${c}&width=${w}&gap=${g}`); await getStatus();"
    "}"
    "async function fire(){ await fetch('/fire'); await getStatus(); }"
    "async function applyMsg(){"
    " const m=encodeURIComponent(document.getElementById('msg').value);"
    " await fetch(`/set_msg?m=${m}`); await getStatus();"
    "}"
    "async function maxOn(){ await fetch('/max?on=1'); await getStatus(); }"
    "async function maxOff(){ await fetch('/max?on=0'); await getStatus(); }"
    "setInterval(getStatus,1000); getStatus();"
    "</script></body></html>"
  );
}

void handleStatus() { server.send(200, "application/json", statusJson()); }

void handleSetDing() {
  if (server.hasArg("count")) dingCount = clampInt(server.arg("count").toInt(), DING_MIN, DING_MAX);
  if (server.hasArg("width")) pulseWidthMs = clampU32((uint32_t)server.arg("width").toInt(), PULSE_MIN_MS, PULSE_MAX_MS);
  if (server.hasArg("gap"))   gapMs       = clampU32((uint32_t)server.arg("gap").toInt(),   GAP_MIN_MS,   GAP_MAX_MS);

  publishRetainedI32(countStateTopic, dingCount);
  publishRetainedU32(widthStateTopic, pulseWidthMs);
  publishRetainedU32(gapStateTopic, gapMs);

  server.send(200, "text/plain", "OK");
}

void handleFire() {
  startDings(dingCount);
  server.send(200, "text/plain", "OK");
}

void handleSetMsg() {
  if (server.hasArg("m")) {
    String m = server.arg("m"); // decoded
    setDisplayText(m.c_str());
  }
  server.send(200, "text/plain", "OK");
}

void handleMax() {
  int on = server.hasArg("on") ? server.arg("on").toInt() : 0;
  maxCurrentTest = (on != 0);
  publishRetainedBool(maxTestStateTopic, maxCurrentTest);
  server.send(200, "text/plain", "OK");
}

// ---------- DISPLAY SERVICE (non-blocking) ----------
static void serviceDisplay() {
  if (maxCurrentTest) {
    allLedsOnAllPanels();
    return;
  }

  if (renderedEpoch != displayEpoch) {
    renderToVirtual(displayText);
    renderedEpoch = displayEpoch;
  }

  const uint32_t now = millis();

  if (!isScrolling) {
    fbClear();
    int n = msgLen(displayText);
    int maxStatic = DISP_H / 8; // 6
    for (int i=0; i<n && i<maxStatic; i++) {
      const uint8_t* r = glyphRows(displayText[i]);
      for (int dy=0; dy<8; dy++) {
        fb[i*8 + dy] = r[dy];
      }
    }
    pushFbToPanels();
    return;
  }

  if (scrollHold) {
    if (now - scrollHoldStartMs >= HOLD_MS) {
      scrollHold = false;
      scrollLastStepMs = now;
    } else {
      windowFromVirtual(scrollTopY);
      pushFbToPanels();
      return;
    }
  }

  if (now - scrollLastStepMs >= SCROLL_STEP_MS) {
    scrollLastStepMs = now;
    scrollTopY++; // TOP -> BOTTOM
    if (vrows && vheightAlloc > 0) {
      if (scrollTopY > (vheightAlloc - DISP_H)) {
        scrollTopY = -DISP_H;
        scrollHold = true;
        scrollHoldStartMs = now;
      }
    }
  }

  windowFromVirtual(scrollTopY);
  pushFbToPanels();
}

// ---------- OTA ----------
static void setupOTA() {
  ArduinoOTA.setHostname(deviceId);
  if (OTA_PASSWORD && OTA_PASSWORD[0] != '\0') ArduinoOTA.setPassword(OTA_PASSWORD);
  ArduinoOTA.begin();
}

// ---------- SETUP / LOOP ----------
void setup() {
  Serial.begin(115200);
  delay(300);
  bootMs = millis();

  // Dinger pins
  pinMode(PULSE_PIN, OUTPUT);
  digitalWrite(PULSE_PIN, LOW);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);

  // Display pins
  pinMode(SCL_TOP, OUTPUT); pinMode(SDA_TOP, OUTPUT);
  pinMode(SCL_MID, OUTPUT); pinMode(SDA_MID, OUTPUT);
  pinMode(SCL_BOT, OUTPUT); pinMode(SDA_BOT, OUTPUT);

  digitalWrite(SCL_TOP, LOW); digitalWrite(SDA_TOP, LOW);
  digitalWrite(SCL_MID, LOW); digitalWrite(SDA_MID, LOW);
  digitalWrite(SCL_BOT, LOW); digitalWrite(SDA_BOT, LOW);

  // Brightness
  setBrightness(SCL_TOP, SDA_TOP, 0x8E);
  setBrightness(SCL_MID, SDA_MID, 0x8E);
  setBrightness(SCL_BOT, SDA_BOT, 0x8E);

  // IDs/topics
  formatDeviceId();
  buildTopics();

  // WiFi
  connectWiFi();

  // OTA
  setupOTA();

  // MQTT
  mqtt.setCallback(mqttCallback);
  connectMQTT();

  // Web
  server.on("/", handleRoot);
  server.on("/status", handleStatus);
  server.on("/set_ding", handleSetDing);
  server.on("/fire", handleFire);
  server.on("/set_msg", handleSetMsg);
  server.on("/max", handleMax);
  server.begin();

  // Init display
  setDisplayText(displayText);
  renderToVirtual(displayText);
  renderedEpoch = displayEpoch;
}

void loop() {
  ArduinoOTA.handle();

  if (WiFi.status() != WL_CONNECTED) connectWiFi();
  if (!mqtt.connected()) connectMQTT();
  mqtt.loop();

  server.handleClient();

  serviceDinger();
  serviceDisplay();
}

Wifi supports multiple networks. So you can test and deploy between multiple locations (like if you’re giving this to a friend).

The board to use for these generic ESP-32’s is DOIT ESP32 DEVKIT V1

Home Assistant Integration

Since the code above contains auto-discovery you can easily write your own dashboard for this unit. The main breakouts are:

  1. Ding
  2. Ding Count
  3. Pulse Width (how long the solenoid stays on for)
  4. Gap (gap between dings)
  5. Text (display message)

Once the MQTT “sensors” are discovered you can make a quick dashboard to test out basic functionality. Keep in mind that the “dinger” sensor is followed by the ESP’s MAC address so you can have multiple of these units. The below HomeAssistant integration code won’t work for you, but you should be able to use your own mac address (advertised) and be up and running.

Bell Control Dashboard Code
type: entities
title: Bell Control
entities:
  - entity: number.dinger_8ad8cbb0_ding_count
    name: Number of dings
  - entity: button.dinger_8ad8cbb0_ding
    name: Ring bell
  - entity: text.dinger_8ad8cbb0_display
  - entity: number.dinger_8ad8cbb0_gap_ms
    name: Delay Between Dings
grid_options:
  columns: 12
  rows: auto

Once you’re happy with the manual control, you’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).

Canned Messages Dashboard Code
type: horizontal-stack
cards:
  - show_name: true
    show_icon: true
    type: button
    name: I LOVE YOU
    icon: mdi:heart
    tap_action:
      action: perform-action
      target:
        entity_id: automation.dinger_love_you
      perform_action: automation.trigger
      data:
        skip_condition: true
  - type: button
    name: Clear
    icon: mdi:close-circle
    tap_action:
      action: call-service
      service: automation.trigger
      target:
        entity_id: automation.dinger_clear_display
  - type: button
    name: Road
    icon: mdi:road
    tap_action:
      action: call-service
      service: automation.trigger
      target:
        entitity_id: automation.road_notification_dinger
  - type: button
    name: Mail
    icon: mdi:mailbox
    tap_action:
      action: call-service
      service: automation.trigger
      target:
        entitity_id: automation.mail

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:

Clear Automation (Clears the LED Display)
alias: dinger_clear_display
description: ""
triggers:
  - trigger: event
    event_type: ""
conditions: []
actions:
  - action: text.set_value
    metadata: {}
    target:
      entity_id: text.dinger_8ad8cbb0_display
    data:
      value: ""
mode: single
I LOVE YOU Automation (Dings the bell 3 times, and displays “I LOVE YOU” on the LED Display)
alias: dinger_love_you
description: ""
triggers:
  - trigger: event
    event_type: ""
conditions: []
actions:
  - action: text.set_value
    metadata: {}
    target:
      entity_id: text.dinger_8ad8cbb0_display
    data:
      value: I LOVE YOU
  - action: number.set_value
    metadata: {}
    target:
      entity_id: number.dinger_8ad8cbb0_ding_count
    data:
      value: "3"
  - action: button.press
    metadata: {}
    target:
      entity_id: button.dinger_8ad8cbb0_ding
    data: {}
mode: single

Project Files

My code is pasted above in the Code section. The 3D files are available for download, modification, or printing on Printables and MakerWorld.

Final Thoughts

The ! Notifier was fun to design and I hope to see others use or remix the design for their own use!