fix: keep a tilt-only shade's lift at 100 whatever sends the command

The web and group Up/Down put a tilt-only shade's lift back at 100, but a
remote's Up/Down/My, a group's My and the web's My while moving left a
lift that something had moved, and the simulation then ran it. Every
Up/Down/My path now pins the lift at 100 and acts on the tilt only.
Reconcile Q22 in SHADE_BEHAVIOUR_PLAN.md, which also records Q23 (the
integrated tilt turn-back) as deferred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-16 22:47:53 +02:00
parent 7220f38fcd
commit 38d4ce5058
4 changed files with 79 additions and 56 deletions

View file

@ -86,8 +86,8 @@ The decisions are the user's. Each question comes with a recommended answer, and
- **Reconcile: what is decided first, and in what order.** The candidates, each pinned by a "today:" test in `test_shadebehaviour` unless marked: - **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.~~ Fixed (Q18). - ~~`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 (`test_today_up_on_tilt_only_*`, `test_today_my_while_tilt_only_moves`). - ~~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`). - 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 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). - ~~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). - The self-echo (Q5) and merging group commands into the frame rule (Q8).
@ -160,3 +160,8 @@ The decisions are the user's. Each question comes with a recommended answer, and
- **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. - **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. - **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`. - **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.

View file

@ -450,6 +450,7 @@ void ShadeBehaviour::onWaitingFrame(uint32_t now) {
else { else {
if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f) this->p_target(this->s.myPos); if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f) this->p_target(this->s.myPos);
if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos); if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos);
this->pinTiltOnlyLift();
} }
this->setMovement(0, now); this->setMovement(0, now);
this->lastFrame.processed = true; this->lastFrame.processed = true;
@ -458,6 +459,7 @@ void ShadeBehaviour::onWaitingFrame(uint32_t now) {
else { else {
this->p_target(this->s.currentPos); this->p_target(this->s.currentPos);
this->p_tiltTarget(this->s.currentTiltPos); this->p_tiltTarget(this->s.currentTiltPos);
this->pinTiltOnlyLift();
} }
if(this->lastFrame.repeats > SETMY_REPEATS + 2) this->lastFrame.processed = true; if(this->lastFrame.repeats > SETMY_REPEATS + 2) this->lastFrame.processed = true;
if(this->lastFrame.processed) { if(this->lastFrame.processed) {
@ -572,6 +574,7 @@ void ShadeBehaviour::onFrame(somfy_frame_t &frame, bool internal, uint32_t now)
} }
else { else {
// If from a remote we will simply be going up. // If from a remote we will simply be going up.
this->pinTiltOnlyLift();
if(this->settings.tiltType == tilt_types::tiltonly && !internal) this->p_tiltTarget(0.0f); if(this->settings.tiltType == tilt_types::tiltonly && !internal) this->p_tiltTarget(0.0f);
else if(!internal) { else if(!internal) {
if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(0.0f); if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(0.0f);
@ -601,6 +604,7 @@ void ShadeBehaviour::onFrame(somfy_frame_t &frame, bool internal, uint32_t now)
} }
else { else {
this->lastFrame.processed = true; this->lastFrame.processed = true;
this->pinTiltOnlyLift();
if(!internal) { if(!internal) {
if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(100.0f); if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(100.0f);
if(this->settings.tiltType != tilt_types::none) this->p_tiltTarget(100.0f); if(this->settings.tiltType != tilt_types::none) this->p_tiltTarget(100.0f);
@ -642,6 +646,7 @@ void ShadeBehaviour::onFrame(somfy_frame_t &frame, bool internal, uint32_t now)
if(this->lastFrame.processed) return; if(this->lastFrame.processed) return;
ESP_LOGI(TAG, "Moving to My target"); ESP_LOGI(TAG, "Moving to My target");
this->lastFrame.processed = true; this->lastFrame.processed = true;
this->pinTiltOnlyLift();
if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos); if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos);
if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f && this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.myPos); if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f && this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.myPos);
this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress); this->emitCommand(cmd, internal ? "internal" : "remote", frame.remoteAddress);
@ -650,6 +655,7 @@ void ShadeBehaviour::onFrame(somfy_frame_t &frame, bool internal, uint32_t now)
else { else {
if(this->lastFrame.processed) return; if(this->lastFrame.processed) return;
this->lastFrame.processed = true; this->lastFrame.processed = true;
this->pinTiltOnlyLift();
if(!internal) { if(!internal) {
if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.currentPos); if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.currentPos);
this->p_tiltTarget(this->s.currentTiltPos); this->p_tiltTarget(this->s.currentTiltPos);
@ -767,12 +773,11 @@ void ShadeBehaviour::onGroupCommand(somfy_commands cmd, uint8_t repeat, uint32_t
else { else {
if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos); if(this->s.myTiltPos >= 0.0f && this->s.myTiltPos <= 100.0f) this->p_tiltTarget(this->s.myTiltPos);
if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f && this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.myPos); if(this->s.myPos >= 0.0f && this->s.myPos <= 100.0f && this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.myPos);
this->pinTiltOnlyLift();
} }
} }
else { else {
if(this->settings.tiltType == tilt_types::tiltonly) { if(this->settings.tiltType == tilt_types::tiltonly) this->pinTiltOnlyLift();
this->p_target(100.0f);
}
else this->p_target(this->s.currentPos); else this->p_target(this->s.currentPos);
this->p_tiltTarget(this->s.currentTiltPos); this->p_tiltTarget(this->s.currentTiltPos);
} }
@ -895,6 +900,11 @@ bool ShadeBehaviour::sendSetMy(uint16_t delayMs, float oldPos, float oldTiltPos,
this->p_myTiltPos(oldTiltPos); this->p_myTiltPos(oldTiltPos);
return false; return false;
} }
void ShadeBehaviour::pinTiltOnlyLift() {
if(this->settings.tiltType != tilt_types::tiltonly) return;
this->p_target(100.0f);
this->p_currentPos(100.0f);
}
void ShadeBehaviour::runToLimit(int8_t dir, bool tilt, uint32_t since) { void ShadeBehaviour::runToLimit(int8_t dir, bool tilt, uint32_t since) {
// The motor didn't get a stop at the target so it keeps going until its end // The motor didn't get a stop at the target so it keeps going until its end
// limit. Carry on simulating that from where and when it should have stopped. // limit. Carry on simulating that from where and when it should have stopped.
@ -1123,6 +1133,7 @@ bool ShadeBehaviour::sendCommand(somfy_commands cmd, uint8_t repeat, uint8_t ste
if(!this->transmit(cmd, repeat, 0, 0, false, now).sent) return false; if(!this->transmit(cmd, repeat, 0, 0, false, now).sent) return false;
if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.currentPos); if(this->settings.tiltType != tilt_types::tiltonly) this->p_target(this->s.currentPos);
this->p_tiltTarget(this->s.currentTiltPos); this->p_tiltTarget(this->s.currentTiltPos);
this->pinTiltOnlyLift();
} }
} }
else if(cmd == somfy_commands::Toggle) { else if(cmd == somfy_commands::Toggle) {

View file

@ -135,6 +135,8 @@ class ShadeBehaviour {
bool storeMyPosition(float pos, float tiltPos, uint32_t now); bool storeMyPosition(float pos, float tiltPos, uint32_t now);
// Repeats for a tilt command, more for a separate tilt motor. // Repeats for a tilt command, more for a separate tilt motor.
uint8_t tiltRepeats() const; 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); void runToLimit(int8_t dir, bool tilt, uint32_t since);
// Sets the targets the sun or wind sends the shade to. // Sets the targets the sun or wind sends the shade to.
void applySensorMove(SensorMove move); void applySensorMove(SensorMove move);

View file

@ -457,58 +457,66 @@ static void test_today_integrated_tilt_turns_back_after_a_lift_target() {
} }
/********************************************************************* /*********************************************************************
* Tilt-only shades: the copies disagree * Tilt-only shades: the lift stays all the way down
********************************************************************/ ********************************************************************/
static Rig *tiltOnly(Rig &r) { 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.shadeType = shade_types::blind;
r.settings.tiltType = tilt_types::tiltonly; r.settings.tiltType = tilt_types::tiltonly;
r.b.restore(100, 50, -1, -1); r.b.restore(100, 50, -1, 30);
return &r; if(moving) {
r.b.onGroupCommand(somfy_commands::Down, 1, r.now);
r.run(500);
}
r.b.setPosition(60, -1);
} }
static void test_today_up_on_tilt_only_from_the_web() { static void tiltOnlyCommand(Rig &r, Source src, somfy_commands cmd) {
Rig r; switch(src) {
tiltOnly(r); case Source::web:
r.b.sendCommand(somfy_commands::Up, 1, 0, r.now); r.b.sendCommand(cmd, 1, 0, r.now);
ASSERT_POS(100, r.st().target); r.onAir();
ASSERT_POS(100, r.st().currentPos); break;
ASSERT_POS(0, r.st().tiltTarget); 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_today_up_on_tilt_only_from_a_remote() { static void test_tilt_only_lift_stays_down_whatever_the_source() {
// today: a remote only sets the tilt target, where the web and groups also const Source sources[] = {Source::web, Source::remote, Source::group};
// put the lift at 100. const somfy_commands cmds[] = {somfy_commands::Up, somfy_commands::Down, somfy_commands::My};
Rig r; for(Source src : sources) {
tiltOnly(r); for(somfy_commands cmd : cmds) {
r.b.restore(60, 50, -1, -1); for(bool moving : {false, true}) {
r.press(somfy_commands::Up); Rig r;
ASSERT_POS(60, r.st().target); tiltOnly(r, moving);
ASSERT_POS(0, r.st().tiltTarget); tiltOnlyCommand(r, src, cmd);
} r.run(1000);
static void test_today_up_on_tilt_only_from_a_group() { char msg[64];
Rig r; snprintf(msg, sizeof(msg), "%s %s%s", sourceName(src), translateSomfyCommand(cmd).c_str(), moving ? " while moving" : "");
tiltOnly(r); TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.5f, 100, r.st().target, msg);
r.b.restore(60, 50, -1, -1); TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.5f, 100, r.st().currentPos, msg);
r.b.onGroupCommand(somfy_commands::Up, 1, r.now); TEST_ASSERT_EQUAL_INT8_MESSAGE(0, r.st().direction, msg);
ASSERT_POS(100, r.st().target); // What the tilt does.
ASSERT_POS(100, r.st().currentPos); if(cmd == somfy_commands::Up) TEST_ASSERT_FLOAT_WITHIN_MESSAGE(0.5f, 0, r.st().tiltTarget, msg);
ASSERT_POS(0, r.st().tiltTarget); 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);
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);
} }
/********************************************************************* /*********************************************************************
@ -650,10 +658,7 @@ int main(int argc, char **argv) {
RUN_TEST(test_sun_flag_frame_turns_it_on_and_saves); RUN_TEST(test_sun_flag_frame_turns_it_on_and_saves);
RUN_TEST(test_integrated_tilt_turns_the_slats_first); 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_integrated_tilt_turns_back_after_a_lift_target);
RUN_TEST(test_today_up_on_tilt_only_from_the_web); RUN_TEST(test_tilt_only_lift_stays_down_whatever_the_source);
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_down_and_step);
RUN_TEST(test_group_sun_flag_needs_a_sensor); 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_here_is_committed_when_all_on_air);