Merge pull request #53 from cjkas/land/shade-behaviour

Land the shade behaviour work (#47-#52) on main
This commit is contained in:
cjkas 2026-10-17 23:00:06 +02:00 • committed by GitHub
commit 8fcb0f7d67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2261 additions and 1346 deletions

View file

@ -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**

View file

@ -201,7 +201,6 @@ NVS stays at `0x9000`, so the rolling codes survive. The file system partition s
Still open, and each can be fixed on its own:
- A restore without shades skips the shade and group records but not the room records, so it reads the rest of the file from the wrong place ([ConfigFile.cpp:578-580](../src/ConfigFile.cpp#L578-L580)).
- `settingPos` is never cleared when the lift arrives, while `settingTiltPos` is. A later arrival may send an extra stop. Not confirmed on a device.
- The Home Assistant integration sends no `apikey` header on shade, group and tilt commands.
- New ids stop two short of the maximum: 30 shades, 14 groups and 14 rooms instead of 32, 16 and 16 (`getNext*Id` stopped at `MAX - 1`). ModelEditor keeps those limits for now.
- `SomfyGroup::fromJSON` reads `linkedShades` into a local array and drops it, so a group's members only change through `/linkToGroup` and `/unlinkFromGroup`.
@ -216,3 +215,6 @@ Fixed by step 3:
Fixed by step 4:
- MQTT `position`/`target` were published as a constant 50, and `direction` as 0 on connect.
- A group whose flags changed sent `groupState` twice.
Fixed later, by [SHADE_BEHAVIOUR_PLAN.md](SHADE_BEHAVIOUR_PLAN.md) (reconcile Q18):
- `settingPos` was never cleared when the lift arrived, while `settingTiltPos` was, so a later step or group move also sent a stop when it arrived.

View file

@ -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:** done, apart from the device runs. PR A is #46 and PR B is #47 (the behaviour is in `ShadeBehaviour`, pinned by `test_shadebehaviour`). The reconcile fixes Q18–Q22 are one PR each, stacked on #47. Q23 is deferred and Q24 is left for later.
**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.** Every candidate is now fixed, deferred or left for later (Q17–Q24). Each was 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.~~ Fixed (Q18).
- ~~Up and My on tilt-only shades differ between the web, a remote and a group.~~ Fixed (Q22).
- 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`). Deferred (Q23).
- ~~A remote step uses the step size this shade last *sent*.~~ Fixed (Q19).
- ~~A group's SunFlag or Flag doesn't make other groups work their flags out again.~~ Fixed (Q20).
- The self-echo (Q5) and merging group commands into the frame rule (Q8). Possible later (Q24).
- `lastFrame` doing two jobs (the Q6 revisit). Possible later (Q24).
- ~~The 500 ms long-press deadline isn't wrap-safe.~~ Fixed (Q21).
## Constraints
@ -135,3 +143,27 @@ 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.
**Reconcile round 1 (2026-10-16), all as recommended.** Each fix is one PR stacked on #47, and each changes a "today:" test.
- **Q17. Order.** Clear bugs first: `settingPos` (Q18), the remote step size (Q19), group sun flags (Q20), the long-press wrap (Q21). Then the rules where the copies disagree: tilt-only Up/Down/My, and the integrated tilt turning back. The structural changes (self-echo, merging group commands, splitting `lastFrame`) wait until those are done and may be dropped.
- **Q18. `settingPos` is cleared on a lift arrival,** as `settingTiltPos` is on a tilt arrival. A later step or group move then stops by itself instead of getting a My, which would have sent a stopped motor to its favorite.
- **Q19. A remote's step uses the frame's own step size** (`frame.stepSize`, 1 when 0) instead of `lastFrame.stepSize`, which `copy()` never updates. `copy()` itself stays as it is.
- **Q20. A group's SunFlag/Flag calls `onSensorFlags()`,** as the frame rule does, so every group works its flags out again.
- **Q21. The long-press wait is wrap-safe:** `(int32_t)(now - await) > 0`.
**Reconcile round 2 (2026-10-16):**
- **Q22. A tilt-only shade's lift is pinned at 100 on every path** (web, remote, group; Up, Down, My, idle or moving), and those commands act on the tilt only. Before, the remote and group My paths and the web's My while moving left a lift that something had moved, and `tick` then simulated it travelling. `test_tilt_only_lift_stays_down_whatever_the_source` checks the 18 combinations.
- **Q23. Integrated tilt turning back: deferred.** No integrated-tilt motor is at hand to watch, and reconcile only changes what has been checked on a real motor. The behaviour stays, still labelled "today:", until someone with such a shade can say whether the turn-back is clean.
- **Q24. The structural changes are left for later:** dropping the self-echo, merging group commands into the frame rule, and splitting `lastFrame`'s two jobs. None of them fixes anything a user would notice, each touches every command path, and the behaviour they would move is now pinned by tests, so they can be picked up any time.
- **Q25. One PR per fix,** stacked on #47, each run on a device before it merges.

View file

@ -93,8 +93,8 @@ What changed in behaviour:
Kept as it was: the fixed 1% step of a held step button in `processWaitingFrame`.
Left out on purpose, since neither fixes a bug or can be reached by the native tests:
- the four near-copy arrive-at-target blocks in `checkMovement`;
- the six `getNext*Id`/`getMax*Order` copies in the controller.
- the four near-copy arrive-at-target blocks in `checkMovement`. They are now in `ShadeBehaviour::tick` ([SHADE_BEHAVIOUR_PLAN.md](SHADE_BEHAVIOUR_PLAN.md)), still four copies, but pinned by native tests;
- the six `getNext*Id`/`getMax*Order` copies in the controller. Since replaced by ModelEditor's `firstFreeId` (step 3 of [LOOP_QUEUE_PLAN.md](LOOP_QUEUE_PLAN.md)).
Flash went from 1,546,797 to 1,545,717 bytes.

View file

@ -113,7 +113,7 @@ lib_deps =
platform = native
test_framework = unity
test_build_src = yes
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ModelSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp>
build_src_filter = -<*> +<Utils.cpp> +<SomfyFrame.cpp> +<JsonFormatter.cpp> +<ShadeMotion.cpp> +<ShadeSensors.cpp> +<ShadeBehaviour.cpp> +<PulseDecoder.cpp> +<TxBuffer.cpp> +<RadioScheduler.cpp> +<ShadeRequests.cpp> +<entryPoints/MqttCommands.cpp> +<ModelSnapshot.cpp> +<ModelEditor.cpp> +<ShadeChanges.cpp>
build_flags =
-std=gnu++17
-Isrc

View file

@ -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<float>(this->readUInt8(255));
myPos = static_cast<float>(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.

1232
src/ShadeBehaviour.cpp Normal file

File diff suppressed because it is too large Load diff

169
src/ShadeBehaviour.h Normal file
View file

@ -0,0 +1,169 @@
#ifndef SHADE_BEHAVIOUR_H
#define SHADE_BEHAVIOUR_H
#include <Arduino.h>
#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;
// A tilt-only shade has no lift to move: it stays all the way down.
void pinTiltOnlyLift();
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

View file

@ -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.

View file

@ -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) {

View file

@ -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,

View file

@ -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

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -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) {

File diff suppressed because it is too large Load diff

View file

@ -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) {

View file

@ -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<uint8_t>(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<uint8_t>(shade.shadeType);
out.bitLength = shade.bitLength;
out.proto = static_cast<uint8_t>(shade.proto);

View file

@ -17,10 +17,10 @@ void SomfyShade::emitState(uint8_t num, const char *evt) {
json->addElem("type", static_cast<uint8_t>(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<uint8_t>(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);

View file

@ -0,0 +1,672 @@
#include <unity.h>
#include <vector>
#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<uint8_t>(expected), static_cast<uint8_t>(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> 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<uint8_t>(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<Command> 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, uint8_t stepSize = 0) {
somfy_frame_t frame;
frame.valid = true;
frame.cmd = cmd;
frame.stepSize = stepSize;
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_a_step_after_move_to_target_sends_no_stop() {
// The stop at the target ends the positioning, so a later step stops by
// itself. A stop sent then would be a My to a still motor: its favorite.
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 + 1, r.link.sent.size());
ASSERT_CMD(somfy_commands::StepDown, r.lastSent().cmd);
TEST_ASSERT_EQUAL_INT8(0, r.st().direction);
}
static void test_a_group_step_after_move_to_target_sends_no_stop() {
Rig r;
r.b.moveToTarget(40, -1, r.now);
r.onAir();
r.run(4100);
const size_t before = r.link.sent.size();
r.b.onGroupCommand(somfy_commands::StepDown, 1, r.now);
r.run(1000);
ASSERT_POS(41, r.st().currentPos);
TEST_ASSERT_EQUAL(before, r.link.sent.size());
}
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_waits_half_a_second_across_the_millis_wrap() {
Rig r;
r.b.restore(0, 0, 60, -1);
r.now = 0xFFFFFFFF - 200;
r.press(somfy_commands::My);
// The deadline is past the wrap; it still waits for it.
r.wait(300);
ASSERT_POS(0, r.st().target);
r.wait(300);
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_frames_step_size() {
Rig r;
r.b.restore(50, 0, -1, -1);
// A frame without a step size is one step.
r.press(somfy_commands::StepDown);
ASSERT_POS(51, r.st().target);
// What this shade last sent doesn't matter.
r.lastFrame.stepSize = 7;
r.press(somfy_commands::StepDown, 0, ADDRESS, 3);
// Steps count from where the shade is, still 50.
ASSERT_POS(53, r.st().target);
}
static void test_web_step_uses_the_step_size_sent() {
Rig r;
r.b.restore(50, 0, -1, -1);
r.b.sendCommand(somfy_commands::StepDown, 1, 3, r.now);
TEST_ASSERT_EQUAL_UINT8(3, r.lastSent().stepSize);
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<uint8_t>(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<uint8_t>(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 lift stays all the way down
********************************************************************/
enum class Source { web, remote, group };
static const char *sourceName(Source src) {
switch(src) {
case Source::web: return "web";
case Source::remote: return "remote";
default: return "group";
}
}
// A tilt-only shade whose favorite tilt is 30, and whose lift something has
// moved to 60. moving starts the tilt turning first.
static void tiltOnly(Rig &r, bool moving) {
r.settings.shadeType = shade_types::blind;
r.settings.tiltType = tilt_types::tiltonly;
r.b.restore(100, 50, -1, 30);
if(moving) {
r.b.onGroupCommand(somfy_commands::Down, 1, r.now);
r.run(500);
}
r.b.setPosition(60, -1);
}
static void tiltOnlyCommand(Rig &r, Source src, somfy_commands cmd) {
switch(src) {
case Source::web:
r.b.sendCommand(cmd, 1, 0, r.now);
r.onAir();
break;
case Source::remote:
r.press(cmd);
// Past the wait that tells a short press from a long one.
r.wait(600);
break;
case Source::group:
r.b.onGroupCommand(cmd, 1, r.now);
break;
}
}
static void test_tilt_only_lift_stays_down_whatever_the_source() {
const Source sources[] = {Source::web, Source::remote, Source::group};
const somfy_commands cmds[] = {somfy_commands::Up, somfy_commands::Down, somfy_commands::My};
for(Source src : sources) {
for(somfy_commands cmd : cmds) {
for(bool moving : {false, true}) {
Rig r;
tiltOnly(r, moving);
tiltOnlyCommand(r, src, cmd);
r.run(1000);
char msg[64];
snprintf(msg, sizeof(msg), "%s %s%s", sourceName(src), translateSomfyCommand(cmd).c_str(), moving ? " while moving" : "");
TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.5f, 100, r.st().target, msg);
TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.5f, 100, r.st().currentPos, msg);
TEST_ASSERT_EQUAL_INT8_MESSAGE(0, r.st().direction, msg);
// What the tilt does.
if(cmd == somfy_commands::Up) TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.5f, 0, r.st().tiltTarget, msg);
else if(cmd == somfy_commands::Down) TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.5f, 100, r.st().tiltTarget, msg);
else if(!moving) TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.5f, 30, r.st().tiltTarget, msg);
}
}
}
}
/*********************************************************************
* 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<uint8_t>(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<uint8_t>(somfy_flags_t::SunFlag));
TEST_ASSERT_EQUAL(1, r.events.saves);
// As for a frame, every group works its flags out again.
TEST_ASSERT_EQUAL(1, r.events.sensorFlags);
r.b.onGroupCommand(somfy_commands::Flag, 1, r.now);
TEST_ASSERT_FALSE(r.st().stateFlags & static_cast<uint8_t>(somfy_flags_t::SunFlag));
TEST_ASSERT_EQUAL(2, 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_a_step_after_move_to_target_sends_no_stop);
RUN_TEST(test_a_group_step_after_move_to_target_sends_no_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_waits_half_a_second_across_the_millis_wrap);
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_frames_step_size);
RUN_TEST(test_web_step_uses_the_step_size_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_tilt_only_lift_stays_down_whatever_the_source);
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();
}