refactor: move the shade's behaviour into ShadeBehaviour

PR B of SHADE_BEHAVIOUR_PLAN.md. What a shade does with frames, commands,
group commands and radio events moves unchanged from SomfyShade into
ShadeBehaviour, which builds natively and is pinned by test_shadebehaviour.

ShadeBehaviour reads ShadeSettings and RemoteSettings, gets the time on
every input, and holds the motion state that other files now read through
state(). It sends through MotorLink and reports through ShadeEvents
(changes, commands, save wanted, sensor flags) as things happen.
SomfyShade wraps it: its MotorLink is SomfyRemote::sendCommandAfter, which
now leaves the sender out of the echo and radio hold because the module
hears its own frame. lastFrame stays shared with the send path.

ConfigFile writes through restore() and clearTilt(), SomfyShadeOps through
setPosition(). The pinning tests label where the copies of a rule disagree
"today:", for the reconcile phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-16 21:21:38 +02:00
parent fa7aaf0d49
commit 4f8eac03ac
18 changed files with 2182 additions and 1343 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

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

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.

1215
src/ShadeBehaviour.cpp Normal file

File diff suppressed because it is too large Load diff

167
src/ShadeBehaviour.h Normal file
View file

@ -0,0 +1,167 @@
#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;
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,632 @@
#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) {
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<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 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<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);
// 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();
}