First version - rotary not working.

This commit is contained in:
Holger Weber
2026-05-15 00:34:46 +02:00
commit 088eb07ff3
11 changed files with 321 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored Normal file
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"
]
}

4
AGENTS.md Normal file
View File

@@ -0,0 +1,4 @@
- Prefer simple solutions over clever ones
- Do not introduce new dependencies without reason
- Always write tests for new features
- Adapt SPEC.md if specification changes or features are added or removed

27
SPEC.md Normal file
View File

@@ -0,0 +1,27 @@
# Tasmota remote pulse timer trigger
Das Programm für einen ESP8266 soll den PulseTimer einer Tasmota Steckdose triggern.
Die Hardware hat einen Rotary Encoder sowie ein OLED Display.
Mit dem Rotary Encoder (RT) stellt man eine Zeit im Bereich von 2s bis 10s mit einer Auflösung von 100ms.
Über den RT soll der Wert pro Tick um 100ms angepasst werden können.
Mit Druck auf den Taster des RT soll zunächst der PulseTimer eingestellt und dann getriggert werden.
Die Einstellungen für die SSID und das Passwort sollen aus einer separaten config.h Datei ausgelesen werden.
Dort soll auch die IP Adresse der Tasmota Steckdose eingestellt werden. Zudem dort noch den Initialwert für den Timer hinterlegen.
Der Project soll mittels PlatformIO und Arduino Framework umgesetzt werden. Das SSD1309 OLED wird mit der Arduino-Library U8g2 angesteuert.
## Umsetzung
Die Tasmota Steckdose wird per HTTP angesprochen. Beim Druck auf den Taster sendet das Programm:
1. `PulseTime1 <wert>` mit `<wert>` in 100ms-Schritten. Der erlaubte Bereich 2s bis 10s entspricht damit `20` bis `100`.
2. `Power1 ON`, um den zuvor gesetzten PulseTimer auszulösen.
Die Datei `include/config.h` enthält:
- `WIFI_SSID`
- `WIFI_PASSWORD`
- `TASMOTA_HOST`
- `INITIAL_TIMER_MS`
Als Vorlage liegt `include/config.example.h` bei.
## Hardware
Board D1 Mini mit ESP8266.
OLED über I2C an den GPIOs 4 und 5. Rotary Encoder mit A und B an GPIO 14 und 16 und der Taster an 12.
OLED hat einen SSD1309 Controller mit 128x64 Pixel.

15
include/PulseTimer.h Normal file
View File

@@ -0,0 +1,15 @@
#pragma once
#include <stdint.h>
namespace trbc {
constexpr uint16_t kMinTimerMs = 2000;
constexpr uint16_t kMaxTimerMs = 10000;
constexpr uint16_t kTimerStepMs = 100;
uint16_t clampTimerMs(int valueMs);
uint16_t adjustTimerMs(uint16_t currentMs, int ticks);
uint16_t tasmotaPulseTimeValue(uint16_t timerMs);
} // namespace trbc

8
include/config.example.h Normal file
View File

@@ -0,0 +1,8 @@
#pragma once
// Copy this file to include/config.h and adjust the values for your network.
#define WIFI_SSID "your-wifi"
#define WIFI_PASSWORD "your-password"
#define TASMOTA_HOST "192.168.1.50"
#define INITIAL_TIMER_MS 5000

9
include/config.h Normal file
View File

@@ -0,0 +1,9 @@
#pragma once
// Local defaults. Adjust for your network or keep this file out of version control
// if the project is moved into a shared repository.
#define WIFI_SSID "Sternenlabor"
#define WIFI_PASSWORD "!Sternenlabor99!"
#define TASMOTA_HOST "192.168.240.101"
#define INITIAL_TIMER_MS 5000

16
platformio.ini Normal file
View File

@@ -0,0 +1,16 @@
[platformio]
default_envs = d1_mini
[env:d1_mini]
platform = espressif8266
board = d1_mini
framework = arduino
monitor_speed = 115200
lib_deps =
olikraus/U8g2 @ ^2.36.12
[env:native]
platform = native
test_filter = test_pulse_timer
test_build_src = yes
build_src_filter = +<PulseTimer.cpp> -<main.cpp>

24
src/PulseTimer.cpp Normal file
View File

@@ -0,0 +1,24 @@
#include "PulseTimer.h"
namespace trbc {
uint16_t clampTimerMs(int valueMs) {
if (valueMs < kMinTimerMs) {
return kMinTimerMs;
}
if (valueMs > kMaxTimerMs) {
return kMaxTimerMs;
}
return static_cast<uint16_t>(valueMs);
}
uint16_t adjustTimerMs(uint16_t currentMs, int ticks) {
const int adjusted = static_cast<int>(currentMs) + ticks * kTimerStepMs;
return clampTimerMs(adjusted);
}
uint16_t tasmotaPulseTimeValue(uint16_t timerMs) {
return clampTimerMs(timerMs) / kTimerStepMs;
}
} // namespace trbc

169
src/main.cpp Normal file
View File

@@ -0,0 +1,169 @@
#include <Arduino.h>
#include <ESP8266HTTPClient.h>
#include <ESP8266WiFi.h>
#include <U8g2lib.h>
#include "PulseTimer.h"
#include "config.h"
namespace {
constexpr uint8_t kOledAddress = 0x3C;
constexpr uint8_t kPinEncoderA = 14;
constexpr uint8_t kPinEncoderB = 16;
constexpr uint8_t kPinButton = 12;
constexpr uint8_t kPinSda = 4;
constexpr uint8_t kPinScl = 5;
constexpr unsigned long kDisplayRefreshMs = 150;
constexpr unsigned long kWifiRetryMs = 10000;
constexpr unsigned long kButtonDebounceMs = 35;
constexpr unsigned long kStatusHoldMs = 2500;
uint16_t timerMs = trbc::clampTimerMs(INITIAL_TIMER_MS);
bool needsDisplayUpdate = true;
unsigned long lastDisplayRefresh = 0;
unsigned long lastWifiAttempt = 0;
unsigned long statusUntil = 0;
String statusLine = "Bereit";
uint8_t lastEncoderState = 0;
int8_t encoderAccumulator = 0;
bool lastButtonReading = HIGH;
bool stableButtonState = HIGH;
unsigned long lastButtonChange = 0;
U8G2_SSD1309_128X64_NONAME0_F_HW_I2C display(U8G2_R0, U8X8_PIN_NONE, kPinScl, kPinSda);
void setStatus(const String &text) {
statusLine = text;
statusUntil = millis() + kStatusHoldMs;
needsDisplayUpdate = true;
}
String formatTimer() {
const uint16_t seconds = timerMs / 1000;
const uint16_t tenths = (timerMs % 1000) / 100;
return String(seconds) + "." + String(tenths) + " S";
}
String wifiStatus() {
if (WiFi.status() == WL_CONNECTED) {
return String("IP ") + WiFi.localIP().toString();
}
return "WLAN verbindet";
}
void refreshDisplay() {
display.clearBuffer();
display.setFont(u8g2_font_6x10_tf);
display.drawStr(0, 10, "TASMOTA PULSE");
display.drawStr(0, 26, ("ZEIT " + formatTimer()).c_str());
display.drawStr(0, 42, wifiStatus().c_str());
display.drawStr(0, 58, (millis() < statusUntil ? statusLine : "DRUECKEN START").c_str());
display.sendBuffer();
}
void connectWifi() {
if (WiFi.status() == WL_CONNECTED) {
return;
}
if (millis() - lastWifiAttempt < kWifiRetryMs && lastWifiAttempt != 0) {
return;
}
lastWifiAttempt = millis();
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
needsDisplayUpdate = true;
}
bool sendTasmotaCommand(const String &command) {
if (WiFi.status() != WL_CONNECTED) {
return false;
}
WiFiClient client;
HTTPClient http;
const String url = String("http://") + TASMOTA_HOST + "/cm?cmnd=" + command;
if (!http.begin(client, url)) {
return false;
}
const int code = http.GET();
http.end();
return code >= 200 && code < 300;
}
void triggerPulse() {
setStatus("SENDE...");
const uint16_t pulseValue = trbc::tasmotaPulseTimeValue(timerMs);
const bool pulseOk = sendTasmotaCommand("PulseTime1%20" + String(pulseValue));
const bool powerOk = pulseOk && sendTasmotaCommand("Power1%20ON");
setStatus(powerOk ? "GETRIGGERT" : "FEHLER");
}
void readEncoder() {
const uint8_t state = (digitalRead(kPinEncoderA) << 1) | digitalRead(kPinEncoderB);
if (state == lastEncoderState) {
return;
}
const uint8_t transition = (lastEncoderState << 2) | state;
if (transition == 0b0001 || transition == 0b0111 || transition == 0b1110 || transition == 0b1000) {
encoderAccumulator++;
} else if (transition == 0b0010 || transition == 0b1011 || transition == 0b1101 || transition == 0b0100) {
encoderAccumulator--;
}
lastEncoderState = state;
if (encoderAccumulator >= 4 || encoderAccumulator <= -4) {
const int ticks = encoderAccumulator / 4;
encoderAccumulator = 0;
timerMs = trbc::adjustTimerMs(timerMs, ticks);
needsDisplayUpdate = true;
}
}
void readButton() {
const bool reading = digitalRead(kPinButton);
if (reading != lastButtonReading) {
lastButtonChange = millis();
lastButtonReading = reading;
}
if (millis() - lastButtonChange <= kButtonDebounceMs || reading == stableButtonState) {
return;
}
stableButtonState = reading;
if (stableButtonState == LOW) {
triggerPulse();
}
}
} // namespace
void setup() {
Serial.begin(115200);
pinMode(kPinEncoderA, INPUT_PULLUP);
pinMode(kPinEncoderB, INPUT_PULLUP);
pinMode(kPinButton, INPUT_PULLUP);
lastEncoderState = (digitalRead(kPinEncoderA) << 1) | digitalRead(kPinEncoderB);
display.setI2CAddress(kOledAddress << 1);
display.begin();
connectWifi();
refreshDisplay();
}
void loop() {
connectWifi();
readEncoder();
readButton();
if (needsDisplayUpdate || millis() - lastDisplayRefresh >= kDisplayRefreshMs) {
refreshDisplay();
lastDisplayRefresh = millis();
needsDisplayUpdate = false;
}
delay(2);
}

View File

@@ -0,0 +1,34 @@
#include <unity.h>
#include "PulseTimer.h"
void test_clamps_timer_range() {
TEST_ASSERT_EQUAL_UINT16(2000, trbc::clampTimerMs(1000));
TEST_ASSERT_EQUAL_UINT16(5000, trbc::clampTimerMs(5000));
TEST_ASSERT_EQUAL_UINT16(10000, trbc::clampTimerMs(12000));
}
void test_adjusts_in_100_ms_steps() {
TEST_ASSERT_EQUAL_UINT16(5100, trbc::adjustTimerMs(5000, 1));
TEST_ASSERT_EQUAL_UINT16(4700, trbc::adjustTimerMs(5000, -3));
}
void test_adjustment_stays_in_range() {
TEST_ASSERT_EQUAL_UINT16(2000, trbc::adjustTimerMs(2000, -1));
TEST_ASSERT_EQUAL_UINT16(10000, trbc::adjustTimerMs(10000, 1));
}
void test_converts_to_tasmota_pulsetime_units() {
TEST_ASSERT_EQUAL_UINT16(20, trbc::tasmotaPulseTimeValue(2000));
TEST_ASSERT_EQUAL_UINT16(55, trbc::tasmotaPulseTimeValue(5500));
TEST_ASSERT_EQUAL_UINT16(100, trbc::tasmotaPulseTimeValue(10000));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_clamps_timer_range);
RUN_TEST(test_adjusts_in_100_ms_steps);
RUN_TEST(test_adjustment_stays_in_range);
RUN_TEST(test_converts_to_tasmota_pulsetime_units);
return UNITY_END();
}