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

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