diff --git a/platformio.ini b/platformio.ini index b8c2ee3..adfc42c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -109,7 +109,7 @@ lib_deps = platform = native test_framework = unity test_build_src = yes -build_src_filter = -<*> + + + + + + +build_src_filter = -<*> + + + + + + + build_flags = -std=gnu++17 -Isrc diff --git a/src/ShadeSensors.cpp b/src/ShadeSensors.cpp new file mode 100644 index 0000000..2f99f37 --- /dev/null +++ b/src/ShadeSensors.cpp @@ -0,0 +1,77 @@ +#include "ShadeSensors.h" + +// Whether a timer started at start has run timeoutMs by now. The unsigned +// subtraction keeps working across the millis() rollover. +static bool due(uint32_t start, uint32_t now, uint32_t timeoutMs) { + return start != 0 && now - start >= timeoutMs; +} +void ShadeSensors::clear() { *this = ShadeSensors(); } +void ShadeSensors::onSensorFrame(bool wasSunny, bool wasWindy, bool sunny, bool windy, uint32_t now) { + if(sunny) { + this->noSunStart = 0; + this->noSunDone = true; + } + else { + this->sunStart = 0; + this->sunDone = true; + } + if(windy) { + this->noWindStart = 0; + this->noWindDone = true; + this->windLast = now; + } + else { + this->windStart = 0; + this->windDone = true; + } + if(sunny && !wasSunny) { + this->sunStart = now; + this->sunDone = false; + } + else if(!sunny && wasSunny) { + this->noSunStart = now; + this->noSunDone = false; + } + if(windy && !wasWindy) { + this->windStart = now; + this->windDone = false; + } + else if(!windy && wasWindy) { + this->noWindStart = now; + this->noWindDone = false; + } +} +SensorMove ShadeSensors::check(bool sunFlag, bool sunny, bool windy, uint32_t now) { + SensorMove move = SensorMove::none; + if(sunFlag) { + if(sunny && !windy) { + // Sunny and calm, so the shade should be extended. + if(this->noWindDone && !this->sunDone && due(this->sunStart, now, SOMFY_SUN_TIMEOUT)) { + this->sunDone = true; + move = SensorMove::extend; + } + if(!this->noWindDone && due(this->noWindStart, now, SOMFY_NO_WIND_TIMEOUT)) { + this->noWindDone = true; + move = SensorMove::extend; + } + } + if(!sunny && !this->noSunDone && due(this->noSunStart, now, SOMFY_NO_SUN_TIMEOUT)) { + this->noSunDone = true; + move = SensorMove::retract; + } + } + if(windy && !this->windDone && due(this->windStart, now, SOMFY_WIND_TIMEOUT)) { + this->windDone = true; + move = SensorMove::retract; + } + return move; +} +SensorMove ShadeSensors::onSunFlag(bool sunny, bool windy) const { + if(windy) return SensorMove::none; + if(sunny && this->sunDone) return SensorMove::extend; + if(!sunny && this->noSunDone) return SensorMove::retract; + return SensorMove::none; +} +bool ShadeSensors::windBlocksDown(uint32_t now) const { + return this->windLast != 0 && now - this->windLast < SOMFY_NO_WIND_REMOTE_TIMEOUT; +} diff --git a/src/ShadeSensors.h b/src/ShadeSensors.h new file mode 100644 index 0000000..2382c65 --- /dev/null +++ b/src/ShadeSensors.h @@ -0,0 +1,52 @@ +#ifndef SHADE_SENSORS_H +#define SHADE_SENSORS_H +#include + +// Sun and wind automation for a shade with a sun sensor. Kept free of the +// shade, radio and network so it can be unit tested on the PC +// (pio test -e native). + +#define SECS_TO_MILLIS(x) ((x) * 1000) +#define MINS_TO_MILLIS(x) SECS_TO_MILLIS((x) * 60) + +// How long the sensor must report a change before the shade follows it. +#define SOMFY_SUN_TIMEOUT MINS_TO_MILLIS(2) +#define SOMFY_NO_SUN_TIMEOUT MINS_TO_MILLIS(20) +#define SOMFY_WIND_TIMEOUT SECS_TO_MILLIS(2) +#define SOMFY_NO_WIND_TIMEOUT MINS_TO_MILLIS(12) +// A remote Down is ignored this long after the last windy sensor frame. +#define SOMFY_NO_WIND_REMOTE_TIMEOUT SECS_TO_MILLIS(30) + +// What the weather tells a shade to do. +enum class SensorMove : uint8_t { + none, + extend, // To the favorite, or all the way down when there is none. + retract // All the way up. +}; + +// The sensor timers of one shade. Times are millis(); a start of 0 means the +// timer isn't running. +struct ShadeSensors { + uint32_t sunStart = 0; + uint32_t noSunStart = 0; + uint32_t windStart = 0; + uint32_t noWindStart = 0; + uint32_t windLast = 0; // Last sensor frame that reported wind. + bool sunDone = true; + bool noSunDone = true; + bool windDone = true; + bool noWindDone = true; + void clear(); + // A sensor frame arrived. was* is what the shade knew before it, sunny and + // windy what the frame reports. + void onSensorFrame(bool wasSunny, bool wasWindy, bool sunny, bool windy, uint32_t now); + // Fires the timers that are due, each once. Sun only moves the shade while + // the sun flag is on; wind always does. + SensorMove check(bool sunFlag, bool sunny, bool windy, uint32_t now); + // Where turning the sun flag on sends the shade right away. Nothing while + // it's windy or while a timer is still running. + SensorMove onSunFlag(bool sunny, bool windy) const; + // Whether a remote Down is ignored because the sensor reported wind lately. + bool windBlocksDown(uint32_t now) const; +}; +#endif diff --git a/src/Somfy.h b/src/Somfy.h index 793d61c..aa97682 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -11,22 +11,13 @@ #define SOMFY_MAX_ROOMS 16 #define SOMFY_MAX_REPEATERS 7 -#define SECS_TO_MILLIS(x) ((x) * 1000) -#define MINS_TO_MILLIS(x) SECS_TO_MILLIS((x) * 60) - -#define SOMFY_SUN_TIMEOUT MINS_TO_MILLIS(2) -#define SOMFY_NO_SUN_TIMEOUT MINS_TO_MILLIS(20) - -#define SOMFY_WIND_TIMEOUT SECS_TO_MILLIS(2) -#define SOMFY_NO_WIND_TIMEOUT MINS_TO_MILLIS(12) -#define SOMFY_NO_WIND_REMOTE_TIMEOUT SECS_TO_MILLIS(30) - #include "SomfyFrame.h" #include "TxBuffer.h" #include "RadioScheduler.h" #include "ShadeTypes.h" #include "ShadeMotion.h" +#include "ShadeSensors.h" #include "Transceiver.h" enum class somfy_flags_t : byte { @@ -127,15 +118,7 @@ class SomfyShade : public SomfyRemote { uint8_t shadeId = 255; uint64_t moveStart = 0; uint64_t tiltStart = 0; - uint64_t noSunStart = 0; - uint64_t sunStart = 0; - uint64_t windStart = 0; - uint64_t windLast = 0; - uint64_t noWindStart = 0; - bool noSunDone = true; - bool sunDone = true; - bool windDone = true; - bool noWindDone = true; + ShadeSensors sensors; float startPos = 0.0f; float startTiltPos = 0.0f; bool settingMyPos = false; diff --git a/src/SomfyShade.cpp b/src/SomfyShade.cpp index c49c837..928a4b1 100644 --- a/src/SomfyShade.cpp +++ b/src/SomfyShade.cpp @@ -19,15 +19,7 @@ void SomfyShade::clear() { this->setRemoteAddress(0); this->moveStart = 0; this->tiltStart = 0; - this->noSunStart = 0; - this->sunStart = 0; - this->windStart = 0; - this->windLast = 0; - this->noWindStart = 0; - this->noSunDone = true; - this->sunDone = true; - this->windDone = true; - this->noWindDone = true; + this->sensors.clear(); this->startPos = 0.0f; this->startTiltPos = 0.0f; this->settingMyPos = false; @@ -160,49 +152,16 @@ void SomfyShade::checkMovement() { case TxHoldCheck::none: break; } - if (sunFlag) { - if (isSunny && !isWindy) { // It is sunny and there is no wind so we should be extended - if (this->noWindDone - && !this->sunDone - && this->sunStart - && (curTime - this->sunStart) >= SOMFY_SUN_TIMEOUT) - { - this->p_target(this->myPos >= 0 ? this->myPos : 100.0f); - //this->target = this->myPos >= 0 ? this->myPos : 100.0f; - this->sunDone = true; - ESP_LOGD(TAG, "[%u] Sun -> done", this->shadeId); - } - if (!this->noWindDone - && this->noWindStart - && (curTime - this->noWindStart) >= SOMFY_NO_WIND_TIMEOUT) - { - this->p_target(this->myPos >= 0 ? this->myPos : 100.0f); - //this->target = this->myPos >= 0 ? this->myPos : 100.0f; - this->noWindDone = true; - ESP_LOGD(TAG, "[%u] No Wind -> done", this->shadeId); - } - } - if (!isSunny - && !this->noSunDone - && this->noSunStart - && (curTime - this->noSunStart) >= SOMFY_NO_SUN_TIMEOUT) - { + switch(this->sensors.check(sunFlag, isSunny, isWindy, (uint32_t)curTime)) { + case SensorMove::extend: + this->p_target(this->myPos >= 0 ? this->myPos : 100.0f); + break; + case SensorMove::retract: if(this->tiltType == tilt_types::tiltonly) this->p_tiltTarget(0.0f); this->p_target(0.0f); - this->noSunDone = true; - ESP_LOGD(TAG, "[%u] No Sun -> done", this->shadeId); - } - } - - if (isWindy - && !this->windDone - && this->windStart - && (curTime - this->windStart) >= SOMFY_WIND_TIMEOUT) - { - if(this->tiltType == tilt_types::tiltonly) this->p_tiltTarget(0.0f); - this->p_target(0.0f); - this->windDone = true; - ESP_LOGD(TAG, "[%u] Wind -> done", this->shadeId); + break; + case SensorMove::none: + break; } if(!tilt_first && this->direction > 0) { @@ -616,51 +575,7 @@ void SomfyShade::processFrame(somfy_frame_t &frame, bool internal) { this->flags &= ~(static_cast(somfy_flags_t::DemoMode)); const bool isSunny = this->flags & static_cast(somfy_flags_t::Sunny); const bool isWindy = this->flags & static_cast(somfy_flags_t::Windy); - if (isSunny) - { - this->noSunStart = 0; - this->noSunDone = true; - } - else - { - this->sunStart = 0; - this->sunDone = true; - } - if (isWindy) - { - this->noWindStart = 0; - this->noWindDone = true; - this->windLast = curTime; - } - else - { - this->windStart = 0; - this->windDone = true; - } - if (isSunny && !wasSunny) - { - this->sunStart = curTime; - this->sunDone = false; - ESP_LOGD(TAG, "[%u] Sun -> start", this->shadeId); - } - else if (!isSunny && wasSunny) - { - this->noSunStart = curTime; - this->noSunDone = false; - ESP_LOGD(TAG, "[%u] No Sun -> start", this->shadeId); - } - if (isWindy && !wasWindy) - { - this->windStart = curTime; - this->windDone = false; - ESP_LOGD(TAG, "[%u] Wind -> start", this->shadeId); - } - else if (!isWindy && wasWindy) - { - this->noWindStart = curTime; - this->noWindDone = false; - ESP_LOGD(TAG, "[%u] No Wind -> start", this->shadeId); - } + this->sensors.onSensorFrame(wasSunny, wasWindy, isSunny, isWindy, (uint32_t)curTime); this->emitState(); somfy.updateGroupFlags(); } @@ -691,23 +606,24 @@ void SomfyShade::processFrame(somfy_frame_t &frame, bool internal) { if(this->lastFrame.rollingCode & 0x8000) return; // Some sensors send bogus frames with a rollingCode >= 32768 that cause them to change the state. { const bool isWindy = this->flags & static_cast(somfy_flags_t::Windy); + const bool isSunny = this->flags & static_cast(somfy_flags_t::Sunny); //this->flags |= static_cast(somfy_flags_t::SunFlag); this->p_sunFlag(true); - if (!isWindy) - { - const bool isSunny = this->flags & static_cast(somfy_flags_t::Sunny); - if (isSunny && this->sunDone) { + switch(this->sensors.onSunFlag(isSunny, isWindy)) { + case SensorMove::extend: if(this->tiltType == tilt_types::tiltonly) this->p_tiltTarget(this->myTiltPos >= 0 ? this->myTiltPos : 100.0f); else this->p_target(this->myPos >= 0 ? this->myPos : 100.0f); - } - else if (!isSunny && this->noSunDone) { + break; + case SensorMove::retract: if(this->tiltType == tilt_types::tiltonly) this->p_tiltTarget(0.0f); else this->p_target(0.0f); - } + break; + case SensorMove::none: + break; } somfy.isDirty = true; this->emitState(); @@ -755,7 +671,7 @@ void SomfyShade::processFrame(somfy_frame_t &frame, bool internal) { this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); return; } - if (!this->windLast || (curTime - this->windLast) >= SOMFY_NO_WIND_REMOTE_TIMEOUT) { + if (!this->sensors.windBlocksDown((uint32_t)curTime)) { if(this->tiltType == tilt_types::tiltmotor || this->tiltType == tilt_types::euromode) { // Wait another half seccond just in case we are potentially processing a tilt. if(!internal) this->lastFrame.await = curTime + 500; @@ -967,7 +883,7 @@ void SomfyShade::processInternalCommand(somfy_commands cmd, uint8_t repeat) { } break; case somfy_commands::Down: - if (!this->windLast || (curTime - this->windLast) >= SOMFY_NO_WIND_REMOTE_TIMEOUT) { + if (!this->sensors.windBlocksDown((uint32_t)curTime)) { if(this->tiltType == tilt_types::tiltmotor) { if(repeat >= TILT_REPEATS) this->p_tiltTarget(100.0f); @@ -1088,15 +1004,18 @@ void SomfyShade::processInternalCommand(somfy_commands cmd, uint8_t repeat) { case somfy_commands::SunFlag: if(this->hasSunSensor()) { const bool isWindy = this->flags & static_cast(somfy_flags_t::Windy); + const bool isSunny = this->flags & static_cast(somfy_flags_t::Sunny); this->p_sunFlag(true); //this->flags |= static_cast(somfy_flags_t::SunFlag); - if (!isWindy) - { - const bool isSunny = this->flags & static_cast(somfy_flags_t::Sunny); - if (isSunny && this->sunDone) + switch(this->sensors.onSunFlag(isSunny, isWindy)) { + case SensorMove::extend: this->p_target(this->myPos >= 0 ? this->myPos : 100.0f); - else if (!isSunny && this->noSunDone) + break; + case SensorMove::retract: this->p_target(0.0f); + break; + case SensorMove::none: + break; } somfy.isDirty = true; this->emitState(); diff --git a/test/test_sensors/test_sensors.cpp b/test/test_sensors/test_sensors.cpp new file mode 100644 index 0000000..e58addf --- /dev/null +++ b/test/test_sensors/test_sensors.cpp @@ -0,0 +1,159 @@ +#include +#include "ShadeSensors.h" + +#define ASSERT_MOVE(expected, actual) TEST_ASSERT_EQUAL_UINT8(static_cast(expected), static_cast(actual)) + +static const bool SUN_FLAG = true; +static const uint32_t T0 = 1000; + +void setUp() {} +void tearDown() {} + +// Sensors that have just reported sun, with no wind, at T0. +static ShadeSensors sunnyAt(uint32_t now) { + ShadeSensors s; + s.onSensorFrame(false, false, true, false, now); + return s; +} + +/********************************************************************* + * sun + ********************************************************************/ +static void test_sun_extends_after_its_timeout() { + ShadeSensors s = sunnyAt(T0); + ASSERT_MOVE(SensorMove::none, s.check(SUN_FLAG, true, false, T0 + SOMFY_SUN_TIMEOUT - 1)); + ASSERT_MOVE(SensorMove::extend, s.check(SUN_FLAG, true, false, T0 + SOMFY_SUN_TIMEOUT)); +} +static void test_sun_extends_once() { + ShadeSensors s = sunnyAt(T0); + ASSERT_MOVE(SensorMove::extend, s.check(SUN_FLAG, true, false, T0 + SOMFY_SUN_TIMEOUT)); + ASSERT_MOVE(SensorMove::none, s.check(SUN_FLAG, true, false, T0 + SOMFY_SUN_TIMEOUT + 60000)); +} +static void test_sun_needs_the_sun_flag() { + ShadeSensors s = sunnyAt(T0); + ASSERT_MOVE(SensorMove::none, s.check(!SUN_FLAG, true, false, T0 + SOMFY_SUN_TIMEOUT)); + // The timer is still due once the flag goes on. + ASSERT_MOVE(SensorMove::extend, s.check(SUN_FLAG, true, false, T0 + SOMFY_SUN_TIMEOUT + 1)); +} +static void test_sun_does_not_extend_in_wind() { + ShadeSensors s = sunnyAt(T0); + ASSERT_MOVE(SensorMove::none, s.check(SUN_FLAG, true, true, T0 + SOMFY_SUN_TIMEOUT)); +} +static void test_steady_sun_does_not_restart_the_timer() { + ShadeSensors s = sunnyAt(T0); + s.onSensorFrame(true, false, true, false, T0 + 60000); + ASSERT_MOVE(SensorMove::extend, s.check(SUN_FLAG, true, false, T0 + SOMFY_SUN_TIMEOUT)); +} +static void test_no_sun_retracts_after_its_timeout() { + ShadeSensors s = sunnyAt(T0); + s.onSensorFrame(true, false, false, false, T0 + 10); + ASSERT_MOVE(SensorMove::none, s.check(SUN_FLAG, false, false, T0 + 10 + SOMFY_NO_SUN_TIMEOUT - 1)); + ASSERT_MOVE(SensorMove::retract, s.check(SUN_FLAG, false, false, T0 + 10 + SOMFY_NO_SUN_TIMEOUT)); + ASSERT_MOVE(SensorMove::none, s.check(SUN_FLAG, false, false, T0 + 10 + SOMFY_NO_SUN_TIMEOUT + 1)); +} +static void test_cloud_cancels_the_sun_timer() { + ShadeSensors s = sunnyAt(T0); + s.onSensorFrame(true, false, false, false, T0 + 10); + s.onSensorFrame(false, false, true, false, T0 + 20); + // Sun came back before the no-sun timeout: neither fires early. + ASSERT_MOVE(SensorMove::none, s.check(SUN_FLAG, true, false, T0 + 20 + SOMFY_SUN_TIMEOUT - 1)); + ASSERT_MOVE(SensorMove::extend, s.check(SUN_FLAG, true, false, T0 + 20 + SOMFY_SUN_TIMEOUT)); +} + +/********************************************************************* + * wind + ********************************************************************/ +static void test_wind_retracts_without_the_sun_flag() { + ShadeSensors s; + s.onSensorFrame(false, false, false, true, T0); + ASSERT_MOVE(SensorMove::none, s.check(!SUN_FLAG, false, true, T0 + SOMFY_WIND_TIMEOUT - 1)); + ASSERT_MOVE(SensorMove::retract, s.check(!SUN_FLAG, false, true, T0 + SOMFY_WIND_TIMEOUT)); + ASSERT_MOVE(SensorMove::none, s.check(!SUN_FLAG, false, true, T0 + SOMFY_WIND_TIMEOUT + 1)); +} +static void test_wind_dying_down_extends_again_in_sun() { + ShadeSensors s = sunnyAt(T0); + ASSERT_MOVE(SensorMove::extend, s.check(SUN_FLAG, true, false, T0 + SOMFY_SUN_TIMEOUT)); + const uint32_t windy = T0 + SOMFY_SUN_TIMEOUT + 10; + s.onSensorFrame(true, false, true, true, windy); + ASSERT_MOVE(SensorMove::retract, s.check(SUN_FLAG, true, true, windy + SOMFY_WIND_TIMEOUT)); + const uint32_t calm = windy + SOMFY_WIND_TIMEOUT + 10; + s.onSensorFrame(true, true, true, false, calm); + ASSERT_MOVE(SensorMove::none, s.check(SUN_FLAG, true, false, calm + SOMFY_NO_WIND_TIMEOUT - 1)); + ASSERT_MOVE(SensorMove::extend, s.check(SUN_FLAG, true, false, calm + SOMFY_NO_WIND_TIMEOUT)); +} +static void test_wind_blocks_a_remote_down_for_a_while() { + ShadeSensors s; + TEST_ASSERT_FALSE(s.windBlocksDown(T0)); + s.onSensorFrame(false, false, false, true, T0); + TEST_ASSERT_TRUE(s.windBlocksDown(T0 + SOMFY_NO_WIND_REMOTE_TIMEOUT - 1)); + TEST_ASSERT_FALSE(s.windBlocksDown(T0 + SOMFY_NO_WIND_REMOTE_TIMEOUT)); +} +static void test_every_windy_frame_extends_the_down_block() { + ShadeSensors s; + s.onSensorFrame(false, false, false, true, T0); + s.onSensorFrame(false, true, false, true, T0 + 20000); + TEST_ASSERT_TRUE(s.windBlocksDown(T0 + SOMFY_NO_WIND_REMOTE_TIMEOUT + 1)); +} + +/********************************************************************* + * sun flag turned on + ********************************************************************/ +static void test_sun_flag_acts_on_a_finished_timer() { + ShadeSensors s; + // Nothing pending: the shade goes where the weather says. + ASSERT_MOVE(SensorMove::extend, s.onSunFlag(true, false)); + ASSERT_MOVE(SensorMove::retract, s.onSunFlag(false, false)); +} +static void test_sun_flag_waits_for_a_running_timer() { + ShadeSensors s = sunnyAt(T0); + ASSERT_MOVE(SensorMove::none, s.onSunFlag(true, false)); + s.onSensorFrame(true, false, false, false, T0 + 10); + ASSERT_MOVE(SensorMove::none, s.onSunFlag(false, false)); +} +static void test_sun_flag_does_nothing_in_wind() { + ShadeSensors s; + ASSERT_MOVE(SensorMove::none, s.onSunFlag(true, true)); + ASSERT_MOVE(SensorMove::none, s.onSunFlag(false, true)); +} + +/********************************************************************* + * clear and millis() rollover + ********************************************************************/ +static void test_clear_forgets_pending_timers() { + ShadeSensors s = sunnyAt(T0); + s.onSensorFrame(false, false, false, true, T0); + s.clear(); + ASSERT_MOVE(SensorMove::none, s.check(SUN_FLAG, true, false, T0 + SOMFY_NO_SUN_TIMEOUT)); + TEST_ASSERT_FALSE(s.windBlocksDown(T0 + 1)); +} +static void test_timers_run_across_a_millis_rollover() { + const uint32_t start = 0xFFFFFFFFUL - 1000; + ShadeSensors s = sunnyAt(start); + ASSERT_MOVE(SensorMove::none, s.check(SUN_FLAG, true, false, start + 2000)); + ASSERT_MOVE(SensorMove::extend, s.check(SUN_FLAG, true, false, start + SOMFY_SUN_TIMEOUT)); + s.onSensorFrame(false, false, false, true, start); + TEST_ASSERT_TRUE(s.windBlocksDown(start + 2000)); +} + +int main(int argc, char **argv) { + (void)argc; + (void)argv; + UNITY_BEGIN(); + RUN_TEST(test_sun_extends_after_its_timeout); + RUN_TEST(test_sun_extends_once); + RUN_TEST(test_sun_needs_the_sun_flag); + RUN_TEST(test_sun_does_not_extend_in_wind); + RUN_TEST(test_steady_sun_does_not_restart_the_timer); + RUN_TEST(test_no_sun_retracts_after_its_timeout); + RUN_TEST(test_cloud_cancels_the_sun_timer); + RUN_TEST(test_wind_retracts_without_the_sun_flag); + RUN_TEST(test_wind_dying_down_extends_again_in_sun); + RUN_TEST(test_wind_blocks_a_remote_down_for_a_while); + RUN_TEST(test_every_windy_frame_extends_the_down_block); + RUN_TEST(test_sun_flag_acts_on_a_finished_timer); + RUN_TEST(test_sun_flag_waits_for_a_running_timer); + RUN_TEST(test_sun_flag_does_nothing_in_wind); + RUN_TEST(test_clear_forgets_pending_timers); + RUN_TEST(test_timers_run_across_a_millis_rollover); + return UNITY_END(); +}