mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 22:02:17 +01:00
Merge pull request #53 from cjkas/land/shade-behaviour
Land the shade behaviour work (#47-#52) on main
This commit is contained in:
commit
8fcb0f7d67
20 changed files with 2261 additions and 1346 deletions
10
GLOSSARY.md
10
GLOSSARY.md
|
|
@ -70,6 +70,16 @@ What shades and groups share about how their frames go on air: flip commands, bi
|
|||
**Flags**
|
||||
The byte a shade is saved and sent with. Its sun sensor, light and simulated My bits are settings; its sun flag, sunny, windy and demo mode bits are state. A group's flags are its shades' flags together.
|
||||
|
||||
**ShadeBehaviour**
|
||||
What a shade does with frames, commands, group commands and radio events, and where that leaves it: positions, targets, directions, the favorite and the sensor state.
|
||||
_Avoid_: shade logic, motion (`ShadeMotion` is the position maths it uses)
|
||||
|
||||
**MotorLink**
|
||||
The seam a shade's commands reach its motor through, radio or GPIO. The shade hears its own frame; every other shade hears it through the link.
|
||||
|
||||
**ShadeEvents**
|
||||
The seam through which a shade's behaviour reports what happened: changes for the change fan-out, commands for clients, a save wanted, and sensor flags for the groups.
|
||||
|
||||
## Entry points and outputs
|
||||
|
||||
**Entry point**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1232
src/ShadeBehaviour.cpp
Normal file
File diff suppressed because it is too large
Load diff
169
src/ShadeBehaviour.h
Normal file
169
src/ShadeBehaviour.h
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
96
src/Somfy.h
96
src/Somfy.h
|
|
@ -9,6 +9,7 @@
|
|||
#include "RadioScheduler.h"
|
||||
#include "ShadeTypes.h"
|
||||
#include "ShadeSettings.h"
|
||||
#include "ShadeBehaviour.h"
|
||||
#include "ShadeMotion.h"
|
||||
#include "ShadeSensors.h"
|
||||
#include "ShadeChanges.h"
|
||||
|
|
@ -37,13 +38,6 @@ class SomfyRoom {
|
|||
void unpublish();
|
||||
};
|
||||
|
||||
// What sending a command did. ticket is the radio job carrying it, 0 when the
|
||||
// command went out over GPIO instead.
|
||||
struct send_result_t {
|
||||
bool sent = false;
|
||||
uint32_t ticket = 0;
|
||||
explicit operator bool() const { return this->sent; }
|
||||
};
|
||||
class SomfyRemote : public RemoteSettings {
|
||||
// These sizes for the data have been
|
||||
// confirmed. The address is actually 24bits
|
||||
|
|
@ -77,7 +71,9 @@ class SomfyRemote : public RemoteSettings {
|
|||
// command. Nothing about the remote or shade state has changed in that case.
|
||||
virtual bool sendCommand(somfy_commands cmd);
|
||||
virtual bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0);
|
||||
send_result_t sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority = false);
|
||||
// hearsItself leaves this remote out of the echo, for a shade whose
|
||||
// ShadeBehaviour hears its own frame.
|
||||
send_result_t sendCommandAfter(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority = false, bool hearsItself = false);
|
||||
bool sendSensorCommand(int8_t isWindy, int8_t isSunny, uint8_t repeat);
|
||||
bool repeatFrame(uint8_t repeat);
|
||||
virtual uint16_t p_lastRollingCode(uint16_t code);
|
||||
|
|
@ -89,47 +85,16 @@ class SomfyLinkedRemote : public SomfyRemote {
|
|||
public:
|
||||
SomfyLinkedRemote();
|
||||
};
|
||||
class SomfyShade : public SomfyRemote, public ShadeSettings {
|
||||
// A shade: its identity, settings, remotes and outputs. What it does with
|
||||
// frames and commands is its ShadeBehaviour; the shade sends for it
|
||||
// (MotorLink) and passes on what it reports (ShadeEvents).
|
||||
class SomfyShade : public SomfyRemote, public ShadeSettings, private MotorLink, private ShadeEvents {
|
||||
protected:
|
||||
uint8_t shadeId = 255;
|
||||
uint64_t moveStart = 0;
|
||||
uint64_t tiltStart = 0;
|
||||
ShadeSensors sensors;
|
||||
float startPos = 0.0f;
|
||||
float startTiltPos = 0.0f;
|
||||
bool settingMyPos = false;
|
||||
bool settingPos = false;
|
||||
bool settingTiltPos = false;
|
||||
uint32_t awaitMy = 0;
|
||||
// The flags bits that are state: sun flag, sunny, windy, demo mode and the
|
||||
// unused lighted. The setting bits are in ShadeSettings and light.
|
||||
uint8_t stateFlags = 0;
|
||||
// Radio jobs whose outcome decides what the motor really did.
|
||||
ShadeTxState tx;
|
||||
bool sendStop(int8_t dir, bool tilt);
|
||||
bool sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos);
|
||||
// Makes pos/tiltPos the favorite and tells the motor to store it.
|
||||
bool storeMyPosition(float pos, float tiltPos);
|
||||
// Repeats for a tilt command, more for a separate tilt motor.
|
||||
uint8_t tiltRepeats() const;
|
||||
void runToLimit(int8_t dir, bool tilt, uint32_t since);
|
||||
// Sets the targets the sun or wind sends the shade to.
|
||||
void applySensorMove(SensorMove move);
|
||||
// Sets the targets for a step in dir. False when the step is dropped.
|
||||
bool applyStep(int8_t dir, uint8_t frameStepSize);
|
||||
public:
|
||||
uint8_t roomId = 0;
|
||||
int8_t sortOrder = 0;
|
||||
bool light = false;
|
||||
float currentPos = 0.0f;
|
||||
float currentTiltPos = 0.0f;
|
||||
int8_t lastMovement = 0;
|
||||
int8_t direction = 0; // 0 = stopped, 1=down, -1=up.
|
||||
int8_t tiltDirection = 0; // 0=stopped, 1=clockwise, -1=counter clockwise
|
||||
float target = 0.0f;
|
||||
float tiltTarget = 0.0f;
|
||||
float myPos = -1.0f;
|
||||
float myTiltPos = -1.0f;
|
||||
SomfyLinkedRemote linkedRemotes[SOMFY_MAX_LINKED_REMOTES];
|
||||
bool paired = false;
|
||||
int8_t validateJSON(JsonObject &obj);
|
||||
|
|
@ -138,11 +103,12 @@ class SomfyShade : public SomfyRemote, public ShadeSettings {
|
|||
char name[21] = "";
|
||||
void setShadeId(uint8_t id) { shadeId = id; }
|
||||
uint8_t getShadeId() { return shadeId; }
|
||||
// Where the shade is and where it is going.
|
||||
const ShadeBehaviour::State &state() const { return this->behaviour.state(); }
|
||||
uint8_t getFlags() override;
|
||||
// Sets the setting and state bits from a saved or sent flags byte.
|
||||
void setFlags(uint8_t flags);
|
||||
bool save();
|
||||
bool isIdle();
|
||||
bool isInGroup();
|
||||
void checkMovement();
|
||||
void processFrame(somfy_frame_t &frame, bool internal = false);
|
||||
|
|
@ -151,17 +117,19 @@ class SomfyShade : public SomfyRemote, public ShadeSettings {
|
|||
void processInternalCommand(somfy_commands cmd, uint8_t repeat = 1);
|
||||
void markTxPending(uint32_t ticket);
|
||||
void processTxEvent(const somfy_tx_event_t &evt);
|
||||
void setTiltMovement(int8_t dir);
|
||||
void setMovement(int8_t dir);
|
||||
void setTarget(float target);
|
||||
bool isAtTarget();
|
||||
bool isToggle();
|
||||
bool isToggle() { return isToggleShade(this->shadeType); }
|
||||
bool moveToTarget(float pos, float tilt = -1.0f);
|
||||
// These return false when the radio refused a command they sent.
|
||||
bool moveToTiltTarget(float target);
|
||||
bool sendTiltCommand(somfy_commands cmd);
|
||||
bool sendCommand(somfy_commands cmd);
|
||||
bool sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t stepSize = 0);
|
||||
// Puts the shade at pos and tilt (-1 leaves one alone) without moving it.
|
||||
void setPosition(float pos, float tilt);
|
||||
// Where the config file says the shade was.
|
||||
void restore(float pos, float tiltPos, float myPos, float myTiltPos) { this->behaviour.restore(pos, tiltPos, myPos, myTiltPos); }
|
||||
// Forgets the tilt of a shade that has none.
|
||||
void clearTilt() { this->behaviour.clearTilt(); }
|
||||
bool linkRemote(uint32_t remoteAddress, uint16_t rollingCode = 0);
|
||||
bool unlinkRemote(uint32_t remoteAddress);
|
||||
// Marks the shade as changed; the change fan-out sends shadeState once per
|
||||
|
|
@ -186,27 +154,11 @@ class SomfyShade : public SomfyRemote, public ShadeSettings {
|
|||
// Publishes the MQTT topics of the SHADE_CHANGED_* bits in changed.
|
||||
void publishTopics(uint16_t changed);
|
||||
void commit();
|
||||
void commitShadePosition();
|
||||
void commitTiltPosition();
|
||||
void commitMyPosition();
|
||||
void clear();
|
||||
int8_t transformPosition(float fpos);
|
||||
void setGPIOs();
|
||||
void triggerGPIOs(somfy_frame_t &frame);
|
||||
bool usesPin(uint8_t pin);
|
||||
// State Setters
|
||||
int8_t p_direction(int8_t dir);
|
||||
int8_t p_tiltDirection(int8_t dir);
|
||||
float p_target(float target);
|
||||
float p_tiltTarget(float target);
|
||||
float p_myPos(float pos);
|
||||
float p_myTiltPos(float pos);
|
||||
bool p_flag(somfy_flags_t flag, bool val);
|
||||
bool p_sunFlag(bool val);
|
||||
bool p_sunny(bool val);
|
||||
bool p_windy(bool val);
|
||||
float p_currentPos(float pos);
|
||||
float p_currentTiltPos(float pos);
|
||||
uint16_t p_lastRollingCode(uint16_t code);
|
||||
bool publish(const char *topic, const char *val, bool retain = false);
|
||||
bool publish(const char *topic, uint8_t val, bool retain = false);
|
||||
|
|
@ -216,6 +168,15 @@ class SomfyShade : public SomfyRemote, public ShadeSettings {
|
|||
bool publish(const char *topic, bool val, bool retain = false);
|
||||
void publishDisco();
|
||||
void unpublishDisco();
|
||||
private:
|
||||
// MotorLink: the radio or GPIO path every remote sends through.
|
||||
send_result_t send(somfy_commands cmd, uint8_t repeat, uint8_t stepSize, uint16_t delayMs, bool priority) override;
|
||||
// ShadeEvents
|
||||
void onChanged(uint16_t bits) override { this->changed |= bits; }
|
||||
void onCommand(somfy_commands cmd, const char *source, uint32_t sourceAddress) override { this->emitCommand(cmd, source, sourceAddress); }
|
||||
void onSaveWanted() override;
|
||||
void onSensorFlags() override;
|
||||
ShadeBehaviour behaviour{*this, *this, this->lastFrame, *this, *this, this->shadeId, this->name};
|
||||
};
|
||||
class SomfyGroup : public SomfyRemote {
|
||||
protected:
|
||||
|
|
@ -305,8 +266,9 @@ class SomfyShadeController {
|
|||
SomfyGroup * findGroupByRemoteAddress(uint32_t address);
|
||||
// Returns the radio job's ticket, 0 when the radio can't take it.
|
||||
uint32_t sendFrame(somfy_frame_t &frame, uint8_t repeats = 0, uint16_t delayMs = 0, bool priority = false);
|
||||
void processFrame(somfy_frame_t &frame, bool internal = false);
|
||||
void markTxPending(uint32_t remoteAddress, uint32_t ticket);
|
||||
// skip, when set, is left out: a shade that hears its own frame.
|
||||
void processFrame(somfy_frame_t &frame, bool internal = false, const SomfyRemote *skip = nullptr);
|
||||
void markTxPending(uint32_t remoteAddress, uint32_t ticket, const SomfyRemote *skip = nullptr);
|
||||
void processTxEvent(const somfy_tx_event_t &evt);
|
||||
void emitState(uint8_t num = 255);
|
||||
// The change fan-out: sends every shade and group that changed since the
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
1221
src/SomfyShade.cpp
1221
src/SomfyShade.cpp
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
672
test/test_shadebehaviour/test_shadebehaviour.cpp
Normal file
672
test/test_shadebehaviour/test_shadebehaviour.cpp
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue