Documentation updated

This commit is contained in:
2026-07-12 15:43:31 +02:00
parent 5d3c82a1e0
commit 132ffe352a
4 changed files with 164 additions and 115 deletions
+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"
]
}
+90 -55
View File
@@ -2,82 +2,94 @@
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
# Build the default ESP32 DevKit environment
pio run -e esp32dev
# Flash firmware
pio run -e esp32dev --target upload
# Upload filesystem image (logo, etc.) — only needed when data/ changes
# Upload the LittleFS image, including data/logo.png
pio run -e esp32dev --target uploadfs
# Serial monitor (115 200 baud)
# Serial monitor at 115200 baud
pio device monitor
```
> **Note:** close the serial monitor before uploading — it holds the serial port exclusively.
The configured environments are:
Board targets: `esp32dev`, `esp32-s3-devkitc-1`
| Environment | Board |
|-------------|-------|
| `esp32dev` | ESP32 Dev Module |
| `esp32-s3-devkitc-1` | ESP32-S3-DevKitC-1 |
## First-time Wi-Fi setup
Use the matching environment name in the commands above. Close the serial monitor before uploading firmware because it holds the serial port.
On first boot the device opens an access point named **Rotary-Dial**. Connect with any phone or laptop; a captive portal will appear.
## First-Time Wi-Fi Setup
1. Enter your Wi-Fi credentials.
2. Optionally set the **Device hostname** (default: `rotary-dial`, max 63 characters).
3. Save. The device joins your network and restarts.
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.
Reachable at `http://rotary-dial.local` or the IP shown in the serial monitor.
The portal also exposes a `Device hostname` field. The default hostname is `rotary-dial`.
### Factory reset
Power-cycle (or press EN) rapidly before the 3-second boot window expires. The reset counter accumulates across rapid reboots:
| Cycles | Effect |
|--------|--------|
| 5 9 | Clears hostname, auth, and MQTT config; restarts into normal Wi-Fi |
| 10+ | Clears all of the above **plus** stored Wi-Fi credentials; restarts into captive portal |
Serial output shows the accumulated count:
After the device joins Wi-Fi, it starts mDNS and the web UI is reachable at:
```text
http://rotary-dial.local
```
[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...
```
The serial monitor also prints the assigned IP address.
## Web UI
Browse to the device address to configure hostname, authentication, and MQTT. The logo in the header is served from `data/logo.png` in the filesystem image.
Browse to the device address to configure:
Click **Save** to persist settings. Changing the hostname automatically restarts the device. Click **Reset Device** to restart immediately.
| 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
@@ -85,23 +97,24 @@ Click **Save** to persist settings. Changing the hostname automatically restarts
|--------|------|-------------|
| `GET` | `/` | HTML configuration page |
| `GET` | `/logo.png` | Logo image served from LittleFS |
| `POST` | `/config` | Save configuration; redirects to `/`. Restarts if hostname changed |
| `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 that shows the last dialled number.
The firmware publishes Home Assistant MQTT discovery for a sensor named `Dialed Number`.
Example automation:
@@ -122,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.
+13 -2
View File
@@ -1,9 +1,20 @@
; 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
lib_deps =
board_build.filesystem = littlefs
lib_deps =
tzapu/WiFiManager@^2.0.17
bblanchon/ArduinoJson@^7.0.0
knolleary/PubSubClient@^2.8
@@ -14,7 +25,7 @@ framework = arduino
platform = espressif32
monitor_speed = 115200
board_build.filesystem = littlefs
lib_deps =
lib_deps =
tzapu/WiFiManager@^2.0.17
bblanchon/ArduinoJson@^7.4.3
knolleary/PubSubClient@^2.8
+51 -58
View File
@@ -22,10 +22,11 @@
// Dial constants
// ---------------------------------------------------------------------------
#define DIAL_PIN 4 // GPIO connected to the pulse contact (to GND)
#define PULSE_DEBOUNCE_MS 100 // 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,19 +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 int dialRawCount = 0; // raw digit count since last publish (odd positions = wind-up spurious)
static void IRAM_ATTR onDialPulse() {
unsigned long now = millis();
if (now - dialLastPulse >= PULSE_DEBOUNCE_MS) {
dialPulses++;
dialLastPulse = now;
}
}
static String dialNumber = "";
static unsigned long dialLastDigit = 0; // millis() of last completed digit
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)
@@ -258,54 +254,49 @@ static void mqttLoop() {
static void dialLoop() {
unsigned long now = millis();
static int lastLoggedPulses = 0;
bool currentOffNormal = digitalRead(OFF_NORMAL_PIN);
bool currentPulse = digitalRead(PULSE_PIN);
// Snapshot volatile state atomically
noInterrupts();
int pulses = dialPulses;
unsigned long lastPulse = dialLastPulse;
interrupts();
// Log each new pulse as it arrives (ISR can't use Serial, so we log here)
if (pulses != lastLoggedPulses) {
Serial.printf("[DIAL] pulse #%d\n", pulses);
lastLoggedPulses = 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 complete pulses=%d -> '%c' number_so_far=%s\n",
digitPulseCount, digit, dialNumber.c_str());
}
digitActive = false;
}
offNormalState = currentOffNormal;
}
// Burst silence exceeded → digit complete
if (pulses > 0 && (now - lastPulse) >= INTER_PULSE_MS) {
noInterrupts();
dialPulses = 0;
interrupts();
lastLoggedPulses = 0;
dialRawCount++;
// 10 pulses = digit '0'; 19 pulses = digits '1''9'
char digit = '0' + (pulses >= 10 ? 0 : pulses);
bool spurious = (dialRawCount % 2 == 1) && (digit == '1');
if (spurious) {
Serial.printf("[DIAL] burst end pulses=%d -> digit='%c' (wind-up spurious, discarded)\n", pulses, digit);
} else {
dialNumber += digit;
dialLastDigit = now;
Serial.printf("[DIAL] burst end pulses=%d -> digit='%c' number_so_far=%s\n", pulses, digit, 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);
}
}
}
// Number complete when no further digits arrive within the timeout
if (dialNumber.length() > 0 && (now - dialLastDigit) >= INTER_DIGIT_MS) {
pulseState = currentPulse;
if (dialNumber.length() > 0 && (now - dialLastDigit) >= NUMBER_COMPLETE_MS) {
Serial.printf("[DIAL] number complete=%s\n", dialNumber.c_str());
mqttPublishDialed(dialNumber);
dialNumber = "";
dialRawCount = 0;
}
// If the sequence stalled with no accepted digit (e.g. user wound up then
// stopped), reset the count so the next wind-up is treated as position 1.
if (dialRawCount > 0 && dialNumber.length() == 0 && pulses == 0
&& (now - lastPulse) >= INTER_DIGIT_MS) {
Serial.println("[DIAL] idle reset");
dialRawCount = 0;
digitActive = false;
digitPulseCount = 0;
}
}
@@ -604,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;