diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -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" + ] +} diff --git a/README.md b/README.md index 27c11e0..b343737 100644 --- a/README.md +++ b/README.md @@ -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 | `/dialed` | Complete dialled number as a string (not retained) | -| Published | `/status` | `online` on connect, `offline` as LWT (retained) | +| Published | `/dialed` | Complete dialled number as a non-retained string event | +| Published | `/status` | `online` on connect, `offline` as retained MQTT last will | +| Published | `homeassistant/sensor//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. diff --git a/platformio.ini b/platformio.ini index e8f3ba1..034a8eb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/main.cpp b/src/main.cpp index 91abbf0..5c22007 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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'; 1–9 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;