diff --git a/controller/tea_poor/lib/Arduino/WaterPumpController.cpp b/controller/tea_poor/lib/Arduino/WaterPumpController.cpp index 6aef2a0..4717f0d 100644 --- a/controller/tea_poor/lib/Arduino/WaterPumpController.cpp +++ b/controller/tea_poor/lib/Arduino/WaterPumpController.cpp @@ -20,10 +20,10 @@ void WaterPumpController::setup() { stop(); } -void WaterPumpController::start() { +void WaterPumpController::start(int power) { _isRunning = true; digitalWrite(_brakePin, LOW); // release breaks - analogWrite(_powerPin, 255); + analogWrite(_powerPin, power); } void WaterPumpController::stop() { diff --git a/controller/tea_poor/lib/Arduino/WaterPumpController.h b/controller/tea_poor/lib/Arduino/WaterPumpController.h index 2e55cac..9d492c7 100644 --- a/controller/tea_poor/lib/Arduino/WaterPumpController.h +++ b/controller/tea_poor/lib/Arduino/WaterPumpController.h @@ -7,14 +7,13 @@ class WaterPumpController: public IWaterPump { const int _directionPin; const int _brakePin; const int _powerPin; - const int _maxPower = 255; bool _isRunning = false; public: WaterPumpController(int directionPin, int brakePin, int powerPin); virtual ~WaterPumpController() override; virtual void setup() override; - virtual void start() override; + virtual void start(int power) override; virtual void stop() override; virtual bool isRunning() const override { return _isRunning; } diff --git a/controller/tea_poor/lib/Core/AdjustedWaterPump.h b/controller/tea_poor/lib/Core/AdjustedWaterPump.h new file mode 100644 index 0000000..6e2a38c --- /dev/null +++ b/controller/tea_poor/lib/Core/AdjustedWaterPump.h @@ -0,0 +1,26 @@ +#ifndef ADJUSTEDWATERPUMP_H +#define ADJUSTEDWATERPUMP_H +#include +#include + +// lightweight wrapper around IWaterPump +// its purpose is to adjust power value to the range of 0..255, for now +class AdjustedWaterPump: public IWaterPump { +private: + const IWaterPumpPtr _pump; +public: + AdjustedWaterPump(IWaterPumpPtr pump) : _pump(pump) {} + virtual ~AdjustedWaterPump() override {} + + virtual void setup() override { _pump->setup(); } + virtual void stop() override { _pump->stop(); } + virtual bool isRunning() const override { return _pump->isRunning(); } + + virtual void start(int powerInPercents) override { + // convert percents to 0..255 range, using float + const float power = (255.0f / 100.0f) * (float)powerInPercents; + _pump->start(floor(power)); + } +}; + +#endif \ No newline at end of file diff --git a/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp b/controller/tea_poor/lib/Core/CommandProcessor.cpp similarity index 86% rename from controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp rename to controller/tea_poor/lib/Core/CommandProcessor.cpp index a58434e..a1f128f 100644 --- a/controller/tea_poor/lib/CommandProcessor/CommandProcessor.cpp +++ b/controller/tea_poor/lib/Core/CommandProcessor.cpp @@ -44,13 +44,17 @@ std::string CommandProcessor::status() { return response.str(); } -std::string CommandProcessor::pour_tea(const char *milliseconds) { +std::string CommandProcessor::pour_tea(const char *milliseconds, const char *power) { if (!isValidIntNumber(milliseconds, _waterPumpSafeThreshold + 1)) { // send error message as JSON return std::string("{ \"error\": \"invalid milliseconds value\" }"); } + if (!isValidIntNumber(power, 101)) { + // send error message as JSON + return std::string("{ \"error\": \"invalid power value\" }"); + } // start pouring tea - _waterPump->start( atoi(milliseconds), _env->time() ); + _waterPump->start( atoi(milliseconds), atoi(power) ); return status(); } diff --git a/controller/tea_poor/lib/CommandProcessor/CommandProcessor.h b/controller/tea_poor/lib/Core/CommandProcessor.h similarity index 91% rename from controller/tea_poor/lib/CommandProcessor/CommandProcessor.h rename to controller/tea_poor/lib/Core/CommandProcessor.h index a7770a0..54cbdb7 100644 --- a/controller/tea_poor/lib/CommandProcessor/CommandProcessor.h +++ b/controller/tea_poor/lib/Core/CommandProcessor.h @@ -20,7 +20,7 @@ class CommandProcessor { {} std::string status(); - std::string pour_tea(const char *milliseconds); + std::string pour_tea(const char *milliseconds, const char *power); std::string stop(); private: const int _waterPumpSafeThreshold; diff --git a/controller/tea_poor/lib/WaterPumpScheduler/WaterPumpScheduler.cpp b/controller/tea_poor/lib/Core/WaterPumpScheduler.cpp similarity index 69% rename from controller/tea_poor/lib/WaterPumpScheduler/WaterPumpScheduler.cpp rename to controller/tea_poor/lib/Core/WaterPumpScheduler.cpp index edf3634..4fe03c1 100644 --- a/controller/tea_poor/lib/WaterPumpScheduler/WaterPumpScheduler.cpp +++ b/controller/tea_poor/lib/Core/WaterPumpScheduler.cpp @@ -1,7 +1,8 @@ #include "WaterPumpScheduler.h" -WaterPumpScheduler::WaterPumpScheduler(IWaterPumpPtr waterPump, unsigned long forceStopIntervalMs) : +WaterPumpScheduler::WaterPumpScheduler(IWaterPumpPtr waterPump, IEnvironmentPtr env, unsigned long forceStopIntervalMs) : _waterPump(waterPump), + _env(env), _forceStopIntervalMs(forceStopIntervalMs) { } @@ -12,9 +13,9 @@ void WaterPumpScheduler::setup() { _waterPump->setup(); } -void WaterPumpScheduler::start(unsigned long runTimeMs, unsigned long currentTimeMs) { - _stopTime = currentTimeMs + runTimeMs; - _waterPump->start(); +void WaterPumpScheduler::start(unsigned long runTimeMs, int power) { + _stopTime = _env->time() + runTimeMs; + _waterPump->start(power); } void WaterPumpScheduler::stop() { @@ -22,7 +23,8 @@ void WaterPumpScheduler::stop() { _stopTime = 0; // a bit of paranoia :) } -void WaterPumpScheduler::tick(unsigned long currentTimeMs) { +void WaterPumpScheduler::tick() { + const auto currentTimeMs = _env->time(); if (_stopTime <= currentTimeMs) { stop(); _stopTime = currentTimeMs + _forceStopIntervalMs; // force stop after X milliseconds diff --git a/controller/tea_poor/lib/WaterPumpScheduler/WaterPumpScheduler.h b/controller/tea_poor/lib/Core/WaterPumpScheduler.h similarity index 65% rename from controller/tea_poor/lib/WaterPumpScheduler/WaterPumpScheduler.h rename to controller/tea_poor/lib/Core/WaterPumpScheduler.h index 4e8aa66..a7dee36 100644 --- a/controller/tea_poor/lib/WaterPumpScheduler/WaterPumpScheduler.h +++ b/controller/tea_poor/lib/Core/WaterPumpScheduler.h @@ -3,6 +3,7 @@ #include #include +#include // This class is responsible for scheduling water pump // It is used to make sure that water pump is running for a limited time @@ -10,20 +11,22 @@ class WaterPumpScheduler : public IWaterPumpSchedulerAPI { private: IWaterPumpPtr _waterPump; + IEnvironmentPtr _env; unsigned long _stopTime = 0; // each X milliseconds will force stop water pump unsigned long _forceStopIntervalMs; public: - WaterPumpScheduler(IWaterPumpPtr waterPump, unsigned long forceStopIntervalMs); - WaterPumpScheduler(IWaterPumpPtr waterPump) : WaterPumpScheduler(waterPump, 1000) {} + WaterPumpScheduler(IWaterPumpPtr waterPump, IEnvironmentPtr env, unsigned long forceStopIntervalMs); + // forceStopIntervalMs is set to 1000ms by default + WaterPumpScheduler(IWaterPumpPtr waterPump, IEnvironmentPtr env) : WaterPumpScheduler(waterPump, env, 1000) {} ~WaterPumpScheduler(); void setup(); // for simplicity and testability we are passing current time as parameter - void tick(unsigned long currentTimeMs); + void tick(); // Public API - void start(unsigned long runTimeMs, unsigned long currentTimeMs) override; + void start(unsigned long runTimeMs, int power) override; void stop() override; WaterPumpStatus status() override; }; diff --git a/controller/tea_poor/lib/interfaces/IWaterPump.h b/controller/tea_poor/lib/interfaces/IWaterPump.h index eef9ee9..251b144 100644 --- a/controller/tea_poor/lib/interfaces/IWaterPump.h +++ b/controller/tea_poor/lib/interfaces/IWaterPump.h @@ -8,7 +8,7 @@ class IWaterPump { virtual ~IWaterPump() {} virtual void setup() = 0; - virtual void start() = 0; + virtual void start(int power) = 0; virtual void stop() = 0; virtual bool isRunning() const = 0; diff --git a/controller/tea_poor/lib/interfaces/IWaterPumpSchedulerAPI.h b/controller/tea_poor/lib/interfaces/IWaterPumpSchedulerAPI.h index b82df7d..01edd3e 100644 --- a/controller/tea_poor/lib/interfaces/IWaterPumpSchedulerAPI.h +++ b/controller/tea_poor/lib/interfaces/IWaterPumpSchedulerAPI.h @@ -22,7 +22,7 @@ class IWaterPumpSchedulerAPI { public: virtual ~IWaterPumpSchedulerAPI() {} virtual void stop() = 0; - virtual void start(unsigned long runTimeMs, unsigned long currentTimeMs) = 0; + virtual void start(unsigned long runTimeMs, int power) = 0; virtual WaterPumpStatus status() = 0; }; diff --git a/controller/tea_poor/src/main.cpp b/controller/tea_poor/src/main.cpp index ad142fe..e6bce12 100644 --- a/controller/tea_poor/src/main.cpp +++ b/controller/tea_poor/src/main.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -9,13 +10,15 @@ #include #include -IEnvironmentPtr env = std::make_shared(); +const auto env = std::make_shared(); // Setting up water pump -auto waterPump = std::make_shared( - std::make_shared( - WATER_PUMP_DIRECTION_PIN, WATER_PUMP_BRAKE_PIN, WATER_PUMP_POWER_PIN - ) +const auto waterPump = std::make_shared( + std::make_shared( + std::make_shared( + WATER_PUMP_DIRECTION_PIN, WATER_PUMP_BRAKE_PIN, WATER_PUMP_POWER_PIN + ) + ), env ); // build command processor @@ -46,8 +49,11 @@ RemoteControl remoteControl( app.get("/pour_tea", [](Request &req, Response &res) { char milliseconds[64]; req.query("milliseconds", milliseconds, 64); + + char power[64]; + req.query("powerLevel", power, 64); - const auto response = commandProcessor.pour_tea(milliseconds); + const auto response = commandProcessor.pour_tea(milliseconds, power); withExtraHeaders(res); res.print(response.c_str()); }); @@ -73,6 +79,6 @@ void setup() { } void loop() { - waterPump->tick(millis()); + waterPump->tick(); remoteControl.process(); }; \ No newline at end of file diff --git a/controller/tea_poor/test/test_native/main.cpp b/controller/tea_poor/test/test_native/main.cpp index b6f09e9..3245894 100644 --- a/controller/tea_poor/test/test_native/main.cpp +++ b/controller/tea_poor/test/test_native/main.cpp @@ -3,6 +3,7 @@ // include tests #include "tests/WaterPumpScheduler_test.h" #include "tests/CommandProcessor_test.h" +#include "tests/AdjustedWaterPump_test.h" int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/controller/tea_poor/test/test_native/tests/AdjustedWaterPump_test.h b/controller/tea_poor/test/test_native/tests/AdjustedWaterPump_test.h new file mode 100644 index 0000000..b84fb82 --- /dev/null +++ b/controller/tea_poor/test/test_native/tests/AdjustedWaterPump_test.h @@ -0,0 +1,18 @@ +#include +#include "mocks/FakeWaterPump.h" +#include + +// test that pumps power passed as percents is converted to 0..255 range +TEST(AdjustedWaterPump, test_pumps_power_passed_as_percents_is_converted_to_0_255_range) { + const auto fakeWaterPump = std::make_shared(); + AdjustedWaterPump adjustedWaterPump(fakeWaterPump); + // list of pairs: (powerInPercents, expectedPower) + const std::vector> tests = { + {0, 0}, {1, 2}, {2, 5}, + {50, 127}, {100, 255} + }; + for(const auto& test: tests) { + adjustedWaterPump.start(test.first); + ASSERT_EQ(fakeWaterPump->power(), test.second); + } +} \ No newline at end of file diff --git a/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h b/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h index ad0ff38..67936cc 100644 --- a/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h +++ b/controller/tea_poor/test/test_native/tests/CommandProcessor_test.h @@ -3,7 +3,9 @@ #include "mocks/FakeWaterPumpSchedulerAPI.h" #include "mocks/FakeEnvironment.h" +const auto VALID_POWER = "100"; const auto INVALID_TIME_ERROR_MESSAGE = "{ \"error\": \"invalid milliseconds value\" }"; +const auto INVALID_POWER_ERROR_MESSAGE = "{ \"error\": \"invalid power value\" }"; // test that pour_tea() method returns error message if milliseconds: // - greater than threshold // - less than 0 @@ -11,36 +13,49 @@ const auto INVALID_TIME_ERROR_MESSAGE = "{ \"error\": \"invalid milliseconds val // - not a number TEST(CommandProcessor, pour_tea_invalid_milliseconds) { CommandProcessor commandProcessor(123, nullptr, nullptr); - ASSERT_EQ(commandProcessor.pour_tea("1234"), INVALID_TIME_ERROR_MESSAGE); - ASSERT_EQ(commandProcessor.pour_tea("-1"), INVALID_TIME_ERROR_MESSAGE); - ASSERT_EQ(commandProcessor.pour_tea(""), INVALID_TIME_ERROR_MESSAGE); - ASSERT_EQ(commandProcessor.pour_tea("abc"), INVALID_TIME_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea("1234", VALID_POWER), INVALID_TIME_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea("-1", VALID_POWER), INVALID_TIME_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea("", VALID_POWER), INVALID_TIME_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea("abc", VALID_POWER), INVALID_TIME_ERROR_MESSAGE); +} + +// test that pour_tea() method returns error message if power: +// - greater than 100 +// - less than 0 +// - empty string +// - not a number +TEST(CommandProcessor, pour_tea_invalid_power) { + CommandProcessor commandProcessor(123, nullptr, nullptr); + ASSERT_EQ(commandProcessor.pour_tea("123", "101"), INVALID_POWER_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea("123", "-1"), INVALID_POWER_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea("123", ""), INVALID_POWER_ERROR_MESSAGE); + ASSERT_EQ(commandProcessor.pour_tea("123", "abc"), INVALID_POWER_ERROR_MESSAGE); } // for simplicity of the UI, we should accept as valid 0 and exactly threshold value TEST(CommandProcessor, pour_tea_valid_boundary_values) { - auto env = std::make_shared(); - auto waterPump = std::make_shared(); + const auto env = std::make_shared(); + const auto waterPump = std::make_shared(env); CommandProcessor commandProcessor(123, env, waterPump); - - ASSERT_NE(commandProcessor.pour_tea("0"), INVALID_TIME_ERROR_MESSAGE); - ASSERT_NE(commandProcessor.pour_tea("123"), INVALID_TIME_ERROR_MESSAGE); + + ASSERT_NE(commandProcessor.pour_tea("0", VALID_POWER), INVALID_TIME_ERROR_MESSAGE); + ASSERT_NE(commandProcessor.pour_tea("123", VALID_POWER), INVALID_TIME_ERROR_MESSAGE); } -// test that start pouring tea by calling pour_tea() method and its stops after T milliseconds +// test that start pouring tea by calling pour_tea() method with specified parameters TEST(CommandProcessor, pour_tea) { - auto env = std::make_shared(); + const auto env = std::make_shared(); + const auto waterPump = std::make_shared(env); env->time(2343); - auto waterPump = std::make_shared(); CommandProcessor commandProcessor(10000, env, waterPump); - const auto response = commandProcessor.pour_tea("1234"); - ASSERT_EQ(waterPump->_log, "start(1234, 2343)\n"); + const auto response = commandProcessor.pour_tea("1234", "23"); + ASSERT_EQ(waterPump->_log, "start(1234, 23, 2343)\n"); } // test that stop() method stops pouring tea TEST(CommandProcessor, stop) { - auto env = std::make_shared(); - auto waterPump = std::make_shared(); + const auto env = std::make_shared(); + const auto waterPump = std::make_shared(env); CommandProcessor commandProcessor(123, env, waterPump); const auto response = commandProcessor.stop(); ASSERT_EQ(waterPump->_log, "stop()\n"); @@ -48,8 +63,8 @@ TEST(CommandProcessor, stop) { // test that status() method returns JSON string with water pump status TEST(CommandProcessor, status) { - auto env = std::make_shared(); - auto waterPump = std::make_shared(); + const auto env = std::make_shared(); + const auto waterPump = std::make_shared(env); CommandProcessor commandProcessor(123, env, waterPump); const auto response = commandProcessor.status(); ASSERT_EQ(response, "{" @@ -65,11 +80,11 @@ TEST(CommandProcessor, status) { // test that status() method returns JSON string with actual time left TEST(CommandProcessor, status_running) { - auto env = std::make_shared(); - auto waterPump = std::make_shared(); + const auto env = std::make_shared(); + const auto waterPump = std::make_shared(env); CommandProcessor commandProcessor(12345, env, waterPump); - commandProcessor.pour_tea("1123"); + commandProcessor.pour_tea("1123", "100"); env->time(123); waterPump->_status.isRunning = true; diff --git a/controller/tea_poor/test/test_native/tests/WaterPumpScheduler_test.h b/controller/tea_poor/test/test_native/tests/WaterPumpScheduler_test.h index 138541b..367d83c 100644 --- a/controller/tea_poor/test/test_native/tests/WaterPumpScheduler_test.h +++ b/controller/tea_poor/test/test_native/tests/WaterPumpScheduler_test.h @@ -1,49 +1,67 @@ #include #include "mocks/FakeWaterPump.h" +#include "mocks/FakeEnvironment.h" #include // test that pump is stopping after given time TEST(WaterPumpScheduler, test_pump_stops_after_given_time) { // random time between 1 and 10 seconds const unsigned long runTimeMs = 1000 + (rand() % 10) * 1000; - IWaterPumpPtr fakeWaterPump = std::make_shared(); - WaterPumpScheduler waterPumpScheduler(fakeWaterPump); + const auto fakeEnvironment = std::make_shared(); + const auto fakeWaterPump = std::make_shared(); + WaterPumpScheduler waterPumpScheduler(fakeWaterPump, fakeEnvironment); waterPumpScheduler.setup(); // start water pump - unsigned long currentTimeMs = 0; - waterPumpScheduler.start(runTimeMs, currentTimeMs); + fakeEnvironment->time(0); + waterPumpScheduler.start(runTimeMs, 1); + ASSERT_EQ(fakeWaterPump->power(), 1); // check status auto status = waterPumpScheduler.status(); ASSERT_TRUE(status.isRunning); ASSERT_EQ(status.stopTime, runTimeMs); - while (currentTimeMs < runTimeMs) { - waterPumpScheduler.tick(currentTimeMs); + while (fakeEnvironment->time() < runTimeMs) { + waterPumpScheduler.tick(); ASSERT_TRUE(fakeWaterPump->isRunning()); - currentTimeMs += 100; + fakeEnvironment->time(fakeEnvironment->time() + 100); } // pump should be stopped after given time - waterPumpScheduler.tick(runTimeMs + 1); + fakeEnvironment->time(runTimeMs + 1); + waterPumpScheduler.tick(); ASSERT_FALSE(fakeWaterPump->isRunning()); } // test that pump is periodically forced to stop after given time TEST(WaterPumpScheduler, test_pump_is_periodically_forced_to_stop_after_given_time) { - IWaterPumpPtr fakeWaterPump = std::make_shared(); - WaterPumpScheduler waterPumpScheduler(fakeWaterPump, 1000); // force stop each 1 second + const auto fakeWaterPump = std::make_shared(); + const auto fakeEnvironment = std::make_shared(); + const int T = 1000; // 1 second + WaterPumpScheduler waterPumpScheduler(fakeWaterPump, fakeEnvironment, T); // force stop each T waterPumpScheduler.setup(); // start water pump - unsigned long currentTimeMs = 0; - waterPumpScheduler.start(1, currentTimeMs); - currentTimeMs += 1; - waterPumpScheduler.tick(currentTimeMs); + fakeEnvironment->time(0); + waterPumpScheduler.start(1, 1); + fakeEnvironment->time(1); + waterPumpScheduler.tick(); ASSERT_FALSE(fakeWaterPump->isRunning()); // pump should be stopped after given time for(int i = 0; i < 10; i++) { // emulate that pump was started again - fakeWaterPump->start(); - currentTimeMs += 1000; - waterPumpScheduler.tick(currentTimeMs); + fakeWaterPump->start(1); + ASSERT_EQ(fakeWaterPump->power(), 1); + fakeEnvironment->time(fakeEnvironment->time() + T); + waterPumpScheduler.tick(); ASSERT_FALSE(fakeWaterPump->isRunning()); // pump should be stopped } +} + +// test that pumps power is set to specified value +TEST(WaterPumpScheduler, test_pumps_power_is_set_to_specified_value) { + const auto fakeWaterPump = std::make_shared(); + const auto fakeEnvironment = std::make_shared(); + WaterPumpScheduler waterPumpScheduler(fakeWaterPump, fakeEnvironment); + waterPumpScheduler.setup(); + const int power = 23; + waterPumpScheduler.start(1, power); + ASSERT_EQ(fakeWaterPump->power(), power); } \ No newline at end of file diff --git a/controller/tea_poor/test/test_native/tests/mocks/FakeWaterPump.h b/controller/tea_poor/test/test_native/tests/mocks/FakeWaterPump.h index 2dcf154..44972d8 100644 --- a/controller/tea_poor/test/test_native/tests/mocks/FakeWaterPump.h +++ b/controller/tea_poor/test/test_native/tests/mocks/FakeWaterPump.h @@ -7,12 +7,17 @@ class FakeWaterPump : public IWaterPump { private: bool _isRunning = false; + int _power = 0; public: void setup() override { _isRunning = false; } - void start() override { _isRunning = true; } void stop() override { _isRunning = false; } + void start(int power) override { + _isRunning = true; + _power = power; + } bool isRunning() const override { return _isRunning; } + int power() const { return _power; } }; #endif // FAKE_WATER_PUMP_H \ No newline at end of file diff --git a/controller/tea_poor/test/test_native/tests/mocks/FakeWaterPumpSchedulerAPI.h b/controller/tea_poor/test/test_native/tests/mocks/FakeWaterPumpSchedulerAPI.h index 896046a..94c1dcb 100644 --- a/controller/tea_poor/test/test_native/tests/mocks/FakeWaterPumpSchedulerAPI.h +++ b/controller/tea_poor/test/test_native/tests/mocks/FakeWaterPumpSchedulerAPI.h @@ -3,16 +3,25 @@ #define FAKE_WATER_PUMP_SCHEDULER_API_H #include +#include #include class FakeWaterPumpSchedulerAPI : public IWaterPumpSchedulerAPI { +private: + const IEnvironmentPtr _env; public: + FakeWaterPumpSchedulerAPI(IEnvironmentPtr env) : _env(env) {} + void stop() override { _log += "stop()\n"; } - void start(unsigned long runTimeMs, unsigned long currentTimeMs) override { - _log += "start(" + std::to_string(runTimeMs) + ", " + std::to_string(currentTimeMs) + ")\n"; + void start(unsigned long runTimeMs, int power) override { + _log += "start(" + + std::to_string(runTimeMs) + ", " + + std::to_string(power) + ", " + + std::to_string(_env->time()) + + ")\n"; } WaterPumpStatus status() override { diff --git a/ui/package-lock.json b/ui/package-lock.json index 56aeacb..aa09c0d 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -17,6 +17,7 @@ "react": "^18.2.0", "react-bootstrap": "^2.9.2", "react-dom": "^18.2.0", + "react-input-slider": "^6.0.1", "react-redux": "^9.0.4", "react-scripts": "5.0.1", "redux-persist": "^6.0.0", @@ -2312,6 +2313,95 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "dependencies": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + }, + "node_modules/@emotion/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@emotion/core/-/core-10.3.1.tgz", + "integrity": "sha512-447aUEjPIm0MnE6QYIaFz9VQOHSXf4Iu6EWOIqq11EAPqinkSZmfymPTmlOE3QjLv846lH4JVZBUOtwGbuQoww==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@emotion/cache": "^10.0.27", + "@emotion/css": "^10.0.27", + "@emotion/serialize": "^0.11.15", + "@emotion/sheet": "0.9.4", + "@emotion/utils": "0.11.3" + }, + "peerDependencies": { + "react": ">=16.3.0" + } + }, + "node_modules/@emotion/css": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-10.0.27.tgz", + "integrity": "sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==", + "dependencies": { + "@emotion/serialize": "^0.11.15", + "@emotion/utils": "0.11.3", + "babel-plugin-emotion": "^10.0.27" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "node_modules/@emotion/memoize": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", + "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==" + }, + "node_modules/@emotion/serialize": { + "version": "0.11.16", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", + "integrity": "sha512-G3J4o8by0VRrO+PFeSc3js2myYNOXVJ3Ya+RGVxnshRYgsvErfAOglKAiy1Eo1vhzxqtUvjCyS5gtewzkmvSSg==", + "dependencies": { + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/unitless": "0.7.5", + "@emotion/utils": "0.11.3", + "csstype": "^2.5.7" + } + }, + "node_modules/@emotion/serialize/node_modules/csstype": { + "version": "2.6.21", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==" + }, + "node_modules/@emotion/sheet": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-0.9.4.tgz", + "integrity": "sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA==" + }, + "node_modules/@emotion/stylis": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", + "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + }, + "node_modules/@emotion/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz", + "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -5713,6 +5803,61 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/babel-plugin-emotion": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/babel-plugin-emotion/-/babel-plugin-emotion-10.2.2.tgz", + "integrity": "sha512-SMSkGoqTbTyUTDeuVuPIWifPdUGkTk1Kf9BWRiXIOIcuyMfsdp2EjeiiFvOzX8NOBvEh/ypKYvUh2rkgAJMCLA==", + "dependencies": { + "@babel/helper-module-imports": "^7.0.0", + "@emotion/hash": "0.8.0", + "@emotion/memoize": "0.7.4", + "@emotion/serialize": "^0.11.16", + "babel-plugin-macros": "^2.0.0", + "babel-plugin-syntax-jsx": "^6.18.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^1.0.5", + "find-root": "^1.1.0", + "source-map": "^0.5.7" + } + }, + "node_modules/babel-plugin-emotion/node_modules/babel-plugin-macros": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", + "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "cosmiconfig": "^6.0.0", + "resolve": "^1.12.0" + } + }, + "node_modules/babel-plugin-emotion/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/babel-plugin-emotion/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-emotion/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -5808,6 +5953,11 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-syntax-jsx": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz", + "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw==" + }, "node_modules/babel-plugin-transform-react-remove-prop-types": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", @@ -8609,6 +8759,11 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -15170,6 +15325,19 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "node_modules/react-input-slider": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/react-input-slider/-/react-input-slider-6.0.1.tgz", + "integrity": "sha512-WASm/k0rVw3cXzvfkoUa4wMnF74SbifeuwSmjPC+zanhV4W9KAmPM+H6yXhIZAjJL4TU+sUw/pgyUyAt/txvIw==", + "dependencies": { + "@babel/runtime": "^7.9.2", + "@emotion/core": "^10.0.14" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/ui/package.json b/ui/package.json index bac5489..a474a8f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ "react": "^18.2.0", "react-bootstrap": "^2.9.2", "react-dom": "^18.2.0", + "react-input-slider": "^6.0.1", "react-redux": "^9.0.4", "react-scripts": "5.0.1", "redux-persist": "^6.0.0", diff --git a/ui/public/gauge.png b/ui/public/gauge.png new file mode 100644 index 0000000..a95679e Binary files /dev/null and b/ui/public/gauge.png differ diff --git a/ui/src/App.js b/ui/src/App.js index 5f6949a..9d40e06 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -10,6 +10,7 @@ import SystemControls from './components/SystemControls.js'; import SystemStatusArea from './components/SystemStatusArea.js'; import CurrentOperationInfoArea from './components/CurrentOperationInfoArea.js'; import HoldToPour from './components/HoldToPour.js'; +import PowerLevel from './components/PowerLevel.js'; function App({ isConnected }) { return ( @@ -21,6 +22,7 @@ function App({ isConnected }) { {isConnected ? ( <> + diff --git a/ui/src/api/CWaterPumpAPI.js b/ui/src/api/CWaterPumpAPI.js index 7c297ac..fa1a4c6 100644 --- a/ui/src/api/CWaterPumpAPI.js +++ b/ui/src/api/CWaterPumpAPI.js @@ -29,7 +29,10 @@ class CWaterPumpAPI { }); } - async start(runTimeMs) { return await this._impl.start(runTimeMs); } + async start(runTimeMs, powerLevelInPercents) { + return await this._impl.start(runTimeMs, powerLevelInPercents); + } + async stop() { return await this._impl.stop(); } async status() { return await this._impl.status(); } } diff --git a/ui/src/api/CWaterPumpAPIImpl.js b/ui/src/api/CWaterPumpAPIImpl.js index 995f260..f71e776 100644 --- a/ui/src/api/CWaterPumpAPIImpl.js +++ b/ui/src/api/CWaterPumpAPIImpl.js @@ -11,9 +11,27 @@ class CWaterPumpAPIImpl { return { response, requestTime: end - start }; } - async start(runTimeMs) { + async start(runTimeMs, powerLevelInPercents) { + // basic validation + const isValidTime = Number.isInteger(runTimeMs) && (0 < runTimeMs); + if(!isValidTime) { + throw new Error('Pouring time is not a valid number'); + } + + const isValidPowerLevel = Number.isInteger(powerLevelInPercents) && + (0 < powerLevelInPercents) && (powerLevelInPercents <= 100); + if(!isValidPowerLevel) { + throw new Error('Power level is not a valid number'); + } + ///////////////////////////////////////////////////////////////// const { response: { data }, requestTime } = await this._execute( - async () => await this._client.get('/pour_tea', { params: { milliseconds: runTimeMs } }) + async () => await this._client.get( + '/pour_tea', + { params: { + milliseconds: runTimeMs, + powerLevel: powerLevelInPercents, + }} + ) ); return this.preprocessResponse({ response: data, requestTime }); } diff --git a/ui/src/api/CWaterPumpAPIImpl.test.js b/ui/src/api/CWaterPumpAPIImpl.test.js index 5a61036..5784434 100644 --- a/ui/src/api/CWaterPumpAPIImpl.test.js +++ b/ui/src/api/CWaterPumpAPIImpl.test.js @@ -52,12 +52,30 @@ describe('CWaterPumpAPIImpl', () => { // tests per method describe('start', () => { it('common test cases', async () => { - const T = Math.random() * 1000; - const callback = async (api) => await api.start(T); + const T = Math.floor(Math.random() * 1000); + const P = Math.floor(Math.random() * 99) + 1; + const callback = async (api) => await api.start(T, P); await shouldThrowErrorFromResponse(callback); await shouldRethrowError(callback); await shouldPreprocessResponse(callback); - await shouldBeCalledWith(callback, '/pour_tea', { milliseconds: T }); + await shouldBeCalledWith(callback, '/pour_tea', { milliseconds: T, powerLevel: P}); + }); + + it('should throw error if pouring time is not a valid number', async () => { + const message = 'Pouring time is not a valid number'; + const api = new CWaterPumpAPIImpl({ client: {} }); + await expect(api.start('abc', 100)).rejects.toThrow(message); + await expect(api.start(-1, 100)).rejects.toThrow(message); + await expect(api.start(0, 100)).rejects.toThrow(message); + }); + + it('should throw error if power level is not a valid number', async () => { + const message = 'Power level is not a valid number'; + const api = new CWaterPumpAPIImpl({ client: {} }); + await expect(api.start(100, 'abc')).rejects.toThrow(message); + await expect(api.start(100, -1)).rejects.toThrow(message); + await expect(api.start(100, 0)).rejects.toThrow(message); + await expect(api.start(100, 101)).rejects.toThrow(message); }); }); diff --git a/ui/src/components/HoldToPour.js b/ui/src/components/HoldToPour.js index 87a8914..cc49fee 100644 --- a/ui/src/components/HoldToPour.js +++ b/ui/src/components/HoldToPour.js @@ -65,12 +65,23 @@ export function HoldToPourComponent({ startPump, stopPump, interval }) { } // Helper wrapper to simplify the code in the component -function HoldToPourComponent_withExtras({ pouringTime, startPump, stopPump }) { +function HoldToPourComponent_withExtras({ pouringTime, powerLevel, startPump, stopPump }) { const api = useWaterPumpAPI().API; + // to prevent the callback from changing when the pouringTime or powerLevel changes + const _pouringTime = React.useRef(pouringTime); + React.useEffect(() => { _pouringTime.current = pouringTime; }, [pouringTime]); + + const _powerLevel = React.useRef(powerLevel); + React.useEffect(() => { _powerLevel.current = powerLevel; }, [powerLevel]); const _startPump = React.useCallback( - async () => { await startPump({ api, pouringTime }); }, - [api, startPump, pouringTime] + async () => { + await startPump({ + api, + pouringTime: _pouringTime.current, + powerLevel: _powerLevel.current, + }); + }, [api, startPump, _pouringTime, _powerLevel] ); const _stopPump = React.useCallback( async () => { await stopPump({ api }); }, @@ -88,6 +99,9 @@ function HoldToPourComponent_withExtras({ pouringTime, startPump, stopPump }) { }; export default connect( - state => ({ pouringTime: state.UI.pouringTime }), + state => ({ + pouringTime: state.UI.pouringTime, + powerLevel: state.UI.powerLevelInPercents, + }), { startPump, stopPump } )(HoldToPourComponent_withExtras); \ No newline at end of file diff --git a/ui/src/components/PowerLevel.js b/ui/src/components/PowerLevel.js new file mode 100644 index 0000000..1de3420 --- /dev/null +++ b/ui/src/components/PowerLevel.js @@ -0,0 +1,57 @@ +import React from 'react'; +import { Form, Row, Col } from 'react-bootstrap'; +import Slider from 'react-input-slider'; +import { connect } from 'react-redux'; +import { updatePowerLevel } from '../store/slices/UI'; + +const SLIDER_STYLE = { + track: { + width: '100%', + height: '3rem', + backgroundColor: 'none', + backgroundImage: `url(gauge.png)`, + backgroundSize: '100% 100%', + backgroundRepeat: 'no-repeat', + border: '1px solid blue', + }, + active: { + backgroundColor: 'silver', + opacity: 0.3, + }, + thumb: { + height: '2.5rem', + width: 3, + borderRadius: 0, + backgroundColor: '#000', + cursor: 'pointer', + boxSizing: 'border-box' + } +}; + +function PowerLevel({ powerLevel, onChange }) { + return ( + + + Power Level: + + + onChange(100 - x)} + styles={SLIDER_STYLE} + /> +
+ {powerLevel + '%' } +
+ +
+ ); +} + +export default connect( + (state) => ({ + powerLevel: state.UI.powerLevelInPercents, + }), + { onChange: updatePowerLevel } +)(PowerLevel); \ No newline at end of file diff --git a/ui/src/contexts/WaterPumpAPIContext.js b/ui/src/contexts/WaterPumpAPIContext.js index 9f5ae25..209177b 100644 --- a/ui/src/contexts/WaterPumpAPIContext.js +++ b/ui/src/contexts/WaterPumpAPIContext.js @@ -15,7 +15,8 @@ export function WaterPumpAPIProvider({ children }) { () => new CWaterPumpAPI({ URL: apiHost }), [apiHost] ); - + // TODO: provide also the API methods with binded values from the store + // to simplify the code in the components (HodlToPour and PowerLevel) const value = { API: apiObject, }; return ( diff --git a/ui/src/store/slices/SystemStatus.js b/ui/src/store/slices/SystemStatus.js index a1f4eae..212b469 100644 --- a/ui/src/store/slices/SystemStatus.js +++ b/ui/src/store/slices/SystemStatus.js @@ -18,9 +18,9 @@ function withNotification(action, message) { // Async thunks export const startPump = createAsyncThunk( 'systemStatus/startPump', - withNotification( - async ({ api, pouringTime }) => { - return await api.start(pouringTime); + withNotification( + async ({ api, pouringTime, powerLevel }) => { + return await api.start(pouringTime, powerLevel); }, 'Failed to start pump' ) diff --git a/ui/src/store/slices/UI.js b/ui/src/store/slices/UI.js index b462371..b936cae 100644 --- a/ui/src/store/slices/UI.js +++ b/ui/src/store/slices/UI.js @@ -1,7 +1,17 @@ import { createSlice } from '@reduxjs/toolkit'; +function validatePowerLevel(value) { + if (!Number.isInteger(value)) { + value = 1; + } + value = Math.min(value, 100); + value = Math.max(value, 1); + return value; +} + const INITIAL_STATE = { pouringTime: 1000, + powerLevelInPercents: 100, apiHost: '', }; // slice for system status @@ -15,8 +25,11 @@ export const UISlice = createSlice({ updateAPIHost(state, action) { state.apiHost = action.payload; }, + updatePowerLevel(state, action) { + state.powerLevelInPercents = validatePowerLevel(action.payload); + } }, }); export const actions = UISlice.actions; -export const { updatePouringTime, updateAPIHost } = actions; \ No newline at end of file +export const { updatePouringTime, updateAPIHost, updatePowerLevel } = actions; \ No newline at end of file