Compare commits

..

5 Commits

5 changed files with 196 additions and 81 deletions
+5
View File
@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
+10
View File
@@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}
+107 -38
View File
@@ -2,72 +2,119 @@
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).
The project is a standalone PlatformIO/Arduino firmware using WiFiManager for first-time Wi-Fi setup, LittleFS for local configuration storage, a small built-in web UI, and PubSubClient for MQTT.
## How it works
## 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.
A classic rotary telephone dial closes and opens contacts as the dial is wound and released. This firmware expects two contacts:
Leading zeros are preserved: dialling `07` sends `"07"`, not `"7"`.
| Contact | GPIO | Purpose |
|---------|------|---------|
| Off-normal / active contact | GPIO 4 | Goes active while a digit is being dialled |
| Pulse contact | GPIO 5 | Generates the digit pulse train |
Both pins use `INPUT_PULLUP`, so each contact should switch the GPIO to GND when active.
When the off-normal contact goes low, the firmware starts a digit. While that contact remains low, it counts rising edges on the pulse contact. When the off-normal contact returns high, the digit is complete:
| Pulses | Digit |
|--------|-------|
| 1-9 | `1`-`9` |
| 10 or more | `0` |
Digits are appended to a string. After 3 seconds without another completed digit, the full number is published to MQTT. Leading zeros are preserved, so dialling `07` publishes `"07"`.
## 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`. |
| MCU | ESP32 board supported by the configured PlatformIO environments |
| Dial | Pulse-generating rotary telephone dial with off-normal and pulse contacts |
| Off-normal pin | GPIO 4 (`OFF_NORMAL_PIN` in `src/main.cpp`) |
| Pulse pin | GPIO 5 (`PULSE_PIN` in `src/main.cpp`) |
| Wiring | Each contact connects its GPIO to GND when active; firmware enables internal pull-ups |
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 uses inverted contact logic, adjust the state checks in `dialLoop()` rather than only changing an interrupt trigger. The current implementation polls both pins and does not use GPIO interrupts.
If your dial's contact is normally closed instead, change the interrupt trigger in `setup()` from `RISING` to `FALLING`.
## Building and Flashing
## Building and flashing
Install PlatformIO, then run:
```bash
# Build (S3 DevKit)
pio run -e esp32-s3-devkitc-1
# Build the default ESP32 DevKit environment
pio run -e esp32dev
# Flash
pio run -e esp32-s3-devkitc-1 --target upload
# Flash firmware
pio run -e esp32dev --target upload
# Serial monitor (115200 baud)
# Upload the LittleFS image, including data/logo.png
pio run -e esp32dev --target uploadfs
# Serial monitor at 115200 baud
pio device monitor
```
## First-time Wi-Fi setup
The configured environments are:
On first boot the device opens an access point named **Rotary-Dial**. Connect with any phone or laptop; a captive portal will appear.
| Environment | Board |
|-------------|-------|
| `esp32dev` | ESP32 Dev Module |
| `esp32-s3-devkitc-1` | ESP32-S3-DevKitC-1 |
1. Enter your Wi-Fi credentials.
2. Optionally set the **Device hostname** (default: `rotary-dial`).
3. Save. The device joins your network and restarts.
Use the matching environment name in the commands above. Close the serial monitor before uploading firmware because it holds the serial port.
Reachable at `http://rotary-dial.local` or the IP shown in the serial monitor.
## First-Time Wi-Fi Setup
### Factory reset
On first boot, WiFiManager opens an access point named `Rotary-Dial`. Connect with a phone or laptop and use the captive portal to enter Wi-Fi credentials.
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.
The portal also exposes a `Device hostname` field. The default hostname is `rotary-dial`.
After the device joins Wi-Fi, it starts mDNS and the web UI is reachable at:
```text
http://rotary-dial.local
```
The serial monitor also prints the assigned IP address.
## Web UI
Browse to the device address to configure hostname, authentication, and MQTT.
Browse to the device address to configure:
| Setting | Notes |
|---------|-------|
| Hostname | Saved to LittleFS; changing it restarts the device |
| Security | Optional HTTP basic auth, username `admin` |
| MQTT | Broker host, port, username, password, topic prefix, and enable switch |
Passwords are only replaced when a new non-empty password is submitted. Leaving an existing password field blank keeps the stored value.
The header logo is served from `/logo.png`, which comes from `data/logo.png` in the uploaded LittleFS image.
## HTTP API
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/` | HTML configuration page |
| `GET` | `/logo.png` | Logo image served from LittleFS |
| `POST` | `/config` | Save hostname, auth, and MQTT settings; redirects to `/`; restarts if hostname changed |
| `POST` | `/reset` | Restart the device immediately |
When HTTP auth is enabled, the username is `admin` and the password is the value configured in the web UI.
## MQTT
### Topics
MQTT is disabled until it is enabled and configured in the web UI.
Default topic prefix: `dial`
| Direction | Topic | Description |
|-----------|-------|-------------|
| Published | `<prefix>/dialed` | Complete dialled number as a string (not retained) |
| Published | `<prefix>/status` | `online` on connect, `offline` as LWT (retained) |
| Published | `<prefix>/dialed` | Complete dialled number as a non-retained string event |
| Published | `<prefix>/status` | `online` on connect, `offline` as retained MQTT last will |
| Published | `homeassistant/sensor/<object_id>/config` | Retained Home Assistant discovery payload |
Default prefix: `dial`
### Home Assistant
On MQTT connect the device publishes a discovery payload so HA automatically creates a **sensor** entity (`sensor.<hostname>_dialed_number`) that shows the last dialled number.
The firmware publishes Home Assistant MQTT discovery for a sensor named `Dialed Number`.
Example automation:
@@ -88,12 +135,34 @@ automation:
message: "Emergency number dialled!"
```
## Timing constants
## Factory Reset
Adjust in `main.cpp` if your dial runs faster or slower than standard:
During boot, the firmware increments a reset counter in LittleFS and waits 3 seconds. Rapidly power-cycle or press EN before that window expires to accumulate reset counts.
| Rapid boot count | Effect |
|------------------|--------|
| 5-9 | Clears hostname, HTTP auth, and MQTT config |
| 10+ | Clears hostname, HTTP auth, MQTT config, and Wi-Fi credentials |
After the 3-second window, the counter is cleared if no reset threshold is reached.
Example serial output:
```text
[BOOT] reset count 3
[BOOT] reset count 4
[BOOT] reset count 5, triggering factory reset
[BOOT] factory reset triggered (clearWifi=0)!
[BOOT] factory reset complete, restarting...
```
## Tuning
The dial behavior is controlled by constants in `src/main.cpp`:
| 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 |
| `PULSE_DEBOUNCE_MS` | 20 ms | Minimum time between counted pulse release edges |
| `NUMBER_COMPLETE_MS` | 3000 ms | Idle time after the last completed digit before publishing the number |
The source also defines `DIGIT_BOUNDARY_MS`, but the current digit boundary is determined by the off-normal contact returning high.
+11
View File
@@ -1,8 +1,19 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:esp32-s3-devkitc-1]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
board_build.filesystem = littlefs
lib_deps =
tzapu/WiFiManager@^2.0.17
bblanchon/ArduinoJson@^7.0.0
+57 -37
View File
@@ -22,10 +22,11 @@
// 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
#define OFF_NORMAL_PIN 4 // GPIO connected to the off-normal / active contact
#define PULSE_PIN 5 // GPIO connected to the pulse contact
#define PULSE_DEBOUNCE_MS 20 // ignore pulse edges faster than this
#define DIGIT_BOUNDARY_MS 300 // pin-4 high gap longer than this separates digits
#define NUMBER_COMPLETE_MS 3000 // idle longer than this ends the number and publishes
struct MqttConfig {
bool enabled = false;
@@ -54,18 +55,14 @@ 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;
}
}
static bool offNormalState = HIGH; // current state of pin 4
static bool pulseState = HIGH; // current state of pin 5
static bool digitActive = false;
static int digitPulseCount = 0;
static unsigned long digitStartedAt = 0;
static unsigned long lastPulseEdgeAt = 0;
// ---------------------------------------------------------------------------
// Config persistence (JSON via LittleFS)
@@ -135,11 +132,14 @@ static void saveConfig() {
// ---------------------------------------------------------------------------
static void mqttPublishDialed(const String& number) {
if (!mqttCfg.enabled || !mqttClient.connected()) return;
if (!mqttCfg.enabled || !mqttClient.connected()) {
Serial.printf("[DIAL] MQTT unavailable, number=%s not sent\n", number.c_str());
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());
bool ok = mqttClient.publish(topic, number.c_str(), false); // not retained — it's an event
Serial.printf("[DIAL] publish %s -> %s ok=%d\n", topic, number.c_str(), ok);
}
static void mqttCallback(char* topic, byte* payload, unsigned int len) {
@@ -254,31 +254,49 @@ static void mqttLoop() {
static void dialLoop() {
unsigned long now = millis();
bool currentOffNormal = digitalRead(OFF_NORMAL_PIN);
bool currentPulse = digitalRead(PULSE_PIN);
// 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'; 19 pulses = digits '1''9'
char digit = '0' + (pulses >= 10 ? 0 : pulses);
if (currentOffNormal != offNormalState) {
if (offNormalState == HIGH && currentOffNormal == LOW) {
digitActive = true;
digitPulseCount = 0;
digitStartedAt = now;
lastPulseEdgeAt = 0;
dialLastDigit = now; // pause idle timer on digit start
Serial.printf("[DIAL] digit start at %lu ms\n", now);
} else if (offNormalState == LOW && currentOffNormal == HIGH) {
if (digitActive) {
char digit = '0' + (digitPulseCount >= 10 ? 0 : digitPulseCount);
dialNumber += digit;
dialLastDigit = now;
Serial.printf("[DIAL] digit=%c number_so_far=%s\n", digit, dialNumber.c_str());
Serial.printf("[DIAL] digit complete pulses=%d -> '%c' number_so_far=%s\n",
digitPulseCount, digit, dialNumber.c_str());
}
digitActive = false;
}
offNormalState = currentOffNormal;
}
// 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());
if (digitActive && currentOffNormal == LOW) {
if (currentPulse == HIGH && pulseState == LOW) {
unsigned long pulseDelta = now - lastPulseEdgeAt;
if (lastPulseEdgeAt == 0 || pulseDelta >= PULSE_DEBOUNCE_MS) {
digitPulseCount++;
lastPulseEdgeAt = now;
Serial.printf("[DIAL] pulse #%d at %lu ms\n", digitPulseCount, now);
}
}
}
pulseState = currentPulse;
if (dialNumber.length() > 0 && (now - dialLastDigit) >= NUMBER_COMPLETE_MS) {
Serial.printf("[DIAL] number complete=%s\n", dialNumber.c_str());
mqttPublishDialed(dialNumber);
dialNumber = "";
digitActive = false;
digitPulseCount = 0;
}
}
@@ -577,8 +595,10 @@ void setup() {
delay(3000);
applyFactoryResetIfNeeded(resetCount);
pinMode(DIAL_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(DIAL_PIN), onDialPulse, RISING);
pinMode(OFF_NORMAL_PIN, INPUT_PULLUP);
pinMode(PULSE_PIN, INPUT_PULLUP);
offNormalState = digitalRead(OFF_NORMAL_PIN);
pulseState = digitalRead(PULSE_PIN);
Serial.println("[WIFI] starting WiFiManager");
WiFiManager wm;