diff --git a/GLOSSARY.md b/GLOSSARY.md index 70964f6..5736cb0 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -70,6 +70,16 @@ What shades and groups share about how their frames go on air: flip commands, bi **Flags** The byte a shade is saved and sent with. Its sun sensor, light and simulated My bits are settings; its sun flag, sunny, windy and demo mode bits are state. A group's flags are its shades' flags together. +**ShadeBehaviour** +What a shade does with frames, commands, group commands and radio events, and where that leaves it: positions, targets, directions, the favorite and the sensor state. +_Avoid_: shade logic, motion (`ShadeMotion` is the position maths it uses) + +**MotorLink** +The seam a shade's commands reach its motor through, radio or GPIO. The shade hears its own frame; every other shade hears it through the link. + +**ShadeEvents** +The seam through which a shade's behaviour reports what happened: changes for the change fan-out, commands for clients, a save wanted, and sensor flags for the groups. + ## Entry points and outputs **Entry point** diff --git a/docs/SHADE_BEHAVIOUR_PLAN.md b/docs/SHADE_BEHAVIOUR_PLAN.md index d4b7910..8a52f7d 100644 --- a/docs/SHADE_BEHAVIOUR_PLAN.md +++ b/docs/SHADE_BEHAVIOUR_PLAN.md @@ -1,6 +1,6 @@ # Plan: deepen the shade behaviour -**Status:** design settled (rounds 1–3, see "Decisions"), apart from the reconcile order, which waits for PR B's pinning tests. PR A (`refactor/shade-settings`) is built; PR B is next. +**Status:** design settled (see "Decisions"). PR A is #46. PR B (`refactor/shade-behaviour`) is built: the behaviour is in `ShadeBehaviour`, pinned by `test_shadebehaviour`. The reconcile order is the open question (below). **Written:** 2026-10-16, on top of `refactor/split-transports` (PR #44, the top of the LoopQueue stack #40–#44, which is not yet merged or run on a device). Line numbers refer to that branch. **Start here:** read [GLOSSARY.md](../GLOSSARY.md), [ADR-0002](adr/0002-loop-task-is-the-only-writer-of-the-shade-model.md) and [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md) first. Their decisions are not reopened here. @@ -84,7 +84,15 @@ The decisions are the user's. Each question comes with a recommended answer, and ### Still open -- **Reconcile: what is decided first, and in what order.** Asked once PR B's pinning tests have listed every "today: …" disagreement. Known candidates: `settingPos` on arrival (may send extra stops), Up/Down/My on tilt-only shades, the self-echo (Q5), and merging group commands into the frame rule (Q8). +- **Reconcile: what is decided first, and in what order.** The candidates, each pinned by a "today:" test in `test_shadebehaviour` unless marked: + - `settingPos` is never cleared on a lift arrival, so a later step also sends a stop (`test_today_a_step_after_move_to_target_sends_a_stop`). + - Up and My on tilt-only shades differ between the web, a remote and a group (`test_today_up_on_tilt_only_*`, `test_today_my_while_tilt_only_moves`). + - An integrated tilt sent to a lift target without a tilt turns its slats back with an Up after the stop (`test_today_integrated_tilt_turns_back_after_a_lift_target`). + - A remote step uses the step size this shade last *sent*, because `copy()` never copies `stepSize` (`test_remote_step_uses_the_step_size_last_sent`). + - A group's SunFlag or Flag doesn't make other groups work their flags out again (`test_group_sun_flag_needs_a_sensor`). + - The self-echo (Q5) and merging group commands into the frame rule (Q8). + - `lastFrame` doing two jobs (the Q6 revisit). + - The 500 ms long-press deadline is compared as `now > await`, which isn't wrap-safe (not tested). ## Constraints @@ -135,3 +143,12 @@ The decisions are the user's. Each question comes with a recommended answer, and - A shade's byte is `getFlags()` (put together) and `setFlags()` (split), used by ConfigFile, JSON, MQTT, events and group flags. - Groups keep their own `flags` byte (the OR of their shades), and `SomfyRemote::getFlags()` is virtual for `sendSensorCommand`. - `test_shadesettings` checks that all 256 byte values come back unchanged. + +**During PR B (2026-10-16):** + +- **Q6 revisited: `lastFrame` is shared.** The module holds a reference to `SomfyRemote::lastFrame` instead of its own copy. A copy would have to mirror five writes exactly: each send overwrites it; the shade's own echo then counts as a repeat; `repeatFrame` adds the queued repeats; a sensor send overwrites it; and `copy()` never copies `stepSize` or `await`. The user chose sharing. Splitting the two jobs is a reconcile candidate. +- **Q12 refined.** `MotorLink::send` returns `{sent, ticket}`; the frame as sent is in the shared `lastFrame`. `SomfyRemote::sendCommandAfter(…, hearsItself = true)` leaves the sender out of the echo and the radio hold; `ShadeBehaviour::transmit` does both for itself. +- **Q7 and Q9 refined: one output seam that fires on the spot.** `ShadeEvents` carries `onChanged` (the change bits), `onCommand`, `onSaveWanted` and `onSensorFlags`. Nothing is collected and read back after each call, so the timing is today's. `onSensorFlags` fires exactly where `somfy.updateGroupFlags()` was called, not on every sensor-bit change. +- **`onTxEvent` takes no `now`:** the event carries its own time. +- **A known ordering change:** when a simMy shade's Favorite or My sends again from inside its own echo, a shade linked to it now hears the outer frame before the inner one. Before, it depended on the two shades' slots. Nothing else changes order. +- **Flash:** `esp32dev` `firmware.bin` grows 1,397,776 → 1,399,776 B (+2,000 B, 88.6%), mostly the virtual calls through the two seams. diff --git a/platformio.ini b/platformio.ini index 6f9e89a..542026f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -113,7 +113,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/ConfigFile.cpp b/src/ConfigFile.cpp index 05622c6..ea4cc32 100644 --- a/src/ConfigFile.cpp +++ b/src/ConfigFile.cpp @@ -835,27 +835,29 @@ bool ShadeConfigFile::readShadeRecord(SomfyShade *shade) { shade->lastRollingCode = max(rc, shade->lastRollingCode); if(rc < shade->lastRollingCode) pref.putUShort(shade->getRemotePrefId(), shade->lastRollingCode); } + float myPos = shade->state().myPos; + float myTiltPos = shade->state().myTiltPos; if(this->header.version < 4) - shade->myPos = static_cast(this->readUInt8(255)); + myPos = static_cast(this->readUInt8(255)); else { - shade->myPos = this->readFloat(-1); - shade->myTiltPos = this->readFloat(-1); + myPos = this->readFloat(-1); + myTiltPos = this->readFloat(-1); } - if(shade->myPos > 100 || shade->myPos < 0) shade->myPos = -1; - if(shade->myTiltPos > 100 || shade->myTiltPos < 0) shade->myTiltPos = -1; - shade->currentPos = this->readFloat(0); - shade->currentTiltPos = this->readFloat(0); + if(myPos > 100 || myPos < 0) myPos = -1; + if(myTiltPos > 100 || myTiltPos < 0) myTiltPos = -1; + float currentPos = this->readFloat(0); + float currentTiltPos = this->readFloat(0); if(shade->tiltType == tilt_types::none || shade->shadeType != shade_types::blind) { - shade->myTiltPos = -1; - shade->currentTiltPos = 0; + myTiltPos = -1; + currentTiltPos = 0; shade->tiltType = tilt_types::none; } if(this->header.version < 3) { - shade->currentPos = shade->currentPos * 100; - shade->currentTiltPos = shade->currentTiltPos * 100; + currentPos = currentPos * 100; + currentTiltPos = currentTiltPos * 100; } - shade->target = floor(shade->currentPos); - shade->tiltTarget = floor(shade->currentTiltPos); + // The targets are where the shade is. + shade->restore(currentPos, currentTiltPos, myPos, myTiltPos); if(this->header.version >= 9) shade->flipCommands = this->readBool(false); if(this->header.version >= 10) shade->flipPosition = this->readBool(false); if(this->header.version >= 12) shade->repeats = this->readUInt8(1); @@ -871,7 +873,7 @@ bool ShadeConfigFile::readShadeRecord(SomfyShade *shade) { shade->gpioFlags = this->readUInt8(shade->gpioFlags); if(shade->getShadeId() == 255) shade->clear(); else if(shade->tiltType == tilt_types::tiltonly) { - shade->myPos = shade->currentPos = shade->target = 100.0f; + shade->restore(100.0f, shade->state().currentTiltPos, 100.0f, shade->state().myTiltPos); } pref.end(); if(shade->proto == radio_proto::GP_Relay || shade->proto == radio_proto::GP_Remote) { @@ -973,8 +975,7 @@ bool ShadeConfigFile::writeRoomRecord(SomfyRoom *room) { } bool ShadeConfigFile::writeShadeRecord(SomfyShade *shade) { if(shade->tiltType == tilt_types::none || shade->shadeType != shade_types::blind) { - shade->myTiltPos = -1; - shade->currentTiltPos = 0; + shade->clearTilt(); shade->tiltType = tilt_types::none; } this->writeUInt8(shade->getShadeId()); @@ -996,10 +997,10 @@ bool ShadeConfigFile::writeShadeRecord(SomfyShade *shade) { this->writeUInt16(shade->lastRollingCode); if(shade->getShadeId() != 255) { this->writeUInt8(shade->getFlags()); - this->writeFloat(shade->myPos, 5); - this->writeFloat(shade->myTiltPos, 5); - this->writeFloat(shade->currentPos, 5); - this->writeFloat(shade->currentTiltPos, 5); + this->writeFloat(shade->state().myPos, 5); + this->writeFloat(shade->state().myTiltPos, 5); + this->writeFloat(shade->state().currentPos, 5); + this->writeFloat(shade->state().currentTiltPos, 5); } else { // Make sure that we write cleared values when the shade is deleted. diff --git a/src/ShadeBehaviour.cpp b/src/ShadeBehaviour.cpp new file mode 100644 index 0000000..fc8c461 --- /dev/null +++ b/src/ShadeBehaviour.cpp @@ -0,0 +1,1215 @@ +#include "esp_log.h" +#include "ShadeBehaviour.h" + +static const char *TAG = "Somfy"; + +#define SETMY_REPEATS 35 +#define TILT_REPEATS 15 + +// How long a shade's simulated movement waits for its command to go on air +// before it starts anyway in case the event was lost. +#define TX_PENDING_TIMEOUT 30000 + +ShadeBehaviour::ShadeBehaviour(const ShadeSettings &settings, const RemoteSettings &remote, somfy_frame_t &lastFrame, + MotorLink &link, ShadeEvents &events, const uint8_t &shadeId, const char *name) + : settings(settings), remote(remote), lastFrame(lastFrame), link(link), events(events), shadeId(shadeId), name(name) {} +void ShadeBehaviour::clear() { + this->moveStart = 0; + this->tiltStart = 0; + this->sensors.clear(); + this->startPos = 0.0f; + this->startTiltPos = 0.0f; + this->settingMyPos = false; + this->settingPos = false; + this->settingTiltPos = false; + this->awaitMy = 0; + this->s.currentPos = 0.0f; + this->s.currentTiltPos = 0.0f; + this->s.direction = 0; + this->s.tiltDirection = 0; + this->s.target = 0.0f; + this->s.tiltTarget = 0.0f; + this->s.myPos = -1.0f; + this->s.myTiltPos = -1.0f; +} +void ShadeBehaviour::setPosition(float pos, float tilt) { + // Through the setters, so MQTT hears about it too. + if(pos >= 0.0f) { + this->p_target(pos); + this->p_currentPos(pos); + } + if(tilt >= 0.0f) { + this->p_tiltTarget(tilt); + this->p_currentTiltPos(tilt); + } +} +void ShadeBehaviour::restore(float pos, float tiltPos, float myPos, float myTiltPos) { + this->s.currentPos = pos; + this->s.currentTiltPos = tiltPos; + this->s.myPos = myPos; + this->s.myTiltPos = myTiltPos; + this->s.target = floor(pos); + this->s.tiltTarget = floor(tiltPos); +} +void ShadeBehaviour::clearTilt() { + this->s.myTiltPos = -1; + this->s.currentTiltPos = 0; +} +somfy_commands ShadeBehaviour::transformCommand(somfy_commands cmd) const { return this->remote.flipCommands ? flipCommand(cmd) : cmd; } +send_result_t ShadeBehaviour::transmit(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority, uint32_t now) { + const send_result_t result = this->link.send(cmd, repeat, stepSize, delayMs, priority); + if(!result.sent) return result; + // The link has put the frame in lastFrame and every other shade has heard + // it. The shade hears it too, as if a remote had sent it. + this->onFrame(this->lastFrame, true, now); + // An idle shade this sets moving waits for the frame to go on air. Stops + // are sent ahead of everything else so they don't need to. + if(result.ticket != 0 && !priority && startsMovement(this->lastFrame.cmd)) this->onTxQueued(result.ticket, now); + return result; +} +bool ShadeBehaviour::isAtTarget() const { + float epsilon = .00001; + if(this->settings.tiltType == tilt_types::tiltonly) return fabs(this->s.currentTiltPos - this->s.tiltTarget) < epsilon; + else if(this->settings.tiltType == tilt_types::none) return fabs(this->s.currentPos - this->s.target) < epsilon; + return fabs(this->s.currentPos - this->s.target) < epsilon && fabs(this->s.currentTiltPos - this->s.tiltTarget) < epsilon; +} +void ShadeBehaviour::tick(uint32_t now) { + const uint32_t curTime = now; + const bool sunFlag = this->s.stateFlags & static_cast(somfy_flags_t::SunFlag); + const bool isSunny = this->s.stateFlags & static_cast(somfy_flags_t::Sunny); + const bool isWindy = this->s.stateFlags & static_cast(somfy_flags_t::Windy); + // We need to first evaluate the sensor flags as these could be triggering movement from previous sensor inputs. So + // we must check this before setting the directional items or it will not get processed until the next loop. + int32_t downTime = (int32_t)this->settings.downTime; + int32_t upTime = (int32_t)this->settings.upTime; + int32_t tiltTime = (int32_t)this->settings.tiltTime; + if(this->settings.shadeType == shade_types::drycontact || this->settings.shadeType == shade_types::drycontact2) downTime = upTime = tiltTime = 1; + + + // We are checking movement for essentially 3 types of motors. + // If this is an integrated tilt we need to first tilt in the direction we are moving then move. We know + // what needs to be done by the tilt type. Set a tilt first flag to indicate whether we should be tilting or + // moving. If this is only a tilt action then the regular tilt action should operate fine. + int8_t currDir = this->s.direction; + int8_t currTiltDir = this->s.tiltDirection; + this->p_direction(this->s.currentPos == this->s.target ? 0 : this->s.currentPos > this->s.target ? -1 : 1); + bool tilt_first = this->settings.tiltType == tilt_types::integrated && ((this->s.direction == -1 && this->s.currentTiltPos != 0.0f) || (this->s.direction == 1 && this->s.currentTiltPos != 100.0f)); + + this->p_tiltDirection(this->s.currentTiltPos == this->s.tiltTarget ? 0 : this->s.currentTiltPos > this->s.tiltTarget ? -1 : 1); + if(tilt_first) this->p_tiltDirection(this->s.direction); + else if(this->s.direction != 0) this->p_tiltDirection(0); + uint8_t currPos = floor(this->s.currentPos); + uint8_t currTiltPos = floor(this->s.currentTiltPos); + if(this->s.direction != 0) this->s.lastMovement = this->s.direction; + switch(this->tx.hold.check(this->s.direction != 0 || this->s.tiltDirection != 0, (uint32_t)curTime, TX_PENDING_TIMEOUT)) { + case TxHoldCheck::holding: + // The command that set us moving hasn't gone on air yet so the motor + // hasn't started. Hold the simulated position until it does. + this->moveStart = this->tiltStart = curTime; + if(currDir != this->s.direction || currTiltDir != this->s.tiltDirection) this->emitState(); + return; + case TxHoldCheck::expired: + ESP_LOGW(TAG, "[Shade %u] Timed out waiting for the radio, moving anyway", this->shadeId); + break; + case TxHoldCheck::none: + break; + } + this->applySensorMove(this->sensors.check(sunFlag, isSunny, isWindy, (uint32_t)curTime)); + + if(!tilt_first && this->s.direction > 0) { + this->p_currentPos(travelPosition(this->startPos, 1, (uint32_t)(curTime - this->moveStart), downTime)); + if(this->s.currentPos >= this->s.target) { + this->p_currentPos(this->s.target); + //if(this->settingMyPos) Serial.printf("IsAtTarget: %d %f=%f\n", this->isAtTarget(), this->s.currentPos, this->s.target); + // If we need to stop the shade do this before we indicate that we are + // not moving otherwise the my function will kick in. + if(this->settingPos) { + // At the end limit the motor stops by itself. If the stop doesn't go + // out the shade is now running on to the limit so leave the tilt be. + if(this->s.target == 100.0 || this->sendStop(1, false, now)) { + if(!isAtTarget()) { + ESP_LOGD(TAG, "We are not at our tilt target: %.2f", this->s.tiltTarget); + // We now need to move the tilt to the position we requested. + this->moveToTiltTarget(this->s.tiltTarget, now); + } + } + } + this->p_direction(0); + this->tiltStart = curTime; + this->startTiltPos = this->s.currentTiltPos; + if(this->isAtTarget()) this->commitShadePosition(); + } + } + else if(!tilt_first && this->s.direction < 0) { + // Shades often move slower up so this uses its own travel time. + this->p_currentPos(travelPosition(this->startPos, -1, (uint32_t)(curTime - this->moveStart), upTime)); + if(this->s.currentPos <= this->s.target) { + this->p_currentPos(this->s.target); + //if(this->settingMyPos) Serial.printf("IsAtTarget: %d %f=%f\n", this->isAtTarget(), this->s.currentPos, this->s.target); + + // If we need to stop the shade do this before we indicate that we are + // not moving otherwise the my function will kick in. + if(this->settingPos) { + // At the end limit the motor stops by itself. If the stop doesn't go + // out the shade is now running on to the limit so leave the tilt be. + if(this->s.target == 0.0 || this->sendStop(-1, false, now)) { + if(!isAtTarget()) { + ESP_LOGD(TAG, "We are not at our tilt target: %.2f", this->s.tiltTarget); + // We now need to move the tilt to the position we requested. + this->moveToTiltTarget(this->s.tiltTarget, now); + } + } + } + this->p_direction(0); + this->tiltStart = curTime; + this->startTiltPos = this->s.currentTiltPos; + if(this->isAtTarget()) this->commitShadePosition(); + } + } + if(this->s.tiltDirection > 0) { + if(tilt_first) this->moveStart = curTime; + this->p_currentTiltPos(travelPosition(this->startTiltPos, 1, (uint32_t)(curTime - this->tiltStart), tiltTime)); + if(tilt_first) { + if(this->s.currentTiltPos >= 100.0f) { + this->p_currentTiltPos(100.0f); + this->moveStart = curTime; + this->startPos = this->s.currentPos; + //this->p_tiltDirection(0); + //Serial.println("Setting tiltDirection to 0 (tilt_first)"); + } + } + else if(this->s.currentTiltPos >= this->s.tiltTarget) { + this->p_currentTiltPos(this->s.tiltTarget); + // If we need to stop the shade do this before we indicate that we are + // not moving otherwise the my function will kick in. + if(this->settingTiltPos) { + if(this->settings.tiltType == tilt_types::integrated) { + // If this is an integrated tilt mechanism the we will simply let it finish. If it is not then we will stop it. + //Serial.printf("Sending My -- tiltTarget: %.2f, tiltDirection: %d\n", this->s.tiltTarget, this->s.tiltDirection); + if(this->s.tiltTarget != 100.0f || this->s.currentPos != 100.0f) this->sendStop(1, true, now); + } + else { + // This is a tilt motor so let it complete if it is going to 100. + if(this->s.tiltTarget != 100.0f) this->sendStop(1, true, now); + } + } + this->p_tiltDirection(0); + this->settingTiltPos = false; + if(this->isAtTarget()) this->commitShadePosition(); + } + } + else if(this->s.tiltDirection < 0) { + if(tilt_first) this->moveStart = curTime; + if(tiltTime == 0) this->p_tiltDirection(0); + this->p_currentTiltPos(travelPosition(this->startTiltPos, -1, (uint32_t)(curTime - this->tiltStart), tiltTime)); + if(tilt_first) { + if(this->s.currentTiltPos <= 0.0f) { + this->p_currentTiltPos(0.0f); + this->moveStart = curTime; + this->startPos = this->s.currentPos; + //this->p_tiltDirection(0); + } + } + else if(this->s.currentTiltPos <= this->s.tiltTarget) { + this->p_currentTiltPos(this->s.tiltTarget); + // If we need to stop the shade do this before we indicate that we are + // not moving otherwise the my function will kick in. + if(this->settingTiltPos) { + if(this->settings.tiltType == tilt_types::integrated) { + // If this is an integrated tilt mechanism the we will simply let it finish. If it is not then we will stop it. + //Serial.printf("Sending My -- tiltTarget: %.2f, tiltDirection: %d\n", this->s.tiltTarget, this->s.tiltDirection); + if(this->s.tiltTarget != 0.0 || this->s.currentPos != 0.0) this->sendStop(-1, true, now); + } + else { + // This is a tilt motor so let it complete if it is going to 0. + if(this->s.tiltTarget != 0.0) this->sendStop(-1, true, now); + } + } + this->p_tiltDirection(0); + this->settingTiltPos = false; + ESP_LOGI(TAG, "Stopping at tilt position"); + if(this->isAtTarget()) this->commitShadePosition(); + } + } + if(this->settingMyPos && this->isAtTarget()) { + // Set this position before sending the command. If you don't the processFrame function + // will send the shade back to its original My position. + const float oldMyPos = this->s.myPos; + const float oldMyTiltPos = this->s.myTiltPos; + if(this->settings.tiltType != tilt_types::none) { + if(this->s.myTiltPos == this->s.currentTiltPos && this->s.myPos == this->s.currentPos) this->s.myPos = this->s.myTiltPos = -1; + else { + this->p_myPos(this->s.currentPos); + this->p_myTiltPos(this->s.currentTiltPos); + } + } + else { + this->p_myTiltPos(-1); + if(this->s.myPos == this->s.currentPos) this->p_myPos(-1); + else this->p_myPos(this->s.currentPos); + } + // Give the motor time to settle at the target before it is told to store it. + this->sendSetMy(200, oldMyPos, oldMyTiltPos, now); + this->settingMyPos = false; + this->emitState(); + } + else if(currDir != this->s.direction || currPos != floor(this->s.currentPos) || currTiltDir != this->s.tiltDirection || currTiltPos != floor(this->s.currentTiltPos)) { + // We need to emit on the socket that our state has changed. + this->emitState(); + } +} + + +// State Setters. Each marks what clients see as changed when it does change; +// the change fan-out publishes it once per loop() pass. +float ShadeBehaviour::p_currentPos(float pos) { + float old = this->s.currentPos; + this->s.currentPos = pos; + if(floor(old) != floor(pos)) this->events.onChanged(SHADE_CHANGED_POSITION); + return old; +} +float ShadeBehaviour::p_currentTiltPos(float pos) { + float old = this->s.currentTiltPos; + this->s.currentTiltPos = pos; + if(floor(old) != floor(pos)) this->events.onChanged(SHADE_CHANGED_TILT_POSITION); + return old; +} +bool ShadeBehaviour::p_flag(somfy_flags_t flag, bool val) { + bool old = !!(this->s.stateFlags & static_cast(flag)); + if(val) + this->s.stateFlags |= static_cast(flag); + else + this->s.stateFlags &= ~(static_cast(flag)); + return old; +} +bool ShadeBehaviour::p_sunFlag(bool val) { + bool old = this->p_flag(somfy_flags_t::SunFlag, val); + if(old != val) this->events.onChanged(SHADE_CHANGED_SUN_FLAG); + return old; +} +bool ShadeBehaviour::p_windy(bool val) { + bool old = this->p_flag(somfy_flags_t::Windy, val); + if(old != val) this->events.onChanged(SHADE_CHANGED_WINDY); + return old; +} +bool ShadeBehaviour::p_sunny(bool val) { + bool old = this->p_flag(somfy_flags_t::Sunny, val); + if(old != val) this->events.onChanged(SHADE_CHANGED_SUNNY); + return old; +} +int8_t ShadeBehaviour::p_direction(int8_t dir) { + int8_t old = this->s.direction; + if(old != dir) { + this->s.direction = dir; + this->events.onChanged(SHADE_CHANGED_DIRECTION); + } + return old; +} +int8_t ShadeBehaviour::p_tiltDirection(int8_t dir) { + int8_t old = this->s.tiltDirection; + if(old != dir) { + this->s.tiltDirection = dir; + this->events.onChanged(SHADE_CHANGED_TILT_DIRECTION); + } + return old; +} +float ShadeBehaviour::p_target(float target) { + float old = this->s.target; + if(old != target) { + this->s.target = target; + if(this->transformPosition(old) != this->transformPosition(target)) + this->events.onChanged(SHADE_CHANGED_TARGET); + } + return old; +} +float ShadeBehaviour::p_tiltTarget(float target) { + float old = this->s.tiltTarget; + if(old != target) { + this->s.tiltTarget = target; + if(this->transformPosition(old) != this->transformPosition(target)) + this->events.onChanged(SHADE_CHANGED_TILT_TARGET); + } + return old; +} +float ShadeBehaviour::p_myPos(float pos) { + float old = this->s.myPos; + if(old != pos) { + this->s.myPos = pos; + if(this->transformPosition(old) != this->transformPosition(pos)) + this->events.onChanged(SHADE_CHANGED_MY_POS); + } + return old; +} +float ShadeBehaviour::p_myTiltPos(float pos) { + float old = this->s.myTiltPos; + if(old != pos) { + this->s.myTiltPos = pos; + if(this->transformPosition(old) != this->transformPosition(pos)) + this->events.onChanged(SHADE_CHANGED_MY_TILT_POS); + } + return old; +} +bool ShadeBehaviour::isIdle() const { + return this->isAtTarget() && this->s.direction == 0 && this->s.tiltDirection == 0; +} +void ShadeBehaviour::onWaitingFrame(uint32_t now) { + if(this->shadeId == 255) { + this->lastFrame.await = 0; + return; + } + if(this->lastFrame.processed) return; + if(this->lastFrame.await > 0 && (now > this->lastFrame.await)) { + somfy_commands cmd = this->transformCommand(this->lastFrame.cmd); + switch(cmd) { + case somfy_commands::StepUp: + this->lastFrame.processed = true; + // Simply move the shade up by 1%. + if(this->s.currentPos > 0) { + this->p_target(floor(this->s.currentPos) - 1); + this->setMovement(-1, now); + this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); + } + break; + case somfy_commands::StepDown: + this->lastFrame.processed = true; + // Simply move the shade down by 1%. + if(this->s.currentPos < 100) { + this->p_target(floor(this->s.currentPos) + 1); + this->setMovement(1, now); + this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); + } + break; + case somfy_commands::Down: + case somfy_commands::Up: + if(this->settings.tiltType == tilt_types::tiltmotor) { // Theoretically this should get here unless it does have a tilt motor. + if(this->lastFrame.repeats >= TILT_REPEATS) { + int8_t dir = this->lastFrame.cmd == somfy_commands::Up ? -1 : 1; + this->p_tiltTarget(dir > 0 ? 100.0f : 0.0f); + this->setTiltMovement(dir, now); + this->lastFrame.processed = true; + ESP_LOGD(TAG, "%s Processing tilt %s after %d repeats", + this->name, translateSomfyCommand(this->lastFrame.cmd).c_str(), this->lastFrame.repeats); + this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); + } + else { + int8_t dir = this->lastFrame.cmd == somfy_commands::Up ? -1 : 1; + this->p_target(dir > 0 ? 100 : 0); + this->setMovement(dir, now); + this->lastFrame.processed = true; + this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); + } + if(this->lastFrame.repeats > TILT_REPEATS + 2) { + this->lastFrame.processed = true; + this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); + } + } + else if(this->settings.tiltType == tilt_types::euromode) { + if(this->lastFrame.repeats >= TILT_REPEATS) { + int8_t dir = this->lastFrame.cmd == somfy_commands::Up ? -1 : 1; + this->p_target(dir > 0 ? 100.0f : 0.0f); + this->setMovement(dir, now); + this->lastFrame.processed = true; + ESP_LOGD(TAG, "%s Processing %s after %d repeats", + this->name, translateSomfyCommand(this->lastFrame.cmd).c_str(), this->lastFrame.repeats); + this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); + } + else { + int8_t dir = this->lastFrame.cmd == somfy_commands::Up ? -1 : 1; + this->p_tiltTarget(dir > 0 ? 100 : 0); + this->setTiltMovement(dir, now); + this->lastFrame.processed = true; + this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); + } + if(this->lastFrame.repeats > TILT_REPEATS + 2) { + this->lastFrame.processed = true; + this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); + } + } + break; + case somfy_commands::My: + if(this->lastFrame.repeats >= SETMY_REPEATS && this->isIdle()) { + if(floor(this->s.myPos) == floor(this->s.currentPos)) { + // We are clearing it. + this->p_myPos(-1); + this->p_myTiltPos(-1); + } + else { + this->p_myPos(this->s.currentPos); + this->p_myTiltPos(this->s.currentTiltPos); + } + this->commitMyPosition(); + this->lastFrame.processed = true; + this->emitState(); + } + else if(this->isIdle()) { + if(this->settings.simMy) + this->moveToMyPosition(now); // Call out like this (instead of move to target) so that we don't get some of the goofy tilt only problems. + else { + if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f) this->p_target(this->s.myPos); + if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos); + } + this->setMovement(0, now); + this->lastFrame.processed = true; + this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); + } + else { + this->p_target(this->s.currentPos); + this->p_tiltTarget(this->s.currentTiltPos); + } + if(this->lastFrame.repeats > SETMY_REPEATS + 2) this->lastFrame.processed = true; + if(this->lastFrame.processed) { + ESP_LOGD(TAG, "%s Processing MY after %d repeats", this->name, this->lastFrame.repeats); + } + break; + default: + break; + } + } +} +void ShadeBehaviour::onFrame(somfy_frame_t &frame, bool internal, uint32_t now) { + // The reason why we are processing all frames here is so + // any linked remotes that may happen to be on the same ESPSomfy RTS + // device can trigger the appropriate actions. + if(this->shadeId == 255) return; + const uint32_t curTime = now; + this->lastFrame.copy(frame); + int8_t dir = 0; + this->moveStart = this->tiltStart = curTime; + this->startPos = this->s.currentPos; + this->startTiltPos = this->s.currentTiltPos; + // If the command is coming from a remote then we are aborting all these positioning operations. + if(!internal) this->settingMyPos = this->settingPos = this->settingTiltPos = false; + somfy_commands cmd = this->transformCommand(frame.cmd); + // At this point we are not processing the combo buttons + // will need to see what the shade does when you press both. + switch(cmd) { + case somfy_commands::Sensor: + this->lastFrame.processed = true; + if(this->settings.shadeType == shade_types::drycontact || this->settings.shadeType == shade_types::drycontact2) return; + { + const uint8_t prevFlags = this->s.stateFlags; + const bool wasSunny = prevFlags & static_cast(somfy_flags_t::Sunny); + const bool wasWindy = prevFlags & static_cast(somfy_flags_t::Windy); + const uint16_t status = frame.rollingCode << 4; + if (status & static_cast(somfy_flags_t::Sunny)) + this->p_sunny(true); + //this->flags |= static_cast(somfy_flags_t::Sunny); + else + this->p_sunny(false); + //this->flags &= ~(static_cast(somfy_flags_t::Sunny)); + if (status & static_cast(somfy_flags_t::Windy)) + this->p_windy(true); + //this->flags |= static_cast(somfy_flags_t::Windy); + else + this->p_windy(false); + //this->flags &= ~(static_cast(somfy_flags_t::Windy)); + if(frame.rollingCode & static_cast(somfy_flags_t::DemoMode)) + this->s.stateFlags |= static_cast(somfy_flags_t::DemoMode); + else + this->s.stateFlags &= ~(static_cast(somfy_flags_t::DemoMode)); + const bool isSunny = this->s.stateFlags & static_cast(somfy_flags_t::Sunny); + const bool isWindy = this->s.stateFlags & static_cast(somfy_flags_t::Windy); + this->sensors.onSensorFrame(wasSunny, wasWindy, isSunny, isWindy, (uint32_t)curTime); + this->emitState(); + this->events.onSensorFlags(); + } + break; + case somfy_commands::Prog: + case somfy_commands::MyUp: + case somfy_commands::MyDown: + case somfy_commands::MyUpDown: + case somfy_commands::UpDown: + this->lastFrame.processed = true; + if(this->settings.shadeType == shade_types::drycontact || this->settings.shadeType == shade_types::drycontact2) return; + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + break; + + case somfy_commands::Flag: + this->lastFrame.processed = true; + if(this->settings.shadeType == shade_types::drycontact || this->settings.shadeType == shade_types::drycontact2) return; + if(this->lastFrame.rollingCode & 0x8000) return; // Some sensors send bogus frames with a rollingCode >= 32768 that cause them to change the state. + this->p_sunFlag(false); + //this->flags &= ~(static_cast(somfy_flags_t::SunFlag)); + this->events.onSaveWanted(); + this->emitState(); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + this->events.onSensorFlags(); + break; + case somfy_commands::SunFlag: + if(this->settings.shadeType == shade_types::drycontact || this->settings.shadeType == shade_types::drycontact2) return; + 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->s.stateFlags & static_cast(somfy_flags_t::Windy); + const bool isSunny = this->s.stateFlags & static_cast(somfy_flags_t::Sunny); + //this->flags |= static_cast(somfy_flags_t::SunFlag); + this->p_sunFlag(true); + this->applySensorMove(this->sensors.onSunFlag(isSunny, isWindy)); + this->events.onSaveWanted(); + this->emitState(); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + this->events.onSensorFlags(); + } + break; + case somfy_commands::Up: + if(this->settings.shadeType == shade_types::drycontact) { + this->lastFrame.processed = true; + return; + } + else if(this->settings.shadeType == shade_types::drycontact2) { + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + if(this->s.currentPos != 0.0f) this->p_target(0); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + return; + } + if(this->settings.tiltType == tilt_types::tiltmotor || this->settings.tiltType == tilt_types::euromode) { + // Wait another half second just in case we are potentially processing a tilt. + if(!internal) this->lastFrame.await = curTime + 500; + else this->lastFrame.processed = true; + } + else { + // If from a remote we will simply be going up. + if(this->settings.tiltType == tilt_types::tiltonly && !internal) this->p_tiltTarget(0.0f); + else if(!internal) { + if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(0.0f); + this->p_tiltTarget(0.0f); + } + this->lastFrame.processed = true; + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + } + break; + case somfy_commands::Down: + if(this->settings.shadeType == shade_types::drycontact) { + this->lastFrame.processed = true; + return; + } + else if(this->settings.shadeType == shade_types::drycontact2) { + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + if(this->s.currentPos != 100.0f) this->p_target(100); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + return; + } + if (!this->sensors.windBlocksDown((uint32_t)curTime)) { + if(this->settings.tiltType == tilt_types::tiltmotor || this->settings.tiltType == tilt_types::euromode) { + // Wait another half seccond just in case we are potentially processing a tilt. + if(!internal) this->lastFrame.await = curTime + 500; + else this->lastFrame.processed = true; + } + else { + this->lastFrame.processed = true; + if(!internal) { + if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(100.0f); + if(this->settings.tiltType != tilt_types::none) this->p_tiltTarget(100.0f); + } + } + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + } + break; + case somfy_commands::My: + if(this->settings.shadeType == shade_types::drycontact2) return; + if(this->isToggle()) { // This is a one button device + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + if(!this->isIdle()) this->p_target(this->s.currentPos); + else if(this->s.currentPos == 100.0f) this->p_target(0.0f); + else if(this->s.currentPos == 0.0f) this->p_target(100.0f); + else this->p_target(this->s.lastMovement == -1 ? 100 : 0); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + return; + } + else if(this->settings.shadeType == shade_types::drycontact) { + // In this case we need to toggle the contact but we only should do this if + // this is not a repeat. + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + if(this->s.currentPos == 100.0f) this->p_target(0); + else if(this->s.currentPos == 0.0f) this->p_target(100); + else this->p_target(this->s.lastMovement == -1 ? 100 : 0); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + return; + } + if(this->isIdle()) { + if(!internal) { + // This frame is coming from a remote. We are potentially setting + // the my position. + this->lastFrame.await = curTime + 500; + } + else { + if(this->lastFrame.processed) return; + ESP_LOGI(TAG, "Moving to My target"); + this->lastFrame.processed = true; + if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos); + if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f && this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.myPos); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + } + } + else { + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + if(!internal) { + if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.currentPos); + this->p_tiltTarget(this->s.currentTiltPos); + } + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + } + break; + case somfy_commands::StepUp: + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + if(this->settings.shadeType == shade_types::drycontact || this->settings.shadeType == shade_types::drycontact2) return; + dir = 0; + if(this->lastFrame.stepSize == 0) this->lastFrame.stepSize = 1; + if(!this->applyStep(-1, this->lastFrame.stepSize)) return; + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + break; + case somfy_commands::StepDown: + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + if(this->settings.shadeType == shade_types::drycontact || this->settings.shadeType == shade_types::drycontact2) return; + dir = 1; + if(this->lastFrame.stepSize == 0) this->lastFrame.stepSize = 1; + if(!this->applyStep(1, this->lastFrame.stepSize)) return; + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + break; + case somfy_commands::Toggle: + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + if(!this->isIdle()) this->p_target(this->s.currentPos); + else if(this->s.currentPos == 100.0f) this->p_target(0); + else if(this->s.currentPos == 0.0f) this->p_target(100); + else this->p_target(this->s.lastMovement == -1 ? 100 : 0); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + break; + case somfy_commands::Stop: + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + this->p_target(this->s.currentPos); + this->p_tiltTarget(this->s.currentTiltPos); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + break; + case somfy_commands::Favorite: + if(this->lastFrame.processed) return; + this->lastFrame.processed = true; + if(this->settings.simMy) { + this->moveToMyPosition(now); + } + else { + if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos); + if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f && this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.myPos); + this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); + } + break; + default: + dir = 0; + break; + } + //if(dir == 0 && this->settings.tiltType == tilt_types::tiltmotor && this->s.tiltDirection != 0) this->setTiltMovement(0, now); + this->setMovement(dir, now); +} +void ShadeBehaviour::onGroupCommand(somfy_commands cmd, uint8_t repeat, uint32_t now) { + // The reason why we are processing all frames here is so + // any linked remotes that may happen to be on the same ESPSomfy RTS + // device can trigger the appropriate actions. + if(this->shadeId == 255) return; + const uint32_t curTime = now; + int8_t dir = 0; + this->moveStart = this->tiltStart = curTime; + this->startPos = this->s.currentPos; + this->startTiltPos = this->s.currentTiltPos; + // If the command is coming from a remote then we are aborting all these positioning operations. + switch(cmd) { + case somfy_commands::Up: + if(this->settings.tiltType == tilt_types::tiltmotor) { + if(repeat >= TILT_REPEATS) + this->p_tiltTarget(0.0f); + else + this->p_target(0.0f); + } + else if(this->settings.tiltType == tilt_types::tiltonly) { + this->p_target(100.0f); + this->p_currentPos(100.0f); + this->p_tiltTarget(0.0f); + } + else { + this->p_target(0.0f); + this->p_tiltTarget(0.0f); + } + break; + case somfy_commands::Down: + if (!this->sensors.windBlocksDown((uint32_t)curTime)) { + if(this->settings.tiltType == tilt_types::tiltmotor) { + if(repeat >= TILT_REPEATS) + this->p_tiltTarget(100.0f); + else + this->p_target(100.0f); + } + else if(this->settings.tiltType == tilt_types::tiltonly) { + this->p_target(100.0f); + this->p_currentPos(100.0f); + this->p_tiltTarget(100.0f); + } + else { + this->p_target(100.0f); + if(this->settings.tiltType != tilt_types::none) this->p_tiltTarget(100.0f); + } + } + break; + case somfy_commands::My: + if(this->isIdle()) { + ESP_LOGD(TAG, "Shade #%d is idle", this->shadeId); + if(this->settings.simMy) { + this->moveToMyPosition(now); + } + else { + if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos); + if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f && this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.myPos); + } + } + else { + if(this->settings.tiltType == tilt_types::tiltonly) { + this->p_target(100.0f); + } + else this->p_target(this->s.currentPos); + this->p_tiltTarget(this->s.currentTiltPos); + } + break; + case somfy_commands::StepUp: + // A group step is always one step. + if(!this->applyStep(-1, 1)) return; + break; + case somfy_commands::StepDown: + dir = 1; + if(!this->applyStep(1, 1)) return; + break; + case somfy_commands::Flag: + this->p_sunFlag(false); + if(this->settings.sunSensor) { + this->events.onSaveWanted(); + this->emitState(); + } + else { + ESP_LOGW(TAG, "Shade does not have sensor %d", joinShadeFlags(this->settings, false, this->s.stateFlags)); + } + break; + case somfy_commands::SunFlag: + if(this->settings.sunSensor) { + const bool isWindy = this->s.stateFlags & static_cast(somfy_flags_t::Windy); + const bool isSunny = this->s.stateFlags & static_cast(somfy_flags_t::Sunny); + this->p_sunFlag(true); + //this->flags |= static_cast(somfy_flags_t::SunFlag); + this->applySensorMove(this->sensors.onSunFlag(isSunny, isWindy)); + this->events.onSaveWanted(); + this->emitState(); + } + else + ESP_LOGW(TAG, "Shade does not have sensor %d", joinShadeFlags(this->settings, false, this->s.stateFlags)); + break; + default: + dir = 0; + break; + } + this->setMovement(dir, now); +} +void ShadeBehaviour::setTiltMovement(int8_t dir, uint32_t now) { + int8_t currDir = this->s.tiltDirection; + if(dir == 0) { + // The shade tilt is stopped. + this->startTiltPos = this->s.currentTiltPos; + this->tiltStart = 0; + this->p_tiltDirection(dir); + if(currDir != dir) { + this->commitTiltPosition(); + } + } + else if(this->s.tiltDirection != dir) { + this->tiltStart = now; + this->startTiltPos = this->s.currentTiltPos; + this->p_tiltDirection(dir); + } + if(this->s.tiltDirection != currDir) { + this->emitState(); + } +} +void ShadeBehaviour::onTxQueued(uint32_t ticket, uint32_t now) { + // This runs before the caller sets the new target so direction still says + // whether the shade was moving. A reversal is simulated as soon as it is + // queued even though the motor turns once it is on air. See TxHold::mark + // for which of several queued commands is held. + this->tx.hold.mark(ticket, this->s.direction != 0 || this->s.tiltDirection != 0, now); +} +void ShadeBehaviour::onTxEvent(const somfy_tx_event_t &evt) { + const ShadeTxActions act = this->tx.onEvent(evt, this->s.direction != 0 || this->s.tiltDirection != 0); + if(act.startMovement) { + // The motor starts now so the simulated movement does too. + this->moveStart = this->tiltStart = act.startAt; + } + if(act.cancelMovement) { + // The motor never got the command so it hasn't moved. + ESP_LOGW(TAG, "[Shade %u] Command dropped by the radio, staying at %.2f%%", this->shadeId, this->s.currentPos); + this->settingPos = this->settingTiltPos = this->settingMyPos = false; + this->p_target(this->s.currentPos); + this->p_tiltTarget(this->s.currentTiltPos); + this->emitState(); + } + if(act.commitMy) this->commitMyPosition(); + if(act.restoreMy) { + ESP_LOGW(TAG, "[Shade %u] Favorite not sent in full, keeping the previous one", this->shadeId); + this->p_myPos(act.myOldPos); + this->p_myTiltPos(act.myOldTiltPos); + this->emitState(); + } + if(act.runToLimit) { + ESP_LOGW(TAG, "[Shade %u] Stop dropped by the radio, running on to the limit", this->shadeId); + this->runToLimit(act.limitDir, act.limitTilt, act.limitSince); + this->emitState(); + } +} +bool ShadeBehaviour::sendStop(int8_t dir, bool tilt, uint32_t now) { + // Stops jump the radio queue so the motor stops as close to the target as it can. + if(const send_result_t sent = this->transmit(somfy_commands::My, this->remote.repeats, 0, 0, true, now)) { + this->tx.stopSent(sent.ticket, now, dir, tilt); + return true; + } + ESP_LOGW(TAG, "[Shade %u] Stop not sent, running on to the limit", this->shadeId); + this->runToLimit(dir, tilt, now); + return false; +} +bool ShadeBehaviour::sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos, uint32_t now) { + // The new favorite is already in myPos/myTiltPos. It is committed once the + // whole long press is on air, see processTxEvent. + if(const send_result_t sent = this->transmit(somfy_commands::My, SETMY_REPEATS, 0, delayMs, false, now)) { + this->tx.setMySent(sent.ticket, oldPos, oldTiltPos); + // Without the radio (GPIO remotes) there is nothing to wait for. + if(sent.ticket == 0) this->commitMyPosition(); + return true; + } + // The motor never learned it so keep the favorite it has. + this->p_myPos(oldPos); + this->p_myTiltPos(oldTiltPos); + return false; +} +void ShadeBehaviour::runToLimit(int8_t dir, bool tilt, uint32_t since) { + // The motor didn't get a stop at the target so it keeps going until its end + // limit. Carry on simulating that from where and when it should have stopped. + if(tilt) { + this->startTiltPos = this->s.currentTiltPos; + this->tiltStart = since; + this->settingTiltPos = false; + this->p_tiltTarget(limitTarget(dir)); + } + else { + this->startPos = this->s.currentPos; + this->moveStart = since; + this->settingPos = false; + this->p_target(limitTarget(dir)); + } + // The favorite can't be set where the shade won't stop. + this->settingMyPos = false; +} +void ShadeBehaviour::applySensorMove(SensorMove move) { + const SensorTargets t = sensorTargets(move, this->settings.tiltType, this->s.myPos, this->s.myTiltPos); + if(t.setLift) this->p_target(t.lift); + if(t.setTilt) this->p_tiltTarget(t.tilt); +} +bool ShadeBehaviour::applyStep(int8_t dir, uint8_t frameStepSize) { + const StepTargets t = stepTargets(this->settings.tiltType, dir, this->s.currentPos, this->s.currentTiltPos, + this->settings.upTime, this->settings.downTime, this->settings.tiltTime, this->settings.stepSize, frameStepSize); + if(t.action == StepAction::ignore) return false; + if(t.setLift) this->p_target(t.lift); + if(t.setTilt) this->p_tiltTarget(t.tilt); + return true; +} +void ShadeBehaviour::setMovement(int8_t dir, uint32_t now) { + int8_t currDir = this->s.direction; + int8_t currTiltDir = this->s.tiltDirection; + if(dir == 0) { + if(currDir != dir || currTiltDir != dir) this->commitShadePosition(); + } + else { + this->tiltStart = this->moveStart = now; + this->startPos = this->s.currentPos; + this->startTiltPos = this->s.currentTiltPos; + } + if(this->s.direction != currDir || currTiltDir != this->s.tiltDirection) { + this->emitState(); + } +} +bool ShadeBehaviour::setMyPosition(int8_t pos, int8_t tilt, uint32_t now) { + if(!this->isIdle()) return true; // Don't do this if it is moving. + bool sent = true; + if(this->settings.tiltType == tilt_types::tiltonly) { + this->p_myPos(-1.0f); + if(tilt != floor(this->s.currentTiltPos)) { + // The favorite is set once we get there, if the move went out. + if(tilt == floor(this->s.myTiltPos)) + sent = this->settingMyPos = this->moveToMyPosition(now); + else + sent = this->settingMyPos = this->moveToTarget(100, tilt, now); + } + else if(tilt == floor(this->s.myTiltPos)) { + // Of so we need to clear the my position. These motors are finicky so send + // a my command to ensure we are actually at the my position then send the clear + // command. There really is no other way to do this. + if(this->s.currentTiltPos != this->s.myTiltPos) { + sent = this->settingMyPos = this->moveToMyPosition(now); + } + else if((sent = this->transmit(somfy_commands::My, this->remote.repeats, 0, 0, false, now).sent)) { + this->settingPos = false; + this->settingMyPos = true; + } + } + else return this->storeMyPosition(-1.0f, this->s.currentTiltPos, now); + this->commitMyPosition(); + this->emitState(); + } + else if(this->settings.tiltType != tilt_types::none) { + if(tilt < 0) tilt = 0; + if(pos != floor(this->s.currentPos) || tilt != floor(this->s.currentTiltPos)) { + // The favorite is set once we get there, if the move went out. + if(pos == floor(this->s.myPos) && tilt == floor(this->s.myTiltPos)) + sent = this->settingMyPos = this->moveToMyPosition(now); + else + sent = this->settingMyPos = this->moveToTarget(pos, tilt, now); + } + else if(pos == floor(this->s.myPos) && tilt == floor(this->s.myTiltPos)) { + // Of so we need to clear the my position. These motors are finicky so send + // a my command to ensure we are actually at the my position then send the clear + // command. There really is no other way to do this. + if(this->s.currentPos != this->s.myPos || this->s.currentTiltPos != this->s.myTiltPos) { + sent = this->settingMyPos = this->moveToMyPosition(now); + } + else if((sent = this->transmit(somfy_commands::My, this->remote.repeats, 0, 0, false, now).sent)) { + this->settingPos = false; + this->settingMyPos = true; + } + } + else return this->storeMyPosition(this->s.currentPos, this->s.currentTiltPos, now); + this->commitMyPosition(); + this->emitState(); + } + else { + if(pos != floor(this->s.currentPos)) { + // The favorite is set once we get there, if the move went out. + if(pos == floor(this->s.myPos)) + sent = this->settingMyPos = this->moveToMyPosition(now); + else + sent = this->settingMyPos = this->moveToTarget(pos, -1.0f, now); + } + else if(pos == floor(this->s.myPos)) { + // Of so we need to clear the my position. These motors are finicky so send + // a my command to ensure we are actually at the my position then send the clear + // command. There really is no other way to do this. + if(this->s.myPos != this->s.currentPos) { + sent = this->settingMyPos = this->moveToMyPosition(now); + } + else if((sent = this->transmit(somfy_commands::My, this->remote.repeats, 0, 0, false, now).sent)) { + this->settingPos = false; + this->settingMyPos = true; + } + } + else sent = this->storeMyPosition(this->s.currentPos, -1.0f, now); + } + return sent; +} +bool ShadeBehaviour::storeMyPosition(float pos, float tiltPos, uint32_t now) { + // Set it before sending so processFrame doesn't head for the old one. It is + // only committed once the motor was told about it. + const float oldPos = this->s.myPos; + const float oldTiltPos = this->s.myTiltPos; + this->p_myPos(pos); + this->p_myTiltPos(tiltPos); + const bool sent = this->sendSetMy(0, oldPos, oldTiltPos, now); + this->emitState(); + return sent; +} +bool ShadeBehaviour::moveToMyPosition(uint32_t now) { + if(!this->isIdle()) return true; + ESP_LOGI(TAG, "Moving to My Position"); + if(this->settings.tiltType == tilt_types::tiltonly) { + this->p_currentPos(100.0f); + this->p_myPos(-1.0f); + } + if(this->s.currentPos == this->s.myPos) { + if(this->settings.tiltType != tilt_types::none) { + if(this->s.currentTiltPos == this->s.myTiltPos) return true; // Nothing to see here since we are already here. + } + else + return true; + } + if(this->s.myPos == -1 && (this->settings.tiltType == tilt_types::none || this->s.myTiltPos == -1)) return true; + this->settingPos = false; + if(this->settings.simMy) { + ESP_LOGI(TAG, "Moving to simulated favorite"); + if(!this->moveToTarget(this->s.myPos, this->s.myTiltPos, now)) return false; + } + else if(!this->transmit(somfy_commands::My, this->remote.repeats, 0, 0, false, now).sent) return false; + if(this->settings.tiltType != tilt_types::tiltonly && this->s.myPos >= 0.0f && this->s.myPos <= 100.0f) this->p_target(this->s.myPos); + if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos); + return true; +} +bool ShadeBehaviour::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint32_t now) { + ESP_LOGD(TAG, "Send command start"); + // This sendCommand function will always be called externally. sendCommand at the remote level + // is expected to be called internally when the motor needs commanded. + // If same direction command sent while already moving, stop the state tracking. + // The real motor stops on its own when it receives the same direction again. + if((cmd == somfy_commands::Up && this->s.direction == -1) || + (cmd == somfy_commands::Down && this->s.direction == 1)) { + ESP_LOGD(TAG, "Same command as dir"); + if(!this->transmit(cmd, repeat, 0, 0, false, now).sent) return false; + this->p_target(this->s.currentPos); + this->p_tiltTarget(this->s.currentTiltPos); + this->setMovement(0, now); + return true; + } + // The simulated state is only moved for commands the radio took. + if(cmd == somfy_commands::Up) { + if(this->settings.tiltType == tilt_types::euromode) { + // In euromode we need to long press for 2 seconds on the + // up command. + if(!this->transmit(cmd, TILT_REPEATS, 0, 0, false, now).sent) return false; + this->p_target(0.0f); + } + else { + if(!this->transmit(cmd, repeat, 0, 0, false, now).sent) return false; + if(this->settings.tiltType == tilt_types::tiltonly) { + this->p_target(100.0f); + this->p_tiltTarget(0.0f); + this->p_currentPos(100.0f); + } + else this->p_target(0.0f); + if(this->settings.tiltType == tilt_types::integrated) this->p_tiltTarget(0.0f); + } + } + else if(cmd == somfy_commands::Down) { + if(this->settings.tiltType == tilt_types::euromode) { + // In euromode we need to long press for 2 seconds on the + // down command. + if(!this->transmit(cmd, TILT_REPEATS, 0, 0, false, now).sent) return false; + this->p_target(100.0f); + } + else { + if(!this->transmit(cmd, repeat, 0, 0, false, now).sent) return false; + if(this->settings.tiltType == tilt_types::tiltonly) { + this->p_target(100.0f); + this->p_tiltTarget(100.0f); + this->p_currentPos(100.0f); + } + else this->p_target(100.0f); + if(this->settings.tiltType == tilt_types::integrated) this->p_tiltTarget(100.0f); + } + } + else if(cmd == somfy_commands::My) { + if(this->isToggle() || this->settings.shadeType == shade_types::drycontact) { + if(!this->transmit(cmd, repeat, 0, 0, false, now).sent) return false; + } + else if(this->settings.shadeType == shade_types::drycontact2){ + ESP_LOGD(TAG, "Send command start 1"); + return true; + } + else if(this->isIdle()) { + bool sent = this->moveToMyPosition(now); + ESP_LOGD(TAG, "Send command end 2"); + return sent; + } + else { + if(!this->transmit(cmd, repeat, 0, 0, false, now).sent) return false; + if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.currentPos); + this->p_tiltTarget(this->s.currentTiltPos); + } + } + else if(cmd == somfy_commands::Toggle) { + if(this->remote.bitLength != 80) { if(!this->transmit(somfy_commands::My, repeat, stepSize, 0, false, now).sent) return false; } + else if(!this->transmit(somfy_commands::Toggle, repeat, 0, 0, false, now).sent) return false; + } + else if(this->isToggle() && cmd == somfy_commands::Prog) { + if(!this->transmit(somfy_commands::Toggle, repeat, stepSize, 0, false, now).sent) return false; + } + else { + if(!this->transmit(cmd, repeat, stepSize, 0, false, now).sent) return false; + } + ESP_LOGD(TAG, "Send command end"); + return true; +} +uint8_t ShadeBehaviour::tiltRepeats() const { return this->settings.tiltType == tilt_types::tiltmotor ? TILT_REPEATS : this->remote.repeats; } +bool ShadeBehaviour::sendTiltCommand(somfy_commands cmd, uint32_t now) { + float target; + if(cmd == somfy_commands::Up) target = 0.0f; + else if(cmd == somfy_commands::Down) target = 100.0f; + else if(cmd == somfy_commands::My) target = this->s.currentTiltPos; + else return true; + if(!this->transmit(cmd, this->tiltRepeats(), 0, 0, false, now).sent) return false; + this->p_tiltTarget(target); + return true; +} +bool ShadeBehaviour::moveToTiltTarget(float target, uint32_t now) { + somfy_commands cmd = somfy_commands::My; + if(target < this->s.currentTiltPos) + cmd = somfy_commands::Up; + else if(target > this->s.currentTiltPos) + cmd = somfy_commands::Down; + if(target >= 0.0f && target <= 100.0f) { + // Only send a command if the lift is not moving. + if(this->s.currentPos == this->s.target || this->settings.tiltType == tilt_types::tiltmotor) { + if(cmd != somfy_commands::My) { + ESP_LOGI(TAG, "Moving Tilt to %.2f%% from %.2f%% using %s", + target, this->s.currentTiltPos, translateSomfyCommand(cmd).c_str()); + if(!this->transmit(cmd, this->tiltRepeats(), 0, 0, false, now).sent) return false; + } + // If the blind is currently moving then the command to stop it + // will occur on its own when the tilt target is set. + } + this->p_tiltTarget(target); + } + if(cmd != somfy_commands::My) this->settingTiltPos = true; + return true; +} +bool ShadeBehaviour::moveToTarget(float pos, float tilt, uint32_t now) { + ESP_LOGI(TAG, "[Shade %u] moveToTarget(pos=%.2f, tilt=%.2f) settingPos=%d direction=%d currentTarget=%.2f currentPos=%.2f", + this->shadeId, pos, tilt, this->settingPos, this->s.direction, this->s.target, this->s.currentPos); + somfy_commands cmd = somfy_commands::My; + if(this->isToggle()) { + // Overload this as we cannot seek a position on a garage door or single button device. + this->p_target(pos); + this->p_currentPos(pos); + this->emitState(); + return true; + } + if(this->settings.tiltType == tilt_types::tiltonly) { + // A tilt only shade is always at 100%. + this->p_target(100.0f); + this->p_myPos(-1.0f); + this->p_currentPos(100.0f); + pos = 100; + if(tilt < this->s.currentTiltPos) cmd = somfy_commands::Up; + else if(tilt > this->s.currentTiltPos) cmd = somfy_commands::Down; + } + else { + if(pos < this->s.currentPos) + cmd = somfy_commands::Up; + else if(pos > this->s.currentPos) + cmd = somfy_commands::Down; + else if(tilt >= 0 && tilt < this->s.currentTiltPos) + cmd = somfy_commands::Up; + else if(tilt >= 0 && tilt > this->s.currentTiltPos) + cmd = somfy_commands::Down; + } + if(cmd != somfy_commands::My) { + if(tilt >= 0) { + ESP_LOGI(TAG, "Moving to %.2f%% from %.2f%% tilt %.2f%% from %.2f%% using %s", + pos, this->s.currentPos, tilt, this->s.currentTiltPos, translateSomfyCommand(cmd).c_str()); + } else { + ESP_LOGI(TAG, "Moving to %.2f%% from %.2f%% using %s", + pos, this->s.currentPos, translateSomfyCommand(cmd).c_str()); + } + if(!this->transmit(cmd, this->settings.tiltType == tilt_types::euromode ? TILT_REPEATS : this->remote.repeats, 0, 0, false, now).sent) return false; + this->settingPos = true; + this->p_target(pos); + if(tilt >= 0) { + this->p_tiltTarget(tilt); + this->settingTiltPos = true; + } + } + return true; +} diff --git a/src/ShadeBehaviour.h b/src/ShadeBehaviour.h new file mode 100644 index 0000000..7af0d21 --- /dev/null +++ b/src/ShadeBehaviour.h @@ -0,0 +1,167 @@ +#ifndef SHADE_BEHAVIOUR_H +#define SHADE_BEHAVIOUR_H +#include +#include "SomfyFrame.h" +#include "ShadeSettings.h" +#include "ShadeMotion.h" +#include "ShadeSensors.h" +#include "ShadeChanges.h" + +// What a shade does with frames, commands and radio events, and where that +// leaves it. Kept free of the shade model, radio and network so it can be +// unit tested on the PC (pio test -e native). Only loop() calls it. + +// What sending a command did. ticket is the radio job carrying it, 0 when the +// command went out over GPIO instead. +struct send_result_t { + bool sent = false; + uint32_t ticket = 0; + explicit operator bool() const { return this->sent; } +}; + +// How a shade's commands reach its motor, over the radio or GPIO. When it +// sends, the frame as sent is in the shade's lastFrame and every other shade +// has heard it; the shade hears its own frame itself. +class MotorLink { + public: + // sent is false when the radio could not take the command. + virtual send_result_t send(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority) = 0; +}; + +// What a shade's behaviour tells the rest of the firmware, as it happens. +class ShadeEvents { + public: + // SHADE_CHANGED_* bits for the change fan-out. + virtual void onChanged(uint16_t bits) = 0; + // A command reached the shade. source is "remote", "internal" and so on. + virtual void onCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress) = 0; + // A position, the favorite or the sun flag changed and is worth saving. + virtual void onSaveWanted() = 0; + // A sensor, sun flag or flag frame arrived, so the groups work their + // flags out again. + virtual void onSensorFlags() = 0; +}; + +class ShadeBehaviour { + public: + // Where the shade is and where it is going. Positions run 0-100, the + // favorite is -1 when there is none. + struct State { + float currentPos = 0.0f; + float currentTiltPos = 0.0f; + float target = 0.0f; + float tiltTarget = 0.0f; + int8_t direction = 0; // 0 = stopped, 1 = down, -1 = up. + int8_t tiltDirection = 0; // 0 = stopped, 1 = clockwise, -1 = counter clockwise. + int8_t lastMovement = 0; + float myPos = -1.0f; + float myTiltPos = -1.0f; + // The flags bits that are state: sun flag, sunny, windy, demo mode and + // the unused lighted. The setting bits are in ShadeSettings. + uint8_t stateFlags = 0; + }; + // lastFrame is the shade's last frame, shared with the send path that + // builds the next one from it. shadeId and name are for the log. + ShadeBehaviour(const ShadeSettings &settings, const RemoteSettings &remote, somfy_frame_t &lastFrame, + MotorLink &link, ShadeEvents &events, const uint8_t &shadeId, const char *name); + ShadeBehaviour(const ShadeBehaviour &) = delete; + ShadeBehaviour &operator=(const ShadeBehaviour &) = delete; + const State &state() const { return this->s; } + bool isIdle() const; + bool isAtTarget() const; + + // A frame from a remote, sensor or linked remote, or one the shade or + // another shade sent (internal). + void onFrame(somfy_frame_t &frame, bool internal, uint32_t now); + // A remote button may still be held: acts on lastFrame once it is clear + // whether the press was short or long. + void onWaitingFrame(uint32_t now); + // A group the shade belongs to sent cmd. + void onGroupCommand(somfy_commands cmd, uint8_t repeat, uint32_t now); + // Every loop() pass: moves the simulated position, stops at the target and + // stores the favorite. + void tick(uint32_t now); + // A command that may set the shade moving was queued as radio job ticket. + void onTxQueued(uint32_t ticket, uint32_t now); + // A radio job started, finished, was aborted or dropped. + void onTxEvent(const somfy_tx_event_t &evt); + + // Commands from the web, MQTT and Home Assistant. False when the radio + // refused a command they sent. + bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint32_t now); + bool sendTiltCommand(somfy_commands cmd, uint32_t now); + bool moveToTarget(float pos, float tilt, uint32_t now); + bool moveToTiltTarget(float target, uint32_t now); + bool setMyPosition(int8_t pos, int8_t tilt, uint32_t now); + bool moveToMyPosition(uint32_t now); + + // Puts the shade at pos and tilt (-1 leaves one alone) without moving it. + void setPosition(float pos, float tilt); + // Where the config file says the shade was. The targets are where it is. + void restore(float pos, float tiltPos, float myPos, float myTiltPos); + // Forgets the tilt of a shade that has none. + void clearTilt(); + void setStateFlags(uint8_t flags) { this->s.stateFlags = flags; } + // Back to a stopped shade at 0 with no favorite, for an empty slot. + void clear(); + + private: + const ShadeSettings &settings; + const RemoteSettings &remote; + somfy_frame_t &lastFrame; + MotorLink &link; + ShadeEvents &events; + const uint8_t &shadeId; + const char *name; + State s; + uint32_t moveStart = 0; + uint32_t tiltStart = 0; + ShadeSensors sensors; + float startPos = 0.0f; + float startTiltPos = 0.0f; + bool settingMyPos = false; + bool settingPos = false; + bool settingTiltPos = false; + uint32_t awaitMy = 0; + // Radio jobs whose outcome decides what the motor really did. + ShadeTxState tx; + + // Sends through the link, then hears the frame as every other shade has + // and waits for it to go on air if it sets the shade moving. + send_result_t transmit(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority, uint32_t now); + bool sendStop(int8_t dir, bool tilt, uint32_t now); + bool sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos, uint32_t now); + // Makes pos/tiltPos the favorite and tells the motor to store it. + bool storeMyPosition(float pos, float tiltPos, uint32_t now); + // Repeats for a tilt command, more for a separate tilt motor. + uint8_t tiltRepeats() const; + void runToLimit(int8_t dir, bool tilt, uint32_t since); + // Sets the targets the sun or wind sends the shade to. + void applySensorMove(SensorMove move); + // Sets the targets for a step in dir. False when the step is dropped. + bool applyStep(int8_t dir, uint8_t frameStepSize); + void setTiltMovement(int8_t dir, uint32_t now); + void setMovement(int8_t dir, uint32_t now); + bool isToggle() const { return isToggleShade(this->settings.shadeType); } + somfy_commands transformCommand(somfy_commands cmd) const; + int8_t transformPosition(float fpos) const { return displayPosition(fpos, this->settings.flipPosition); } + void emitState() { this->events.onChanged(SHADE_CHANGED_STATE); } + void emitCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress) { this->events.onCommand(cmd, source, sourceAddress); } + void commitShadePosition() { this->events.onSaveWanted(); } + void commitTiltPosition() { this->events.onSaveWanted(); } + void commitMyPosition() { this->events.onSaveWanted(); } + // State setters. Each marks what clients see as changed when it does change. + float p_currentPos(float pos); + float p_currentTiltPos(float pos); + int8_t p_direction(int8_t dir); + int8_t p_tiltDirection(int8_t dir); + float p_target(float target); + float p_tiltTarget(float target); + float p_myPos(float pos); + float p_myTiltPos(float pos); + bool p_flag(somfy_flags_t flag, bool val); + bool p_sunFlag(bool val); + bool p_sunny(bool val); + bool p_windy(bool val); +}; +#endif diff --git a/src/ShadeController.cpp b/src/ShadeController.cpp index 86fdd03..87e0b06 100644 --- a/src/ShadeController.cpp +++ b/src/ShadeController.cpp @@ -119,14 +119,16 @@ void SomfyRoom::clear() { strcpy(this->name, ""); } bool SomfyRoom::save() { somfy.commit(); return true; } -void SomfyShadeController::processFrame(somfy_frame_t &frame, bool internal) { +void SomfyShadeController::processFrame(somfy_frame_t &frame, bool internal, const SomfyRemote *skip) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { + if(&this->shades[i] == skip) continue; if(this->shades[i].getShadeId() != 255) this->shades[i].processFrame(frame, internal); } } -void SomfyShadeController::markTxPending(uint32_t remoteAddress, uint32_t ticket) { +void SomfyShadeController::markTxPending(uint32_t remoteAddress, uint32_t ticket, const SomfyRemote *skip) { for(uint8_t i = 0; i < SOMFY_MAX_SHADES; i++) { SomfyShade *shade = &this->shades[i]; + if(shade == skip) continue; if(shade->getShadeId() != 255 && shade->findRemote(remoteAddress)) shade->markTxPending(ticket); } // Group commands move their member shades through processInternalCommand. diff --git a/src/ShadeGpio.cpp b/src/ShadeGpio.cpp index 2b07f10..0024326 100644 --- a/src/ShadeGpio.cpp +++ b/src/ShadeGpio.cpp @@ -9,17 +9,17 @@ void SomfyShade::setGPIOs() { uint8_t p_on = (this->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger) == 0x00 ? HIGH : LOW; uint8_t p_off = (this->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger) == 0x00 ? LOW : HIGH; - int8_t dir = this->direction; + int8_t dir = this->state().direction; if(dir == 0 && this->tiltType == tilt_types::integrated) - dir = this->tiltDirection; + dir = this->state().tiltDirection; else if(this->tiltType == tilt_types::tiltonly) - dir = this->tiltDirection; + dir = this->state().tiltDirection; if(this->shadeType == shade_types::drycontact) { - digitalWrite(this->gpioDown, this->currentPos == 100 ? p_on : p_off); - this->gpioDir = this->currentPos == 100 ? 1 : -1; + digitalWrite(this->gpioDown, this->state().currentPos == 100 ? p_on : p_off); + this->gpioDir = this->state().currentPos == 100 ? 1 : -1; } else if(this->shadeType == shade_types::drycontact2) { - if(this->currentPos == 100) { + if(this->state().currentPos == 100) { digitalWrite(this->gpioDown, p_off); digitalWrite(this->gpioUp, p_on); } @@ -27,7 +27,7 @@ void SomfyShade::setGPIOs() { digitalWrite(this->gpioUp, p_off); digitalWrite(this->gpioDown, p_on); } - this->gpioDir = this->currentPos == 100 ? 1 : -1; + this->gpioDir = this->state().currentPos == 100 ? 1 : -1; } else { switch(dir) { diff --git a/src/ShadeTypes.h b/src/ShadeTypes.h index 7e2921d..0fca5cc 100644 --- a/src/ShadeTypes.h +++ b/src/ShadeTypes.h @@ -35,6 +35,19 @@ enum class shade_types : byte { cgate1 = 0x0F, rgate1 = 0x10 }; +// A garage door or gate worked with one button: each press moves, stops or +// reverses it, so it can't be sent to a position. +inline bool isToggleShade(shade_types type) { + switch(type) { + case shade_types::garage1: + case shade_types::lgate1: + case shade_types::cgate1: + case shade_types::rgate1: + return true; + default: + return false; + } +} // The bits of a shade's or group's flags. enum class somfy_flags_t : byte { SunFlag = 0x01, diff --git a/src/Somfy.h b/src/Somfy.h index b95ab61..417566f 100644 --- a/src/Somfy.h +++ b/src/Somfy.h @@ -9,6 +9,7 @@ #include "RadioScheduler.h" #include "ShadeTypes.h" #include "ShadeSettings.h" +#include "ShadeBehaviour.h" #include "ShadeMotion.h" #include "ShadeSensors.h" #include "ShadeChanges.h" @@ -37,13 +38,6 @@ class SomfyRoom { void unpublish(); }; -// What sending a command did. ticket is the radio job carrying it, 0 when the -// command went out over GPIO instead. -struct send_result_t { - bool sent = false; - uint32_t ticket = 0; - explicit operator bool() const { return this->sent; } -}; class SomfyRemote : public RemoteSettings { // These sizes for the data have been // confirmed. The address is actually 24bits @@ -77,7 +71,9 @@ class SomfyRemote : public RemoteSettings { // command. Nothing about the remote or shade state has changed in that case. virtual bool sendCommand(somfy_commands cmd); virtual bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); - send_result_t sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority = false); + // hearsItself leaves this remote out of the echo, for a shade whose + // ShadeBehaviour hears its own frame. + send_result_t sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority = false, bool hearsItself = false); bool sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat); bool repeatFrame(uint8_t repeat); virtual uint16_t p_lastRollingCode(uint16_t code); @@ -89,47 +85,16 @@ class SomfyLinkedRemote : public SomfyRemote { public: SomfyLinkedRemote(); }; -class SomfyShade : public SomfyRemote, public ShadeSettings { +// A shade: its identity, settings, remotes and outputs. What it does with +// frames and commands is its ShadeBehaviour; the shade sends for it +// (MotorLink) and passes on what it reports (ShadeEvents). +class SomfyShade : public SomfyRemote, public ShadeSettings, private MotorLink, private ShadeEvents { protected: uint8_t shadeId = 255; - uint64_t moveStart = 0; - uint64_t tiltStart = 0; - ShadeSensors sensors; - float startPos = 0.0f; - float startTiltPos = 0.0f; - bool settingMyPos = false; - bool settingPos = false; - bool settingTiltPos = false; - uint32_t awaitMy = 0; - // The flags bits that are state: sun flag, sunny, windy, demo mode and the - // unused lighted. The setting bits are in ShadeSettings and light. - uint8_t stateFlags = 0; - // Radio jobs whose outcome decides what the motor really did. - ShadeTxState tx; - bool sendStop(int8_t dir, bool tilt); - bool sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos); - // Makes pos/tiltPos the favorite and tells the motor to store it. - bool storeMyPosition(float pos, float tiltPos); - // Repeats for a tilt command, more for a separate tilt motor. - uint8_t tiltRepeats() const; - void runToLimit(int8_t dir, bool tilt, uint32_t since); - // Sets the targets the sun or wind sends the shade to. - void applySensorMove(SensorMove move); - // Sets the targets for a step in dir. False when the step is dropped. - bool applyStep(int8_t dir, uint8_t frameStepSize); public: uint8_t roomId = 0; int8_t sortOrder = 0; bool light = false; - float currentPos = 0.0f; - float currentTiltPos = 0.0f; - int8_t lastMovement = 0; - int8_t direction = 0; // 0 = stopped, 1=down, -1=up. - int8_t tiltDirection = 0; // 0=stopped, 1=clockwise, -1=counter clockwise - float target = 0.0f; - float tiltTarget = 0.0f; - float myPos = -1.0f; - float myTiltPos = -1.0f; SomfyLinkedRemote linkedRemotes[SOMFY_MAX_LINKED_REMOTES]; bool paired = false; int8_t validateJSON(JsonObject &obj); @@ -138,11 +103,12 @@ class SomfyShade : public SomfyRemote, public ShadeSettings { char name[21] = ""; void setShadeId(uint8_t id) { shadeId = id; } uint8_t getShadeId() { return shadeId; } + // Where the shade is and where it is going. + const ShadeBehaviour::State &state() const { return this->behaviour.state(); } uint8_t getFlags() override; // Sets the setting and state bits from a saved or sent flags byte. void setFlags(uint8_t flags); bool save(); - bool isIdle(); bool isInGroup(); void checkMovement(); void processFrame(somfy_frame_t &frame, bool internal = false); @@ -151,17 +117,19 @@ class SomfyShade : public SomfyRemote, public ShadeSettings { void processInternalCommand(somfy_commands cmd, uint8_t repeat = 1); void markTxPending(uint32_t ticket); void processTxEvent(const somfy_tx_event_t &evt); - void setTiltMovement(int8_t dir); - void setMovement(int8_t dir); - void setTarget(float target); - bool isAtTarget(); - bool isToggle(); + bool isToggle() { return isToggleShade(this->shadeType); } bool moveToTarget(float pos, float tilt = -1.0f); // These return false when the radio refused a command they sent. bool moveToTiltTarget(float target); bool sendTiltCommand(somfy_commands cmd); bool sendCommand(somfy_commands cmd); bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0); + // Puts the shade at pos and tilt (-1 leaves one alone) without moving it. + void setPosition(float pos, float tilt); + // Where the config file says the shade was. + void restore(float pos, float tiltPos, float myPos, float myTiltPos) { this->behaviour.restore(pos, tiltPos, myPos, myTiltPos); } + // Forgets the tilt of a shade that has none. + void clearTilt() { this->behaviour.clearTilt(); } bool linkRemote(uint32_t remoteAddress, uint16_t rollingCode = 0); bool unlinkRemote(uint32_t remoteAddress); // Marks the shade as changed; the change fan-out sends shadeState once per @@ -186,27 +154,11 @@ class SomfyShade : public SomfyRemote, public ShadeSettings { // Publishes the MQTT topics of the SHADE_CHANGED_* bits in changed. void publishTopics(uint16_t changed); void commit(); - void commitShadePosition(); - void commitTiltPosition(); - void commitMyPosition(); void clear(); int8_t transformPosition(float fpos); void setGPIOs(); void triggerGPIOs(somfy_frame_t &frame); bool usesPin(uint8_t pin); - // State Setters - int8_t p_direction(int8_t dir); - int8_t p_tiltDirection(int8_t dir); - float p_target(float target); - float p_tiltTarget(float target); - float p_myPos(float pos); - float p_myTiltPos(float pos); - bool p_flag(somfy_flags_t flag, bool val); - bool p_sunFlag(bool val); - bool p_sunny(bool val); - bool p_windy(bool val); - float p_currentPos(float pos); - float p_currentTiltPos(float pos); uint16_t p_lastRollingCode(uint16_t code); bool publish(const char *topic, const char *val, bool retain = false); bool publish(const char *topic, uint8_t val, bool retain = false); @@ -216,6 +168,15 @@ class SomfyShade : public SomfyRemote, public ShadeSettings { bool publish(const char *topic, bool val, bool retain = false); void publishDisco(); void unpublishDisco(); + private: + // MotorLink: the radio or GPIO path every remote sends through. + send_result_t send(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority) override; + // ShadeEvents + void onChanged(uint16_t bits) override { this->changed |= bits; } + void onCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress) override { this->emitCommand(cmd, source, sourceAddress); } + void onSaveWanted() override; + void onSensorFlags() override; + ShadeBehaviour behaviour{*this, *this, this->lastFrame, *this, *this, this->shadeId, this->name}; }; class SomfyGroup : public SomfyRemote { protected: @@ -305,8 +266,9 @@ class SomfyShadeController { SomfyGroup * findGroupByRemoteAddress(uint32_t address); // Returns the radio job's ticket, 0 when the radio can't take it. uint32_t sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false); - void processFrame(somfy_frame_t &frame, bool internal = false); - void markTxPending(uint32_t remoteAddress, uint32_t ticket); + // skip, when set, is left out: a shade that hears its own frame. + void processFrame(somfy_frame_t &frame, bool internal = false, const SomfyRemote *skip = nullptr); + void markTxPending(uint32_t remoteAddress, uint32_t ticket, const SomfyRemote *skip = nullptr); void processTxEvent(const somfy_tx_event_t &evt); void emitState(uint8_t num = 255); // The change fan-out: sends every shade and group that changed since the diff --git a/src/SomfyFrame.cpp b/src/SomfyFrame.cpp index 893f2af..e590548 100644 --- a/src/SomfyFrame.cpp +++ b/src/SomfyFrame.cpp @@ -431,3 +431,17 @@ void somfy_frame_t::copy(somfy_frame_t &frame) { this->repeats = frame.repeats; } } +bool startsMovement(somfy_commands cmd) { + switch(cmd) { + case somfy_commands::My: + case somfy_commands::Up: + case somfy_commands::Down: + case somfy_commands::StepUp: + case somfy_commands::StepDown: + case somfy_commands::Toggle: + case somfy_commands::Favorite: + return true; + default: + return false; + } +} diff --git a/src/SomfyFrame.h b/src/SomfyFrame.h index d578f73..2f27e4a 100644 --- a/src/SomfyFrame.h +++ b/src/SomfyFrame.h @@ -39,6 +39,8 @@ somfy_commands translateSomfyCommand(const String& string); somfy_commands flipCommand(somfy_commands cmd); // The command that turns the sun flag on (SunFlag) or off (Flag). somfy_commands sunFlagCommand(bool on); +// Whether cmd can set a shade moving. +bool startsMovement(somfy_commands cmd); struct somfy_rx_t; struct somfy_frame_t { diff --git a/src/SomfyRemote.cpp b/src/SomfyRemote.cpp index 27c5417..484df1a 100644 --- a/src/SomfyRemote.cpp +++ b/src/SomfyRemote.cpp @@ -7,21 +7,6 @@ static const char *TAG = "Somfy"; extern SomfyShadeController somfy; -// Commands that can set a shade moving. -static bool startsMovement(somfy_commands cmd) { - switch(cmd) { - case somfy_commands::My: - case somfy_commands::Up: - case somfy_commands::Down: - case somfy_commands::StepUp: - case somfy_commands::StepDown: - case somfy_commands::Toggle: - case somfy_commands::Favorite: - return true; - default: - return false; - } -} void SomfyRemote::setRemoteAddress(uint32_t address) { this->m_remoteAddress = address; this->m_rollingCodeSynced = false; snprintf(this->m_remotePrefId, sizeof(this->m_remotePrefId), "_%lu", (unsigned long)this->m_remoteAddress); } uint32_t SomfyRemote::getRemoteAddress() { return this->m_remoteAddress; } somfy_commands SomfyRemote::transformCommand(somfy_commands cmd) { return this->flipCommands ? flipCommand(cmd) : cmd; } @@ -52,7 +37,7 @@ bool SomfyRemote::sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repe } bool SomfyRemote::sendCommand(somfy_commands cmd) { return this->sendCommand(cmd, this->repeats); } bool SomfyRemote::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { return this->sendCommandAfter(cmd, repeat, stepSize, 0).sent; } -send_result_t SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority) { +send_result_t SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority, bool hearsItself) { // Build the frame aside and only take the rolling code once the frame is // queued so a dropped command leaves the remote untouched. somfy_frame_t frame = this->lastFrame; @@ -94,10 +79,12 @@ send_result_t SomfyRemote::sendCommandAfter(somfy_commands cmd, uint8_t repeat, result.sent = true; this->commitRollingCode(frame.rollingCode); this->lastFrame = frame; - somfy.processFrame(this->lastFrame, true); + // A shade that hears its own frame (ShadeBehaviour) is left out here. + const SomfyRemote *skip = hearsItself ? this : nullptr; + somfy.processFrame(this->lastFrame, true, skip); // Idle shades (and group members) this sets moving wait for the frame to go // on air. Stops are sent ahead of everything else so they don't need to. - if(result.ticket != 0 && !priority && startsMovement(frame.cmd)) somfy.markTxPending(frame.remoteAddress, result.ticket); + if(result.ticket != 0 && !priority && startsMovement(frame.cmd)) somfy.markTxPending(frame.remoteAddress, result.ticket, skip); return result; } bool SomfyRemote::isLastCommand(somfy_commands cmd) { diff --git a/src/SomfyShade.cpp b/src/SomfyShade.cpp index 1b42aa2..cf12c75 100644 --- a/src/SomfyShade.cpp +++ b/src/SomfyShade.cpp @@ -1,45 +1,21 @@ #include #include "esp_log.h" #include "Somfy.h" -#include "ShadeMotion.h" static const char *TAG = "Somfy"; extern SomfyShadeController somfy; -#define SETMY_REPEATS 35 -#define TILT_REPEATS 15 - -// How long a shade's simulated movement waits for its command to go on air -// before it starts anyway in case the event was lost. -#define TX_PENDING_TIMEOUT 30000 - void SomfyShade::clear() { this->setShadeId(255); this->setRemoteAddress(0); - this->moveStart = 0; - this->tiltStart = 0; - this->sensors.clear(); - this->startPos = 0.0f; - this->startTiltPos = 0.0f; - this->settingMyPos = false; - this->settingPos = false; - this->settingTiltPos = false; - this->awaitMy = 0; + this->behaviour.clear(); this->flipPosition = false; this->flipCommands = false; this->lastRollingCode = 0; this->shadeType = shade_types::roller; this->tiltType = tilt_types::none; //this->txQueue.clear(); - this->currentPos = 0.0f; - this->currentTiltPos = 0.0f; - this->direction = 0; - this->tiltDirection = 0; - this->target = 0.0f; - this->tiltTarget = 0.0f; - this->myPos = -1.0f; - this->myTiltPos = -1.0f; this->bitLength = somfy.transceiver.config.type; this->proto = somfy.transceiver.config.proto; for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) @@ -74,18 +50,6 @@ bool SomfyShade::linkRemote(uint32_t address, uint16_t rollingCode) { return false; } void SomfyShade::commit() { somfy.commit(); } -void SomfyShade::commitShadePosition() { - Preferences pref; - somfy.isDirty = true; -} -void SomfyShade::commitMyPosition() { - Preferences pref; - somfy.isDirty = true; -} -void SomfyShade::commitTiltPosition() { - Preferences pref; - somfy.isDirty = true; -} bool SomfyShade::unlinkRemote(uint32_t address) { Preferences pref; for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) { @@ -97,13 +61,6 @@ bool SomfyShade::unlinkRemote(uint32_t address) { } return false; } -bool SomfyShade::isAtTarget() { - float epsilon = .00001; - if(this->tiltType == tilt_types::tiltonly) return fabs(this->currentTiltPos - this->tiltTarget) < epsilon; - else if(this->tiltType == tilt_types::none) return fabs(this->currentPos - this->target) < epsilon; - return fabs(this->currentPos - this->target) < epsilon && fabs(this->currentTiltPos - this->tiltTarget) < epsilon; -} - bool SomfyShade::isInGroup() { if(this->getShadeId() == 255) return false; for(uint8_t i = 0; i < SOMFY_MAX_GROUPS; i++) { @@ -111,408 +68,18 @@ bool SomfyShade::isInGroup() { } return false; } -void SomfyShade::checkMovement() { - const uint64_t curTime = millis(); - const bool sunFlag = this->stateFlags & static_cast(somfy_flags_t::SunFlag); - const bool isSunny = this->stateFlags & static_cast(somfy_flags_t::Sunny); - const bool isWindy = this->stateFlags & static_cast(somfy_flags_t::Windy); - // We need to first evaluate the sensor flags as these could be triggering movement from previous sensor inputs. So - // we must check this before setting the directional items or it will not get processed until the next loop. - int32_t downTime = (int32_t)this->downTime; - int32_t upTime = (int32_t)this->upTime; - int32_t tiltTime = (int32_t)this->tiltTime; - if(this->shadeType == shade_types::drycontact || this->shadeType == shade_types::drycontact2) downTime = upTime = tiltTime = 1; - - - // We are checking movement for essentially 3 types of motors. - // If this is an integrated tilt we need to first tilt in the direction we are moving then move. We know - // what needs to be done by the tilt type. Set a tilt first flag to indicate whether we should be tilting or - // moving. If this is only a tilt action then the regular tilt action should operate fine. - int8_t currDir = this->direction; - int8_t currTiltDir = this->tiltDirection; - this->p_direction(this->currentPos == this->target ? 0 : this->currentPos > this->target ? -1 : 1); - bool tilt_first = this->tiltType == tilt_types::integrated && ((this->direction == -1 && this->currentTiltPos != 0.0f) || (this->direction == 1 && this->currentTiltPos != 100.0f)); - - this->p_tiltDirection(this->currentTiltPos == this->tiltTarget ? 0 : this->currentTiltPos > this->tiltTarget ? -1 : 1); - if(tilt_first) this->p_tiltDirection(this->direction); - else if(this->direction != 0) this->p_tiltDirection(0); - uint8_t currPos = floor(this->currentPos); - uint8_t currTiltPos = floor(this->currentTiltPos); - if(this->direction != 0) this->lastMovement = this->direction; - switch(this->tx.hold.check(this->direction != 0 || this->tiltDirection != 0, (uint32_t)curTime, TX_PENDING_TIMEOUT)) { - case TxHoldCheck::holding: - // The command that set us moving hasn't gone on air yet so the motor - // hasn't started. Hold the simulated position until it does. - this->moveStart = this->tiltStart = curTime; - if(currDir != this->direction || currTiltDir != this->tiltDirection) this->emitState(); - return; - case TxHoldCheck::expired: - ESP_LOGW(TAG, "[Shade %u] Timed out waiting for the radio, moving anyway", this->shadeId); - break; - case TxHoldCheck::none: - break; - } - this->applySensorMove(this->sensors.check(sunFlag, isSunny, isWindy, (uint32_t)curTime)); - - if(!tilt_first && this->direction > 0) { - this->p_currentPos(travelPosition(this->startPos, 1, (uint32_t)(curTime - this->moveStart), downTime)); - if(this->currentPos >= this->target) { - this->p_currentPos(this->target); - //if(this->settingMyPos) Serial.printf("IsAtTarget: %d %f=%f\n", this->isAtTarget(), this->currentPos, this->target); - // If we need to stop the shade do this before we indicate that we are - // not moving otherwise the my function will kick in. - if(this->settingPos) { - // At the end limit the motor stops by itself. If the stop doesn't go - // out the shade is now running on to the limit so leave the tilt be. - if(this->target == 100.0 || this->sendStop(1, false)) { - if(!isAtTarget()) { - ESP_LOGD(TAG, "We are not at our tilt target: %.2f", this->tiltTarget); - // We now need to move the tilt to the position we requested. - this->moveToTiltTarget(this->tiltTarget); - } - } - } - this->p_direction(0); - this->tiltStart = curTime; - this->startTiltPos = this->currentTiltPos; - if(this->isAtTarget()) this->commitShadePosition(); - } - } - else if(!tilt_first && this->direction < 0) { - // Shades often move slower up so this uses its own travel time. - this->p_currentPos(travelPosition(this->startPos, -1, (uint32_t)(curTime - this->moveStart), upTime)); - if(this->currentPos <= this->target) { - this->p_currentPos(this->target); - //if(this->settingMyPos) Serial.printf("IsAtTarget: %d %f=%f\n", this->isAtTarget(), this->currentPos, this->target); - - // If we need to stop the shade do this before we indicate that we are - // not moving otherwise the my function will kick in. - if(this->settingPos) { - // At the end limit the motor stops by itself. If the stop doesn't go - // out the shade is now running on to the limit so leave the tilt be. - if(this->target == 0.0 || this->sendStop(-1, false)) { - if(!isAtTarget()) { - ESP_LOGD(TAG, "We are not at our tilt target: %.2f", this->tiltTarget); - // We now need to move the tilt to the position we requested. - this->moveToTiltTarget(this->tiltTarget); - } - } - } - this->p_direction(0); - this->tiltStart = curTime; - this->startTiltPos = this->currentTiltPos; - if(this->isAtTarget()) this->commitShadePosition(); - } - } - if(this->tiltDirection > 0) { - if(tilt_first) this->moveStart = curTime; - this->p_currentTiltPos(travelPosition(this->startTiltPos, 1, (uint32_t)(curTime - this->tiltStart), tiltTime)); - if(tilt_first) { - if(this->currentTiltPos >= 100.0f) { - this->p_currentTiltPos(100.0f); - this->moveStart = curTime; - this->startPos = this->currentPos; - //this->p_tiltDirection(0); - //Serial.println("Setting tiltDirection to 0 (tilt_first)"); - } - } - else if(this->currentTiltPos >= this->tiltTarget) { - this->p_currentTiltPos(this->tiltTarget); - // If we need to stop the shade do this before we indicate that we are - // not moving otherwise the my function will kick in. - if(this->settingTiltPos) { - if(this->tiltType == tilt_types::integrated) { - // If this is an integrated tilt mechanism the we will simply let it finish. If it is not then we will stop it. - //Serial.printf("Sending My -- tiltTarget: %.2f, tiltDirection: %d\n", this->tiltTarget, this->tiltDirection); - if(this->tiltTarget != 100.0f || this->currentPos != 100.0f) this->sendStop(1, true); - } - else { - // This is a tilt motor so let it complete if it is going to 100. - if(this->tiltTarget != 100.0f) this->sendStop(1, true); - } - } - this->p_tiltDirection(0); - this->settingTiltPos = false; - if(this->isAtTarget()) this->commitShadePosition(); - } - } - else if(this->tiltDirection < 0) { - if(tilt_first) this->moveStart = curTime; - if(tiltTime == 0) this->p_tiltDirection(0); - this->p_currentTiltPos(travelPosition(this->startTiltPos, -1, (uint32_t)(curTime - this->tiltStart), tiltTime)); - if(tilt_first) { - if(this->currentTiltPos <= 0.0f) { - this->p_currentTiltPos(0.0f); - this->moveStart = curTime; - this->startPos = this->currentPos; - //this->p_tiltDirection(0); - } - } - else if(this->currentTiltPos <= this->tiltTarget) { - this->p_currentTiltPos(this->tiltTarget); - // If we need to stop the shade do this before we indicate that we are - // not moving otherwise the my function will kick in. - if(this->settingTiltPos) { - if(this->tiltType == tilt_types::integrated) { - // If this is an integrated tilt mechanism the we will simply let it finish. If it is not then we will stop it. - //Serial.printf("Sending My -- tiltTarget: %.2f, tiltDirection: %d\n", this->tiltTarget, this->tiltDirection); - if(this->tiltTarget != 0.0 || this->currentPos != 0.0) this->sendStop(-1, true); - } - else { - // This is a tilt motor so let it complete if it is going to 0. - if(this->tiltTarget != 0.0) this->sendStop(-1, true); - } - } - this->p_tiltDirection(0); - this->settingTiltPos = false; - ESP_LOGI(TAG, "Stopping at tilt position"); - if(this->isAtTarget()) this->commitShadePosition(); - } - } - if(this->settingMyPos && this->isAtTarget()) { - // Set this position before sending the command. If you don't the processFrame function - // will send the shade back to its original My position. - const float oldMyPos = this->myPos; - const float oldMyTiltPos = this->myTiltPos; - if(this->tiltType != tilt_types::none) { - if(this->myTiltPos == this->currentTiltPos && this->myPos == this->currentPos) this->myPos = this->myTiltPos = -1; - else { - this->p_myPos(this->currentPos); - this->p_myTiltPos(this->currentTiltPos); - } - } - else { - this->p_myTiltPos(-1); - if(this->myPos == this->currentPos) this->p_myPos(-1); - else this->p_myPos(this->currentPos); - } - // Give the motor time to settle at the target before it is told to store it. - this->sendSetMy(200, oldMyPos, oldMyTiltPos); - this->settingMyPos = false; - this->emitState(); - } - else if(currDir != this->direction || currPos != floor(this->currentPos) || currTiltDir != this->tiltDirection || currTiltPos != floor(this->currentTiltPos)) { - // We need to emit on the socket that our state has changed. - this->emitState(); - } -} - - -// State Setters. Each marks what clients see as changed when it does change; -// the change fan-out publishes it once per loop() pass. -float SomfyShade::p_currentPos(float pos) { - float old = this->currentPos; - this->currentPos = pos; - if(floor(old) != floor(pos)) this->changed |= SHADE_CHANGED_POSITION; - return old; -} -float SomfyShade::p_currentTiltPos(float pos) { - float old = this->currentTiltPos; - this->currentTiltPos = pos; - if(floor(old) != floor(pos)) this->changed |= SHADE_CHANGED_TILT_POSITION; - return old; -} uint16_t SomfyShade::p_lastRollingCode(uint16_t code) { uint16_t old = SomfyRemote::p_lastRollingCode(code); if(old != code) this->changed |= SHADE_CHANGED_ROLLING_CODE; return old; } -bool SomfyShade::p_flag(somfy_flags_t flag, bool val) { - bool old = !!(this->stateFlags & static_cast(flag)); - if(val) - this->stateFlags |= static_cast(flag); - else - this->stateFlags &= ~(static_cast(flag)); - return old; +uint8_t SomfyShade::getFlags() { return joinShadeFlags(*this, this->light, this->state().stateFlags); } +void SomfyShade::setFlags(uint8_t flags) { + uint8_t stateFlags = 0; + splitShadeFlags(flags, *this, this->light, stateFlags); + this->behaviour.setStateFlags(stateFlags); } -uint8_t SomfyShade::getFlags() { return joinShadeFlags(*this, this->light, this->stateFlags); } -void SomfyShade::setFlags(uint8_t flags) { splitShadeFlags(flags, *this, this->light, this->stateFlags); } -bool SomfyShade::p_sunFlag(bool val) { - bool old = this->p_flag(somfy_flags_t::SunFlag, val); - if(old != val) this->changed |= SHADE_CHANGED_SUN_FLAG; - return old; -} -bool SomfyShade::p_windy(bool val) { - bool old = this->p_flag(somfy_flags_t::Windy, val); - if(old != val) this->changed |= SHADE_CHANGED_WINDY; - return old; -} -bool SomfyShade::p_sunny(bool val) { - bool old = this->p_flag(somfy_flags_t::Sunny, val); - if(old != val) this->changed |= SHADE_CHANGED_SUNNY; - return old; -} -int8_t SomfyShade::p_direction(int8_t dir) { - int8_t old = this->direction; - if(old != dir) { - this->direction = dir; - this->changed |= SHADE_CHANGED_DIRECTION; - } - return old; -} -int8_t SomfyShade::p_tiltDirection(int8_t dir) { - int8_t old = this->tiltDirection; - if(old != dir) { - this->tiltDirection = dir; - this->changed |= SHADE_CHANGED_TILT_DIRECTION; - } - return old; -} -float SomfyShade::p_target(float target) { - float old = this->target; - if(old != target) { - this->target = target; - if(this->transformPosition(old) != this->transformPosition(target)) - this->changed |= SHADE_CHANGED_TARGET; - } - return old; -} -float SomfyShade::p_tiltTarget(float target) { - float old = this->tiltTarget; - if(old != target) { - this->tiltTarget = target; - if(this->transformPosition(old) != this->transformPosition(target)) - this->changed |= SHADE_CHANGED_TILT_TARGET; - } - return old; -} -float SomfyShade::p_myPos(float pos) { - float old = this->myPos; - if(old != pos) { - this->myPos = pos; - if(this->transformPosition(old) != this->transformPosition(pos)) - this->changed |= SHADE_CHANGED_MY_POS; - } - return old; -} -float SomfyShade::p_myTiltPos(float pos) { - float old = this->myTiltPos; - if(old != pos) { - this->myTiltPos = pos; - if(this->transformPosition(old) != this->transformPosition(pos)) - this->changed |= SHADE_CHANGED_MY_TILT_POS; - } - return old; -} - int8_t SomfyShade::transformPosition(float fpos) { return displayPosition(fpos, this->flipPosition); } -bool SomfyShade::isIdle() { - return this->isAtTarget() && this->direction == 0 && this->tiltDirection == 0; -} -void SomfyShade::processWaitingFrame() { - if(this->shadeId == 255) { - this->lastFrame.await = 0; - return; - } - if(this->lastFrame.processed) return; - if(this->lastFrame.await > 0 && (millis() > this->lastFrame.await)) { - somfy_commands cmd = this->transformCommand(this->lastFrame.cmd); - switch(cmd) { - case somfy_commands::StepUp: - this->lastFrame.processed = true; - // Simply move the shade up by 1%. - if(this->currentPos > 0) { - this->p_target(floor(this->currentPos) - 1); - this->setMovement(-1); - this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); - } - break; - case somfy_commands::StepDown: - this->lastFrame.processed = true; - // Simply move the shade down by 1%. - if(this->currentPos < 100) { - this->p_target(floor(this->currentPos) + 1); - this->setMovement(1); - this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); - } - break; - case somfy_commands::Down: - case somfy_commands::Up: - if(this->tiltType == tilt_types::tiltmotor) { // Theoretically this should get here unless it does have a tilt motor. - if(this->lastFrame.repeats >= TILT_REPEATS) { - int8_t dir = this->lastFrame.cmd == somfy_commands::Up ? -1 : 1; - this->p_tiltTarget(dir > 0 ? 100.0f : 0.0f); - this->setTiltMovement(dir); - this->lastFrame.processed = true; - ESP_LOGD(TAG, "%s Processing tilt %s after %d repeats", - this->name, translateSomfyCommand(this->lastFrame.cmd).c_str(), this->lastFrame.repeats); - this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); - } - else { - int8_t dir = this->lastFrame.cmd == somfy_commands::Up ? -1 : 1; - this->p_target(dir > 0 ? 100 : 0); - this->setMovement(dir); - this->lastFrame.processed = true; - this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); - } - if(this->lastFrame.repeats > TILT_REPEATS + 2) { - this->lastFrame.processed = true; - this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); - } - } - else if(this->tiltType == tilt_types::euromode) { - if(this->lastFrame.repeats >= TILT_REPEATS) { - int8_t dir = this->lastFrame.cmd == somfy_commands::Up ? -1 : 1; - this->p_target(dir > 0 ? 100.0f : 0.0f); - this->setMovement(dir); - this->lastFrame.processed = true; - ESP_LOGD(TAG, "%s Processing %s after %d repeats", - this->name, translateSomfyCommand(this->lastFrame.cmd).c_str(), this->lastFrame.repeats); - this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); - } - else { - int8_t dir = this->lastFrame.cmd == somfy_commands::Up ? -1 : 1; - this->p_tiltTarget(dir > 0 ? 100 : 0); - this->setTiltMovement(dir); - this->lastFrame.processed = true; - this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); - } - if(this->lastFrame.repeats > TILT_REPEATS + 2) { - this->lastFrame.processed = true; - this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); - } - } - break; - case somfy_commands::My: - if(this->lastFrame.repeats >= SETMY_REPEATS && this->isIdle()) { - if(floor(this->myPos) == floor(this->currentPos)) { - // We are clearing it. - this->p_myPos(-1); - this->p_myTiltPos(-1); - } - else { - this->p_myPos(this->currentPos); - this->p_myTiltPos(this->currentTiltPos); - } - this->commitMyPosition(); - this->lastFrame.processed = true; - this->emitState(); - } - else if(this->isIdle()) { - if(this->simMy) - this->moveToMyPosition(); // Call out like this (instead of move to target) so that we don't get some of the goofy tilt only problems. - else { - if(this->myPos >= 0.0f && this->myPos <= 100.0f) this->p_target(this->myPos); - if(this->myTiltPos >= 0.0f && this->myTiltPos <= 100.0f) this->p_tiltTarget(this->myTiltPos); - } - this->setMovement(0); - this->lastFrame.processed = true; - this->emitCommand(cmd, "remote", this->lastFrame.remoteAddress); - } - else { - this->p_target(this->currentPos); - this->p_tiltTarget(this->currentTiltPos); - } - if(this->lastFrame.repeats > SETMY_REPEATS + 2) this->lastFrame.processed = true; - if(this->lastFrame.processed) { - ESP_LOGD(TAG, "%s Processing MY after %d repeats", this->name, this->lastFrame.repeats); - } - break; - default: - break; - } - } -} SomfyRemote *SomfyShade::findRemote(uint32_t address) { if(this->getRemoteAddress() == address) return this; for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) { @@ -528,776 +95,34 @@ void SomfyShade::processFrame(somfy_frame_t &frame, bool internal) { SomfyRemote *remote = this->findRemote(frame.remoteAddress); if(!remote) return; if(remote != this && frame.cmd != somfy_commands::Sensor) remote->setRollingCode(frame.rollingCode); - const uint64_t curTime = millis(); - this->lastFrame.copy(frame); - int8_t dir = 0; - this->moveStart = this->tiltStart = curTime; - this->startPos = this->currentPos; - this->startTiltPos = this->currentTiltPos; - // If the command is coming from a remote then we are aborting all these positioning operations. - if(!internal) this->settingMyPos = this->settingPos = this->settingTiltPos = false; - somfy_commands cmd = this->transformCommand(frame.cmd); - // At this point we are not processing the combo buttons - // will need to see what the shade does when you press both. - switch(cmd) { - case somfy_commands::Sensor: - this->lastFrame.processed = true; - if(this->shadeType == shade_types::drycontact || this->shadeType == shade_types::drycontact2) return; - { - const uint8_t prevFlags = this->stateFlags; - const bool wasSunny = prevFlags & static_cast(somfy_flags_t::Sunny); - const bool wasWindy = prevFlags & static_cast(somfy_flags_t::Windy); - const uint16_t status = frame.rollingCode << 4; - if (status & static_cast(somfy_flags_t::Sunny)) - this->p_sunny(true); - //this->flags |= static_cast(somfy_flags_t::Sunny); - else - this->p_sunny(false); - //this->flags &= ~(static_cast(somfy_flags_t::Sunny)); - if (status & static_cast(somfy_flags_t::Windy)) - this->p_windy(true); - //this->flags |= static_cast(somfy_flags_t::Windy); - else - this->p_windy(false); - //this->flags &= ~(static_cast(somfy_flags_t::Windy)); - if(frame.rollingCode & static_cast(somfy_flags_t::DemoMode)) - this->stateFlags |= static_cast(somfy_flags_t::DemoMode); - else - this->stateFlags &= ~(static_cast(somfy_flags_t::DemoMode)); - const bool isSunny = this->stateFlags & static_cast(somfy_flags_t::Sunny); - const bool isWindy = this->stateFlags & static_cast(somfy_flags_t::Windy); - this->sensors.onSensorFrame(wasSunny, wasWindy, isSunny, isWindy, (uint32_t)curTime); - this->emitState(); - somfy.updateGroupFlags(); - } - break; - case somfy_commands::Prog: - case somfy_commands::MyUp: - case somfy_commands::MyDown: - case somfy_commands::MyUpDown: - case somfy_commands::UpDown: - this->lastFrame.processed = true; - if(this->shadeType == shade_types::drycontact || this->shadeType == shade_types::drycontact2) return; - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - break; - - case somfy_commands::Flag: - this->lastFrame.processed = true; - if(this->shadeType == shade_types::drycontact || this->shadeType == shade_types::drycontact2) return; - if(this->lastFrame.rollingCode & 0x8000) return; // Some sensors send bogus frames with a rollingCode >= 32768 that cause them to change the state. - this->p_sunFlag(false); - //this->flags &= ~(static_cast(somfy_flags_t::SunFlag)); - somfy.isDirty = true; - this->emitState(); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - somfy.updateGroupFlags(); - break; - case somfy_commands::SunFlag: - if(this->shadeType == shade_types::drycontact || this->shadeType == shade_types::drycontact2) return; - 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->stateFlags & static_cast(somfy_flags_t::Windy); - const bool isSunny = this->stateFlags & static_cast(somfy_flags_t::Sunny); - //this->flags |= static_cast(somfy_flags_t::SunFlag); - this->p_sunFlag(true); - this->applySensorMove(this->sensors.onSunFlag(isSunny, isWindy)); - somfy.isDirty = true; - this->emitState(); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - somfy.updateGroupFlags(); - } - break; - case somfy_commands::Up: - if(this->shadeType == shade_types::drycontact) { - this->lastFrame.processed = true; - return; - } - else if(this->shadeType == shade_types::drycontact2) { - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - if(this->currentPos != 0.0f) this->p_target(0); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - return; - } - if(this->tiltType == tilt_types::tiltmotor || this->tiltType == tilt_types::euromode) { - // Wait another half second just in case we are potentially processing a tilt. - if(!internal) this->lastFrame.await = curTime + 500; - else this->lastFrame.processed = true; - } - else { - // If from a remote we will simply be going up. - if(this->tiltType == tilt_types::tiltonly && !internal) this->p_tiltTarget(0.0f); - else if(!internal) { - if(this->tiltType != tilt_types::tiltonly) this->p_target(0.0f); - this->p_tiltTarget(0.0f); - } - this->lastFrame.processed = true; - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - } - break; - case somfy_commands::Down: - if(this->shadeType == shade_types::drycontact) { - this->lastFrame.processed = true; - return; - } - else if(this->shadeType == shade_types::drycontact2) { - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - if(this->currentPos != 100.0f) this->p_target(100); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - return; - } - 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; - else this->lastFrame.processed = true; - } - else { - this->lastFrame.processed = true; - if(!internal) { - if(this->tiltType != tilt_types::tiltonly) this->p_target(100.0f); - if(this->tiltType != tilt_types::none) this->p_tiltTarget(100.0f); - } - } - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - } - break; - case somfy_commands::My: - if(this->shadeType == shade_types::drycontact2) return; - if(this->isToggle()) { // This is a one button device - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - if(!this->isIdle()) this->p_target(this->currentPos); - else if(this->currentPos == 100.0f) this->p_target(0.0f); - else if(this->currentPos == 0.0f) this->p_target(100.0f); - else this->p_target(this->lastMovement == -1 ? 100 : 0); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - return; - } - else if(this->shadeType == shade_types::drycontact) { - // In this case we need to toggle the contact but we only should do this if - // this is not a repeat. - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - if(this->currentPos == 100.0f) this->p_target(0); - else if(this->currentPos == 0.0f) this->p_target(100); - else this->p_target(this->lastMovement == -1 ? 100 : 0); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - return; - } - if(this->isIdle()) { - if(!internal) { - // This frame is coming from a remote. We are potentially setting - // the my position. - this->lastFrame.await = curTime + 500; - } - else { - if(this->lastFrame.processed) return; - ESP_LOGI(TAG, "Moving to My target"); - this->lastFrame.processed = true; - if(this->myTiltPos >= 0.0f && this->myTiltPos <= 100.0f) this->p_tiltTarget(this->myTiltPos); - if(this->myPos >= 0.0f && this->myPos <= 100.0f && this->tiltType != tilt_types::tiltonly) this->p_target(this->myPos); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - } - } - else { - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - if(!internal) { - if(this->tiltType != tilt_types::tiltonly) this->p_target(this->currentPos); - this->p_tiltTarget(this->currentTiltPos); - } - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - } - break; - case somfy_commands::StepUp: - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - if(this->shadeType == shade_types::drycontact || this->shadeType == shade_types::drycontact2) return; - dir = 0; - if(this->lastFrame.stepSize == 0) this->lastFrame.stepSize = 1; - if(!this->applyStep(-1, this->lastFrame.stepSize)) return; - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - break; - case somfy_commands::StepDown: - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - if(this->shadeType == shade_types::drycontact || this->shadeType == shade_types::drycontact2) return; - dir = 1; - if(this->lastFrame.stepSize == 0) this->lastFrame.stepSize = 1; - if(!this->applyStep(1, this->lastFrame.stepSize)) return; - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - break; - case somfy_commands::Toggle: - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - if(!this->isIdle()) this->p_target(this->currentPos); - else if(this->currentPos == 100.0f) this->p_target(0); - else if(this->currentPos == 0.0f) this->p_target(100); - else this->p_target(this->lastMovement == -1 ? 100 : 0); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - break; - case somfy_commands::Stop: - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - this->p_target(this->currentPos); - this->p_tiltTarget(this->currentTiltPos); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - break; - case somfy_commands::Favorite: - if(this->lastFrame.processed) return; - this->lastFrame.processed = true; - if(this->simMy) { - this->moveToMyPosition(); - } - else { - if(this->myTiltPos >= 0.0f && this->myTiltPos <= 100.0f) this->p_tiltTarget(this->myTiltPos); - if(this->myPos >= 0.0f && this->myPos <= 100.0f && this->tiltType != tilt_types::tiltonly) this->p_target(this->myPos); - this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); - } - break; - default: - dir = 0; - break; - } - //if(dir == 0 && this->tiltType == tilt_types::tiltmotor && this->tiltDirection != 0) this->setTiltMovement(0); - this->setMovement(dir); -} -void SomfyShade::processInternalCommand(somfy_commands cmd, uint8_t repeat) { - // The reason why we are processing all frames here is so - // any linked remotes that may happen to be on the same ESPSomfy RTS - // device can trigger the appropriate actions. - if(this->shadeId == 255) return; - const uint64_t curTime = millis(); - int8_t dir = 0; - this->moveStart = this->tiltStart = curTime; - this->startPos = this->currentPos; - this->startTiltPos = this->currentTiltPos; - // If the command is coming from a remote then we are aborting all these positioning operations. - switch(cmd) { - case somfy_commands::Up: - if(this->tiltType == tilt_types::tiltmotor) { - if(repeat >= TILT_REPEATS) - this->p_tiltTarget(0.0f); - else - this->p_target(0.0f); - } - else if(this->tiltType == tilt_types::tiltonly) { - this->p_target(100.0f); - this->p_currentPos(100.0f); - this->p_tiltTarget(0.0f); - } - else { - this->p_target(0.0f); - this->p_tiltTarget(0.0f); - } - break; - case somfy_commands::Down: - if (!this->sensors.windBlocksDown((uint32_t)curTime)) { - if(this->tiltType == tilt_types::tiltmotor) { - if(repeat >= TILT_REPEATS) - this->p_tiltTarget(100.0f); - else - this->p_target(100.0f); - } - else if(this->tiltType == tilt_types::tiltonly) { - this->p_target(100.0f); - this->p_currentPos(100.0f); - this->p_tiltTarget(100.0f); - } - else { - this->p_target(100.0f); - if(this->tiltType != tilt_types::none) this->p_tiltTarget(100.0f); - } - } - break; - case somfy_commands::My: - if(this->isIdle()) { - ESP_LOGD(TAG, "Shade #%d is idle", this->getShadeId()); - if(this->simMy) { - this->moveToMyPosition(); - } - else { - if(this->myTiltPos >= 0.0f && this->myTiltPos <= 100.0f) this->p_tiltTarget(this->myTiltPos); - if(this->myPos >= 0.0f && this->myPos <= 100.0f && this->tiltType != tilt_types::tiltonly) this->p_target(this->myPos); - } - } - else { - if(this->tiltType == tilt_types::tiltonly) { - this->p_target(100.0f); - } - else this->p_target(this->currentPos); - this->p_tiltTarget(this->currentTiltPos); - } - break; - case somfy_commands::StepUp: - // A group step is always one step. - if(!this->applyStep(-1, 1)) return; - break; - case somfy_commands::StepDown: - dir = 1; - if(!this->applyStep(1, 1)) return; - break; - case somfy_commands::Flag: - this->p_sunFlag(false); - if(this->sunSensor) { - somfy.isDirty = true; - this->emitState(); - } - else { - ESP_LOGW(TAG, "Shade does not have sensor %d", this->getFlags()); - } - break; - case somfy_commands::SunFlag: - if(this->sunSensor) { - const bool isWindy = this->stateFlags & static_cast(somfy_flags_t::Windy); - const bool isSunny = this->stateFlags & static_cast(somfy_flags_t::Sunny); - this->p_sunFlag(true); - //this->flags |= static_cast(somfy_flags_t::SunFlag); - this->applySensorMove(this->sensors.onSunFlag(isSunny, isWindy)); - somfy.isDirty = true; - this->emitState(); - } - else - ESP_LOGW(TAG, "Shade does not have sensor %d", this->getFlags()); - break; - default: - dir = 0; - break; - } - this->setMovement(dir); -} -void SomfyShade::setTiltMovement(int8_t dir) { - int8_t currDir = this->tiltDirection; - if(dir == 0) { - // The shade tilt is stopped. - this->startTiltPos = this->currentTiltPos; - this->tiltStart = 0; - this->p_tiltDirection(dir); - if(currDir != dir) { - this->commitTiltPosition(); - } - } - else if(this->tiltDirection != dir) { - this->tiltStart = millis(); - this->startTiltPos = this->currentTiltPos; - this->p_tiltDirection(dir); - } - if(this->tiltDirection != currDir) { - this->emitState(); - } -} -void SomfyShade::markTxPending(uint32_t ticket) { - // This runs before the caller sets the new target so direction still says - // whether the shade was moving. A reversal is simulated as soon as it is - // queued even though the motor turns once it is on air. See TxHold::mark - // for which of several queued commands is held. - this->tx.hold.mark(ticket, this->direction != 0 || this->tiltDirection != 0, millis()); -} -void SomfyShade::processTxEvent(const somfy_tx_event_t &evt) { - const ShadeTxActions act = this->tx.onEvent(evt, this->direction != 0 || this->tiltDirection != 0); - if(act.startMovement) { - // The motor starts now so the simulated movement does too. - this->moveStart = this->tiltStart = act.startAt; - } - if(act.cancelMovement) { - // The motor never got the command so it hasn't moved. - ESP_LOGW(TAG, "[Shade %u] Command dropped by the radio, staying at %.2f%%", this->shadeId, this->currentPos); - this->settingPos = this->settingTiltPos = this->settingMyPos = false; - this->p_target(this->currentPos); - this->p_tiltTarget(this->currentTiltPos); - this->emitState(); - } - if(act.commitMy) this->commitMyPosition(); - if(act.restoreMy) { - ESP_LOGW(TAG, "[Shade %u] Favorite not sent in full, keeping the previous one", this->shadeId); - this->p_myPos(act.myOldPos); - this->p_myTiltPos(act.myOldTiltPos); - this->emitState(); - } - if(act.runToLimit) { - ESP_LOGW(TAG, "[Shade %u] Stop dropped by the radio, running on to the limit", this->shadeId); - this->runToLimit(act.limitDir, act.limitTilt, act.limitSince); - this->emitState(); - } -} -bool SomfyShade::sendStop(int8_t dir, bool tilt) { - const uint32_t now = millis(); - // Stops jump the radio queue so the motor stops as close to the target as it can. - if(const send_result_t sent = SomfyRemote::sendCommandAfter(somfy_commands::My, this->repeats, 0, 0, true)) { - this->tx.stopSent(sent.ticket, now, dir, tilt); - return true; - } - ESP_LOGW(TAG, "[Shade %u] Stop not sent, running on to the limit", this->shadeId); - this->runToLimit(dir, tilt, now); - return false; -} -bool SomfyShade::sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos) { - // The new favorite is already in myPos/myTiltPos. It is committed once the - // whole long press is on air, see processTxEvent. - if(const send_result_t sent = SomfyRemote::sendCommandAfter(somfy_commands::My, SETMY_REPEATS, 0, delayMs)) { - this->tx.setMySent(sent.ticket, oldPos, oldTiltPos); - // Without the radio (GPIO remotes) there is nothing to wait for. - if(sent.ticket == 0) this->commitMyPosition(); - return true; - } - // The motor never learned it so keep the favorite it has. - this->p_myPos(oldPos); - this->p_myTiltPos(oldTiltPos); - return false; -} -void SomfyShade::runToLimit(int8_t dir, bool tilt, uint32_t since) { - // The motor didn't get a stop at the target so it keeps going until its end - // limit. Carry on simulating that from where and when it should have stopped. - if(tilt) { - this->startTiltPos = this->currentTiltPos; - this->tiltStart = since; - this->settingTiltPos = false; - this->p_tiltTarget(limitTarget(dir)); - } - else { - this->startPos = this->currentPos; - this->moveStart = since; - this->settingPos = false; - this->p_target(limitTarget(dir)); - } - // The favorite can't be set where the shade won't stop. - this->settingMyPos = false; -} -void SomfyShade::applySensorMove(SensorMove move) { - const SensorTargets t = sensorTargets(move, this->tiltType, this->myPos, this->myTiltPos); - if(t.setLift) this->p_target(t.lift); - if(t.setTilt) this->p_tiltTarget(t.tilt); -} -bool SomfyShade::applyStep(int8_t dir, uint8_t frameStepSize) { - const StepTargets t = stepTargets(this->tiltType, dir, this->currentPos, this->currentTiltPos, - this->upTime, this->downTime, this->tiltTime, this->stepSize, frameStepSize); - if(t.action == StepAction::ignore) return false; - if(t.setLift) this->p_target(t.lift); - if(t.setTilt) this->p_tiltTarget(t.tilt); - return true; -} -void SomfyShade::setMovement(int8_t dir) { - int8_t currDir = this->direction; - int8_t currTiltDir = this->tiltDirection; - if(dir == 0) { - if(currDir != dir || currTiltDir != dir) this->commitShadePosition(); - } - else { - this->tiltStart = this->moveStart = millis(); - this->startPos = this->currentPos; - this->startTiltPos = this->currentTiltPos; - } - if(this->direction != currDir || currTiltDir != this->tiltDirection) { - this->emitState(); - } -} -bool SomfyShade::setMyPosition(int8_t pos, int8_t tilt) { - if(!this->isIdle()) return true; // Don't do this if it is moving. - bool sent = true; - if(this->tiltType == tilt_types::tiltonly) { - this->p_myPos(-1.0f); - if(tilt != floor(this->currentTiltPos)) { - // The favorite is set once we get there, if the move went out. - if(tilt == floor(this->myTiltPos)) - sent = this->settingMyPos = this->moveToMyPosition(); - else - sent = this->settingMyPos = this->moveToTarget(100, tilt); - } - else if(tilt == floor(this->myTiltPos)) { - // Of so we need to clear the my position. These motors are finicky so send - // a my command to ensure we are actually at the my position then send the clear - // command. There really is no other way to do this. - if(this->currentTiltPos != this->myTiltPos) { - sent = this->settingMyPos = this->moveToMyPosition(); - } - else if((sent = SomfyRemote::sendCommand(somfy_commands::My, this->repeats))) { - this->settingPos = false; - this->settingMyPos = true; - } - } - else return this->storeMyPosition(-1.0f, this->currentTiltPos); - this->commitMyPosition(); - this->emitState(); - } - else if(this->tiltType != tilt_types::none) { - if(tilt < 0) tilt = 0; - if(pos != floor(this->currentPos) || tilt != floor(this->currentTiltPos)) { - // The favorite is set once we get there, if the move went out. - if(pos == floor(this->myPos) && tilt == floor(this->myTiltPos)) - sent = this->settingMyPos = this->moveToMyPosition(); - else - sent = this->settingMyPos = this->moveToTarget(pos, tilt); - } - else if(pos == floor(this->myPos) && tilt == floor(this->myTiltPos)) { - // Of so we need to clear the my position. These motors are finicky so send - // a my command to ensure we are actually at the my position then send the clear - // command. There really is no other way to do this. - if(this->currentPos != this->myPos || this->currentTiltPos != this->myTiltPos) { - sent = this->settingMyPos = this->moveToMyPosition(); - } - else if((sent = SomfyRemote::sendCommand(somfy_commands::My, this->repeats))) { - this->settingPos = false; - this->settingMyPos = true; - } - } - else return this->storeMyPosition(this->currentPos, this->currentTiltPos); - this->commitMyPosition(); - this->emitState(); - } - else { - if(pos != floor(this->currentPos)) { - // The favorite is set once we get there, if the move went out. - if(pos == floor(this->myPos)) - sent = this->settingMyPos = this->moveToMyPosition(); - else - sent = this->settingMyPos = this->moveToTarget(pos); - } - else if(pos == floor(this->myPos)) { - // Of so we need to clear the my position. These motors are finicky so send - // a my command to ensure we are actually at the my position then send the clear - // command. There really is no other way to do this. - if(this->myPos != this->currentPos) { - sent = this->settingMyPos = this->moveToMyPosition(); - } - else if((sent = SomfyRemote::sendCommand(somfy_commands::My, this->repeats))) { - this->settingPos = false; - this->settingMyPos = true; - } - } - else sent = this->storeMyPosition(this->currentPos, -1.0f); - } - return sent; -} -bool SomfyShade::storeMyPosition(float pos, float tiltPos) { - // Set it before sending so processFrame doesn't head for the old one. It is - // only committed once the motor was told about it. - const float oldPos = this->myPos; - const float oldTiltPos = this->myTiltPos; - this->p_myPos(pos); - this->p_myTiltPos(tiltPos); - const bool sent = this->sendSetMy(0, oldPos, oldTiltPos); - this->emitState(); - return sent; -} -bool SomfyShade::moveToMyPosition() { - if(!this->isIdle()) return true; - ESP_LOGI(TAG, "Moving to My Position"); - if(this->tiltType == tilt_types::tiltonly) { - this->p_currentPos(100.0f); - this->p_myPos(-1.0f); - } - if(this->currentPos == this->myPos) { - if(this->tiltType != tilt_types::none) { - if(this->currentTiltPos == this->myTiltPos) return true; // Nothing to see here since we are already here. - } - else - return true; - } - if(this->myPos == -1 && (this->tiltType == tilt_types::none || this->myTiltPos == -1)) return true; - this->settingPos = false; - if(this->simMy) { - ESP_LOGI(TAG, "Moving to simulated favorite"); - if(!this->moveToTarget(this->myPos, this->myTiltPos)) return false; - } - else if(!SomfyRemote::sendCommand(somfy_commands::My, this->repeats)) return false; - if(this->tiltType != tilt_types::tiltonly && this->myPos >= 0.0f && this->myPos <= 100.0f) this->p_target(this->myPos); - if(this->myTiltPos >= 0.0f && this->myTiltPos <= 100.0f) this->p_tiltTarget(this->myTiltPos); - return true; + this->behaviour.onFrame(frame, internal, millis()); } +void SomfyShade::processWaitingFrame() { this->behaviour.onWaitingFrame(millis()); } +void SomfyShade::processInternalCommand(somfy_commands cmd, uint8_t repeat) { this->behaviour.onGroupCommand(cmd, repeat, millis()); } +void SomfyShade::checkMovement() { this->behaviour.tick(millis()); } +void SomfyShade::markTxPending(uint32_t ticket) { this->behaviour.onTxQueued(ticket, millis()); } +void SomfyShade::processTxEvent(const somfy_tx_event_t &evt) { this->behaviour.onTxEvent(evt); } bool SomfyShade::sendCommand(somfy_commands cmd) { return this->sendCommand(cmd, this->repeats); } bool SomfyShade::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize) { - ESP_LOGD(TAG, "Send command start"); // This sendCommand function will always be called externally. sendCommand at the remote level // is expected to be called internally when the motor needs commanded. if(this->bitLength == 0) this->bitLength = somfy.transceiver.config.type; - // If same direction command sent while already moving, stop the state tracking. - // The real motor stops on its own when it receives the same direction again. - if((cmd == somfy_commands::Up && this->direction == -1) || - (cmd == somfy_commands::Down && this->direction == 1)) { - ESP_LOGD(TAG, "Same command as dir"); - if(!SomfyRemote::sendCommand(cmd, repeat)) return false; - this->p_target(this->currentPos); - this->p_tiltTarget(this->currentTiltPos); - this->setMovement(0); - return true; - } - // The simulated state is only moved for commands the radio took. - if(cmd == somfy_commands::Up) { - if(this->tiltType == tilt_types::euromode) { - // In euromode we need to long press for 2 seconds on the - // up command. - if(!SomfyRemote::sendCommand(cmd, TILT_REPEATS)) return false; - this->p_target(0.0f); - } - else { - if(!SomfyRemote::sendCommand(cmd, repeat)) return false; - if(this->tiltType == tilt_types::tiltonly) { - this->p_target(100.0f); - this->p_tiltTarget(0.0f); - this->p_currentPos(100.0f); - } - else this->p_target(0.0f); - if(this->tiltType == tilt_types::integrated) this->p_tiltTarget(0.0f); - } - } - else if(cmd == somfy_commands::Down) { - if(this->tiltType == tilt_types::euromode) { - // In euromode we need to long press for 2 seconds on the - // down command. - if(!SomfyRemote::sendCommand(cmd, TILT_REPEATS)) return false; - this->p_target(100.0f); - } - else { - if(!SomfyRemote::sendCommand(cmd, repeat)) return false; - if(this->tiltType == tilt_types::tiltonly) { - this->p_target(100.0f); - this->p_tiltTarget(100.0f); - this->p_currentPos(100.0f); - } - else this->p_target(100.0f); - if(this->tiltType == tilt_types::integrated) this->p_tiltTarget(100.0f); - } - } - else if(cmd == somfy_commands::My) { - if(this->isToggle() || this->shadeType == shade_types::drycontact) { - if(!SomfyRemote::sendCommand(cmd, repeat)) return false; - } - else if(this->shadeType == shade_types::drycontact2){ - ESP_LOGD(TAG, "Send command start 1"); - return true; - } - else if(this->isIdle()) { - bool sent = this->moveToMyPosition(); - ESP_LOGD(TAG, "Send command end 2"); - return sent; - } - else { - if(!SomfyRemote::sendCommand(cmd, repeat)) return false; - if(this->tiltType != tilt_types::tiltonly) this->p_target(this->currentPos); - this->p_tiltTarget(this->currentTiltPos); - } - } - else if(cmd == somfy_commands::Toggle) { - if(this->bitLength != 80) { if(!SomfyRemote::sendCommand(somfy_commands::My, repeat, stepSize)) return false; } - else if(!SomfyRemote::sendCommand(somfy_commands::Toggle, repeat)) return false; - } - else if(this->isToggle() && cmd == somfy_commands::Prog) { - if(!SomfyRemote::sendCommand(somfy_commands::Toggle, repeat, stepSize)) return false; - } - else { - if(!SomfyRemote::sendCommand(cmd, repeat, stepSize)) return false; - } - ESP_LOGD(TAG, "Send command end"); - return true; + return this->behaviour.sendCommand(cmd, repeat, stepSize, millis()); } -uint8_t SomfyShade::tiltRepeats() const { return this->tiltType == tilt_types::tiltmotor ? TILT_REPEATS : this->repeats; } -bool SomfyShade::sendTiltCommand(somfy_commands cmd) { - float target; - if(cmd == somfy_commands::Up) target = 0.0f; - else if(cmd == somfy_commands::Down) target = 100.0f; - else if(cmd == somfy_commands::My) target = this->currentTiltPos; - else return true; - if(!SomfyRemote::sendCommand(cmd, this->tiltRepeats())) return false; - this->p_tiltTarget(target); - return true; -} -bool SomfyShade::moveToTiltTarget(float target) { - somfy_commands cmd = somfy_commands::My; - if(target < this->currentTiltPos) - cmd = somfy_commands::Up; - else if(target > this->currentTiltPos) - cmd = somfy_commands::Down; - if(target >= 0.0f && target <= 100.0f) { - // Only send a command if the lift is not moving. - if(this->currentPos == this->target || this->tiltType == tilt_types::tiltmotor) { - if(cmd != somfy_commands::My) { - ESP_LOGI(TAG, "Moving Tilt to %.2f%% from %.2f%% using %s", - target, this->currentTiltPos, translateSomfyCommand(cmd).c_str()); - if(!SomfyRemote::sendCommand(cmd, this->tiltRepeats())) return false; - } - // If the blind is currently moving then the command to stop it - // will occur on its own when the tilt target is set. - } - this->p_tiltTarget(target); - } - if(cmd != somfy_commands::My) this->settingTiltPos = true; - return true; -} -bool SomfyShade::moveToTarget(float pos, float tilt) { - ESP_LOGI(TAG, "[Shade %u] moveToTarget(pos=%.2f, tilt=%.2f) settingPos=%d direction=%d currentTarget=%.2f currentPos=%.2f", - this->getShadeId(), pos, tilt, this->settingPos, this->direction, this->target, this->currentPos); - somfy_commands cmd = somfy_commands::My; - if(this->isToggle()) { - // Overload this as we cannot seek a position on a garage door or single button device. - this->p_target(pos); - this->p_currentPos(pos); - this->emitState(); - return true; - } - if(this->tiltType == tilt_types::tiltonly) { - // A tilt only shade is always at 100%. - this->p_target(100.0f); - this->p_myPos(-1.0f); - this->p_currentPos(100.0f); - pos = 100; - if(tilt < this->currentTiltPos) cmd = somfy_commands::Up; - else if(tilt > this->currentTiltPos) cmd = somfy_commands::Down; - } - else { - if(pos < this->currentPos) - cmd = somfy_commands::Up; - else if(pos > this->currentPos) - cmd = somfy_commands::Down; - else if(tilt >= 0 && tilt < this->currentTiltPos) - cmd = somfy_commands::Up; - else if(tilt >= 0 && tilt > this->currentTiltPos) - cmd = somfy_commands::Down; - } - if(cmd != somfy_commands::My) { - if(tilt >= 0) { - ESP_LOGI(TAG, "Moving to %.2f%% from %.2f%% tilt %.2f%% from %.2f%% using %s", - pos, this->currentPos, tilt, this->currentTiltPos, translateSomfyCommand(cmd).c_str()); - } else { - ESP_LOGI(TAG, "Moving to %.2f%% from %.2f%% using %s", - pos, this->currentPos, translateSomfyCommand(cmd).c_str()); - } - if(!SomfyRemote::sendCommand(cmd, this->tiltType == tilt_types::euromode ? TILT_REPEATS : this->repeats)) return false; - this->settingPos = true; - this->p_target(pos); - if(tilt >= 0) { - this->p_tiltTarget(tilt); - this->settingTiltPos = true; - } - } - return true; +bool SomfyShade::sendTiltCommand(somfy_commands cmd) { return this->behaviour.sendTiltCommand(cmd, millis()); } +bool SomfyShade::moveToTarget(float pos, float tilt) { return this->behaviour.moveToTarget(pos, tilt, millis()); } +bool SomfyShade::moveToTiltTarget(float target) { return this->behaviour.moveToTiltTarget(target, millis()); } +bool SomfyShade::setMyPosition(int8_t pos, int8_t tilt) { return this->behaviour.setMyPosition(pos, tilt, millis()); } +bool SomfyShade::moveToMyPosition() { return this->behaviour.moveToMyPosition(millis()); } +void SomfyShade::setPosition(float pos, float tilt) { this->behaviour.setPosition(pos, tilt); } +send_result_t SomfyShade::send(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority) { + return SomfyRemote::sendCommandAfter(cmd, repeat, stepSize, delayMs, priority, true); } +void SomfyShade::onSaveWanted() { somfy.isDirty = true; } +void SomfyShade::onSensorFlags() { somfy.updateGroupFlags(); } bool SomfyShade::save() { Preferences pref; this->commit(); this->publish(); return true; } -bool SomfyShade::isToggle() { - switch(this->shadeType) { - case shade_types::garage1: - case shade_types::lgate1: - case shade_types::cgate1: - case shade_types::rgate1: - return true; - default: - break; - } - return false; -} - - - - - - - - - - diff --git a/src/SomfyShadeOps.cpp b/src/SomfyShadeOps.cpp index 89eae0c..062b17f 100644 --- a/src/SomfyShadeOps.cpp +++ b/src/SomfyShadeOps.cpp @@ -12,9 +12,9 @@ bool SomfyShadeOps::findShade(uint8_t shadeId, shade_info_t &info) { info.tiltType = shade->tiltType; info.repeats = shade->repeats; info.bitLength = shade->bitLength; - info.currentPos = shade->currentPos; - info.myPos = shade->myPos; - info.myTiltPos = shade->myTiltPos; + info.currentPos = shade->state().currentPos; + info.myPos = shade->state().myPos; + info.myTiltPos = shade->state().myTiltPos; return true; } bool SomfyShadeOps::findGroup(uint8_t groupId, uint8_t &repeats) { @@ -40,15 +40,7 @@ bool SomfyShadeOps::sendSensorCommand(uint8_t shadeId, int8_t windy, int8_t sunn bool SomfyShadeOps::setMyPosition(uint8_t shadeId, int8_t pos, int8_t tilt) { return somfy.getShadeById(shadeId)->setMyPosition(pos, tilt); } void SomfyShadeOps::setPositions(uint8_t shadeId, float pos, float tilt) { SomfyShade *shade = somfy.getShadeById(shadeId); - // Through the setters, so MQTT hears about it too. - if(pos >= 0.0f) { - shade->p_target(pos); - shade->p_currentPos(pos); - } - if(tilt >= 0.0f) { - shade->p_tiltTarget(tilt); - shade->p_currentTiltPos(tilt); - } + shade->setPosition(pos, tilt); shade->emitState(); } void SomfyShadeOps::setUnpaired(uint8_t shadeId) { diff --git a/src/SomfySnapshot.cpp b/src/SomfySnapshot.cpp index 657e0f6..b5e69a4 100644 --- a/src/SomfySnapshot.cpp +++ b/src/SomfySnapshot.cpp @@ -24,15 +24,15 @@ void snapshotShade(SomfyShade &shade, shade_snapshot_t &out) { out.stepSize = shade.stepSize; out.paired = shade.paired; out.lastRollingCode = shade.lastRollingCode; - out.position = shade.transformPosition(shade.currentPos); - out.target = shade.transformPosition(shade.target); - out.myPos = shade.transformPosition(shade.myPos); - out.direction = shade.direction; + out.position = shade.transformPosition(shade.state().currentPos); + out.target = shade.transformPosition(shade.state().target); + out.myPos = shade.transformPosition(shade.state().myPos); + out.direction = shade.state().direction; out.tiltType = static_cast(shade.tiltType); - out.tiltPosition = shade.transformPosition(shade.currentTiltPos); - out.tiltTarget = shade.transformPosition(shade.tiltTarget); - out.myTiltPos = shade.transformPosition(shade.myTiltPos); - out.tiltDirection = shade.tiltDirection; + out.tiltPosition = shade.transformPosition(shade.state().currentTiltPos); + out.tiltTarget = shade.transformPosition(shade.state().tiltTarget); + out.myTiltPos = shade.transformPosition(shade.state().myTiltPos); + out.tiltDirection = shade.state().tiltDirection; out.shadeType = static_cast(shade.shadeType); out.bitLength = shade.bitLength; out.proto = static_cast(shade.proto); diff --git a/src/outputs/SomfyEvents.cpp b/src/outputs/SomfyEvents.cpp index 9383570..ff87d85 100644 --- a/src/outputs/SomfyEvents.cpp +++ b/src/outputs/SomfyEvents.cpp @@ -17,10 +17,10 @@ void SomfyShade::emitState(uint8_t num, const char *evt) { json->addElem("type", static_cast(this->shadeType)); json->addElem("remoteAddress", (uint32_t)this->getRemoteAddress()); json->addElem("name", this->name); - json->addElem("direction", this->direction); - json->addElem("position", this->transformPosition(this->currentPos)); - json->addElem("target", this->transformPosition(this->target)); - json->addElem("myPos", this->transformPosition(this->myPos)); + json->addElem("direction", this->state().direction); + json->addElem("position", this->transformPosition(this->state().currentPos)); + json->addElem("target", this->transformPosition(this->state().target)); + json->addElem("myPos", this->transformPosition(this->state().myPos)); json->addElem("tiltType", static_cast(this->tiltType)); json->addElem("flipCommands", this->flipCommands); json->addElem("flipPosition", this->flipPosition); @@ -29,10 +29,10 @@ void SomfyShade::emitState(uint8_t num, const char *evt) { json->addElem("light", this->light); json->addElem("sortOrder", this->sortOrder); if(this->tiltType != tilt_types::none) { - json->addElem("tiltDirection", this->tiltDirection); - json->addElem("tiltTarget", this->transformPosition(this->tiltTarget)); - json->addElem("tiltPosition", this->transformPosition(this->currentTiltPos)); - json->addElem("myTiltPos", this->transformPosition(this->myTiltPos)); + json->addElem("tiltDirection", this->state().tiltDirection); + json->addElem("tiltTarget", this->transformPosition(this->state().tiltTarget)); + json->addElem("tiltPosition", this->transformPosition(this->state().currentTiltPos)); + json->addElem("myTiltPos", this->transformPosition(this->state().myTiltPos)); } json->endObject(); sockEmit.endEmit(num); diff --git a/test/test_shadebehaviour/test_shadebehaviour.cpp b/test/test_shadebehaviour/test_shadebehaviour.cpp new file mode 100644 index 0000000..6b31f96 --- /dev/null +++ b/test/test_shadebehaviour/test_shadebehaviour.cpp @@ -0,0 +1,632 @@ +#include +#include +#include "ShadeBehaviour.h" + +// Scenario tests pinning what ShadeBehaviour does today, moved unchanged from +// SomfyShade. Where the copies of a rule disagree the test says "today:" so +// a later reconcile shows up as a test change (SHADE_BEHAVIOUR_PLAN.md). + +#define ASSERT_POS(expected, actual) TEST_ASSERT_FLOAT_WITHIN(0.5f, (expected), (actual)) +#define ASSERT_CMD(expected, actual) TEST_ASSERT_EQUAL_HEX8(static_cast(expected), static_cast(actual)) + +static const uint32_t ADDRESS = 0x123456; +static const uint32_t REMOTE = 0x654321; + +// One command the shade sent through its MotorLink. +struct Sent { + somfy_commands cmd; // As it went on air, after flipCommands. + uint8_t repeat; + uint8_t stepSize; + uint16_t delayMs; + bool priority; + uint32_t ticket; +}; +// Builds the frame the way SomfyRemote::sendCommandAfter does and hands out +// tickets 1, 2, 3, ... gpio sends without a ticket, refuseNext makes the +// radio refuse the next command. +struct FakeLink : MotorLink { + somfy_frame_t &lastFrame; + const RemoteSettings &remote; + std::vector sent; + uint16_t rollingCode = 100; + uint32_t nextTicket = 1; + bool refuseNext = false; + bool gpio = false; + FakeLink(somfy_frame_t &lastFrame, const RemoteSettings &remote) : lastFrame(lastFrame), remote(remote) {} + send_result_t send(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority) override { + send_result_t result; + if(this->refuseNext) { + this->refuseNext = false; + return result; + } + somfy_frame_t frame = this->lastFrame; + frame.rollingCode = this->rollingCode + 1; + frame.remoteAddress = ADDRESS; + frame.cmd = this->remote.flipCommands ? flipCommand(cmd) : cmd; + frame.repeats = repeat; + frame.bitLength = this->remote.bitLength; + frame.stepSize = stepSize; + frame.valid = true; + frame.encKey = 0xA0 | static_cast(frame.rollingCode & 0x000F); + frame.processed = false; + if(!this->gpio) { + somfy_tx_job_t job; + encodeTxJob(job, frame, repeat); + result.ticket = this->nextTicket++; + } + result.sent = true; + this->rollingCode = frame.rollingCode; + this->lastFrame = frame; + this->sent.push_back({frame.cmd, repeat, stepSize, delayMs, priority, result.ticket}); + return result; + } +}; +struct Command { + somfy_commands cmd; + std::string source; +}; +struct FakeEvents : ShadeEvents { + uint16_t changed = 0; + std::vector commands; + int saves = 0; + int sensorFlags = 0; + void onChanged(uint16_t bits) override { this->changed |= bits; } + void onCommand(somfy_commands cmd, const char *source, uint32_t) override { this->commands.push_back({cmd, source}); } + void onSaveWanted() override { this->saves++; } + void onSensorFlags() override { this->sensorFlags++; } +}; + +// A shade with its settings, link and events, and a clock. +struct Rig { + ShadeSettings settings; + RemoteSettings remote; + somfy_frame_t lastFrame; + FakeLink link{lastFrame, remote}; + FakeEvents events; + uint8_t id = 1; + char name[21] = "Test"; + ShadeBehaviour b{settings, remote, lastFrame, link, events, id, name}; + uint32_t now = 1000; + uint16_t remoteCode = 500; + Rig() { + this->remote.bitLength = 56; + this->lastFrame.remoteAddress = ADDRESS; + } + const ShadeBehaviour::State &st() const { return this->b.state(); } + // loop() passes for ms, 10 ms apart. + void run(uint32_t ms) { + for(uint32_t t = 0; t < ms; t += 10) { + this->now += 10; + this->b.tick(this->now); + } + } + void event(tx_event_t type, uint32_t ticket) { + somfy_tx_event_t evt; + evt.type = type; + evt.ticket = ticket; + evt.at = this->now; + this->b.onTxEvent(evt); + } + // The radio starts the last job sent. + void onAir() { this->event(tx_event_t::started, this->lastSent().ticket); } + const Sent &lastSent() const { return this->link.sent.back(); } + // A remote press: the first frame, then repeats more of the same frame. + void press(somfy_commands cmd, uint8_t repeats = 0, uint32_t address = ADDRESS) { + somfy_frame_t frame; + frame.valid = true; + frame.cmd = cmd; + frame.remoteAddress = address; + frame.rollingCode = ++this->remoteCode; + this->b.onFrame(frame, false, this->now); + for(uint8_t i = 0; i < repeats; i++) { + this->now += 30; + this->b.onFrame(frame, false, this->now); + } + } + // Receive passes with no frame, as loop() calls onWaitingFrame. + void wait(uint32_t ms) { + for(uint32_t t = 0; t < ms; t += 10) { + this->now += 10; + this->b.onWaitingFrame(this->now); + this->b.tick(this->now); + } + } + void sensorFrame(bool sunny, bool windy) { + somfy_frame_t frame; + frame.valid = true; + frame.cmd = somfy_commands::Sensor; + frame.remoteAddress = ADDRESS; + // The sensor's bits ride in the rolling code, shifted down by four. + frame.rollingCode = (sunny ? 0x02 : 0) | (windy ? 0x01 : 0); + this->b.onFrame(frame, false, this->now); + } +}; + +void setUp() {} +void tearDown() {} + +/********************************************************************* + * Commands from the web, MQTT and Home Assistant + ********************************************************************/ +static void test_down_waits_for_the_radio_then_travels() { + Rig r; + TEST_ASSERT_TRUE(r.b.sendCommand(somfy_commands::Down, 1, 0, r.now)); + ASSERT_CMD(somfy_commands::Down, r.lastSent().cmd); + ASSERT_POS(100, r.st().target); + r.run(2000); + // The motor hasn't started until the command is on air. + ASSERT_POS(0, r.st().currentPos); + TEST_ASSERT_EQUAL_INT8(1, r.st().direction); + r.onAir(); + r.run(5000); + ASSERT_POS(50, r.st().currentPos); + r.run(5100); + ASSERT_POS(100, r.st().currentPos); + TEST_ASSERT_EQUAL_INT8(0, r.st().direction); + TEST_ASSERT_TRUE(r.events.saves > 0); + TEST_ASSERT_TRUE(r.events.changed & SHADE_CHANGED_POSITION); + // A run to the end limit sends no stop. + TEST_ASSERT_EQUAL(1, r.link.sent.size()); +} +static void test_refused_command_moves_nothing() { + Rig r; + r.link.refuseNext = true; + TEST_ASSERT_FALSE(r.b.sendCommand(somfy_commands::Down, 1, 0, r.now)); + ASSERT_POS(0, r.st().target); + r.run(1000); + TEST_ASSERT_EQUAL_INT8(0, r.st().direction); + TEST_ASSERT_EQUAL(0, r.events.commands.size()); +} +static void test_hold_times_out_without_a_radio_event() { + Rig r; + r.b.sendCommand(somfy_commands::Down, 1, 0, r.now); + r.run(29000); + ASSERT_POS(0, r.st().currentPos); + r.run(2000); + // After 30 s it moves anyway, timed from the end of the hold. + TEST_ASSERT_TRUE(r.st().currentPos > 0); +} +static void test_gpio_command_moves_at_once() { + Rig r; + r.link.gpio = true; + r.b.sendCommand(somfy_commands::Down, 1, 0, r.now); + r.run(5000); + ASSERT_POS(50, r.st().currentPos); +} +static void test_same_direction_again_stops_tracking() { + Rig r; + r.b.sendCommand(somfy_commands::Down, 1, 0, r.now); + r.onAir(); + r.run(3000); + TEST_ASSERT_TRUE(r.b.sendCommand(somfy_commands::Down, 1, 0, r.now)); + // The motor stops by itself when it gets the same direction again. + ASSERT_POS(r.st().currentPos, r.st().target); + r.run(1000); + ASSERT_POS(30, r.st().currentPos); + TEST_ASSERT_EQUAL_INT8(0, r.st().direction); +} +static void test_stop_from_the_web_works_through_the_echo() { + Rig r; + r.b.sendCommand(somfy_commands::Down, 1, 0, r.now); + r.onAir(); + r.run(4000); + // sendCommand only sends Stop; hearing its own frame is what stops the shade. + r.b.sendCommand(somfy_commands::Stop, 1, 0, r.now); + ASSERT_CMD(somfy_commands::Stop, r.lastSent().cmd); + ASSERT_POS(40, r.st().target); + ASSERT_CMD(somfy_commands::Stop, r.events.commands.back().cmd); + TEST_ASSERT_EQUAL_STRING("internal", r.events.commands.back().source.c_str()); +} +static void test_move_to_target_sends_a_stop_there() { + Rig r; + TEST_ASSERT_TRUE(r.b.moveToTarget(40, -1, r.now)); + ASSERT_CMD(somfy_commands::Down, r.lastSent().cmd); + r.onAir(); + r.run(4100); + ASSERT_POS(40, r.st().currentPos); + TEST_ASSERT_EQUAL(2, r.link.sent.size()); + ASSERT_CMD(somfy_commands::My, r.lastSent().cmd); + TEST_ASSERT_TRUE(r.lastSent().priority); + r.event(tx_event_t::finished, r.lastSent().ticket); + r.run(1000); + ASSERT_POS(40, r.st().currentPos); +} +static void test_dropped_stop_runs_on_to_the_limit() { + Rig r; + r.b.moveToTarget(40, -1, r.now); + r.onAir(); + r.run(4100); + r.event(tx_event_t::dropped, r.lastSent().ticket); + ASSERT_POS(100, r.st().target); + r.run(7000); + ASSERT_POS(100, r.st().currentPos); +} +static void test_refused_stop_runs_on_to_the_limit() { + Rig r; + r.b.moveToTarget(40, -1, r.now); + r.onAir(); + r.run(3900); + r.link.refuseNext = true; + r.run(200); + TEST_ASSERT_EQUAL(1, r.link.sent.size()); + ASSERT_POS(100, r.st().target); +} +static void test_today_a_step_after_move_to_target_sends_a_stop() { + // today: arriving at a lift target never clears settingPos (the tilt + // blocks do clear settingTiltPos), so a later step also sends a stop when + // it arrives. On the "still open" list of LOOP_QUEUE_PLAN.md. + Rig r; + r.b.moveToTarget(40, -1, r.now); + r.onAir(); + r.run(4100); + r.event(tx_event_t::finished, r.lastSent().ticket); + const size_t before = r.link.sent.size(); + r.b.sendCommand(somfy_commands::StepDown, 1, 1, r.now); + r.onAir(); + r.run(2000); + TEST_ASSERT_EQUAL(before + 2, r.link.sent.size()); + ASSERT_CMD(somfy_commands::My, r.lastSent().cmd); +} +static void test_toggle_on_a_56_bit_shade_sends_my() { + Rig r; + r.b.sendCommand(somfy_commands::Toggle, 1, 0, r.now); + ASSERT_CMD(somfy_commands::My, r.lastSent().cmd); + r.remote.bitLength = 80; + r.b.sendCommand(somfy_commands::Toggle, 1, 0, r.now); + ASSERT_CMD(somfy_commands::Toggle, r.lastSent().cmd); +} +static void test_flipped_shade_sends_the_other_way() { + Rig r; + r.remote.flipCommands = true; + r.b.sendCommand(somfy_commands::Up, 1, 0, r.now); + // On air it is Down; the shade itself still heads for the top. + ASSERT_CMD(somfy_commands::Down, r.lastSent().cmd); + ASSERT_POS(0, r.st().target); +} + +/********************************************************************* + * Frames from remotes + ********************************************************************/ +static void test_remote_down_and_my_stop() { + Rig r; + r.press(somfy_commands::Down); + ASSERT_POS(100, r.st().target); + ASSERT_CMD(somfy_commands::Down, r.events.commands.back().cmd); + TEST_ASSERT_EQUAL_STRING("remote", r.events.commands.back().source.c_str()); + // A remote's frame is already on air, so nothing is held. + r.run(3000); + ASSERT_POS(30, r.st().currentPos); + r.press(somfy_commands::My); + ASSERT_POS(30, r.st().target); + r.run(100); + TEST_ASSERT_EQUAL_INT8(0, r.st().direction); + TEST_ASSERT_EQUAL(0, r.link.sent.size()); +} +static void test_remote_my_when_idle_goes_to_the_favorite_after_half_a_second() { + Rig r; + r.b.restore(0, 0, 60, -1); + r.press(somfy_commands::My); + ASSERT_POS(0, r.st().target); + r.wait(400); + ASSERT_POS(0, r.st().target); + r.wait(200); + ASSERT_POS(60, r.st().target); +} +static void test_remote_my_held_sets_the_favorite() { + Rig r; + r.b.restore(30, 0, 60, -1); + r.press(somfy_commands::My, 35); + r.wait(600); + ASSERT_POS(30, r.st().myPos); + ASSERT_POS(30, r.st().target); + TEST_ASSERT_TRUE(r.events.saves > 0); +} +static void test_remote_held_up_on_a_tilt_motor_tilts() { + Rig r; + r.settings.shadeType = shade_types::blind; + r.settings.tiltType = tilt_types::tiltmotor; + r.b.restore(50, 50, -1, -1); + r.press(somfy_commands::Up); + r.wait(600); + // A short press moves the lift. + ASSERT_POS(0, r.st().target); + ASSERT_POS(50, r.st().tiltTarget); + + Rig h; + h.settings.shadeType = shade_types::blind; + h.settings.tiltType = tilt_types::tiltmotor; + h.b.restore(50, 50, -1, -1); + h.press(somfy_commands::Up, 15); + h.wait(600); + // A long one moves the tilt. + ASSERT_POS(50, h.st().target); + ASSERT_POS(0, h.st().tiltTarget); +} +static void test_remote_step_uses_the_step_size_last_sent() { + // today: copy() never copies stepSize, so a remote step uses whatever step + // size this shade last sent, or 1 when that was 0. + Rig r; + r.b.restore(50, 0, -1, -1); + r.press(somfy_commands::StepDown); + ASSERT_POS(51, r.st().target); + r.lastFrame.stepSize = 3; + r.lastFrame.processed = true; + r.press(somfy_commands::StepDown); + ASSERT_POS(53, r.st().target); +} +static void test_wind_blocks_a_remote_down() { + Rig r; + r.settings.sunSensor = true; + r.sensorFrame(false, true); + TEST_ASSERT_EQUAL(1, r.events.sensorFlags); + TEST_ASSERT_TRUE(r.events.changed & SHADE_CHANGED_WINDY); + r.press(somfy_commands::Down); + ASSERT_POS(0, r.st().target); +} +static void test_wind_retracts_the_shade() { + Rig r; + r.b.restore(80, 0, -1, -1); + r.sensorFrame(false, true); + r.run(2100); + ASSERT_POS(0, r.st().target); +} +static void test_sun_flag_frame_turns_it_on_and_saves() { + Rig r; + r.press(somfy_commands::SunFlag); + TEST_ASSERT_TRUE(r.st().stateFlags & static_cast(somfy_flags_t::SunFlag)); + TEST_ASSERT_EQUAL(1, r.events.saves); + TEST_ASSERT_EQUAL(1, r.events.sensorFlags); + r.press(somfy_commands::Flag); + TEST_ASSERT_FALSE(r.st().stateFlags & static_cast(somfy_flags_t::SunFlag)); +} + +/********************************************************************* + * Integrated tilt + ********************************************************************/ +static void integrated(Rig &r) { + r.settings.shadeType = shade_types::blind; + r.settings.tiltType = tilt_types::integrated; + r.settings.tiltTime = 2000; +} +static void test_integrated_tilt_turns_the_slats_first() { + Rig r; + integrated(r); + TEST_ASSERT_TRUE(r.b.moveToTarget(40, 100, r.now)); + r.onAir(); + r.run(1500); + // Still turning the slats; the lift waits. + ASSERT_POS(75, r.st().currentTiltPos); + ASSERT_POS(0, r.st().currentPos); + r.run(2500); + ASSERT_POS(100, r.st().currentTiltPos); + ASSERT_POS(20, r.st().currentPos); + r.run(2100); + ASSERT_POS(40, r.st().currentPos); + ASSERT_CMD(somfy_commands::My, r.lastSent().cmd); + // The radio drops the stop and the shade runs on to 100%. + r.event(tx_event_t::dropped, r.lastSent().ticket); + r.run(6100); + ASSERT_POS(100, r.st().currentPos); +} +static void test_today_integrated_tilt_turns_back_after_a_lift_target() { + // today: moveToTarget without a tilt leaves the tilt target where it was, + // so after the stop the shade turns its slats back with an Up. + Rig r; + integrated(r); + r.b.moveToTarget(40, -1, r.now); + r.onAir(); + r.run(6100); + TEST_ASSERT_EQUAL(3, r.link.sent.size()); + ASSERT_CMD(somfy_commands::My, r.link.sent[1].cmd); + ASSERT_CMD(somfy_commands::Up, r.link.sent[2].cmd); + ASSERT_POS(0, r.st().tiltTarget); + r.onAir(); + r.run(2100); + ASSERT_POS(0, r.st().currentTiltPos); + ASSERT_POS(40, r.st().currentPos); +} + +/********************************************************************* + * Tilt-only shades: the copies disagree + ********************************************************************/ +static Rig *tiltOnly(Rig &r) { + r.settings.shadeType = shade_types::blind; + r.settings.tiltType = tilt_types::tiltonly; + r.b.restore(100, 50, -1, -1); + return &r; +} +static void test_today_up_on_tilt_only_from_the_web() { + Rig r; + tiltOnly(r); + r.b.sendCommand(somfy_commands::Up, 1, 0, r.now); + ASSERT_POS(100, r.st().target); + ASSERT_POS(100, r.st().currentPos); + ASSERT_POS(0, r.st().tiltTarget); +} +static void test_today_up_on_tilt_only_from_a_remote() { + // today: a remote only sets the tilt target, where the web and groups also + // put the lift at 100. + Rig r; + tiltOnly(r); + r.b.restore(60, 50, -1, -1); + r.press(somfy_commands::Up); + ASSERT_POS(60, r.st().target); + ASSERT_POS(0, r.st().tiltTarget); +} +static void test_today_up_on_tilt_only_from_a_group() { + Rig r; + tiltOnly(r); + r.b.restore(60, 50, -1, -1); + r.b.onGroupCommand(somfy_commands::Up, 1, r.now); + ASSERT_POS(100, r.st().target); + ASSERT_POS(100, r.st().currentPos); + ASSERT_POS(0, r.st().tiltTarget); +} +static void test_today_my_while_tilt_only_moves() { + // today: a group's My puts the lift target at 100, a remote's leaves it. + Rig g; + tiltOnly(g); + g.b.restore(60, 50, -1, -1); + g.b.onGroupCommand(somfy_commands::Down, 1, g.now); + g.run(1000); + g.b.onGroupCommand(somfy_commands::My, 1, g.now); + ASSERT_POS(100, g.st().target); + + Rig r; + tiltOnly(r); + r.b.restore(60, 50, -1, -1); + r.press(somfy_commands::Down); + r.run(1000); + r.press(somfy_commands::My); + ASSERT_POS(60, r.st().target); +} + +/********************************************************************* + * Groups + ********************************************************************/ +static void test_group_down_and_step() { + Rig r; + r.b.onGroupCommand(somfy_commands::Down, 1, r.now); + ASSERT_POS(100, r.st().target); + // A group command sends nothing itself. + TEST_ASSERT_EQUAL(0, r.link.sent.size()); + Rig s; + s.b.restore(50, 0, -1, -1); + s.b.onGroupCommand(somfy_commands::StepDown, 1, s.now); + ASSERT_POS(51, s.st().target); +} +static void test_group_sun_flag_needs_a_sensor() { + Rig r; + r.b.onGroupCommand(somfy_commands::SunFlag, 1, r.now); + TEST_ASSERT_FALSE(r.st().stateFlags & static_cast(somfy_flags_t::SunFlag)); + r.settings.sunSensor = true; + r.b.onGroupCommand(somfy_commands::SunFlag, 1, r.now); + TEST_ASSERT_TRUE(r.st().stateFlags & static_cast(somfy_flags_t::SunFlag)); + TEST_ASSERT_EQUAL(1, r.events.saves); + // today: unlike the frame rule, the group rule doesn't ask for the group + // flags to be worked out again; SomfyGroup does that for its own group. + TEST_ASSERT_EQUAL(0, r.events.sensorFlags); +} + +/********************************************************************* + * The favorite + ********************************************************************/ +static void test_set_favorite_here_is_committed_when_all_on_air() { + Rig r; + r.b.restore(30, 0, -1, -1); + TEST_ASSERT_TRUE(r.b.setMyPosition(30, -1, r.now)); + ASSERT_CMD(somfy_commands::My, r.lastSent().cmd); + TEST_ASSERT_EQUAL_UINT8(35, r.lastSent().repeat); + ASSERT_POS(30, r.st().myPos); + const int saves = r.events.saves; + r.event(tx_event_t::finished, r.lastSent().ticket); + TEST_ASSERT_EQUAL(saves + 1, r.events.saves); +} +static void test_set_favorite_dropped_keeps_the_old_one() { + Rig r; + r.b.restore(30, 0, 70, -1); + r.b.setMyPosition(30, -1, r.now); + r.event(tx_event_t::dropped, r.lastSent().ticket); + ASSERT_POS(70, r.st().myPos); +} +static void test_set_favorite_elsewhere_moves_there_first() { + Rig r; + TEST_ASSERT_TRUE(r.b.setMyPosition(40, -1, r.now)); + ASSERT_CMD(somfy_commands::Down, r.lastSent().cmd); + r.onAir(); + r.run(4100); + // The stop, then the long My 200 ms later that stores it. + ASSERT_POS(40, r.st().myPos); + ASSERT_CMD(somfy_commands::My, r.lastSent().cmd); + TEST_ASSERT_EQUAL_UINT8(35, r.lastSent().repeat); + TEST_ASSERT_EQUAL_UINT16(200, r.lastSent().delayMs); +} +static void test_sim_my_favorite_moves_to_it() { + Rig r; + r.settings.simMy = true; + r.b.restore(0, 0, 60, -1); + TEST_ASSERT_TRUE(r.b.moveToMyPosition(r.now)); + ASSERT_CMD(somfy_commands::Down, r.lastSent().cmd); + ASSERT_POS(60, r.st().target); +} + +/********************************************************************* + * Dry contacts and one-button devices + ********************************************************************/ +static void test_garage_my_toggles_open_and_shut() { + Rig r; + r.settings.shadeType = shade_types::garage1; + r.press(somfy_commands::My); + ASSERT_POS(100, r.st().target); + r.run(10100); + r.press(somfy_commands::My); + ASSERT_POS(0, r.st().target); +} + +/********************************************************************* + * Time, setup and the outside writers + ********************************************************************/ +static void test_travel_across_the_millis_wrap() { + Rig r; + r.now = 0xFFFFFFFF - 2000; + r.press(somfy_commands::Down); + r.run(5000); + ASSERT_POS(50, r.st().currentPos); +} +static void test_restore_set_position_and_clear_tilt() { + Rig r; + r.b.restore(42.6f, 10.2f, 30, 5); + ASSERT_POS(42.6f, r.st().currentPos); + // The targets are the whole positions. + TEST_ASSERT_EQUAL_FLOAT(42.0f, r.st().target); + TEST_ASSERT_EQUAL_FLOAT(10.0f, r.st().tiltTarget); + TEST_ASSERT_EQUAL_UINT16(0, r.events.changed); + r.b.setPosition(70, -1); + ASSERT_POS(70, r.st().currentPos); + ASSERT_POS(70, r.st().target); + ASSERT_POS(10.2f, r.st().currentTiltPos); + TEST_ASSERT_TRUE(r.events.changed & SHADE_CHANGED_POSITION); + r.b.clearTilt(); + TEST_ASSERT_EQUAL_FLOAT(0.0f, r.st().currentTiltPos); + TEST_ASSERT_EQUAL_FLOAT(-1.0f, r.st().myTiltPos); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_down_waits_for_the_radio_then_travels); + RUN_TEST(test_refused_command_moves_nothing); + RUN_TEST(test_hold_times_out_without_a_radio_event); + RUN_TEST(test_gpio_command_moves_at_once); + RUN_TEST(test_same_direction_again_stops_tracking); + RUN_TEST(test_stop_from_the_web_works_through_the_echo); + RUN_TEST(test_move_to_target_sends_a_stop_there); + RUN_TEST(test_dropped_stop_runs_on_to_the_limit); + RUN_TEST(test_refused_stop_runs_on_to_the_limit); + RUN_TEST(test_today_a_step_after_move_to_target_sends_a_stop); + RUN_TEST(test_toggle_on_a_56_bit_shade_sends_my); + RUN_TEST(test_flipped_shade_sends_the_other_way); + RUN_TEST(test_remote_down_and_my_stop); + RUN_TEST(test_remote_my_when_idle_goes_to_the_favorite_after_half_a_second); + RUN_TEST(test_remote_my_held_sets_the_favorite); + RUN_TEST(test_remote_held_up_on_a_tilt_motor_tilts); + RUN_TEST(test_remote_step_uses_the_step_size_last_sent); + RUN_TEST(test_wind_blocks_a_remote_down); + RUN_TEST(test_wind_retracts_the_shade); + RUN_TEST(test_sun_flag_frame_turns_it_on_and_saves); + RUN_TEST(test_integrated_tilt_turns_the_slats_first); + RUN_TEST(test_today_integrated_tilt_turns_back_after_a_lift_target); + RUN_TEST(test_today_up_on_tilt_only_from_the_web); + RUN_TEST(test_today_up_on_tilt_only_from_a_remote); + RUN_TEST(test_today_up_on_tilt_only_from_a_group); + RUN_TEST(test_today_my_while_tilt_only_moves); + RUN_TEST(test_group_down_and_step); + RUN_TEST(test_group_sun_flag_needs_a_sensor); + RUN_TEST(test_set_favorite_here_is_committed_when_all_on_air); + RUN_TEST(test_set_favorite_dropped_keeps_the_old_one); + RUN_TEST(test_set_favorite_elsewhere_moves_there_first); + RUN_TEST(test_sim_my_favorite_moves_to_it); + RUN_TEST(test_garage_my_toggles_open_and_shut); + RUN_TEST(test_travel_across_the_millis_wrap); + RUN_TEST(test_restore_set_position_and_clear_tilt); + return UNITY_END(); +}