Skip to content

Instantly share code, notes, and snippets.

@pardeike
Created June 12, 2025 21:05
Show Gist options
  • Save pardeike/5b06dd0c3cd044901d85b39b86292695 to your computer and use it in GitHub Desktop.
Save pardeike/5b06dd0c3cd044901d85b39b86292695 to your computer and use it in GitHub Desktop.
Dynamically maintaining Home Assistant MQTT topics for a presence sensor from an ESP32
#include <Arduino.h>
#include <vector>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include "led.h"
#include "secrets.h"
const char *DEVICE_ID = "esp32_presence";
const char *DEVICE_NAME = "ESP32 Presence";
#define MAX_ZONES 3
#define NAME_LEN 16 // "Zone123" fits
#define ID_LEN 12 // "zone123" fits
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
struct Zone
{
bool active;
char id[ID_LEN];
char name[NAME_LEN];
bool state;
};
Zone zones[MAX_ZONES] = {}; // all zero-initialised
uint8_t activeCnt = 0;
uint16_t nameCounter = 0;
void connectWiFi()
{
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED)
delay(250);
}
void connectMQTT()
{
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(1024);
while (!mqtt.connected())
mqtt.connect(DEVICE_ID, MQTT_USER, MQTT_PASS, "homeassistant/status", 0, true, "offline");
}
void topicConfig(char *out, size_t len, const Zone &z)
{
snprintf(out, len, "homeassistant/binary_sensor/%s_%s/config", DEVICE_ID, z.id);
}
void topicState(char *out, size_t len, const Zone &z)
{
snprintf(out, len, "esp32/%s/%s", DEVICE_ID, z.id);
}
void publishDiscovery(const Zone &z)
{
JsonDocument doc; // ← heap-backed, auto-grows
doc["name"] = z.name;
doc["unique_id"] = String(DEVICE_ID) + "_" + z.id;
char st[64];
snprintf(st, sizeof(st), "esp32/%s/%s", DEVICE_ID, z.id);
doc["state_topic"] = st;
doc["device_class"] = "occupancy";
doc["payload_on"] = "ON";
doc["payload_off"] = "OFF";
JsonObject dev = doc["device"].to<JsonObject>();
dev["name"] = DEVICE_NAME;
JsonArray ids = dev["identifiers"].to<JsonArray>();
ids.add(DEVICE_ID);
char topic[128];
snprintf(topic, sizeof(topic), "homeassistant/binary_sensor/%s_%s/config", DEVICE_ID, z.id);
size_t len = measureJson(doc); // you can reserve() if you like
uint8_t *buf = new uint8_t[len + 1];
serializeJson(doc, buf, len + 1);
mqtt.publish(topic, buf, len, true);
delete[] buf;
}
void unpublishDiscovery(const Zone &z)
{
char topic[128], st[64];
topicConfig(topic, sizeof(topic), z);
topicState(st, sizeof(st), z);
mqtt.publish(topic, nullptr, 0, true); // delete retained config
mqtt.publish(st, nullptr, 0, true); // delete last state
}
void publishState(const Zone &z)
{
char st[64];
topicState(st, sizeof(st), z);
mqtt.publish(st, z.state ? "ON" : "OFF", true);
}
Zone *randomActiveZone()
{
if (!activeCnt) return nullptr;
uint8_t pick = random(activeCnt);
for (uint8_t i = 0; i < MAX_ZONES; ++i)
if (zones[i].active)
if (!pick--) return &zones[i];
return nullptr; // should never hit
}
void addZone()
{
if (activeCnt >= MAX_ZONES) return;
for (Zone &z : zones)
if (!z.active)
{
z.active = true;
++nameCounter;
snprintf(z.id, ID_LEN, "zone%u", nameCounter);
snprintf(z.name, NAME_LEN, "Zone%u", nameCounter);
z.state = false;
++activeCnt;
publishDiscovery(z);
publishState(z);
Serial.printf("[Add] %s='%s' created\n", z.id, z.name);
setLED(0, 8, 0);
return;
}
}
void removeZone()
{
Zone *z = randomActiveZone();
if (!z) return;
unpublishDiscovery(*z);
z->active = false;
--activeCnt;
Serial.printf("[Remove] %s='%s' removed\n", z->id, z->name);
setLED(8, 0, 0);
}
void renameZone()
{
Zone *z = randomActiveZone();
if (!z) return;
unpublishDiscovery(*z);
++nameCounter;
snprintf(z->name, NAME_LEN, "Zone%u", nameCounter);
publishDiscovery(*z);
publishState(*z);
Serial.printf("[Rename] %s='%s'\n", z->id, z->name);
setLED(0, 0, 8);
}
void toggleZone()
{
Zone *z = randomActiveZone();
if (!z) return;
z->state = !z->state;
publishState(*z);
Serial.printf("[Toggle] %s='%s' ➜ %s\n", z->id, z->name, z->state ? "ON" : "OFF");
setLED(8, 0, 8);
}
void randomAction()
{
bool canAdd = activeCnt < MAX_ZONES;
bool canChange = activeCnt > 0;
bool canRemove = activeCnt > 1;
uint8_t choices[4];
uint8_t n = 0;
if (canAdd)
choices[n++] = 0; // add
if (canRemove)
choices[n++] = 1; // remove
if (canChange)
{
choices[n++] = 2; // rename
choices[n++] = 3; // toggle
}
if (!n) return;
switch (choices[random(n)])
{
case 0:
addZone();
break;
case 1:
removeZone();
break;
case 2:
renameZone();
break;
case 3:
toggleZone();
break;
}
}
void setup()
{
Serial.begin(115200);
setLED(0, 0, 0);
delay(1000);
randomSeed(esp_random());
setLED(0, 8, 0);
connectWiFi();
setLED(8, 0, 8);
connectMQTT();
}
unsigned long lastTick = 0;
void loop()
{
if (!mqtt.connected()) connectMQTT();
mqtt.loop();
if (millis() - lastTick > PUBLISH_INTERVAL)
{
randomAction();
lastTick = millis();
}
}
@pardeike
Copy link
Author

Also needs this LED.h file:

#include <Adafruit_NeoPixel.h>

Adafruit_NeoPixel neo_pixels = Adafruit_NeoPixel(1, 12, NEO_GRB + NEO_KHZ800);

void initLED()
{
	neo_pixels.clear();
	neo_pixels.begin();
}

void setLED()
{
	neo_pixels.setPixelColor(0, 0);
	neo_pixels.show();
}

void setLED(uint8_t r, uint8_t g, uint8_t b)
{
	neo_pixels.setPixelColor(0, neo_pixels.Color(r, g, b));
	neo_pixels.show();
}

@pardeike
Copy link
Author

And for an M5Stack Atom3, this platformio.ini:

[env:atom-s3]
platform = espressif32
board = m5stack-atoms3
framework = arduino
monitor_speed = 115200
build_flags =
	-DARDUINO_USB_CDC_ON_BOOT=1
	-DMQTT_MAX_PACKET_SIZE=1024
lib_deps =
	m5stack/M5Unified@^0.1.17
	knolleary/PubSubClient@^2.8
	bblanchon/ArduinoJson@^7.4.1
	adafruit/Adafruit NeoPixel@^1.12.4

@pardeike
Copy link
Author

Not to forget this secrets.h file template:

#pragma once
// ---------- Wi-Fi ----------
#define WIFI_SSID ""
#define WIFI_PASS ""

// ---------- MQTT ----------
#define MQTT_HOST "hass.local" // IP or mDNS name of broker
#define MQTT_PORT 1883
#define MQTT_USER ""
#define MQTT_PASS ""

// How often (ms) to publish simulated states
#define PUBLISH_INTERVAL 8000

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment