Initial commit: rotary dial firmware on ESP32 device framework
This commit is contained in:
@@ -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 | `<prefix>/dialed` | Complete dialled number as a string (not retained) |
|
||||||
|
| Published | `<prefix>/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.<hostname>_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 |
|
||||||
@@ -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
|
||||||
+569
@@ -0,0 +1,569 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include <WiFiManager.h>
|
||||||
|
#include <ESPmDNS.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <WebServer.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <WiFiClient.h>
|
||||||
|
#include <PubSubClient.h>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<const char*>())
|
||||||
|
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<JsonObject>();
|
||||||
|
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<JsonObject>();
|
||||||
|
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 +=
|
||||||
|
"<fieldset><legend>Security</legend>"
|
||||||
|
"<div class=row>"
|
||||||
|
"<label class=tgl-lbl>Require password</label>"
|
||||||
|
"<label class=switch><input type=checkbox name=auth_en" + checked + "><span class=slid></span></label>"
|
||||||
|
"</div>"
|
||||||
|
"<div class=row><label>Password</label><input name=auth_pass type=password placeholder='" + String(passHint) + "'></div>"
|
||||||
|
"</fieldset>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +=
|
||||||
|
"<fieldset><legend>MQTT</legend>"
|
||||||
|
"<div class=row>"
|
||||||
|
"<label class=tgl-lbl>Enable</label>"
|
||||||
|
"<label class=switch><input type=checkbox name=mqtt_en" + checked + "><span class=slid></span></label>"
|
||||||
|
"</div>"
|
||||||
|
"<div class=row><label>Broker</label><input name=mqtt_host value='" + hostVal + "'></div>"
|
||||||
|
"<div class=row><label>Port</label><input name=mqtt_port type=number value=" + String(portStr) + "></div>"
|
||||||
|
"<div class=row><label>User</label><input name=mqtt_user value='" + userVal + "'></div>"
|
||||||
|
"<div class=row><label>Pass</label><input name=mqtt_pass type=password placeholder='" + String(passHint) + "'></div>"
|
||||||
|
"<div class=row><label>Prefix</label><input name=mqtt_prefix value='" + prefVal + "'></div>"
|
||||||
|
"</fieldset>";
|
||||||
|
}
|
||||||
|
|
||||||
|
String html;
|
||||||
|
html.reserve(3072);
|
||||||
|
html += "<!DOCTYPE html><html><head><meta charset=utf-8>";
|
||||||
|
html += "<meta name=viewport content='width=device-width,initial-scale=1'><title>Rotary Dial</title><style>";
|
||||||
|
html += "*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}";
|
||||||
|
html += "body{font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Oxygen,Ubuntu,sans-serif;background:#f2f4f8;color:#1a1a2e;display:flex;justify-content:center;align-items:flex-start;min-height:100vh;padding:24px 16px}";
|
||||||
|
html += ".card{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:32px;width:100%;max-width:560px}";
|
||||||
|
html += "h1{font-size:24px;margin-bottom:4px}.sub{color:#666;font-size:14px;margin-bottom:24px}";
|
||||||
|
html += ".info{background:#f8f9fc;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:14px}";
|
||||||
|
html += ".info div{display:flex;justify-content:space-between;padding:4px 0}.info div span:first-child{color:#666}";
|
||||||
|
html += "label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}";
|
||||||
|
html += "input{width:100%;padding:9px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;outline:none;transition:border-color .2s;margin-bottom:12px}";
|
||||||
|
html += "input:focus{border-color:#4361ee;box-shadow:0 0 0 3px rgba(67,97,238,.15)}";
|
||||||
|
html += "fieldset{border:1px solid #e5e7eb;border-radius:10px;padding:16px;margin-bottom:16px}";
|
||||||
|
html += "legend{font-weight:600;font-size:14px;padding:0 6px;color:#4361ee}";
|
||||||
|
html += "fieldset .row{display:flex;gap:8px;align-items:center;margin-bottom:8px}";
|
||||||
|
html += "fieldset .row label{width:70px;margin-bottom:0;flex-shrink:0}";
|
||||||
|
html += "fieldset .row input{margin-bottom:0}";
|
||||||
|
html += ".btn{width:100%;margin-top:8px;padding:12px;background:#4361ee;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}";
|
||||||
|
html += ".btn:hover{background:#3651d4}.mac{font-size:12px;color:#999;text-align:center;margin-top:20px}";
|
||||||
|
html += ".tgl-lbl{margin-bottom:0!important;line-height:18px}.switch{position:relative;display:inline-block;width:32px;height:18px;margin-left:auto;flex-shrink:0}fieldset .row label.switch{width:32px}";
|
||||||
|
html += ".switch input{opacity:0;width:0;height:0;margin-bottom:0}.slid{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;border-radius:18px;transition:.3s}";
|
||||||
|
html += ".slid::before{content:\"\";position:absolute;height:14px;width:14px;left:2px;top:2px;background:#fff;border-radius:50%;transition:.3s;box-shadow:0 1px 3px rgba(0,0,0,.2)}";
|
||||||
|
html += ".switch input:checked+.slid{background:#4361ee}.switch input:checked+.slid::before{transform:translateX(14px)}";
|
||||||
|
html += "</style></head><body><div class=card>";
|
||||||
|
html += "<h1>Rotary Dial</h1><div class=sub>Configuration</div><div class=info>";
|
||||||
|
html += "<div><span>Hostname</span><span>" + String(hostname) + "</span></div>";
|
||||||
|
html += "<div><span>IP</span><span>" + WiFi.localIP().toString() + "</span></div></div>";
|
||||||
|
html += "<form action=/config method=post>";
|
||||||
|
html += "<label>Hostname</label><input name=hostname value='" + String(hostname) + "'>";
|
||||||
|
html += authSection;
|
||||||
|
html += mqttSection;
|
||||||
|
html += "<button class=btn type=submit>Save</button></form>";
|
||||||
|
html += "<div class=mac>" + WiFi.macAddress() + "</div></div>";
|
||||||
|
html += "</body></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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user