diff --git a/library.json b/library.json index a07b490..3f30174 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "OFM-LedModule", - "version": "0.12.0-DEV", + "version": "0.13.0-DEV", "dependencies": { "adafruit/Adafruit PWM Servo Driver Library": "3.0.2", "adafruit/Adafruit NeoPixel": "1.12.4", diff --git a/src/Channels/Schedule.cpp b/src/Channels/Schedule.cpp new file mode 100644 index 0000000..eef8734 --- /dev/null +++ b/src/Channels/Schedule.cpp @@ -0,0 +1,224 @@ +#include "Schedule.h" +#include "SingleChannel.h" +#include "knxprod.h" +#include + +// SUN_OFFSETS table — indexed by (timeByte mod 17). MUST match enum ordering +// in PT-ScheduleSunOffset (LedModule.share.xml). idx<17 ⇒ sunrise, ≥17 ⇒ sunset. +// Sonnenaufgang/-untergang -5h/-4h/-3h/-2h/-1h/-45min/-30min/-15min/+-0min/+15min/+30min/+45min/+1h/+2h/+3h/+4h/+5h +static constexpr int16_t SUN_OFFSETS[17] = { + -300, -240, -180, -120, -60, + -45, -30, -15, 0, 15, + 30, 45, 60, 120, 180, + 240, 300}; + +Schedule::Schedule(uint8_t channelIndex, SingleChannel *pChannel, HWDimmer *pDimmer) + : _channelIndex(channelIndex), _pChannel(pChannel), _pDimmer(pDimmer) +{ +} + +void Schedule::setup() +{ + _numPoints = ParamLED_SC_ChScheduleNumPoints; + if (_numPoints < 2) _numPoints = 2; + if (_numPoints > MAX_POINTS) _numPoints = MAX_POINTS; + _offBehavior = ParamLED_SC_ChScheduleOffBehavior == 1 + ? OffBehavior::Ausschalten + : OffBehavior::SequenzStoppen; + _fallbackMinutes = ParamLED_SC_ChScheduleFallbackMin; + _schaltzeiten = ParamLED_SC_ChScheduleSchaltzeiten == 1 + ? Schaltzeiten::Sonne + : Schaltzeiten::Uhrzeit; + + // Stützpunkte: time byte (Uhrzeit/SunOffset share storage), brightness byte. + // Note: ParamLED_SC_ChSchedulePoint{N}_Uhrzeit is the SAME byte as ..._SunOffset. + // Reading either macro returns the byte; interpretation depends on _schaltzeiten. + _points[0].timeByte = ParamLED_SC_ChSchedulePoint1_Uhrzeit; + _points[0].brightness = ParamLED_SC_ChSchedulePoint1_Brightness; + _points[1].timeByte = ParamLED_SC_ChSchedulePoint2_Uhrzeit; + _points[1].brightness = ParamLED_SC_ChSchedulePoint2_Brightness; + _points[2].timeByte = ParamLED_SC_ChSchedulePoint3_Uhrzeit; + _points[2].brightness = ParamLED_SC_ChSchedulePoint3_Brightness; + _points[3].timeByte = ParamLED_SC_ChSchedulePoint4_Uhrzeit; + _points[3].brightness = ParamLED_SC_ChSchedulePoint4_Brightness; + _points[4].timeByte = ParamLED_SC_ChSchedulePoint5_Uhrzeit; + _points[4].brightness = ParamLED_SC_ChSchedulePoint5_Brightness; + _points[5].timeByte = ParamLED_SC_ChSchedulePoint6_Uhrzeit; + _points[5].brightness = ParamLED_SC_ChSchedulePoint6_Brightness; + _points[6].timeByte = ParamLED_SC_ChSchedulePoint7_Uhrzeit; + _points[6].brightness = ParamLED_SC_ChSchedulePoint7_Brightness; + _points[7].timeByte = ParamLED_SC_ChSchedulePoint8_Uhrzeit; + _points[7].brightness = ParamLED_SC_ChSchedulePoint8_Brightness; + _points[8].timeByte = ParamLED_SC_ChSchedulePoint9_Uhrzeit; + _points[8].brightness = ParamLED_SC_ChSchedulePoint9_Brightness; + _points[9].timeByte = ParamLED_SC_ChSchedulePoint10_Uhrzeit; + _points[9].brightness = ParamLED_SC_ChSchedulePoint10_Brightness; + + _active = true; + _suspendedByManual = false; + _lastLevelSent = -1; + _lastEvalMillis = 0; + // Default KO to "On" — reaching setup() already implies ETS-enabled. + KoLED_SC_ChScheduleActive.valueNoSend(true, DPT_Switch); + publishStatus(true); +} + +int16_t Schedule::pointTimeMinutes(const StuetzPunkt &p) const +{ + if (_schaltzeiten == Schaltzeiten::Uhrzeit) + { + // timeByte = hour 0..23, minute always 0 (Phase A simplification per UX feedback) + return (int16_t)p.timeByte * 60; + } + // Sonne mode: requires valid sun calc + if (!openknx.sun.isSunCalculatioValid()) return -1; + bool isSunrise = (p.timeByte < 17); + int16_t offset = SUN_OFFSETS[p.timeByte % 17]; + auto t = isSunrise ? openknx.sun.sunRiseLocalTime() : openknx.sun.sunSetLocalTime(); + int32_t absMin = (int32_t)t.hour * 60 + t.minute + offset; + // Wrap into 0..1439 + while (absMin < 0) absMin += 1440; + while (absMin >= 1440) absMin -= 1440; + return (int16_t)absMin; +} + +void Schedule::loop() +{ + // Manual-suspend fallback handling — runs even while suspended/inactive + if (_suspendedByManual && _fallbackMinutes > 0) + { + uint32_t elapsedMin = (millis() - _suspendedSinceMillis) / 60000UL; + if (elapsedMin >= _fallbackMinutes) + { + _suspendedByManual = false; + _lastLevelSent = -1; + publishStatus(true); + } + } + + if (!_active || _suspendedByManual) return; + + if (!_pChannel->isOn()) return; + + if (millis() - _lastEvalMillis < 1000) return; + _lastEvalMillis = millis(); + + if (!openknx.time.isValid()) return; + auto tinfo = openknx.time.getLocalTime(); + uint16_t nowMin = (uint16_t)tinfo.hour * 60 + tinfo.minute; + + uint8_t newLevel = computeInterpolatedLevel(nowMin); + if ((int16_t)newLevel != _lastLevelSent) + { + _pChannel->setBrightness((uint16_t)(newLevel * VALUE_KNX_MULTIPLY)); + _lastLevelSent = newLevel; + } +} + +bool Schedule::processInputKo(GroupObject &ko, int16_t relKo) +{ + if (relKo == LED_SC_KoChScheduleActive) + { + bool turnOn = ko.value(DPT_Switch); + _active = turnOn; + if (!turnOn) + { + if (_offBehavior == OffBehavior::Ausschalten) + _pChannel->setSwitch(false); + publishStatus(false); + } + else + { + _suspendedByManual = false; + _lastLevelSent = -1; + publishStatus(true); + } + return true; + } + return false; +} + +void Schedule::notifyManualChange() +{ + if (!_active) return; + _suspendedByManual = true; + _suspendedSinceMillis = millis(); + publishStatus(false); +} + +void Schedule::notifyLampOff() +{ + _lastLevelSent = -1; +} + +uint8_t Schedule::computeInterpolatedLevel(uint16_t nowMin) const +{ + // Build list of valid (time, brightness) pairs for active points. + // In Sonne mode, points may be invalid (sun calc not ready yet). + int16_t times[MAX_POINTS]; + uint8_t brights[MAX_POINTS]; + uint8_t count = 0; + for (uint8_t i = 0; i < _numPoints; i++) + { + int16_t t = pointTimeMinutes(_points[i]); + if (t < 0) continue; + times[count] = t; + brights[count] = _points[i].brightness; + count++; + } + if (count == 0) return 0; + if (count == 1) return brights[0]; + + // Find prev (largest time ≤ nowMin) and next (smallest time > nowMin), + // wrap-aware. + int8_t prevIdx = -1, nextIdx = -1; + uint16_t prevDelta = 0xFFFF, nextDelta = 0xFFFF; + for (uint8_t i = 0; i < count; i++) + { + if (times[i] <= (int16_t)nowMin) + { + uint16_t d = (uint16_t)nowMin - (uint16_t)times[i]; + if (d < prevDelta) { prevDelta = d; prevIdx = i; } + } + else + { + uint16_t d = (uint16_t)times[i] - (uint16_t)nowMin; + if (d < nextDelta) { nextDelta = d; nextIdx = i; } + } + } + if (prevIdx == -1) + { + // No point ≤ nowMin → previous is the latest point of the previous day + uint16_t latestTime = 0; + for (uint8_t i = 0; i < count; i++) + if ((uint16_t)times[i] >= latestTime) { latestTime = (uint16_t)times[i]; prevIdx = i; } + } + if (nextIdx == -1) + { + // No point > nowMin → next is the earliest point of the next day + uint16_t earliestTime = 1440; + for (uint8_t i = 0; i < count; i++) + if ((uint16_t)times[i] <= earliestTime) { earliestTime = (uint16_t)times[i]; nextIdx = i; } + } + + if (prevIdx == nextIdx) return brights[prevIdx]; + + uint16_t prevTime = (uint16_t)times[prevIdx]; + uint16_t nextTime = (uint16_t)times[nextIdx]; + uint16_t span = (nextTime > prevTime) ? (nextTime - prevTime) : (1440 + nextTime - prevTime); + uint16_t pos = (nowMin >= prevTime) ? (nowMin - prevTime) : (1440 + nowMin - prevTime); + if (span == 0) return brights[prevIdx]; + + int32_t deltaB = (int32_t)brights[nextIdx] - (int32_t)brights[prevIdx]; + int32_t level = (int32_t)brights[prevIdx] + (deltaB * (int32_t)pos) / (int32_t)span; + if (level < 0) level = 0; + if (level > 100) level = 100; + return (uint8_t)level; +} + +void Schedule::publishStatus(bool active) +{ + if (active == _lastStatusPublished) return; + KoLED_SC_ChScheduleStatus.value(active, DPT_State); + _lastStatusPublished = active; +} diff --git a/src/Channels/Schedule.h b/src/Channels/Schedule.h new file mode 100644 index 0000000..d0608d2 --- /dev/null +++ b/src/Channels/Schedule.h @@ -0,0 +1,86 @@ +// Phase A + B — Uhrzeitabhängiges Dimmen (per-channel time-based dimming evaluator). +// MDT 4.4.14 parity: continuous tracking of an N-point linear-interpolated +// brightness curve. Stützpunkt times are either fixed clock hours OR +// sunrise/sunset-relative offsets (channel-level mode). +#pragma once + +#include "OpenKNX.h" +#include "HWDimmer/HWDimmer.h" +#include + +class SingleChannel; + +class Schedule +{ + public: + Schedule(uint8_t channelIndex, SingleChannel *pChannel, HWDimmer *pDimmer); + + void setup(); + void loop(); + + // Returns true if the KO was handled (Schedule_Active). + bool processInputKo(GroupObject &ko, int16_t relKo); + + // Manual override: pause tracking; resume after fallback minutes (if set). + void notifyManualChange(); + + // Lamp turned off externally: don't actively re-write while off, resume on next ON. + void notifyLampOff(); + + bool isActive() const { return _active && !_suspendedByManual; } + + private: + static constexpr uint8_t MAX_POINTS = 10; + + enum class OffBehavior : uint8_t + { + SequenzStoppen = 0, + Ausschalten = 1 + }; + + enum class Schaltzeiten : uint8_t + { + Uhrzeit = 0, + Sonne = 1 + }; + + // For Schaltzeiten=Uhrzeit: timeByte holds hour (0..23). + // For Schaltzeiten=Sonne: timeByte holds enum 0..33; decoded via + // isSunrise() + sunOffsetMinutes() (uses SUN_OFFSETS[idx % 17]). + struct StuetzPunkt + { + uint8_t timeByte; + uint8_t brightness; // 0..100 percent + }; + + uint8_t _channelIndex; + SingleChannel *_pChannel; + HWDimmer *_pDimmer; + + // ETS-loaded parameters + StuetzPunkt _points[MAX_POINTS]; + uint8_t _numPoints = 4; + OffBehavior _offBehavior = OffBehavior::Ausschalten; + uint16_t _fallbackMinutes = 0; + Schaltzeiten _schaltzeiten = Schaltzeiten::Uhrzeit; + + // Runtime state + bool _active = true; + bool _suspendedByManual = false; + uint32_t _suspendedSinceMillis = 0; + int16_t _lastLevelSent = -1; + uint32_t _lastEvalMillis = 0; + bool _lastStatusPublished = false; + + // Returns minutes-since-midnight (0..1439) for a stützpunkt under current + // Schaltzeiten mode. For Sonne mode, returns -1 if the sun calc isn't valid + // yet (caller must skip the point). + int16_t pointTimeMinutes(const StuetzPunkt &p) const; + + // Computes interpolated brightness 0..100 for nowMin (minutes-since-midnight), + // wrap-aware. Returns 0 if no valid points. + uint8_t computeInterpolatedLevel(uint16_t nowMin) const; + + // Send Schedule_Status KO if state changed. + void publishStatus(bool active); +}; diff --git a/src/Channels/SingleChannel.cpp b/src/Channels/SingleChannel.cpp index b8660e4..5680e96 100644 --- a/src/Channels/SingleChannel.cpp +++ b/src/Channels/SingleChannel.cpp @@ -1,4 +1,5 @@ #include "SingleChannel.h" +#include "Schedule.h" #include SingleChannel::SingleChannel(uint8_t index, HWDimmer* pDimmer, uint8_t hwChannels[1]) @@ -14,6 +15,13 @@ SingleChannel::SingleChannel(uint8_t index, HWDimmer* pDimmer, uint8_t hwChannel KoLED_SC_ChStateOnOff.value(false, DPT_State); KoLED_SC_ChBrightnessStatus.value((uint16_t)(_brightness.value() / VALUE_KNX_MULTIPLY), DPT_Scaling); + // Phase A — Uhrzeitabhängiges Dimmen: instantiate evaluator if enabled in ETS. + if (_channelActive && ParamLED_SC_ChScheduleActive) + { + _schedule = new Schedule(index, this, pDimmer); + _schedule->setup(); + } + #ifdef EXT_DEBUG_LOG logDebugP("Idx\tScNr\tFUNC\tVAL\tLkObj\tLkFnc\tFix\tval0\tval1\tval2"); for (int i = 0; i < N_SCENES; i++) @@ -23,6 +31,11 @@ SingleChannel::SingleChannel(uint8_t index, HWDimmer* pDimmer, uint8_t hwChannel #endif } +SingleChannel::~SingleChannel() +{ + delete _schedule; +} + const std::string SingleChannel::name() { return "SingleChannel"; @@ -115,6 +128,9 @@ void SingleChannel::loop() _brightness.setTargetValue(0, dimmingTimeOFF()); } } + + // Phase A — Uhrzeitabhängiges Dimmen: tick the schedule (no-op if not enabled). + if (_schedule) _schedule->loop(); } void SingleChannel::processInputKo(GroupObject& ko) @@ -131,6 +147,10 @@ void SingleChannel::processInputKo(GroupObject& ko) else relKO = -1; + // Phase A — Schedule_Active KO is owned by the Schedule evaluator + if (_schedule && _schedule->processInputKo(ko, relKO)) + return; + if (relKO == LED_SC_KoChLocking) _isLocked = ko.value(DPT_Switch); else if (!_isLocked) @@ -139,12 +159,22 @@ void SingleChannel::processInputKo(GroupObject& ko) { case LED_SC_KoChSwitch: if (!getLock()) - setSwitch(ko.value(DPT_Switch)); + { + bool on = ko.value(DPT_Switch); + setSwitch(on); + // Switch ON does not pause the schedule (it should immediately + // pick up tracking). Switch OFF pauses (lamp is off). + if (!on && _schedule) _schedule->notifyLampOff(); + } break; case LED_SC_KoChSwitchNoDim: if (!getLock()) - setSwitchNoDim(ko.value(DPT_Switch)); + { + bool on = ko.value(DPT_Switch); + setSwitchNoDim(on); + if (!on && _schedule) _schedule->notifyLampOff(); + } break; case LED_SC_KoChLocking: @@ -157,6 +187,7 @@ void SingleChannel::processInputKo(GroupObject& ko) { setBrightness((uint16_t)((uint16_t)ko.value(DPT_Scaling) * VALUE_KNX_MULTIPLY)); logDebugP("Brightness KO: %d -> BR.value %d", (uint16_t)ko.value(DPT_Scaling), (uint16_t)((uint16_t)ko.value(DPT_Scaling) * VALUE_KNX_MULTIPLY)); + if (_schedule) _schedule->notifyManualChange(); } break; @@ -172,6 +203,7 @@ void SingleChannel::processInputKo(GroupObject& ko) relDimDown(); if (tmpu16 == 0x00 || tmpu16 == 0x08) relDimStop(); + if (_schedule) _schedule->notifyManualChange(); } break; @@ -180,6 +212,7 @@ void SingleChannel::processInputKo(GroupObject& ko) { handleScene(ko.value(DPT_SceneNumber)); _sceneNumberActive = (uint8_t)ko.value(DPT_SceneNumber) + 1; + if (_schedule) _schedule->notifyManualChange(); } break; diff --git a/src/Channels/SingleChannel.h b/src/Channels/SingleChannel.h index 1259488..23173a6 100644 --- a/src/Channels/SingleChannel.h +++ b/src/Channels/SingleChannel.h @@ -5,10 +5,13 @@ #include "OpenKNX.h" #include +class Schedule; + class SingleChannel : public LightChannel { public: SingleChannel(uint8_t channel_number, HWDimmer* pDimmer, uint8_t hwChannels[1]); + ~SingleChannel(); void processInputKo(GroupObject& ko); void update(); void loop(); @@ -30,6 +33,9 @@ class SingleChannel : public LightChannel void relDimDown(); void relDimStop(); + // Phase A — Uhrzeitabhängiges Dimmen + bool isOn() { return _brightness.target() > 0; } + private: const std::string name() override; @@ -38,4 +44,6 @@ class SingleChannel : public LightChannel { BRIGHTNESS = 0 }; + + Schedule *_schedule = nullptr; }; diff --git a/src/LedModule.share.xml b/src/LedModule.share.xml index d49a3ab..9a09d10 100644 --- a/src/LedModule.share.xml +++ b/src/LedModule.share.xml @@ -163,6 +163,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -350,7 +462,7 @@ - + @@ -541,7 +653,7 @@ - + @@ -563,4 +675,4 @@ - \ No newline at end of file + diff --git a/src/Schedule.part.xml b/src/Schedule.part.xml new file mode 100644 index 0000000..9a62361 --- /dev/null +++ b/src/Schedule.part.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Single.templ.xml b/src/Single.templ.xml index 6b2a89f..dc22490 100644 --- a/src/Single.templ.xml +++ b/src/Single.templ.xml @@ -23,6 +23,13 @@ + + + + + + + @@ -86,6 +93,22 @@ + + + + + + + + + + + + + + + + @@ -119,6 +142,14 @@ + + + + + + + + @@ -133,6 +164,9 @@ + + + @@ -148,6 +182,9 @@ + + + @@ -256,7 +293,23 @@ - + + + + + + + + + + + + + + + + +