diff --git a/interface/src/project/Scheduler.tsx b/interface/src/project/Scheduler.tsx index 7389c0e5c..1262f0030 100644 --- a/interface/src/project/Scheduler.tsx +++ b/interface/src/project/Scheduler.tsx @@ -87,7 +87,7 @@ const Scheduler: FC = () => { const schedule_theme = useTheme({ Table: ` - --data-table-library_grid-template-columns: 36px 324px 50px 192px repeat(1, minmax(100px, 1fr)) 160px; + --data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px; `, BaseRow: ` font-size: 14px; @@ -198,7 +198,7 @@ const Scheduler: FC = () => { active: false, deleted: false, flags: 0, - time: '12:00', + time: '', cmd: '', value: '', name: '' @@ -216,11 +216,21 @@ const Scheduler: FC = () => { = ScheduleFlag.SCHEDULE_TIMER && si.flags !== flag + ? 'gray' + : (si.flags & flag) === flag + ? 'primary' + : 'grey' + } > {flag === ScheduleFlag.SCHEDULE_TIMER ? LL.TIMER(0) - : dow[Math.log(flag) / Math.log(2)]} + : flag === ScheduleFlag.SCHEDULE_ONCHANGE + ? 'OnChange' + : flag === ScheduleFlag.SCHEDULE_CONDITION + ? 'Condition' + : dow[Math.log(flag) / Math.log(2)]} @@ -245,7 +255,7 @@ const Scheduler: FC = () => { {LL.SCHEDULE(0)} - {LL.TIME(0)} + {LL.TIME(0)}/Cond. {LL.COMMAND(0)} {LL.VALUE(0)} {LL.NAME(0)} @@ -268,16 +278,25 @@ const Scheduler: FC = () => { )} - + - {dayBox(si, ScheduleFlag.SCHEDULE_MON)} - {dayBox(si, ScheduleFlag.SCHEDULE_TUE)} - {dayBox(si, ScheduleFlag.SCHEDULE_WED)} - {dayBox(si, ScheduleFlag.SCHEDULE_THU)} - {dayBox(si, ScheduleFlag.SCHEDULE_FRI)} - {dayBox(si, ScheduleFlag.SCHEDULE_SAT)} - {dayBox(si, ScheduleFlag.SCHEDULE_SUN)} - {dayBox(si, ScheduleFlag.SCHEDULE_TIMER)} + {si.flags < ScheduleFlag.SCHEDULE_TIMER ? ( + <> + {dayBox(si, ScheduleFlag.SCHEDULE_MON)} + {dayBox(si, ScheduleFlag.SCHEDULE_TUE)} + {dayBox(si, ScheduleFlag.SCHEDULE_WED)} + {dayBox(si, ScheduleFlag.SCHEDULE_THU)} + {dayBox(si, ScheduleFlag.SCHEDULE_FRI)} + {dayBox(si, ScheduleFlag.SCHEDULE_SAT)} + {dayBox(si, ScheduleFlag.SCHEDULE_SUN)} + + ) : ( + <> + {dayBox(si, ScheduleFlag.SCHEDULE_TIMER)} + {dayBox(si, ScheduleFlag.SCHEDULE_ONCHANGE)} + {dayBox(si, ScheduleFlag.SCHEDULE_CONDITION)} + + )} {si.time} @@ -341,7 +360,7 @@ const Scheduler: FC = () => { )} + + {isOnChange ? ( + + ) : ( + + )} + + + {isCondition ? ( + + ) : ( + + )} + - - {isTimer && ( - - {LL.SCHEDULER_HELP_2()} - + {isCondition || isOnChange ? ( + + ) : ( + <> + + {isTimer && ( + + {LL.SCHEDULER_HELP_2()} + + )} + )} = DeviceValueTAG::TAG_HC1) { + snprintf(cmd, sizeof(cmd), "%s/%s/%s", device_type_2_device_name(device_type_), tag_to_mqtt(dv.tag), dv.short_name); + } else { + snprintf(cmd, sizeof(cmd), "%s/%s", device_type_2_device_name(device_type_), (dv.short_name)); + } + EMSESP::webSchedulerService.onChange(cmd); } } } diff --git a/src/temperaturesensor.cpp b/src/temperaturesensor.cpp index 56c61631a..b9844f676 100644 --- a/src/temperaturesensor.cpp +++ b/src/temperaturesensor.cpp @@ -441,6 +441,9 @@ void TemperatureSensor::publish_sensor(const Sensor & sensor) { char payload[10]; Mqtt::queue_publish(topic, Helpers::render_value(payload, sensor.temperature_c, 10, EMSESP::system_.fahrenheit() ? 2 : 0)); } + char cmd[COMMAND_MAX_LENGTH]; + snprintf(cmd, sizeof(cmd), "%s/%s", F_(temperaturesensor), sensor.name().c_str()); + EMSESP::webSchedulerService.onChange(cmd); } // send empty config topic to remove the entry from HA diff --git a/src/web/WebCustomEntityService.cpp b/src/web/WebCustomEntityService.cpp index 93cf39231..be9fb8de0 100644 --- a/src/web/WebCustomEntityService.cpp +++ b/src/web/WebCustomEntityService.cpp @@ -175,6 +175,9 @@ bool WebCustomEntityService::command_setvalue(const char * value, const std::str if (EMSESP::mqtt_.get_publish_onchange(0)) { publish(); } + char cmd[COMMAND_MAX_LENGTH]; + snprintf(cmd, sizeof(cmd_function_p), "custom/%s", entityItem.name.c_str()); + EMSESP::webSchedulerService.onChange(cmd); return true; } } @@ -591,7 +594,7 @@ void WebCustomEntityService::fetch() { bool WebCustomEntityService::get_value(std::shared_ptr telegram) { bool has_change = false; EMSESP::webCustomEntityService.read([&](WebCustomEntity & webEntity) { customEntityItems = &webEntity.customEntityItems; }); - // read-length of BOOL, INT, UINT, SHORT, USHORT, ULONG, TIME + // read-length of BOOL, INT8, UINT8, INT16, UINT16, UINT24, TIME, UINT32 const uint8_t len[] = {1, 1, 1, 2, 2, 3, 3, 4}; for (auto & entity : *customEntityItems) { if (entity.value_type == DeviceValueType::STRING && telegram->type_id == entity.type_id && telegram->src == entity.device_id @@ -604,6 +607,9 @@ bool WebCustomEntityService::get_value(std::shared_ptr telegram) } else if (EMSESP::mqtt_.get_publish_onchange(0)) { has_change = true; } + char cmd[COMMAND_MAX_LENGTH]; + snprintf(cmd, sizeof(cmd_function_p), "custom/%s", entity.name.c_str()); + EMSESP::webSchedulerService.onChange(cmd); } } else if (entity.value_type != DeviceValueType::STRING && telegram->type_id == entity.type_id && telegram->src == entity.device_id && telegram->offset <= entity.offset && (telegram->offset + telegram->message_length) >= (entity.offset + len[entity.value_type])) { @@ -618,6 +624,9 @@ bool WebCustomEntityService::get_value(std::shared_ptr telegram) } else if (EMSESP::mqtt_.get_publish_onchange(0)) { has_change = true; } + char cmd[COMMAND_MAX_LENGTH]; + snprintf(cmd, sizeof(cmd_function_p), "%s/%s", "custom", entity.name.c_str()); + EMSESP::webSchedulerService.onChange(cmd); } // EMSESP::logger().debug("custom entity %s received with value %d", entity.name.c_str(), (int)entity.val); } diff --git a/src/web/WebSchedulerService.cpp b/src/web/WebSchedulerService.cpp index 12ec11146..e25868971 100644 --- a/src/web/WebSchedulerService.cpp +++ b/src/web/WebSchedulerService.cpp @@ -367,26 +367,64 @@ bool WebSchedulerService::command(const char * cmd, const char * data) { return false; } +#include "shuntingYard.hpp" + +bool WebSchedulerService::onChange(const char * cmd) { + for (const ScheduleItem & scheduleItem : *scheduleItems_) { + if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_ONCHANGE && Helpers::toLower(scheduleItem.time) == Helpers::toLower(cmd)) { + // emsesp::EMSESP::logger().debug(scheduleItem.cmd.c_str()); + return command(scheduleItem.cmd.c_str(), compute(scheduleItem.value.c_str()).c_str()); + } + } + return false; +} + +void WebSchedulerService::condition() { + for (ScheduleItem & scheduleItem : *scheduleItems_) { + if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_CONDITION) { + auto match = compute(scheduleItem.time.c_str()); +#ifdef EMESESP_DEBUG + emsesp::EMSESP::logger().debug("condition match: %s", match.c_str()); +#endif + if (!match.empty() && match.c_str()[0] == '1') { + if (scheduleItem.retry_cnt == 0xFF) { // default unswitched + scheduleItem.retry_cnt = command(scheduleItem.cmd.c_str(), compute(scheduleItem.value.c_str()).c_str()) ? 1 : 0xFF; + } + } else if (scheduleItem.retry_cnt == 1) { + scheduleItem.retry_cnt = 0xFF; + } + } + } +} + // process any scheduled jobs // checks on the minute and at startup void WebSchedulerService::loop() { // initialize static value on startup - static int8_t last_tm_min = -1; // invalid value also used for startup commands + static int8_t last_tm_min = -2; // invalid value also used for startup commands static uint32_t last_uptime_min = 0; + static uint32_t last_uptime_sec = 0; // get list of scheduler events and exit if it's empty if (scheduleItems_->size() == 0) { return; } + // check conditions every 10 seconds + uint32_t uptime_sec = uuid::get_uptime_sec() / 10; + if (last_uptime_sec != uptime_sec) { + condition(); + last_uptime_sec = uptime_sec; + } + // check startup commands - if (last_tm_min == -1) { + if (last_tm_min == -2) { for (ScheduleItem & scheduleItem : *scheduleItems_) { if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min == 0) { - scheduleItem.retry_cnt = command(scheduleItem.cmd.c_str(), scheduleItem.value.c_str()) ? 0xFF : 0; + scheduleItem.retry_cnt = command(scheduleItem.cmd.c_str(), compute(scheduleItem.value.c_str()).c_str()) ? 0xFF : 0; } } - last_tm_min = 0; // startup done, now use for RTC + last_tm_min = -1; // startup done, now use for RTC } // check timer every minute, sync to EMS-ESP clock @@ -401,7 +439,7 @@ void WebSchedulerService::loop() { // scheduled timer commands if (scheduleItem.active && scheduleItem.flags == SCHEDULEFLAG_SCHEDULE_TIMER && scheduleItem.elapsed_min > 0 && (uptime_min % scheduleItem.elapsed_min == 0)) { - command(scheduleItem.cmd.c_str(), scheduleItem.value.c_str()); + command(scheduleItem.cmd.c_str(), compute(scheduleItem.value.c_str()).c_str()); } } last_uptime_min = uptime_min; @@ -416,8 +454,9 @@ void WebSchedulerService::loop() { uint16_t real_min = tm->tm_hour * 60 + tm->tm_min; for (const ScheduleItem & scheduleItem : *scheduleItems_) { - if (scheduleItem.active && (real_dow & scheduleItem.flags) && real_min == scheduleItem.elapsed_min) { - command(scheduleItem.cmd.c_str(), scheduleItem.value.c_str()); + uint8_t dow = scheduleItem.flags & SCHEDULEFLAG_SCHEDULE_TIMER ? 0 : scheduleItem.flags; + if (scheduleItem.active && (real_dow & dow) && real_min == scheduleItem.elapsed_min) { + command(scheduleItem.cmd.c_str(), compute(scheduleItem.value.c_str()).c_str()); } } last_tm_min = tm->tm_min; diff --git a/src/web/WebSchedulerService.h b/src/web/WebSchedulerService.h index 4b7e87897..65d12033d 100644 --- a/src/web/WebSchedulerService.h +++ b/src/web/WebSchedulerService.h @@ -22,8 +22,10 @@ #define EMSESP_SCHEDULER_FILE "/config/emsespScheduler.json" #define EMSESP_SCHEDULER_SERVICE_PATH "/rest/schedule" // GET and POST -#define SCHEDULEFLAG_SCHEDULE_TIMER 0x80 // 7th bit for Timer -#define MAX_STARTUP_RETRIES 3 // retry the start-up commands x times +#define SCHEDULEFLAG_SCHEDULE_TIMER 0x80 // 7th bit for Timer +#define SCHEDULEFLAG_SCHEDULE_ONCHANGE 0x81 // 7th+1st bit for OnChange +#define SCHEDULEFLAG_SCHEDULE_CONDITION 0x82 // 7th+2nd bit for Condition +#define MAX_STARTUP_RETRIES 3 // retry the start-up commands x times namespace emsesp { @@ -61,6 +63,7 @@ class WebSchedulerService : public StatefulService { void ha_reset() { ha_registered_ = false; } + bool onChange(const char * cmd); #if defined(EMSESP_TEST) void test(); @@ -71,6 +74,7 @@ class WebSchedulerService : public StatefulService { private: #endif bool command(const char * cmd, const char * data); + void condition(); HttpEndpoint _httpEndpoint; FSPersistence _fsPersistence; diff --git a/src/web/shuntingYard.hpp b/src/web/shuntingYard.hpp new file mode 100644 index 000000000..fac408c6c --- /dev/null +++ b/src/web/shuntingYard.hpp @@ -0,0 +1,404 @@ +// Shunting-yard Algorithm +// https://en.wikipedia.org/wiki/Shunting-yard_algorithm +// +// Implementation notes for unary operators by Austin Taylor +// https://stackoverflow.com/a/5240912 +// +// Example: +// https://ideone.com/VocUTq +// +// License: +// If you use this code in binary / compiled / un-commented (removing all text comments) form, +// you can use it under CC0 license. +// +// But if you use this code as source code / readable text, since main content of this code is +// their notes, I recommend you to indicate notices which conform CC-BY-SA. For example, +// +// --- --- +// YOUR-CONTENT uses the following materials. +// (1) Wikipedia article [Shunting-yard algorithm](https://en.wikipedia.org/wiki/Shunting-yard_algorithm), +// which is released under the [Creative Commons Attribution-Share-Alike License 3.0](https://creativecommons.org/licenses/by-sa/3.0/). +// (2) [Implementation notes for unary operators in Shunting-Yard algorithm](https://stackoverflow.com/a/5240912) by Austin Taylor +// which is released under the [Creative Commons Attribution-Share-Alike License 2.5](https://creativecommons.org/licenses/by-sa/2.5/). +// --- --- +// copy from https://gist.github.com/t-mat/b9f681b7591cdae712f6 +// modified MDvP, 06.2024 +// +#include +#include +#include +#include + +class Token { + public: + enum class Type { + Unknown, + Number, + Operator, + LeftParen, + RightParen, + }; + + Token(Type type, const std::string & s, int8_t precedence = -1, bool rightAssociative = false, bool unary = false) + : type{type} + , str(s) + , precedence{precedence} + , rightAssociative{rightAssociative} + , unary{unary} { + } + + const Type type; + const std::string str; + const int8_t precedence; + const bool rightAssociative; + const bool unary; +}; + +std::deque exprToTokens(const std::string & expr) { + std::deque tokens; + + for (const auto * p = expr.c_str(); *p; ++p) { + if (isblank(*p)) { + // do nothing + } else if ((*p >= 'a' && *p <= 'z')) { + tokens.clear(); + return tokens; + } else if (isdigit(*p)) { + const auto * b = p; + while (isdigit(*p) || *p == '.') { + ++p; + } + const auto s = std::string(b, p); + tokens.push_back(Token{Token::Type::Number, s}); + --p; + } else { + Token::Type token = Token::Type::Operator; + int8_t precedence = -1; + bool rightAssociative = false; + bool unary = false; + char c = *p; + switch (c) { + default: + token = Token::Type::Unknown; + break; + case '(': + token = Token::Type::LeftParen; + break; + case ')': + token = Token::Type::RightParen; + break; + case '^': + precedence = 4; + rightAssociative = true; + break; + case '*': + precedence = 3; + break; + case '/': + precedence = 3; + break; + case '%': + precedence = 3; + break; + case '+': + precedence = 2; + break; + case '-': + // If current token is '-' + // and if it is the first token, or preceded by another operator, or left-paren, + if (tokens.empty() || tokens.back().type == Token::Type::Operator || tokens.back().type == Token::Type::LeftParen) { + // it's unary '-' + // note#1 : 'm' is a special operator name for unary '-' + // note#2 : It has highest precedence than any of the infix operators + unary = true; + c = 'm'; + precedence = 5; + } else { + // otherwise, it's binary '-' + precedence = 2; + } + break; + case '&': + if (p[1] == '&') + ++p; + precedence = 0; + break; + case '|': + if (p[1] == '|') + ++p; + precedence = 0; + break; + case '!': + unary = true; + precedence = 1; + break; + case '<': + if (p[1] == '=') { + ++p; + c = '{'; + } + precedence = 1; + break; + case '>': + if (p[1] == '=') { + ++p; + c = '}'; + } + precedence = 1; + break; + case '=': + if (p[1] == '=') + ++p; + precedence = 1; + break; + } + const auto s = std::string(1, c); + tokens.push_back(Token{token, s, precedence, rightAssociative, unary}); + } + } + + return tokens; +} + + +std::deque shuntingYard(const std::deque & tokens) { + std::deque queue; + std::vector stack; + + // While there are tokens to be read: + for (auto token : tokens) { + // Read a token + switch (token.type) { + case Token::Type::Number: + // If the token is a number, then add it to the output queue + queue.push_back(token); + break; + + case Token::Type::Operator: { + // If the token is operator, o1, then: + const auto o1 = token; + + // while there is an operator token, + while (!stack.empty()) { + // o2, at the top of stack, and + const auto o2 = stack.back(); + + // either o1 is left-associative and its precedence is + // *less than or equal* to that of o2, + // or o1 if right associative, and has precedence + // *less than* that of o2, + if ((!o1.rightAssociative && o1.precedence <= o2.precedence) || (o1.rightAssociative && o1.precedence < o2.precedence)) { + // then pop o2 off the stack, + stack.pop_back(); + // onto the output queue; + queue.push_back(o2); + + continue; + } + + // @@ otherwise, exit. + break; + } + + // push o1 onto the stack. + stack.push_back(o1); + } break; + + case Token::Type::LeftParen: + // If token is left parenthesis, then push it onto the stack + stack.push_back(token); + break; + + case Token::Type::RightParen: + // If token is right parenthesis: + { + bool match = false; + + // Until the token at the top of the stack + // is a left parenthesis, + while (!stack.empty() && stack.back().type != Token::Type::LeftParen) { + // pop operators off the stack + // onto the output queue. + queue.push_back(stack.back()); + stack.pop_back(); + match = true; + } + + if (!match && stack.empty()) { + // If the stack runs out without finding a left parenthesis, + // then there are mismatched parentheses. + return {}; + } + + // Pop the left parenthesis from the stack, + // but not onto the output queue. + stack.pop_back(); + } + break; + + default: + return {}; + } + } + + // When there are no more tokens to read: + // While there are still operator tokens in the stack: + while (!stack.empty()) { + // If the operator token on the top of the stack is a parenthesis, + // then there are mismatched parentheses. + if (stack.back().type == Token::Type::LeftParen) { + return {}; + } + + // Pop the operator onto the output queue. + queue.push_back(std::move(stack.back())); + stack.pop_back(); + } + return queue; +} + +// replace commands like "//" with its value" +std::string commands(std::string & expr) { + for (uint8_t device = 0; device < emsesp::EMSdevice::DeviceType::UNKNOWN; device++) { + const char * d = emsesp::EMSdevice::device_type_2_device_name(device); + auto f = expr.find(d); + while (f != std::string::npos) { + auto e = expr.find_first_of(" )=<>|&+-*\0", f); + if (e == std::string::npos) { + e = expr.length(); + } + char cmd[COMMAND_MAX_LENGTH]; + size_t l = e - f; + if (l >= sizeof(cmd) - 1) { + break; + } + expr.copy(cmd, l, f); + cmd[l] = '\0'; + if (strstr(cmd, "/value") == nullptr) { + strlcat(cmd, "/value", sizeof(cmd) - 6); + } + JsonDocument doc_out, doc_in; + JsonObject output = doc_out.to(); + JsonObject input = doc_in.to(); + std::string cmd_s = "api/" + std::string(cmd); + emsesp::Command::process(cmd_s.c_str(), true, input, output); + if (output.containsKey("api_data")) { + std::string data = output["api_data"].as(); + if (data == "true" || data == "ON" || data == "on") { + data = "1"; + } + if (data == "false" || data == "OFF" || data == "off") { + data = "0"; + } + expr.replace(f, l, data); + e = f + data.length(); + } + f = expr.find(d, e); + } + } + return expr; +} + +std::string compute(const std::string & expr) { + auto expr_new = emsesp::Helpers::toLower(expr); +#ifdef EMESESP_DEBUG + emsesp::EMSESP::logger().debug("calculate: %s", expr_new.c_str()); +#endif + commands(expr_new); +#ifdef EMESESP_DEBUG + emsesp::EMSESP::logger().debug("calculate: %s", expr_new.c_str()); +#endif + const auto tokens = exprToTokens(expr_new); + if (tokens.empty()) { + return "Error: no tokens"; + } + auto queue = shuntingYard(tokens); + std::vector stack; + + while (!queue.empty()) { + const auto token = queue.front(); + queue.pop_front(); + switch (token.type) { + case Token::Type::Number: + stack.push_back(std::stod(token.str)); + break; + + case Token::Type::Operator: { + if (token.unary) { + // unray operators + const auto rhs = stack.back(); + stack.pop_back(); + switch (token.str[0]) { + default: + return ""; + break; + case 'm': // Special operator name for unary '-' + stack.push_back(-rhs); + break; + case '!': + stack.push_back(!(int)rhs); + break; + } + } else { + // binary operators + const auto rhs = stack.back(); + stack.pop_back(); + const auto lhs = stack.back(); + stack.pop_back(); + + switch (token.str[0]) { + default: + return ""; + break; + case '^': + stack.push_back(static_cast(pow(lhs, rhs))); + break; + case '*': + stack.push_back(lhs * rhs); + break; + case '/': + stack.push_back(lhs / rhs); + break; + case '%': + stack.push_back((int)lhs % (int)rhs); + break; + case '+': + stack.push_back(lhs + rhs); + break; + case '-': + stack.push_back(lhs - rhs); + break; + case '&': + stack.push_back(((int)lhs && (int)rhs) ? 1 : 0); + break; + case '|': + stack.push_back(((int)lhs || (int)rhs) ? 1 : 0); + break; + case '<': + stack.push_back(((int)lhs < (int)rhs) ? 1 : 0); + break; + case '{': + stack.push_back(((int)lhs <= (int)rhs) ? 1 : 0); + break; + case '>': + stack.push_back(((int)lhs > (int)rhs) ? 1 : 0); + break; + case '}': + stack.push_back(((int)lhs >= (int)rhs) ? 1 : 0); + break; + case '=': + stack.push_back(((int)lhs == (int)rhs) ? 1 : 0); + break; + } + } + } break; + + default: + return ""; + } + } + if (stack.back() == (int)stack.back()) { + return (std::to_string((int)stack.back())); + } + return std::to_string(stack.back()); +}