From 088eb07ff378d8e49cae3bddba5fe20569230965 Mon Sep 17 00:00:00 2001 From: Holger Weber Date: Fri, 15 May 2026 00:34:46 +0200 Subject: [PATCH] First version - rotary not working. --- .gitignore | 5 + .vscode/extensions.json | 10 ++ AGENTS.md | 4 + SPEC.md | 27 +++++ include/PulseTimer.h | 15 +++ include/config.example.h | 8 ++ include/config.h | 9 ++ platformio.ini | 16 +++ src/PulseTimer.cpp | 24 ++++ src/main.cpp | 169 ++++++++++++++++++++++++++++ test/test_pulse_timer/test_main.cpp | 34 ++++++ 11 files changed, 321 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 AGENTS.md create mode 100644 SPEC.md create mode 100644 include/PulseTimer.h create mode 100644 include/config.example.h create mode 100644 include/config.h create mode 100644 platformio.ini create mode 100644 src/PulseTimer.cpp create mode 100644 src/main.cpp create mode 100644 test/test_pulse_timer/test_main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch 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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ec2aa76 --- /dev/null +++ b/AGENTS.md @@ -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 \ No newline at end of file diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..7f8af1e --- /dev/null +++ b/SPEC.md @@ -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 ` mit `` 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. diff --git a/include/PulseTimer.h b/include/PulseTimer.h new file mode 100644 index 0000000..46db6ea --- /dev/null +++ b/include/PulseTimer.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +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 diff --git a/include/config.example.h b/include/config.example.h new file mode 100644 index 0000000..dd63df4 --- /dev/null +++ b/include/config.example.h @@ -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 diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..ba576c5 --- /dev/null +++ b/include/config.h @@ -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 diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..64a249f --- /dev/null +++ b/platformio.ini @@ -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 = + - diff --git a/src/PulseTimer.cpp b/src/PulseTimer.cpp new file mode 100644 index 0000000..49f6834 --- /dev/null +++ b/src/PulseTimer.cpp @@ -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(valueMs); +} + +uint16_t adjustTimerMs(uint16_t currentMs, int ticks) { + const int adjusted = static_cast(currentMs) + ticks * kTimerStepMs; + return clampTimerMs(adjusted); +} + +uint16_t tasmotaPulseTimeValue(uint16_t timerMs) { + return clampTimerMs(timerMs) / kTimerStepMs; +} + +} // namespace trbc diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..9c1256b --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,169 @@ +#include +#include +#include +#include + +#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); +} diff --git a/test/test_pulse_timer/test_main.cpp b/test/test_pulse_timer/test_main.cpp new file mode 100644 index 0000000..df93b5f --- /dev/null +++ b/test/test_pulse_timer/test_main.cpp @@ -0,0 +1,34 @@ +#include + +#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(); +}