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

On Documentation

I’ve forgotten more projects than most people have started. I’m going to try to do a better job at laying them out on here. This is my repository for archaic knowledge.

Retro CRT Weather Display

Intro

I first heard of these displays when I saw a snipped of Adrian Black using them on Adrian’s Digital Basement in 2021. For only $20 per CRT it seemed like the perfect dumb project for me. Fast forward 4 years and I’ve done nothing with them. In fact I’ve had a kid, conceived another, and changed jobs since I bought these things. Time to put them to use. All my design files and code are available on my github.

Scope

At first I wanted to have a simple display…then I thought on it for a while…then I got goaded into a full audio-visual-tactile experience. Now I had a few objectives:

  • Composite video output
  • Speaker for music
  • Display on/off control
  • Rotary encoder & button for volume/mute/power control
  • LEDs (because they come with the encoder)
  • USB -> Serial UART for console access without WiFi

Mechanical Design

I really wanted to mimic some of the wood and plastic designs from the late 80’s early 90’s. I also knew I wanted heat-set inserts for easy modifications/improvements.

Initial prints were solely there to fit-check the CRT as my calipers don’t open wide enough to get an accurate height of the unit. Once the CRT fit the bezels I designed I was good to design the rest of the base.

I’m pretty bad at enclosure design. But everything fit after a print or two….

With the existance of 0201 components and robots, I typically give my PCB a form factor before I place a single chip…or even put my design into schematic. The workflow goes as such:

  1. Define large-scale components (encoders, speakers, buttons, ports, etc)
  2. Import raspberry pi
  3. Mechanically position large scale components
  4. Check fit in the main assembly
  5. Fillet, add mounting holes, make pretty
  6. Export DXF
  7. Import into KiCad user.comments layer
  8. Layout board
  9. Export PCB as step
  10. Re-import into Solidworks (This step helps check the fit of other components like resistors or large inductors)
You can see exactly where the headers need to live.

For my first rev of the PCB you can see a whole bunch of through-hole parts on the left side of the board. Initially I was going to go with a bulkhead-style USB C connector, however the part cost on that was like $30 and USB C connectors are sold for like $0.3 now. I digress.

To get the wood/plastic aesthetic, I used bambulab teak wood filament and matte black filament. In hindsight I probably would have used glossy black, but this was close enough.

Electrical Design

I decided to keep notes in powerpoint, because I can draw shapes and such. I think it was good diligence for a solo-design. Not sure if I would do it again in the future as it was quite tedious, but I had links and notes for every major component on the PCB. This is especially helpful when I needed example circuits and references.

Example of one of my slides

I’m not sure why I had it in my head, but I did not want to give the CRT 12V boost power. I really wanted to USB PD to negotiate for the needed 12V.

In hindsight it’s quite goofy to ask a buck converter to also pass-through voltage which is what I was planning on. Also, the USB PD chip I was using did not work. After this revision of the board, I decided it would be best just to provider 12V from the rail that can be supplied universally from all USB ports (power not guaranteed!)

The new design was significantly more simple, but gives the user no protections if the port cannot supply the necessary 1A. Consider yourself warned.

For the schematic I followed as simple of a layout as I could. I think it’s a relatively new thing to add pictures into KiCad but I leveraged that to make a quick and dirty pinout of the Pi and confirm USB C connections.

Layout of the PCB in this case was quite simple. I chose the cheapest trace/spacing specs offered by JLC PCB and went to work.

I was presented with no difficulties, until I tried to fit a bunch of 0805 components on to the PCB. This is where I reached the decision to drop down to 0402s and go with an assembly service.

In principal I like to design my projects such that they can be easily reverse-engineered. This generally includes reference designators for all components in the silk screen, however I simply did not have the room on this board with the 0805s and really wanted to get the second revision out without focusing too much on silkscreen design.

One side thing I’m quite happy about was my poka yoke method of adding in the CRT connector (on the right side of the board). The connector can be installed either way and the CRT will still function as expected.

During extended testing I found out that I was overpowering the speaker and managed to burn one out. Luckily the I2S chip has short circuit protection and overtemp protection so the board was okay. I still need to figure out proper power settings for the speakers I bought.

Revision 1 (with bodge wire). I always get cocky on rev 1 and make the board a fun color (like I’m not going to revise it again lol)
Revision 2: Standard-green solder mask 🙁 but hey, no bodge wires!

Software

I started off with a project in visual studio. This allows me to prototype code much faster than a remote session on the pi. I also used relative directory positioning such that my code could be run on either system. For some reason I could not do remote development on my PI Zero W through VS Code. I was able to do this for a previous project, but that feature does not seem to be available anymore? Luckily, until I got PCBs in hand, I could write a majority of the code as system-agnostic besides GPIO stuffs. For GPIO development I turned to old-trusty WinSCP. Save the file in VSCode, drag and drop it on the pi, run it from the pi over SSH. Rinse and repeat.

I’m pretty proud of how clever I was with the GUI. Instead of re-inventing the WeatherStar4000+ from scratch, I just copied it graphically, and added in dynamic elements only.

Weather Channel Screencap on the left, my custom background on the right. Text gets filled in on the GUI side.

99% of this code was written with ChatGPT. As such, I wanted to make it as atomic as possible to avoid the bogging down that comes with excessively long sections of code. In my infinite free time I’ll come in and optimize things a bit

Side Bar: I had no idea that the WeatherStar4000+ Emulator existed. Knowing what I know now, I probably would have used that and just played the video from a webpage instead of re-inventing this from scratch.

The GPIO handling works well enough, but it would be nice to figure out how to get it to be more responsive. Right now I’ve got some really janky software PWM for the LED color control and weird debouncing going on. I probably should have written it as C code so it’s a bit less bloated.

I don’t play with VENVs in python so all libraries are imported as root “–break-system-packages”. Not the best thing to do, but this is dedicated hardware running a dedicated service. So, hell with it.

Another thing to note is the shapefiles come from NaturalEarth. The shapefiles are too large to host on Github so you’ll need to manually download them and add them to the “shapefiles” folder.

It’s been a while since I touched the code on this, but the get_radar.py script took forever to make, and I’m super proud I was able to get png weather data and accurately place it on a latitude-and-longitude grid, with boarders, then animate it. If anyone has any leads for free radar I would appreciate it. I was using NOAAs GOES satellite data, but it didn’t fit the aesthetic, and it also didn’t give good local data.

Closing Thoughts

It’s pretty, it’s goofy, it makes music, and gives me the weather for my friends and family.

Don’t turn up the volume too loud or you’ll melt the speaker (apparently).

I’m glad I got to get back out and do some small scale engineering again, even if it was for a hobby project. My current job has been more nut-and-bolt/simulation oriented and I missed the inconsequential nature of small hardware development.

I’m currently working on a youtube video version of this post for those audio-visual oriented. I’ll update this post when it’s published.

Acknowledgements

I’d like to thank the TWCClassics group for their Star JR font. Open weather map for the weather data. I would also like to thank Natural Earth data for their boundary shapefiles. This made the radar map much easier to implement. Tristan and Remington did a great job reviewing my PCB and caught some issues with R1 before I sent it out, so they earned a spot on my silkscreen.

Custom Backgrounds on the reMarkable 2 Tablet

I’ve been fawning over the reMarkable 2 tablet for a while as I’ve always had fidelity issues with lab books, note books, and note pads. Upon receipt of this fine (hackable!!!!) device. I decided to make it my Christmas mission to allow the reMarkable to have custom backgrounds that cycle. With a little help from chatGPT I’ve written a service (reMarkable doesn’t have crontab installed), timer, and installer that does all the heavy lifting.

Step 1: SSH Into Your Remarkable 2 and Download Backgrounds + Installer

Go to Settings->General->Help->Copyrights and Licenses. Clicking this will bring up a whole bunch of text. Mainly, the ip address and password are at the bottom of this first page (no scrolling necessary). It should look something like this

using the information above you would ssh in using the following command:

ssh [email protected]

It’ll prompt for the password (blacked out above) and you’ll type that in and hit enter. After login you’ll be greeted with this:

Next create a “backgrounds” directory in your current directory with the following command and navigate into that directory.

mkdir backgrounds
cd backgrounds

From there, you’ll use wget to download my installer and a few backgrounds.

After the file is downloaded, unzip the archive.

wget https://github.com/Radacon/reMarkable_Background_Shuffler/raw/main/BG_Shuffler_Release_2023.tar.xz
tar –xvzf BG_Shuffler_Release_2023.tar.xz –C /home/root/backgrounds

After unzipping is complete double check to make sure all files are inside of your backgrounds folder as such (use the ls command).

Next, get the installer ready to execute and run the installer.

chmod +x install.sh
./install.sh

It should output something like this once its installed

Thank goodness systemd-tmpfiles-clean.timer was already running as a service. It served as a bit of a guide for some of the nuances of getting this working. Either way, the script is designed to run once per hour. This shouldn’t effect battery life much, as the remarkable never really goes into “sleep”.

Step 2: Mod Away

The changebg.timer can be edited with

nano /var/systemd/system/changebg.timer

Currently the timer is set to change the background every hour but you can uncomment a line to change the background every minute or once per day.

I also included 18 sample images (that I took) that you can use outright or replace with your own. Keep in mind the images you use must be SEQUENTIALLY numbered from suspend_opt00.png-suspend_optxx.png for the randomizer script to work.

reMarkable automatically places owner’s information OVER the image. I would recommend adding your name/contact information manually to each image to avoid a black text on black background issue where nobody would be able to see your name/contact info. You can also remove a chunk of the images and replace them with white but I think that’s hideous. Keep in mind you should disable that feature in settings->security->personal information to keep redundant info from showing up.

I would also recommend turning off automatic updates for the remarkable. Keep in mind each time the device is updates your backgrounds will disappear and you will need to repeat these steps again! I don’t know of any locations that are safe from the update process just yet.

The github repo is here: https://github.com/Radacon/reMarkable_Background_Shuffler/tree/main

Merry Christmas!

UPDATES

I was told on reddit that all files in the /home/ directory are preserved during a firmware update. If you have automatic updates enabled, your background will change back to its default. All you need to do when that happens is to re-run the install script as it is preserved in your /home/root/backgrounds directory.

It’s also not important to have the images in sequential order. They do need to have the same name “suspend_optxx.png” though.

ESP Remote Start Scheduler

My wife got a new car! Excellent, it has all the bells and whistles, on-star, android auto, those dumb key fobs that let you unlock your car/drive without removing them from your pocket, and the option to pay $15/month to remote start the thing from your phone (there’s a few other features).

In an effort to get back at “The Man” and upgrade my 2013 Silverado, I decided to make my own remote-start app.

The premise is simple, I want to be able to lock, unlock, and start my vehicles remotely. I also want to be able to schedule a remote-start. Where we live, the vehicles are ALWAYS parked outside. Winter’s aren’t as harsh as when I used to live up north, but it’s nice to a) have a warm vehicle to scrape the snow off, and b) have a warmed-up vehicle to reduce wear-and-tear when the fluids are thicker than molasses.

Not too shabby.

I wrote the CSS and HTML from scratch (with the help of a few websites noted later in this article). This project was a good exercise in learning AJAX. From an EE point of view, it’s surprisingly easy.

Required Components

  1. One or More remote-start capable vehicles.
  2. Sacrificial key-fobs
    1. What the hell is up with the cost of new key fobs? $150 for an extra for my wife’s car!
    2. If you buy a new set, you’ll probably have to take them to a dealer to get them programmed.
  3. An ESP8266. I am using a Wemos D1 Pro knockoff from AliExpress
    1. In hindsight, just buy a legit Wemos D1 from Amazon or whatever.
  4. Soldering Iron.
  5. Solder.
  6. A 3D printer.
  7. Some bits of wire.

Warnings and Limitations

Common sense ain’t all that common, so I feel like I have to warn you (the reader/maker/builder/user) not to try this yourself. Why? Let’s say you park your vehicle inside a garage. Let’s also say you have an auto-start scheduled for 5AM. You’re sleeping in your bedroom above the garage, your car starts, you don’t notice, and suddenly you’re in the obituaries as “That dumbass who accidentally killed himeslf/others.” “But…”, you may tell yourself, “I’m not dumb enough to do that!”. Well, consider that this code implements 7 different libraries and uses features I have not thoroughly tested or verified as good. Also, consider that my code, as it’s written, allows over-the-air updates from anyone on your local network. Even if you don’t die as a result of someone hacking this, you’ve just left your vehicle more venerable to theft.

This is one of those dumb projects that I just HAD to do. There is precedent here: Who the hell pays $15/month to remote start their car? Not me.

Okay now the limitations:

  1. The Wemos D1 Mini hardware can support up to 3 key fobs (lock, unlock, and start connected). My code only has provisions for 2 users.
  2. The webpage is COMPLETELY unsecured. Literally anyone on your network with an IP address (hell, mDNS hostname: http://cars.local) can connect and start/unlock your vehicles.
  3. The code is a complete bodge-job but I have to say I’m proud that I wrote the CSS by myself.
  4. This doesn’t give your car superpowers, it’s just hooking an arduino up to your key-fob. As a result you cannot:
    1. Start your vehicle from your phone when you’re at work (away from your wifi network)
    2. Schedule a remote start time while your vehicle is away from the base station

Hardware

Key fobs always interested me because they’re these tiny coin-cell powered devices with an absolutely minimal number of components, they have a range of 100’s of feet, and they last for months/years! It’s the type of EE I can get behind. Now it’s our turn to reverse engineer them. What gets me are these devices are ALWAYS powered on. Sipping micro/nano amps from the coin cell when waiting for a button press.

The microcontroller in the remotes is pretty obscure. I was unable to find a datasheet on the elusive chip, but I was able to make some educated guesses with a multimeter

  1. The microcontroller is always powered on
  2. All 4 inputs are pull-up
  3. It’ll probably handle 3.3V
It’s really this simple

The GPIO pins are initialized high (3.3V) as the microcontroller is looking for a “low” (0V) signal for a button press. When you want to press a button, toggle the GPIO pin low. Easy! We do this hookup twice, once for each remote.

The wiring diagrams always look more professional than the actual wiring.

Code

I’ll keep this section short as I have the code on Github.

The code needs to do 5 things:

  1. Host a website
  2. Toggle some GPIO
  3. Keep time
  4. Hold a schedule
  5. OTA Firmware Updates

Hosting a website, toggling GPIO, keeping time, and OTA firmware updates are very common things people want a microcontroller to do. The base code is available as libraries which I import.

Scheduling turned out the be the most difficult part of this project. There’s a library called alarms which is complete trash for the ESP8266 as you need to use alarm_delay instead of regular delays. This causes the ESP to crash frequently.

I still haven’t quite figured out the bug yet. But I need to publish this blog post before winter hits. I’ll publish an update to this post when I figure out why time keeps slipping away for starts >24 hours in the future.

The code is available on my github here.

Webcode

Most of the web design I do is derived from html5up.net. It’s a great resource for getting simple webpages going for people you care about. Well, I wanted a proper course in web design as I’ve been using templates for years and have never formally taken a web design course. I started with the basics of CSS and built little widgets as I went along. I use brackets.io for code editing and live previews of changes I make. It’s very handy and keeps me from uploading useless code to the ESP.

One of the more handy things I was able to learn was the use of AJAX. Which let’s parts of my webpage be refreshed on command. Kind of like a data stream. There’s a great tutorial here for implementing this.

Once a website was created, I was able to flash all of the page data and resources onto the ESP8266 via this handy tutorial.

Mechanical Design

I really didn’t go all-out on this design. I really needed a box that could be mounted to a wall and hide my hideous wiring job. It serves that purpose. Again, the files are available on my github if you feel like following in my steps.

Conclusion

This was a really fun project involving my new favorite microcontroller. I was able to make something useful and web-based. There are still some bugs in the code I am trying to stomp out, but things are working well enough for me to get some use out of this system on my smart phone. Again, I wouldn’t recommend this if there’s ANY chance you’ll be parking in your garage or if you care about your vehicle getting broken into. Code like this should not be responsible for safety or security and there’s a good chance you’ll forget about this device a few months after building it.

Bluetooth Environmental Monitoring

I love the idea of being able to get the status and history of temperatures and humidity around my house. You can use the data to infer certain things like when a shower is being taken, a door/window being left open, or when you’re heating a room you don’t need to. I can use this data to re-balance my forced-air system as well.

Please understand that I do not recommend the use of unencrypted wireless devices in any way, shape, or form for control systems. There are major safety vulnerabilities that can crop up from otherwise, well-written code. The data generated from these devices is used to help me make an informed decision about my environmental control. The data is not tied into a control loop at all.

The units that are available on the market are typically bluetooth and require an app to read the data. WiFi units send the data off to “the cloud” and likely log everything, but can be accessed by a phone/browser.

So what’s a good compromise between designing my own hardware and giving all of my information away to China? Enter Aaron Christophel!

I saw a post on Hackaday about Aaron Christophel modifying the firmware of $2 Xiaomi thermometers from Aliexpress. So I went ahead and bought 16 of them to scatter around the house.

The problem Aaron is trying to address is the fact that these thermometers obfuscate temperature and humidity data contained within their broadcast information. There is a way to pull this data out if you know the token and bind key (something the app does). But Aaron’s firmware takes out the need for bind keys and tokens and instead lets the thermometers broadcast the data as plain text so to speak. No app, and a simple python script can read the data.

Aaron wrote a really nice bootloader that uses google chrome and your computer’s bluetooth radio to flash new firmware on to the units. It’s a little slow taking about 1 minute to connect, activate, and re-flash the unit’s firmware.

Step 1: Preparation

Out of the 16 units I purchased, I only ended up deciding on locations for 13 units. Deciding on a location is the first step. Next you’ll transfer all of the locations to an excel spreadsheet as a column. Next door to the locations column you’ll put the mac address column. This will help you form an array later for matching up broadcast mac addresses with the rooms the devices are in.

Step 2: Re-Flashing Firmware

I’m condensing Aaron’s instructions here, but a full how-to exists on the project’s github page. First, download the ATC_Thermometer.bin file. This is the new compiled firmware that Aaron wrote. Okay now is when you use Aaron’s web-browser DFU tool. Select the .bin firmware file, connect to the thermometer, activate the thermometer, and then start flashing.

Keep an eye on the progress bar, and when it gets up around 90% watch the bottom right two digits on the thermometer. They will flash the last 3 bytes of the mac address in hex. Write these six characters down as you’ll need to pull the battery and put it back in to get it to display the mac again. If you have lot’s of devices it’s important to have the mac address written on the unit.

Before writing the address on the unit however, connect to the device via bluetooth and change the appropriate settings that make you happy. For example, I set my devices to display Fahrenheit, and to show the battery in the LCD. We do this step first to double check the mac address because the number 6 displays as the letter b and that can introduce problems if you read and wrote the address incorrectly.

Once you’re sure you’ve got the correct mac address for the correct device, go ahead and scribble down the address on the bottom of the unit. I chose the bottom because I could still read the address once they’re adhered to the wall. Add this mac address to your spreadsheet and now you have a relationship established between the room and the mac address.

Step 3: Parsing and Aggregating Broadcast Information

Tom Nardi has a good writeup exploring the software side of this project, including some example code to get you started reading data off of the sensors. I’ll post the full code below as I had to make several modifications to filter out redundant information his code would be saving. My script also saves the data to an array of verified sensors and then writes that to a file. This is important because the py-bluetooth-utils runs until keyboard interrupt, and the devices will all update their broadcast information out of sync. This isn’t 100% the best way to do this, but python really isn’t good at running continuously if your script is super bloated.

I wrote a script that runs on startup and simply writes all values broadcast by the thermometer to a 2Dx1 array and then writes that information to a file. This has been running stable for over a month now on a raspberry pi zero w.

Step 4: Publishing MQTT Information

If all you want to do is log this data to a CSV file then you’re pretty much done here, except for generating a CSV file. But if you’d like to get the information into a web service, I’d recommend using mqtt since it’s a fairly lightweight protocol and Home Assistant plays nice with mqtt. I wrote a second script which reads the output file written to by the BLE listener, and parses it into MQTT topics with data. A chronjob runs this second python script which publishes data once per minute (which is the refresh rate of the sensor).

Step 5: Receiving and plotting the sensor data in Home Assistant

If you just want the data aggregated in a place that you have control over and can plot then Home Assistant is a pretty good option. There’s also plugins available for HASS such as GRAFANA if you want more graphing options. I’m not going to go into Home Assistant setup but will outline how to install and configure the Mosquito MQTT Plugin and add the published data as a “sensor”

Step 6: Viewing and acting on Data

Since these sensors are not part of a control loop, we’ll use the information to make manual adjustments.

As you can see here,

My Movie Room (upstairs bedroom) temperature is about 6 degrees hotter than the rest of the house. While this doesn’t look so odd at first glance, I’ll say that the movie room is heated with electric baseboard heating. This means we’re spending more money heating a rarely-used room above what we consider a comfortable winter temperature. Clearly a point of cost savings.

Another thing you can do is determine if a room has proper ventilation. Below is a historical line graph of the relative humidity of the master bathroom.

See those spikes? Those indicate a shower was taken. The current relative humidity outside is 40%, so seeing the spikes return to “baseline” in a timely manner, indicates good air flow. Since our master bathroom doesn’t have a vent fan, this is an important variable to monitor to combat mold growth. From the looks of this, we don’t need to install a vent fan.

These sensors are marketed to last about a year on one coin cell. I’m sure their battery life could be extended by changing the broadcast interval in the firmware, however, planned sensor maintenance can be carried out as the sensors are broadcasting their battery level!

We can even determine a failure based on current time and the Last RX Time variable. If it’s last reported battery level was 50% and the last RX time was 2 days ago, the unit is probably dead or the scanning computer can’t pick it up anymore.

Closing Comments

So why all this hassle? Why not just use the xiaomi app and not hack the sensors? You’ve seen that this data can be used to infer more than just the temperature of a house. A company can gather some serious data about your behaviors all the way down to your showering habits, yikes!

Update: 2-21-21

I’ve started using Grafana and InfluxDB to store and graph my information. It works much better than the built-in graphing in Home Assistant.

Solidworks Musings: Design tables and bulk-design.

I’m what you might call a casual mechanical engineer. By trade, I’m an electrical engineer, but there’s something cathartic about designing parts in solidworks. I started off in high school and didn’t think much of it, but as I went through college I contracted out my skills to model up jewelry for clients. This jewelry was then 3D printed and cast into final products. Don’t worry, I actually was dumb enough to drop several thousand dollars on a “personal” copy of solidworks.

It was an enjoyable experience for both myself and my clients as we could hang out, drink scotch, and work through what exactly they were looking for. Eventually a design came up where they needed variations on a theme so to speak. Small changes in the design, but the overall concept was the same. Learning about design tables saved me so much time as a result. A guy can alter variables in an excel spreadsheet and solidworks will build derived configurations with these new variables.

Fast forward 5 years and I’m designing a part that helps me position my chopsaw back clamp at specific angles.

DEWALT Chop Saw, Quick-Change, 14-Inch, Old Model (D28715) - Cut Off Saw -  Amazon.com
You get increments of 10 degrees out of the scale built into this thing.

Getting my inspiration from machinist’s angle blocks I designed a part that fits in the track the front clamp rides on and butts up to the back clamp.

It’s not pretty, but it’s 3D printable.

Okay here’s the thing. I have the solidworks part reliably re-building from 0 degrees to 45 degrees. This means I can feasibly make any angle I’d like without throwing any weird solidworks errors. I don’t want to manually enter the angle I want, and then revise the text for all of the infinity of angles that exist between 0 and 45 degrees. I also want to generate a pallet of blocks that step from 0 to 45 degrees in increments of 5 degrees. I ALSO want the text on the side of the block to update with the angle the block was set to so I don’t have to go in to every derived configuration and update the text.

Here’s how we do this (keep in mind I’m running SW14, so your mileage may vary).

  1. Make your part how you want it, and rename your “primary value” of your smart dimensions that you want to change to something meaningful eg: “set_angle@Sketch3”
  2. File->Insert->Tables->Design Table…
  3. You should now be prompted with a list of primary values from your part. This is why you took the time to rename your primary value. Select “set_angle@Sketch3” and excel will automatically populate a new column with that name at the top and the value of that directly below.
  4. You can make as many new configurations as you’d like by adding another row. You will have to name them by entering the name in the column where “Defult” resides. I’d recommend something meaningful.
  5. This should successfully generate x number of spin-off parts with the values of smart dimensions changed appropriately.
  6. To get sketch text to update as a variable go to file->properties…->configuration specific
  7. Make a new property named “text”, type: text, value: 69420
  8. Go to your extruded cut or boss and edit the sketch text as so: $PRP:”text”
  9. Go back to your design table by right clicking on it and edit it. It should prompt you asking if you want to add the “text” property you just made.
  10. Now your angles and text values reside as variables in an excel table!

Use this to create as many variations as you want of your model. These derived configurations can then be loaded into an assembly with ease. This makes palleting up parts for 3D printing easy.

After inserting your part into an assembly you can right click and chose the derived configuration you want.
My “fleet” of angle guides.

I inserted a sketch of my build plate are into my assembly as a guide for how many I can fit on to the build plate at once. From here you can generate an STL from the assembly as one part to save you hassle from having to get things stacked up in Cura or whatever.

File->save as->.stl for the type->Options…->Check “Save all components of an assembly in a single file”

Import this STL into cura and now you have a pallet of different models you can print!

Fits Nice!
Having a little first-layer adhesion problem. Going to dial my printer in more before I do a 17 hour print of all of the other blocks.

Here’s an angle block in the saw. Works like a charm!

Now don’t go expecting to get decimal-place-precision out of your chopsaw. But these guides work way better than the scale on the saw itself and can give you reproducible cuts if you need to adjust every time you cut.

Sources:

https://centralinnovation.com/technical-resources/learn/add-sketch-text-to-design-table/

WoWLAN With a Raspberry Pi

Post In Progress!

Starting this blog post so I’m forced to follow through on my hare-brained idea.

Okay so picture this: You have a license to a brand-spanking-new copy of Solidworks 2014 sitting on your desktop computer. You live in the mountainous South West, it’s summer, and the idea of air conditioning never caught on in your town so you try to keep all of your electronic equipment off at all times unless it’s in use.

Finally! Relief! You find yourself a cool dark place to set up shop for the day and open your laptop so you can remote into your desktop back at home and do some designing on Solidworks! But wait. Your computer isn’t on, and home is a half hour away! Oh no! I wish there was some way to leverage this internet-of-bullshit to my advantage.

Well say hello to WoWLAN! Wake on Wireless LAN. This is the greatest idea that will never catch on. And unfortunately for me, it’s not available on my PC. Well, it is, but only from hibernate mode and windblows10 likes to wake up my PC from hibernation periodically so that option is OUT. Time for some hardware.

Step 1: Raspberry Pi

I’m purchasing a raspberry pi zero w for this project because they’re small and have wireless built in. Unfortunately they’re the slowest of the Pi’s so Raspbian isn’t going to work. Go ahead and install dietPi. It’s worked great for me and has low overhead.

Step 2: Software

Sudo apt-get install python3 and paho-mqtt on your Pi and download MQTT Dash on your smartphone. From here you can write a python script that connects to the MQTT test broker and you can start sending and receiving messages and debugging any issues that come along the way. I am leaving my code unpublished at the moment because it isn’t robust enough to deploy. But it’s in my best interest to fix it so it’ll be released eventually.

Step 3: More Hardware

It’s kind of a dick move to use a public MQTT broker (also insecure) to handle your web traffic so get a piece of hardware that is web-facing that you can install Mosquitto MQTT on. This can be a webserver you have or, in my case, an AWS instance. You get a free year of the t2.micro after that the t2.nano should run around $50/year and work just fine. If I ever figure out how to field Mosquitto on my lightsail instance it’ll save me from paying double for AWS services. I’ll update that here.

Be sure to use DuckDNS and your domain to adapt to your dynamic IP address. Also don’t forget to start Mosquitto MQTT on boot.

Step 4: Even more hardware

I started out with the idea of using a solid state relay to emulate a button press. Here’s my schematic:

And here’s my PCB.

Here’s My Bill of Materials:

Step 5: Integration

Build up your PCB, solder it on to the raspberry pi and install it on your PC.

Hey, you’re done!

Good Luck!

-Conrad

Amazon AWS Minecraft Server (With Remote Start of Course)

My wife and I set up a little rinky dink minecraft server on one of my Toshiba laptops. Unfortunately it’s starting to suffer from reliability issues. Instead of building a computer/server from scratch I decided to pay (or not) for the convenience of an AWS server. This should be cheaper than the upfront cost of a new computer + electricity + my time and effort setting up and maintaining it. Of course owning a server will be cheaper in the long run, but I simply do not want one in my house.

These servers have an hourly rate of $0-$0.22/hr. This project will outline my steps towards creating the minecraft server and implementing a method to save money when it’s not in use.

Requirements

  • Must host a minecraft server
    • Up to 7 players
  • Must shut down when not in use
  • Minimal effort in starting it back up
    • Needs to be accessible by all friends
  • Easily re-accessible

Plan to execute

  1. Establish an EC2 Instance
  2. Transfer over minecraft files
  3. Write Duckdns cronjob to update the IP address on startup
  4. Write minecraft server auto-start script
  5. Write a script that checks active connections on port 25565 (minecraft) and 22 (ssh) and shuts down the server if it’s not being talked to for over an hour
  6. Set up a webpage that lets users log in and turn on the server remotely

1) EC2 Instance

Starting an instance easily done by creating an amazon AWS account and following the tutorial below.

There is enough info here to get this thing running but we’ll have to deviate from the path to get it running on it’s own. The cool thing here is you can start in the free tier and if you’d like to upgrade the hardware, all you have to do is change the instance type.

2) The Transfer

Since I had a minecraft server already working, I figured I’d just delete the install on the AWS server and copy mine over. This was particularly difficult as I’m currently in Alaska and my computer is in New Mexico. It doesn’t like to stay alive longer than 20 or 30 minutes. What I did was zip up the entire minecraft server folder and transfer it as one chunk. Previous transfers failed mid-way through and I think a few files got corrupted. I got it transferred over thanks to the help of Jake and Melissa who kept rebooting my laptop!

Note for me for future scp transfers:

scp [email protected]:/opt/zippedserver.zip /opt/destination_folder

3) DuckDNS Cronjob

This makes connecting easier as each time the server turns on, it gets a new ip address and having a name to connect to is a lot easier than copy-pasting the new ip address every time you start up the server.

I use DuckDNS because it’s free and has been working for me. Use whatever flavor you’d like for your dynamic DNS reroutes.

Here is the tutorial for “installing” DuckDNS as a linux cronjob.

4) Minecraft Auto Start

I had one of my nerd friends (Jake) get this working on my original rig. So this will be a challenge for me.

This tutorial seems to work just fine but I had to make a couple edits and I’ll clarify some of the code in it.

4.1) Writing a service

navigate to /etc/systemd/system/ and create a new file using your favorite text editor

sudo nano /etc/systemd/system/[email protected] 

I use nano, bite me. Also, insert the following text into the file:

Note and then omit the comments in parentheses.

[Unit]
Description=Minecraft Server: %i
After=network.target

[Service]
WorkingDirectory=/opt/minecraft/%i 

(THIS WORKING DIRECTORY IS IMPORTANT, PUT IT 1 FOLDER BACK FROM YOUR server.jar FOLDER)

User=minecraft (MAKE SURE YOU HAVE SET UP A USER WITH APPROPRIATE PRIVILEGES)
Group=minecraft (MINE IS DIFFERENT. DELETE COMMENTS IN PARENTHESES)

Restart=always

ExecStart=/usr/bin/screen -DmS mc-%i /usr/bin/java -Xmx2G -jar minecraft_server.jar nogui

ExecStop=/usr/bin/screen -p 0 -S mc-%i -X eval 'stuff "say SERVER SHUTTING DOWN IN 5 SECONDS. SAVING ALL MAPS..."\015'
ExecStop=/bin/sleep 5
ExecStop=/usr/bin/screen -p 0 -S mc-%i -X eval 'stuff "save-all"\015'
ExecStop=/usr/bin/screen -p 0 -S mc-%i -X eval 'stuff "stop"\015'


[Install]
WantedBy=multi-user.target

Start the service with this command.

sudo systemctl start minecraft@SERVERFOLDER

I then use the top command to ensure Java is running. Next you need to enable the service so it runs on boot.

sudo systemctl enable minecraft@SERVERFOLDER

This should work, but be sure to check the status as follows.

sudo systemctl status minecraft@SERVERFOLDER

If it looks good, go ahead and shut down the machine (this stops the instance on the EC2 control panel). When the instance is fully stopped, start it back up from the control panel and refresh your server list in minecraft. It takes a minute or two as I don’t think the dns changes propagate right away.

At this point, you have a fully functional minecraft server! If you have the urge to spend $2000/year on hosting, you’re all done! Pat yourself on the back. If you’re ready for a challenge continue on!

5) Auto Shutdown

This is really easy (in concept) as we don’t have to figure out how to talk to AWS, we can just write a script on the machine to turn itself off. I’m personally not worried about 25 cents, so the script will turn on only once an hour and check port 25565 and 22 for active connections.

First, let’s make a bash script called check4users.sh and put it in it’s own folder inside of your minecraft directory.

mkdir scripts
cd scripts
sudo nano check4users.sh

Throw a shebang on the first line of the file

#!/bin/bash

Close, and save the script. I know! We haven’t written anything yet! Let’s chmod this first so it can run!

sudo chmod +x check4users.sh

Okay now, let’s return to the script! Paste this code into check4users.sh

#!/bin/bash
#Checks for active connections on  port 22 (ssh) and 25565 (minecraft)
#If no connections are found, the script gracefully shuts down the
#Minecraft server (saving progress) and then shuts down the Service
#This will stop the EC2 instance and save money.

sshCons=$(netstat -anp | grep :22 | grep ESTABLISHED | wc -l)
mcCons=$(netstat -anp | grep :25565 | grep ESTABLISHED | wc -l)
echo "Active SSH Connections: $sshCons"
echo "Active Minecraft Connections: $mcCons"

if [ $((mcCons)) = 0 ]
then
        echo "We normally shutdown here, but let's check for SSH connections"
        if [[ $((sshCons)) = 0 ]]
        then
                echo "no ssh connections, shutting down server"
                sudo systemctl stop minecraft@christian
                sudo shutdown
        else
                echo "There are 1 or more active ssh connections, skip termination"
        fi
else
        echo "Somebody is playing minecraft, do nothing!"
fi

Next (again as root) add this script to your crontab.

crontab -e

It’s vim (ick) so press i to insert text and paste the following text.

0 */1 * * * /opt/minecraft/SCRIPTDIRECTORY/check4users.sh

Press esc to stop your text insertion. Then “:wq!” it’ll save and update your cronjobs.

This will run the check at the top of every hour. If you need to change this time, make it no less than every 10 minutes. I set it to every 5 and had some trouble catching the server before it shut itself off (remember, the server takes time to boot, and duckdns takes time to update).

One flaw in this script is that if you turn on the instance at, say, 7:58pm. The server could boot, run the chronjob at 8pm, see nobody connected, and shut itself down. I’ve only experienced this problem once so far, but I just don’t want to go down the rabbit hole of re-writing this.

6) Starting the server back up

Now that we have the server running minecraft on startup and then shutting down when nobody is playing it we need a way to start it remotely. I can do this by logging into amazon aws and clicking start instance.

However, I don’t want to answer every text message of “I want to play minecraft”. I also don’t want to share my amazon aws login information. So, let’s create a website that a person can log in to and start the server from there.

Requirements:

  1. A website (I think www.farnsworth.engineering/minecraft will do!)
  2. SQL Database
    1. Stores user information
    2. Stores some usage data from the AWS instance
  3. PHP Experience
    1. Sends a request through the AWS SDK to start, stop, and restart the server
    2. Handles login and account creation.
  4. XAMPP + Brackets
    1. This let’s you test out this code outside of production.

Rant: Long story short, I got this part working a long time ago. However, my hosting provider Arvixe has been dragging their feet for two weeks to solve a simple clock problem. It was literally faster for me to pack up everything and migrate it over to Amazon Lightsail than it was for Arvixe to update the system clock. End of Rant. Unfortunately my old blog got nuked…so here’s my first post.

Let’s get started!

6.1) Establishing a Database

Let’s make a database! In plesk it looks a lot like this:

Go ahead and name the database minecraft, related site: example.com, username: minecraft, generate the password (and copy it down). These will be your credentials for accessing the database later on. If you’re going to prototype this code with XAMPP you can use phpmyadmin to make the database. Do yourself a favor and don’t test your code in production.

Okay, now we need to create a table in phpmyadmin (webserver or local machine) called users. It’ll have 4 columns. “username, password, email, activated”.

6.2) PHP

This part has been bootstrapped off of a few other projects, but works fine. Download this zip file (minecraft_server.zip). Fair warning: the HTML is garbage. But this provides the PHP necessary to talk to AWS.

Speaking of talking to AWS, download the SDK here (don’t worry it’s from AWS).

READ THE DEVELOPER GUIDE.

Unzip both minecraft_server.zip and aws.zip into your webserver/local server.

Things you need to provide in server.php:

  1. Remember to edit the credentials you copied from your database into line 5.
  2. Line 15 of server.php should point to the location of the SDK file. It’s a little tricky to figure out at first, but if you turn on debugging, the error message should give you the basic directory structure of your hosting server.
  3. Line 25 and 26 need to be security credentials you generated from your user account.
  4. Line 30 is your particular EC2 Instance ID.

Extras:

  1. Remember the column you made in your database named “activated”? You’ll need to manually set it to value 1 from phpmyadmin. This keeps random yeahoos from making an account and starting your server. If you’re interested in a challenge you can have your server email you every time an account is created and you can click a link to approve/disapprove the account. I figured with just 7 players it wasn’t much of a hassle.

2. Server.php has the functions “stop” and “restart” in it. I omitted them as buttons from index.php to keep people from stopping the server while you’re playing. If you need to restart. You can log into your control panel yourself and do it. However, if you trust your players, you can add it in or put another column in your database as “administrator” or something like that and just give yourself permission. Worst case scenario, your players can log off and the server will shut down at the top of the hour.

From here you should have a working website that lets you create an account, log in, and turn on your AWS minecraft server!

Congrats!

Now write your own CSS and spiffy up the website!

UPDATE!

I’ve since Spiffied up the website with CSS (I bought a theme a while ago for another project) and recycled it into this.

The # of users is of course the number of rows I have in my users database. Easiest variable I’m importing here.

Runtime is calculated by incrementing a variable by 1 every time the check4users.sh script is ran. The result is sent via a post request to my webserver and that number overwrites the most current value of runtime in my database. This is a little janky I know. But it allows for asynchronous updating of the table without calling the Cost Explorer Service every hour.

Spent dollars is calculated using the AWS cost explorer sdk (included in the AWS folder). THIS CAN GET PRICEY. BE CAREFUL. The cost explorer charges your account $0.01 for every request. I have a separate php file that runs via crontab on the webserver once a month and updates a new table in my minecraft database. This keeps my users from charging me a penny every time they log in, to update a variable I already should know.

Saved dollars is simply the cost to run my instance every hour multiplied by the number of hours between when I started using this service and today’s date.

Thus far, I’ve saved $169 by using this service, and spent only $17!

$17 is actually an all encompassing amount as that includes costs for EC2+Lightsail+CostExplorer.