commit 82375a196bd98a1685a53ad6f6f603031e84d677 Author: Adrian A. Baumann Date: Mon Jun 29 01:06:20 2026 +0200 Initial commit: rotary dial firmware on ESP32 device framework diff --git a/README.md b/README.md new file mode 100644 index 0000000..6307f1e --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Rotary Dial + +ESP32 firmware that reads a pulse-generating telephone rotary dial and publishes the dialled number to Home Assistant over MQTT. + +Built on the [ESP32 Device Framework](../Device-Framework). + +## How it works + +A classic rotary telephone dial generates a burst of pulses as it returns to rest after each digit — one pulse for `1`, two for `2`, …, nine for `9`, ten for `0`. The firmware counts those pulses on a single GPIO pin and assembles digits into a string. When the user stops dialling (2.5 s silence), the complete number is published to MQTT. + +Leading zeros are preserved: dialling `07` sends `"07"`, not `"7"`. + +## Hardware + +| Item | Details | +|------|---------| +| MCU | ESP32 (any variant) | +| Dial | Pulse-generating rotary telephone dial | +| Pulse pin | GPIO 4 (`DIAL_PIN` in `main.cpp`) | +| Wiring | Pulse contact between GPIO 4 and GND. Pin uses `INPUT_PULLUP`. | + +The pulse contact is normally open. As the dial returns it briefly shorts GPIO 4 to GND on each pulse — the rising edge (release) is what the interrupt counts. + +If your dial's contact is normally closed instead, change the interrupt trigger in `setup()` from `RISING` to `FALLING`. + +## Building and flashing + +```bash +# Build (S3 DevKit) +pio run -e esp32-s3-devkitc-1 + +# Flash +pio run -e esp32-s3-devkitc-1 --target upload + +# Serial monitor (115200 baud) +pio device monitor +``` + +## First-time Wi-Fi setup + +On first boot the device opens an access point named **Rotary-Dial**. Connect with any phone or laptop; a captive portal will appear. + +1. Enter your Wi-Fi credentials. +2. Optionally set the **Device hostname** (default: `rotary-dial`). +3. Save. The device joins your network and restarts. + +Reachable at `http://rotary-dial.local` or the IP shown in the serial monitor. + +### Factory reset + +Power-cycle or press EN **5 times in quick succession** (before boot completes). All settings are cleared and the device restarts into the Wi-Fi portal. + +## Web UI + +Browse to the device address to configure hostname, authentication, and MQTT. + +## MQTT + +### Topics + +| Direction | Topic | Description | +|-----------|-------|-------------| +| Published | `/dialed` | Complete dialled number as a string (not retained) | +| Published | `/status` | `online` on connect, `offline` as LWT (retained) | + +Default prefix: `dial` + +### Home Assistant + +On MQTT connect the device publishes a discovery payload so HA automatically creates a **sensor** entity (`sensor._dialed_number`) that shows the last dialled number. + +Example automation: + +```yaml +automation: + - alias: Act on dialled number + trigger: + platform: mqtt + topic: dial/dialed + action: + - choose: + - conditions: + - condition: template + value_template: "{{ trigger.payload == '112' }}" + sequence: + - service: notify.mobile_app + data: + message: "Emergency number dialled!" +``` + +## Timing constants + +Adjust in `main.cpp` if your dial runs faster or slower than standard: + +| Constant | Default | Description | +|----------|---------|-------------| +| `PULSE_DEBOUNCE_MS` | 15 ms | Minimum time between counted edges | +| `INTER_PULSE_MS` | 350 ms | Silence after last pulse = digit complete | +| `INTER_DIGIT_MS` | 2500 ms | Silence after last digit = number complete | diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..5516710 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,19 @@ +[env:esp32-s3-devkitc-1] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +lib_deps = + tzapu/WiFiManager@^2.0.17 + bblanchon/ArduinoJson@^7.0.0 + knolleary/PubSubClient@^2.8 + +[env:esp32dev] +board = esp32dev +framework = arduino +platform = espressif32 +monitor_speed = 115200 +lib_deps = + tzapu/WiFiManager@^2.0.17 + bblanchon/ArduinoJson@^7.4.3 + knolleary/PubSubClient@^2.8 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..bd9ec28 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,569 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Framework constants +// --------------------------------------------------------------------------- + +#define HOSTNAME_DEFAULT "rotary-dial" +#define MQTT_MANUFACTURER "Baumann Enkataleiptics" +#define AUTH_USERNAME "admin" +#define MQTT_PROBE_TIMEOUT_MS 400 // keep short: blocks loop()/handleClient() per reconnect attempt +#define MQTT_PAYLOAD_AVAILABLE "online" +#define MQTT_PAYLOAD_NOT_AVAILABLE "offline" + +// --------------------------------------------------------------------------- +// Dial constants +// --------------------------------------------------------------------------- + +#define DIAL_PIN 4 // GPIO connected to the pulse contact (to GND) +#define PULSE_DEBOUNCE_MS 15 // ignore edges faster than this +#define INTER_PULSE_MS 350 // silence longer than this = digit complete +#define INTER_DIGIT_MS 2500 // silence longer than this = number complete, publish + +struct MqttConfig { + bool enabled = false; + char host[64] = ""; + uint16_t port = 1883; + char user[32] = ""; + char pass[32] = ""; + char prefix[32] = "dial"; +}; + +struct AuthConfig { + bool enabled = false; + char pass[32] = ""; +}; + +static char hostname[64] = HOSTNAME_DEFAULT; +static MqttConfig mqttCfg; +static AuthConfig authCfg; +static WiFiManagerParameter hostnameParam("hostname", "Device hostname", hostname, 63); +static WebServer server(80); +static WiFiClient wifiClient; +static PubSubClient mqttClient(wifiClient); +static unsigned long mqttReconnectAt = 0; + +// --------------------------------------------------------------------------- +// Dial state +// --------------------------------------------------------------------------- + +static volatile int dialPulses = 0; +static volatile unsigned long dialLastPulse = 0; // millis() of last counted pulse +static String dialNumber = ""; +static unsigned long dialLastDigit = 0; // millis() of last completed digit + +static void IRAM_ATTR onDialPulse() { + unsigned long now = millis(); + if (now - dialLastPulse >= PULSE_DEBOUNCE_MS) { + dialPulses++; + dialLastPulse = now; + } +} + +// --------------------------------------------------------------------------- +// Config persistence (JSON via LittleFS) +// --------------------------------------------------------------------------- + +static void loadConfig() { + Serial.println("[CFG] loading config"); + if (!LittleFS.exists("/config.json")) { Serial.println("[CFG] no config file"); return; } + File f = LittleFS.open("/config.json", "r"); + if (!f) { Serial.println("[CFG] failed to open"); return; } + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, f); + f.close(); + if (err) { Serial.printf("[CFG] parse error: %s\n", err.c_str()); return; } + + if (doc["hostname"].is()) + strlcpy(hostname, doc["hostname"], sizeof(hostname)); + + JsonObject mq = doc["mqtt"]; + if (!mq.isNull()) { + mqttCfg.enabled = mq["en"] | false; + strlcpy(mqttCfg.host, mq["host"] | "", sizeof(mqttCfg.host)); + mqttCfg.port = mq["port"] | 1883; + strlcpy(mqttCfg.user, mq["user"] | "", sizeof(mqttCfg.user)); + strlcpy(mqttCfg.pass, mq["pass"] | "", sizeof(mqttCfg.pass)); + strlcpy(mqttCfg.prefix, mq["prefix"] | "dial", sizeof(mqttCfg.prefix)); + } + + JsonObject auth = doc["auth"]; + if (!auth.isNull()) { + authCfg.enabled = auth["en"] | false; + strlcpy(authCfg.pass, auth["pass"] | "", sizeof(authCfg.pass)); + } + + Serial.printf("[CFG] loaded hostname=%s mqtt_en=%d\n", hostname, mqttCfg.enabled); +} + +static void saveConfig() { + JsonDocument doc; + doc["hostname"] = hostname; + + JsonObject mq = doc["mqtt"].to(); + mq["en"] = mqttCfg.enabled; + mq["host"] = mqttCfg.host; + mq["port"] = mqttCfg.port; + mq["user"] = mqttCfg.user; + mq["pass"] = mqttCfg.pass; + mq["prefix"] = mqttCfg.prefix; + + JsonObject auth = doc["auth"].to(); + auth["en"] = authCfg.enabled; + auth["pass"] = authCfg.pass; + + File f = LittleFS.open("/config.json", "w"); + if (f) { + serializeJson(doc, f); + f.close(); + Serial.printf("[CFG] saved hostname=%s mqtt_en=%d\n", hostname, mqttCfg.enabled); + } else { + Serial.println("[CFG] save failed"); + } +} + +// --------------------------------------------------------------------------- +// MQTT +// --------------------------------------------------------------------------- + +static void mqttPublishDialed(const String& number) { + if (!mqttCfg.enabled || !mqttClient.connected()) return; + char topic[128]; + snprintf(topic, sizeof(topic), "%s/dialed", mqttCfg.prefix); + mqttClient.publish(topic, number.c_str(), false); // not retained — it's an event + Serial.printf("[DIAL] published %s -> %s\n", topic, number.c_str()); +} + +static void mqttCallback(char* topic, byte* payload, unsigned int len) { + // Dial is output-only; no incoming commands. + String val; + for (unsigned i = 0; i < len; i++) val += (char)payload[i]; + Serial.printf("[MQTT] rcvd topic=%s payload=%s\n", topic, val.c_str()); +} + +static void mqttPublishDiscovery() { + if (!mqttCfg.enabled || !mqttClient.connected()) return; + + uint8_t mac[6]; + WiFi.macAddress(mac); + char devId[32]; + snprintf(devId, sizeof(devId), "esp32_%02x%02x%02x", mac[3], mac[4], mac[5]); + String objId = String(devId) + "_dialed"; + + JsonDocument doc; + doc["unique_id"] = objId; + doc["name"] = "Dialed Number"; + doc["state_topic"] = String(mqttCfg.prefix) + "/dialed"; + doc["availability_topic"] = String(mqttCfg.prefix) + "/status"; + doc["payload_available"] = MQTT_PAYLOAD_AVAILABLE; + doc["payload_not_available"] = MQTT_PAYLOAD_NOT_AVAILABLE; + doc["device"]["identifiers"][0] = devId; + doc["device"]["name"] = hostname; + doc["device"]["manufacturer"] = MQTT_MANUFACTURER; + doc["device"]["model"] = "Rotary Dial"; + + char topic[128]; + snprintf(topic, sizeof(topic), "homeassistant/sensor/%s/config", objId.c_str()); + char payload[512]; + serializeJson(doc, payload, sizeof(payload)); + bool ok = mqttClient.publish(topic, payload, true); + Serial.printf("[MQTT] discovery %s ok=%d\n", topic, ok); +} + +static void mqttSubscribe() { + // Dial is output-only; nothing to subscribe to. +} + +static bool mqttConnect() { + if (!mqttCfg.enabled || strlen(mqttCfg.host) == 0) { + Serial.printf("[MQTT] connect skipped en=%d host=%d\n", + mqttCfg.enabled, strlen(mqttCfg.host) > 0); + return false; + } + + char clientId[32]; + { + uint8_t mac[6]; + WiFi.macAddress(mac); + snprintf(clientId, sizeof(clientId), "esp32-%02x%02x%02x", mac[3], mac[4], mac[5]); + } + + char statusTopic[128]; + snprintf(statusTopic, sizeof(statusTopic), "%s/status", mqttCfg.prefix); + + mqttClient.setServer(mqttCfg.host, mqttCfg.port); + mqttClient.setCallback(mqttCallback); + mqttClient.setBufferSize(1024); + + // Pass NULL for empty credentials so the broker treats it as an anonymous + // connection rather than an empty-string login (which some brokers reject). + const char* user = strlen(mqttCfg.user) > 0 ? mqttCfg.user : nullptr; + const char* pass = strlen(mqttCfg.pass) > 0 ? mqttCfg.pass : nullptr; + + Serial.printf("[MQTT] connecting to %s:%d as %s\n", mqttCfg.host, mqttCfg.port, clientId); + bool ok = mqttClient.connect(clientId, user, pass, + statusTopic, 0, true, MQTT_PAYLOAD_NOT_AVAILABLE); + + if (ok) { + Serial.printf("[MQTT] connected to %s:%d\n", mqttCfg.host, mqttCfg.port); + mqttClient.publish(statusTopic, MQTT_PAYLOAD_AVAILABLE, true); + mqttSubscribe(); + mqttPublishDiscovery(); + } else { + Serial.printf("[MQTT] failed rc=%d\n", mqttClient.state()); + } + return ok; +} + +static void mqttLoop() { + if (!mqttCfg.enabled || strlen(mqttCfg.host) == 0) return; + + if (!mqttClient.connected()) { + unsigned long now = millis(); + if (now > mqttReconnectAt) { + Serial.printf("[MQTT] probing %s:%d\n", mqttCfg.host, mqttCfg.port); + WiFiClient probe; + bool reachable = probe.connect(mqttCfg.host, mqttCfg.port, MQTT_PROBE_TIMEOUT_MS); + probe.stop(); + + if (reachable) { + Serial.println("[MQTT] probe OK, connecting"); + if (mqttConnect()) mqttReconnectAt = 0; + else mqttReconnectAt = now + 30000; + } else { + Serial.println("[MQTT] probe failed, retry in 30s"); + mqttReconnectAt = now + 30000; + } + } + } else { + mqttClient.loop(); + } +} + +// --------------------------------------------------------------------------- +// Dial processing +// --------------------------------------------------------------------------- + +static void dialLoop() { + unsigned long now = millis(); + + // Snapshot volatile state atomically + noInterrupts(); + int pulses = dialPulses; + unsigned long lastPulse = dialLastPulse; + interrupts(); + + // Burst silence exceeded → digit complete + if (pulses > 0 && (now - lastPulse) >= INTER_PULSE_MS) { + noInterrupts(); + dialPulses = 0; + interrupts(); + + // 10 pulses = digit '0'; 1–9 pulses = digits '1'–'9' + char digit = '0' + (pulses >= 10 ? 0 : pulses); + dialNumber += digit; + dialLastDigit = now; + Serial.printf("[DIAL] digit=%c number_so_far=%s\n", digit, dialNumber.c_str()); + } + + // Number complete when no further digits arrive within the timeout + if (dialNumber.length() > 0 && (now - dialLastDigit) >= INTER_DIGIT_MS) { + Serial.printf("[DIAL] complete number=%s\n", dialNumber.c_str()); + mqttPublishDialed(dialNumber); + dialNumber = ""; + } +} + +// --------------------------------------------------------------------------- +// mDNS +// --------------------------------------------------------------------------- + +static void startMDNS() { + if (MDNS.begin(hostname)) { + Serial.printf("mDNS: %s.local\n", hostname); + MDNS.addService("http", "tcp", 80); + } else { + Serial.println("mDNS start failed"); + } +} + +// --------------------------------------------------------------------------- +// Web helpers +// --------------------------------------------------------------------------- + +static String escHtml(const String& s) { + String out; + for (unsigned i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '&': out += "&"; break; + case '<': out += "<"; break; + case '>': out += ">"; break; + case '\'': out += "'"; break; + case '"': out += """; break; + default: out += c; + } + } + return out; +} + +static bool requireAuth() { + if (!authCfg.enabled) return true; + if (server.authenticate(AUTH_USERNAME, authCfg.pass)) return true; + server.requestAuthentication(); + return false; +} + +// --------------------------------------------------------------------------- +// Web handlers +// --------------------------------------------------------------------------- + +static void handleRoot() { + if (!requireAuth()) { Serial.println("[HTTP] GET / -> 401"); return; } + Serial.println("[HTTP] GET /"); + + String authSection; + { + String checked = authCfg.enabled ? " checked" : ""; + const char* passHint = strlen(authCfg.pass) > 0 ? "set — leave blank to keep" : "enter password"; + + authSection += + "
Security" + "
" + "" + "" + "
" + "
" + "
"; + } + + String mqttSection; + { + char portStr[8]; + snprintf(portStr, sizeof(portStr), "%u", mqttCfg.port); + String checked = mqttCfg.enabled ? " checked" : ""; + String hostVal = escHtml(mqttCfg.host); + String userVal = escHtml(mqttCfg.user); + String prefVal = escHtml(mqttCfg.prefix); + const char* passHint = strlen(mqttCfg.pass) > 0 ? "set — leave blank to keep" : "enter password"; + + mqttSection += + "
MQTT" + "
" + "" + "" + "
" + "
" + "
" + "
" + "
" + "
" + "
"; + } + + String html; + html.reserve(3072); + html += ""; + html += "Rotary Dial
"; + html += "

Rotary Dial

Configuration
"; + html += "
Hostname" + String(hostname) + "
"; + html += "
IP" + WiFi.localIP().toString() + "
"; + html += "
"; + html += ""; + html += authSection; + html += mqttSection; + html += "
"; + html += "
" + WiFi.macAddress() + "
"; + html += ""; + server.send(200, "text/html", html); + Serial.printf("[HTTP] GET / -> 200 (%u bytes)\n", html.length()); +} + +static void handleConfig() { + if (!requireAuth()) { Serial.println("[HTTP] POST /config -> 401"); return; } + Serial.println("[HTTP] POST /config"); + + if (server.hasArg("hostname")) { + String val = server.arg("hostname"); + val.trim(); + if (val.length() > 0) + val.toCharArray(hostname, sizeof(hostname)); + } + + // Persist connection fields regardless of enable checkbox so edits made while + // disabling MQTT aren't discarded. + if (server.hasArg("mqtt_host")) + strlcpy(mqttCfg.host, server.arg("mqtt_host").c_str(), sizeof(mqttCfg.host)); + if (server.hasArg("mqtt_port")) + mqttCfg.port = server.arg("mqtt_port").toInt(); + if (server.hasArg("mqtt_user")) + strlcpy(mqttCfg.user, server.arg("mqtt_user").c_str(), sizeof(mqttCfg.user)); + if (server.hasArg("mqtt_pass") && server.arg("mqtt_pass").length() > 0) + strlcpy(mqttCfg.pass, server.arg("mqtt_pass").c_str(), sizeof(mqttCfg.pass)); + if (server.hasArg("mqtt_prefix")) + strlcpy(mqttCfg.prefix, server.arg("mqtt_prefix").c_str(), sizeof(mqttCfg.prefix)); + + mqttCfg.enabled = server.hasArg("mqtt_en"); + Serial.printf("[HTTP] mqtt enabled=%d host=%s port=%u user=%s prefix=%s\n", + mqttCfg.enabled, mqttCfg.host, mqttCfg.port, mqttCfg.user, mqttCfg.prefix); + + if (server.hasArg("auth_en")) { + if (server.hasArg("auth_pass") && server.arg("auth_pass").length() > 0) + strlcpy(authCfg.pass, server.arg("auth_pass").c_str(), sizeof(authCfg.pass)); + authCfg.enabled = strlen(authCfg.pass) > 0; + Serial.printf("[HTTP] auth enabled=%d\n", authCfg.enabled); + } else { + authCfg.enabled = false; + Serial.println("[HTTP] auth disabled"); + } + + saveConfig(); + + // let mqttLoop handle reconnection to avoid blocking the HTTP handler + if (mqttClient.connected()) mqttClient.disconnect(); + mqttReconnectAt = 0; + + server.sendHeader("Location", "/", true); + server.send(303); +} + +static void startServer() { + server.on("/", handleRoot); + server.on("/config", HTTP_POST, handleConfig); + server.begin(); + Serial.println("HTTP server started"); +} + +// --------------------------------------------------------------------------- +// Factory reset (rapid reset 5x within a few seconds) +// --------------------------------------------------------------------------- + +static void factoryReset() { + Serial.println("[BOOT] factory reset triggered!"); + + strlcpy(hostname, HOSTNAME_DEFAULT, sizeof(hostname)); + hostnameParam.setValue(hostname, strlen(hostname)); + + authCfg.enabled = false; + authCfg.pass[0] = '\0'; + + mqttCfg.enabled = false; + mqttCfg.host[0] = '\0'; + mqttCfg.port = 1883; + mqttCfg.user[0] = '\0'; + mqttCfg.pass[0] = '\0'; + strlcpy(mqttCfg.prefix, "dial", sizeof(mqttCfg.prefix)); + + saveConfig(); + + Serial.println("[BOOT] factory reset complete, restarting..."); + delay(200); + ESP.restart(); +} + +static void checkFactoryReset() { + const int THRESHOLD = 5; + int count = 0; + File f = LittleFS.open("/reset_count", "r"); + if (f) { + count = f.parseInt(); + f.close(); + } + count++; + if (count >= THRESHOLD) { + LittleFS.remove("/reset_count"); + Serial.printf("[BOOT] reset count %d, triggering factory reset\n", count); + factoryReset(); + return; + } + f = LittleFS.open("/reset_count", "w"); + if (f) { + f.print(count); + f.close(); + Serial.printf("[BOOT] reset count %d/%d\n", count, THRESHOLD); + } +} + +// --------------------------------------------------------------------------- +// Setup & loop +// --------------------------------------------------------------------------- + +void setup() { + Serial.begin(115200); + Serial.println("\n\n=== Rotary Dial boot ==="); + LittleFS.begin(true); + + loadConfig(); + hostnameParam.setValue(hostname, strlen(hostname)); + + checkFactoryReset(); + + // Reset-counter settle window: if the device stays powered past this point it + // wasn't a rapid power-cycle, so clear the counter before the (possibly + // indefinitely blocking) WiFi portal. + delay(3000); + LittleFS.remove("/reset_count"); + + pinMode(DIAL_PIN, INPUT_PULLUP); + attachInterrupt(digitalPinToInterrupt(DIAL_PIN), onDialPulse, RISING); + + Serial.println("[WIFI] starting WiFiManager"); + WiFiManager wm; + wm.setTitle("Rotary Dial"); + + wm.addParameter(&hostnameParam); + wm.setSaveParamsCallback([]() { + strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname)); + saveConfig(); + }); + + if (!wm.autoConnect("Rotary-Dial")) { + Serial.println("[WIFI] failed, restarting"); + ESP.restart(); + } + Serial.printf("[WIFI] connected, IP: %s\n", WiFi.localIP().toString().c_str()); + + strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname)); + saveConfig(); + startServer(); + startMDNS(); + mqttReconnectAt = 0; + + Serial.printf("[BOOT] ready at http://%s.local or http://%s\n", + hostname, WiFi.localIP().toString().c_str()); +} + +void loop() { + server.handleClient(); + mqttLoop(); + dialLoop(); +}