From 5864b28d1e15c6015fa029e58ada7b1bb5b8cac2 Mon Sep 17 00:00:00 2001 From: Ton Huisman Date: Tue, 4 Aug 2026 21:45:59 +0200 Subject: [PATCH 1/4] [Rules] Add string functions unescape, escape, parse and json --- docs/source/Rules/Rules.rst | 190 ++++++++++++++++++++ src/_P037_MQTTImport.ino | 23 ++- src/src/CustomBuild/define_plugin_sets.h | 8 + src/src/ESPEasyCore/ESPEasyRules.cpp | 216 +++++++++++++++++++---- src/src/Helpers/HTTPResponseParser.cpp | 119 ++----------- src/src/Helpers/JSON_helper.cpp | 167 ++++++++++++++++++ src/src/Helpers/JSON_helper.h | 12 ++ src/src/Helpers/StringParser.cpp | 4 +- src/src/Helpers/StringParser.h | 4 +- src/src/PluginStructs/P037_data_struct.h | 9 +- static/espeasy.js | 2 +- static/espeasy.min.js | 2 +- 12 files changed, 604 insertions(+), 152 deletions(-) create mode 100644 src/src/Helpers/JSON_helper.cpp create mode 100644 src/src/Helpers/JSON_helper.h diff --git a/docs/source/Rules/Rules.rst b/docs/source/Rules/Rules.rst index 192692923f..7a53b64ccd 100644 --- a/docs/source/Rules/Rules.rst +++ b/docs/source/Rules/Rules.rst @@ -1100,6 +1100,196 @@ Usage: ``{lookup:::}`` ````: The combined string with all lookup values padded to have the same length, f.e. ``"Off.Fan HeatCool"``, where all lookup values are of length 4. The index to retrieve the word ``Off.`` is 0, ``Fan`` (including a space) is index 1, etc. This string should be wrapped in quotes if it contains space or colon ``:`` character(s). +UnEscape and Escape +^^^^^^^^^^^^^^^^^^^ + +(Added: 2026/08/01, only available when String Variables feature is included in the build) + +With ``UnEscape`` any ``\`` escape characters added before ``%``, ``[``, ``]``, ``{``, ``}``, ``(``, ``)`` and/or ``:`` is removed so the intended string value is made available, f.e. when sending to an external destination. + +Usage: ``{unescape:}`` + +As P037, and possibly other plugins, can generate events that include escaped JSON content, this helps to pass that on to other destinations. + +With ``Escape`` in a string containing ``%``, ``[``, ``]``, ``{``, ``}``, ``(``, ``)`` and/or ``:``, any of these characters is prefixed with a ``\``, so it isn't interpreted as possible rules variables or functions. + +Usage: ``{escape:}`` + +For passing received JSON or similar data as an argument to an event function. + +Example: + +.. code-block:: none + + On testEscape Do + LetStr,j1,'\{"method"\:"setDo0","params"\:true\}' + LetStr,j2,`{unescape:[str#j1]}` + LogEntry,'j1=[str#j1], unescape=[str#j2], escape={escape:[str#j2]}' + Endon + +Output: + +.. code-block:: none + + 00:04:25.248 : (86376) Info | ACT : LetStr,j1,'\{"method"\:"setDo0","params"\:true\}' + 00:04:25.257 : (86380) Info | ACT : LetStr,j2,`{"method":"setDo0","params":true}` + 00:04:25.267 : (86216) Info | ACT : LogEntry,'j1=\{"method"\:"setDo0","params"\:true\}, unescape={"method":"setDo0","params":true}, escape=\{"method"\:"setDo0","params"\:true\}' + 00:04:25.272 : > LogEntry,'j1=\{"method"\:"setDo0","params"\:true\}, unescape={"method":"setDo0","params":true}, escape=\{"method"\:"setDo0","params"\:true\}' + 00:04:25.274 : (85772) Info | j1=\{"method"\:"setDo0","params"\:true\}, unescape={"method":"setDo0","params":true}, escape=\{"method"\:"setDo0","params"\:true\} + 00:04:25.276 : > j1=\{"method"\:"setDo0","params"\:true\}, unescape={"method":"setDo0","params":true}, escape=\{"method"\:"setDo0","params"\:true\} + 00:04:25.280 : > OK + + +Parse +^^^^^ + +(Added: 2026/08/02, only available when String Variables feature is included in the build) + +Helps to 'parse out' parts of a string, based on a provided separator, based on the internal ``parseString()`` function. All input for the string processing functions is un-escaped before processing. + +Usage: ``{parse::[]:}`` + +``param``: the nth option in the ``string-to-parse`` provided. n = 1-based + +``separator``: choose the desired separator for parsing out the desired value. Defaults to ``,`` (comma) when left empty. + +Example: + +.. code-block:: none + + On testParse Do + LetStr,p1,"!RFLink#AcuriteV2;ID=feb1;TEMP=80d5;HUM=65;BAT=OK;^^" // an input like received from RFlink + LetStr,RFDevice,{parse:2:#:{parse:1:;:[str#p1]}} // Split on ';', 1st element, then split on '#' > AcuriteV2 + LetStr,RFID,{parse:2:=:{parse:2:;:[str#p1]}} // Split on ';', 2nd element, then split on '=' > feb1 + LetStr,p2,{parse:2:=:{parse:3:;:'[str#p1]'}} // Split on ';', 3rd element, then split on '=' > 80d5 + Let,temp,{and:0x[str#p2]:0x3ff}/10 // Calculate (nb. partially correct) + LetStr,p3,{parse:2:=:{parse:4:;:'[str#p1]'}} // Split on ';', 4th element, then split on '=' > 65 + Let,hum,[str#p3] + LetStr,bat,{parse:2:=:{parse:5:;:'[str#p1]'}} // Split on ';', 5th element, then split on '=' > OK + LogEntry,'p2=[str#p2], p3=[str#p3]' // Check values + LogEntry,'[str#RFDevice]-[str#RFID],{"temp":[var#temp],"hum":%v_hum%,"bat":"[str#bat]"}' + Endon + +Output: + +.. code-block:: none + + 00:09:21.204 : (89356) Info | ACT : LetStr,p1,"!RFLink#AcuriteV2;ID=feb1;TEMP=80d5;HUM=65;BAT=OK;^^" + 00:09:21.212 : (89392) Info | ACT : LetStr,RFDevice,{parse:2:#:!RFLink#AcuriteV2} + 00:09:21.221 : (89436) Info | ACT : LetStr,RFID,{parse:2:=:ID=feb1} + 00:09:21.229 : (89436) Info | ACT : LetStr,p2,{parse:2:=:TEMP=80d5} + 00:09:21.236 : (89444) Info | ACT : Let,temp,213/10 + 00:09:21.244 : (89436) Info | ACT : LetStr,p3,{parse:2:=:HUM=65} + 00:09:21.251 : (89524) Info | ACT : Let,hum,65 + 00:09:21.258 : (89436) Info | ACT : LetStr,bat,{parse:2:=:BAT=OK} + 00:09:21.265 : (89436) Info | ACT : LogEntry,'p2=80d5, p3=65' + 00:09:21.268 : > LogEntry,'p2=80d5, p3=65' + 00:09:21.269 : (89324) Info | p2=80d5, p3=65 + 00:09:21.270 : > p2=80d5, p3=65 + 00:09:21.278 : (89308) Info | ACT : LogEntry,'AcuriteV2-feb1,{"temp":21.3,"hum":65,"bat":"OK"}' + 00:09:21.283 : > LogEntry,'AcuriteV2-feb1,{"temp":21.3,"hum":65,"bat":"OK"}' + 00:09:21.284 : (89092) Info | AcuriteV2-feb1,{"temp":21.3,"hum":65,"bat":"OK"} + 00:09:21.286 : > AcuriteV2-feb1,{"temp":21.3,"hum":65,"bat":"OK"} + 00:09:21.288 : > OK + + +Json +^^^^ + +(Added: 2026/08/02, only available when JSON Parse feature is included in the build) + +Extract a value (numeric, text, bool) from a (valid) JSON object (string). All input for the string processing functions is un-escaped before processing. + +Usage: ``{json::[]:}`` + +``attribute-to-retrieve`` can use this format: ``level.attr[n].subattr``, multi-level supported, arrays[] are 0-based + +``asJson``: Set to 1 (true) to return the value in JSON format (quoted strings, bool = true/false), default: 0 (false), some examples included below. + +Returns 0/1 for a false/true ``bool`` value. + +Returns a comma-separated list for array values without an index, see example. Returns ``unknown`` for unsupported value types, and a comma-separated name/value list for objects. + +Example: + +.. code-block:: none + + On testJson Do + LetStr,j1,'\{"method"\:"setDo0","params"\:true,"values":\[1,2,3\],"args":\{"bla"\:"that","blip"\:false\},"array"\:\[\{"a":1,"b":2\},\{"a":10,"b":20\}\],"sub":\{"array":\[\{"c":1,"d":2\},\{"c":10,"d":20\}\]\}\}' + LetStr,j2,'{json:params::`[str#j1]`}' + LetStr,j3,'{json:values::[str#j1]}' + LetStr,j4,'{json:values[1]::[str#j1]}' + LogEntry,'json: {unescape:[str#j1]}' + LogEntry,'attributes: params=[str#j2], values=[str#j3], values[1]=[str#j4], bla={json:args.bla::[str#j1]},{json:args.bla:1:[str#j1]}, blip={json:args.blip::[str#j1]},{json:args.blip:1:[str#j1]}, array[1].b={json:array[1].b::[str#j1]}, sub.array[0].c={json:sub.array[0].c::[str#j1]}' + LogEntry,'array={json:array::[str#j1]}, sub={json:sub::[str#j1]}, (JSON)sub={json:sub:1:[str#j1]}' + Endon + +The input, as assigned to ``j1``, is like it can be received from MQTT Import plugin. You can also use an incoming JSON payload, ``escape`` that (see above), and parse it. As JSON contains at least some ``{``, ``}`` and ``:`` characters, escaping is *required*, as these string functions use ``:`` as the parameter separator, and ``}`` as the function terminator. + +Formatted JSON as used in the above example: (values marked with ``//`` are retrieved in the example) + +.. code-block:: json + + { + "method": "setDo0", + "params": true, // + "values": [ // + 1, + 2, // + 3 + ], + "args": { + "bla": "that", // + "blip": false // + }, + "array": [ // + { + "a": 1, + "b": 2 + }, + { + "a": 10, // + "b": 20 + } + ], + "sub": { // + "array": [ + { + "c": 1, // + "d": 2 + }, + { + "c": 10, + "d": 20 + } + ] + } + } + + +Output: + +.. code-block:: none + + 00:01:22.983 : (87572) Info | ACT : LetStr,j1,'\{"method"\:"setDo0","params"\:true,"values":\[1,2,3\],"args":\{"bla"\:"that","blip"\:false\},"array"\:\[\{"a":1,"b":2\},\{"a":10,"b":20\}\],"sub":\{"array":\[\{"c":1,"d":2\},\{"c":10,"d":20\}\]\}\}' + 00:01:22.996 : (87712) Info | ACT : LetStr,j2,'1' + 00:01:23.005 : (87572) Info | ACT : LetStr,j3,'1,2,3' + 00:01:23.014 : (87600) Info | ACT : LetStr,j4,'2' + 00:01:23.023 : (87116) Info | ACT : LogEntry,'json: {"method":"setDo0","params":true,"values":[1,2,3],"args":{"bla":"that","blip":false},"array":[{"a":1,"b":2},{"a":10,"b":20}],"sub":{"array":[{"c":1,"d":2},{"c":10,"d":20}]}}' + 00:01:23.034 : > LogEntry,'json: {"method":"setDo0","params":true,"values":[1,2,3],"args":{"bla":"that","blip":false},"array":[{"a":1,"b":2},{"a":10,"b":20}],"sub":{"array":[{"c":1,"d":2},{"c":10,"d":20}]}}' + 00:01:23.036 : (86528) Info | json: {"method":"setDo0","params":true,"values":[1,2,3],"args":{"bla":"that","blip":false},"array":[{"a":1,"b":2},{"a":10,"b":20}],"sub":{"array":[{"c":1,"d":2},{"c":10,"d":20}]}} + 00:01:23.038 : > json: {"method":"setDo0","params":true,"values":[1,2,3],"args":{"bla":"that","blip":false},"array":[{"a":1,"b":2},{"a":10,"b":20}],"sub":{"array":[{"c":1,"d":2},{"c":10,"d":20}]}} + 00:01:23.063 : (87132) Info | ACT : LogEntry,'attributes: params=1, values=1,2,3, values[1]=2, bla=that,"that", blip=0,false, array[1].b=20, sub.array[0].c=1' + 00:01:23.067 : > LogEntry,'attributes: params=1, values=1,2,3, values[1]=2, bla=that,"that", blip=0,false, array[1].b=20, sub.array[0].c=1' + 00:01:23.077 : (86724) Info | attributes: params=1, values=1,2,3, values[1]=2, bla=that,"that", blip=0,false, array[1].b=20, sub.array[0].c=1 + 00:01:23.096 : > attributes: params=1, values=1,2,3, values[1]=2, bla=that,"that", blip=0,false, array[1].b=20, sub.array[0].c=1 + 00:01:23.110 : (86748) Info | ACT : LogEntry,'array=a,1,b,2,a,10,b,20, sub=array,c,1,d,2,c,10,d,20, (JSON)sub={"array",[{"c",1},{"d",2},{"c",10},{"d",20}]}' + 00:01:23.116 : > LogEntry,'array=a,1,b,2,a,10,b,20, sub=array,c,1,d,2,c,10,d,20, (JSON)sub={"array",[{"c",1},{"d",2},{"c",10},{"d",20}]}' + 00:01:23.124 : (86356) Info | array=a,1,b,2,a,10,b,20, sub=array,c,1,d,2,c,10,d,20, (JSON)sub={"array",[{"c",1},{"d",2},{"c",10},{"d",20}]} + 00:01:23.135 : > array=a,1,b,2,a,10,b,20, sub=array,c,1,d,2,c,10,d,20, (JSON)sub={"array",[{"c",1},{"d",2},{"c",10},{"d",20}]} + 00:01:23.139 : > OK + + IndexOf and IndexOf_ci ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/_P037_MQTTImport.ino b/src/_P037_MQTTImport.ino index d355fe267e..d45ddf4c9f 100644 --- a/src/_P037_MQTTImport.ino +++ b/src/_P037_MQTTImport.ino @@ -11,6 +11,7 @@ // This task reads data from the MQTT Import input stream and saves the value /** + * 2026-08-02 tonhuisman: Add better support for JSON parsing, that handles multiple levels and arrays * 2025-08-20 tonhuisman: Generate events with numeric values using the configured decimals setting. * 2025-06-14 tonhuisman: Add support for Custom Value Type per task value * 2025-01-12 tonhuisman: Add support for MQTT AutoDiscovery (not supported for MQTT Import) @@ -58,10 +59,11 @@ # define P037_MAX_QUEUEDEPTH 150 -bool MQTT_unsubscribe_037(struct EventStruct *event); -bool MQTTSubscribe_037(struct EventStruct *event); +bool MQTT_unsubscribe_037(struct EventStruct *event); +bool MQTTSubscribe_037(struct EventStruct *event); # if P037_MAPPING_SUPPORT || P037_JSON_SUPPORT + String P037_getMQTTLastTopicPart(const String& topic) { const int16_t lastSlash = topic.lastIndexOf('/'); @@ -470,7 +472,8 @@ boolean Plugin_037(uint8_t function, struct EventStruct *event, String& string) // json filter check if (checkJson && P037_data->hasFilters()) { // See if we pass the filters for all json attributes - do { + do + { key = P037_data->iter->key().c_str(); Payload = P037_data->iter->value().as(); # if P037_MAPPING_SUPPORT @@ -521,7 +524,8 @@ boolean Plugin_037(uint8_t function, struct EventStruct *event, String& string) if (checkJson && P037_data->hasFilters()) { // See if we pass the filters for all json attributes P037_data->iter = P037_data->doc.begin(); - do { + do + { key = P037_data->iter->key().c_str(); Payload = P037_data->iter->value().as(); # if P037_MAPPING_SUPPORT @@ -541,7 +545,8 @@ boolean Plugin_037(uint8_t function, struct EventStruct *event, String& string) # endif // P037_FILTER_PER_TOPIC # endif // if P037_JSON_SUPPORT { - do { + do + { # if P037_JSON_SUPPORT if (checkJson && (P037_data->iter != P037_data->doc.end())) { @@ -552,13 +557,7 @@ boolean Plugin_037(uint8_t function, struct EventStruct *event, String& string) if (!jsonAttribute.isEmpty()) { key = jsonAttribute; - if (key.indexOf('.') > -1) { - String part1 = parseStringKeepCase(key, 1, '.'); - String part2 = parseStringKeepCase(key, 2, '.'); - Payload = P037_data->doc[part1][part2].as(); - } else { - Payload = P037_data->doc[key].as(); - } + Payload = getJsonValue(P037_data->root, key, false); unparsedPayload = Payload; int8_t jIndex = jsonIndex.toInt(); diff --git a/src/src/CustomBuild/define_plugin_sets.h b/src/src/CustomBuild/define_plugin_sets.h index 0ddd3935bd..054f6ac4fb 100644 --- a/src/src/CustomBuild/define_plugin_sets.h +++ b/src/src/CustomBuild/define_plugin_sets.h @@ -4394,6 +4394,14 @@ To create/register a plugin, you have to : #endif //-------------------End of HTTPResponseParser Section---------- +#ifndef FEATURE_JSON_PARSE + #if defined(USES_P037) || FEATURE_JSON_EVENT // and other JSON-parsing features + #define FEATURE_JSON_PARSE 1 + #else + #define FEATURE_JSON_PARSE 0 + #endif // if defined(USES_P037) || FEATURE_JSON_EVENT +#endif // ifndef FEATURE_JSON_PARSE + #if !(defined(SOC_DAC_SUPPORTED) && SOC_DAC_SUPPORTED) #ifdef USES_P152 #undef USES_P152 diff --git a/src/src/ESPEasyCore/ESPEasyRules.cpp b/src/src/ESPEasyCore/ESPEasyRules.cpp index f7197b7b11..91e9554135 100644 --- a/src/src/ESPEasyCore/ESPEasyRules.cpp +++ b/src/src/ESPEasyCore/ESPEasyRules.cpp @@ -29,6 +29,11 @@ #include #include +#if FEATURE_JSON_PARSE +#include +#include "../Helpers/JSON_helper.h" +#endif // if FEATURE_JSON_PARSE + #ifdef WEBSERVER_NEW_RULES String EventToFileName(const String& eventName) { int size = eventName.length(); @@ -466,7 +471,12 @@ bool parse_math_functions(const String& cmd_s_lower, const String& arg1, const S const char string_commands[] PROGMEM = "substring|indexof|indexof_ci|equals|equals_ci|timetomin|timetosec|strtol|tobin|tohex|ord|urlencode" #if FEATURE_STRING_VARIABLES "|lookup" + "|unescape|escape" + "|parse" #endif // if FEATURE_STRING_VARIABLES + #if FEATURE_JSON_PARSE + "|json" + #endif // if FEATURE_JSON_PARSE ; enum class string_commands_e { substring, @@ -483,17 +493,17 @@ enum class string_commands_e { urlencode, #if FEATURE_STRING_VARIABLES lookup, + unescape, + escape, + parse, #endif // if FEATURE_STRING_VARIABLES + #if FEATURE_JSON_PARSE + json, + #endif // if FEATURE_JSON_PARSE }; -void parse_string_commands(String& line) { - unsigned int startIndex = 0; - int closingIndex; - - bool mustReplaceMaskedChars = false; - bool mustReplaceEscapedBracket = false; - bool mustReplaceEscapedCurlyBracket = false; +void hideEscaped(String &line, bool &mustReplaceEscapedBracket, bool &mustReplaceEscapedCurlyBracket, bool &mustReplaceEscapedColon) { String MaskEscapedBracket; if (hasEscapedCharacter(line,'(') || hasEscapedCharacter(line,')')) { @@ -505,6 +515,7 @@ void parse_string_commands(String& line) { line.replace(F("\\)"), MaskEscapedBracket); mustReplaceEscapedBracket = true; } + if (hasEscapedCharacter(line,'{') || hasEscapedCharacter(line,'}')) { // replace the \{ and \} with other characters to mask the escaped curly brackets so we can continue parsing. // We have to unmask then after we're finished. @@ -515,6 +526,83 @@ void parse_string_commands(String& line) { mustReplaceEscapedCurlyBracket = true; } + if (hasEscapedCharacter(line, ':')) { + // replace the \: with another character to mask the escaped colon so we can continue parsing. + // We have to unmask then after we're finished. + MaskEscapedBracket = static_cast(0x15); // ASCII 0x15 = NAK + line.replace(F("\\:"), MaskEscapedBracket); + mustReplaceEscapedColon = true; + } +} + +void hideMasked(String &line, bool mustReplaceMaskedChars) { + if (mustReplaceMaskedChars) { + line.replace('{', static_cast(0x02)); + line.replace('}', static_cast(0x03)); + } +} + +void restoreMaskedAndEscaped(String &line, bool mustReplaceMaskedChars, bool mustReplaceEscapedBracket, bool mustReplaceEscapedCurlyBracket, bool mustReplaceEscapedColon) { + String MaskEscapedBracket; + + if (mustReplaceMaskedChars) { + // We now have to check if we did mask some parts and unmask them. + // Let's hope we don't mess up any Unicode here. + line.replace(static_cast(0x02), '{'); + line.replace(static_cast(0x03), '}'); + } + + if (mustReplaceEscapedBracket) { + // We now have to check if we did mask some escaped bracket and unmask them. + // Let's hope we don't mess up any Unicode here. + MaskEscapedBracket = static_cast(0x11); // ASCII 0x11 = Device control 1 + line.replace(MaskEscapedBracket, F("\\(")); + MaskEscapedBracket = static_cast(0x12); // ASCII 0x12 = Device control 2 + line.replace(MaskEscapedBracket, F("\\)")); + } + + if (mustReplaceEscapedCurlyBracket) { + // We now have to check if we did mask some escaped curly bracket and unmask them. + // Let's hope we don't mess up any Unicode here. + MaskEscapedBracket = static_cast(0x13); // ASCII 0x13 = Device control 3 + line.replace(MaskEscapedBracket, F("\\{")); + MaskEscapedBracket = static_cast(0x14); // ASCII 0x14 = Device control 4 + line.replace(MaskEscapedBracket, F("\\}")); + } + + if (mustReplaceEscapedColon) { + // We now have to check if we did mask some escaped colon and unmask them. + // Let's hope we don't mess up any Unicode here. + MaskEscapedBracket = static_cast(0x15); // ASCII 0x15 = NAK + line.replace(MaskEscapedBracket, F("\\:")); + } +} + +void parse_string_commands(String& line) { + unsigned int startIndex = 0; + int closingIndex; + + #if FEATURE_JSON_PARSE + DynamicJsonDocument*root = nullptr; + uint16_t lastJsonMessageLength = 512; + + // Cleanup lambda to deallocate resources + auto cleanupJSON = [&root]() { + if (root != nullptr) { + root->clear(); + delete root; + root = nullptr; + } + }; + #endif // if FEATURE_JSON_PARSE + bool mustReplaceMaskedChars = false; + bool mustReplaceEscapedBracket = false; + bool mustReplaceEscapedCurlyBracket = false; + bool mustReplaceEscapedColon = false; + String MaskEscapedBracket; + + hideEscaped(line, mustReplaceEscapedBracket, mustReplaceEscapedCurlyBracket, mustReplaceEscapedColon); + while (get_next_inner_bracket(line, startIndex, closingIndex, '}')) { // Command without opening and closing brackets. const String fullCommand = line.substring(startIndex + 1, closingIndex); @@ -568,6 +656,22 @@ void parse_string_commands(String& line) { if (arg1valid && arg2valid && startpos > -1 && endpos > -1) { replacement = arg3.substring(startpos * endpos, (startpos + 1) * endpos); } + break; + case string_commands_e::unescape: + case string_commands_e::escape: + replacement = parseStringToEndKeepCaseNoTrim(fullCommand, 2, ':'); + // Undo escape masks + restoreMaskedAndEscaped(replacement, mustReplaceMaskedChars, mustReplaceEscapedBracket, mustReplaceEscapedCurlyBracket, mustReplaceEscapedColon); + + stripEscapeCharacters(replacement); // Always strip first to avoid double-escaping + + if (string_commands_e::escape == command) { + addEscapeCharacters(replacement); + } + // Redo escape masks and masked {} characters + hideEscaped(replacement, mustReplaceEscapedBracket, mustReplaceEscapedCurlyBracket, mustReplaceEscapedColon); + hideMasked(replacement, mustReplaceMaskedChars); + break; #endif // if FEATURE_STRING_VARIABLES case string_commands_e::indexof: @@ -666,6 +770,75 @@ void parse_string_commands(String& line) { replacement = URLEncode(arg1); } break; + #if FEATURE_STRING_VARIABLES + case string_commands_e::parse: + // parse: a function to retrieve nth param (1-based), with optional separator, default: comma + // {parse::[]:} + if (arg1valid) { + String de_escaped = parseStringToEndKeepCaseNoTrim(fullCommand, 4, ':'); + restoreMaskedAndEscaped(de_escaped, mustReplaceMaskedChars, mustReplaceEscapedBracket, mustReplaceEscapedCurlyBracket, mustReplaceEscapedColon); + stripEscapeCharacters(de_escaped); + + replacement = parseStringKeepCase(de_escaped, startpos, arg2.isEmpty() ? ',' : arg2[0]); + + addEscapeCharacters(replacement); + hideEscaped(replacement, mustReplaceEscapedBracket, mustReplaceEscapedCurlyBracket, mustReplaceEscapedColon); + hideMasked(replacement, mustReplaceMaskedChars); // re-apply + } + break; + #endif // if FEATURE_STRING_VARIABLES + #if FEATURE_JSON_PARSE + case string_commands_e::json: + // json: get a value from a (valid) JSON string + // {json::[]:} + // attribute-to-retrieve can use: level.attr[n].subattr, multi-level supported, arrays[] are 0-based + // asJson: 1 (true), return a JSON compatible value, useful when retrieving a part of the input for passing on, default: 0 (false) + { + String jsonInput = parseStringToEndKeepCase(fullCommand, 4, ':'); + if (arg1.isEmpty() || jsonInput.isEmpty()) { + break; + } + restoreMaskedAndEscaped(jsonInput, mustReplaceMaskedChars, mustReplaceEscapedBracket, mustReplaceEscapedCurlyBracket, mustReplaceEscapedColon); + stripEscapeCharacters(jsonInput); + String arg1_ = arg1; + stripEscapeCharacters(arg1_); + + if ((nullptr != root) && (jsonInput.length() * 2.5 > lastJsonMessageLength)) { + cleanupJSON(); + } + + // Resize lastJsonMessageLength if needed + if (jsonInput.length() * 2 > lastJsonMessageLength) { + lastJsonMessageLength = jsonInput.length() * 2; + } + + // Allocate memory for root if needed + if (nullptr == root) { + // Try to allocate in PSRAM or 2nd heap if possible + constexpr unsigned size = sizeof(DynamicJsonDocument); + void *ptr = special_calloc(1, size); + if (ptr) { + root = new (ptr) DynamicJsonDocument(lastJsonMessageLength); // Dynamic allocation + } + } + + if (nullptr != root) { + // if (loglevelActiveFor(LOG_LEVEL_INFO)) { // for development debugging + // addLog(LOG_LEVEL_INFO, strformat(F("parse json: %s"), jsonInput.c_str())); + // } + // Parse the JSON + DeserializationError error = deserializeJson(*root, jsonInput); + + if (!error) { + // if (loglevelActiveFor(LOG_LEVEL_INFO)) { // for development debugging + // addLog(LOG_LEVEL_INFO, strformat(F("parse json for %s"), arg1.c_str())); + // } + replacement = getJsonValue(root, arg1_, arg2valid && 1 == endpos); + } + } + break; + } + #endif // if FEATURE_JSON_PARSE } } } @@ -676,9 +849,8 @@ void parse_string_commands(String& line) { // We have to unmask then after we're finished. // See: https://github.com/letscontrolit/ESPEasy/issues/2932#issuecomment-596139096 replacement = line.substring(startIndex, closingIndex + 1); - replacement.replace('{', static_cast(0x02)); - replacement.replace('}', static_cast(0x03)); mustReplaceMaskedChars = true; + hideMasked(replacement, mustReplaceMaskedChars); // apply } // Replace the full command including opening and closing brackets. @@ -692,30 +864,14 @@ void parse_string_commands(String& line) { } } - if (mustReplaceMaskedChars) { - // We now have to check if we did mask some parts and unmask them. - // Let's hope we don't mess up any Unicode here. - line.replace(static_cast(0x02), '{'); - line.replace(static_cast(0x03), '}'); - } + #if FEATURE_JSON_PARSE - if (mustReplaceEscapedBracket) { - // We now have to check if we did mask some escaped bracket and unmask them. - // Let's hope we don't mess up any Unicode here. - MaskEscapedBracket = static_cast(0x11); // ASCII 0x11 = Device control 1 - line.replace(MaskEscapedBracket, F("\\(")); - MaskEscapedBracket = static_cast(0x12); // ASCII 0x12 = Device control 2 - line.replace(MaskEscapedBracket, F("\\)")); + if (nullptr != root) { + cleanupJSON(); } + #endif // if FEATURE_JSON_PARSE - if (mustReplaceEscapedCurlyBracket) { - // We now have to check if we did mask some escaped curly bracket and unmask them. - // Let's hope we don't mess up any Unicode here. - MaskEscapedBracket = static_cast(0x13); // ASCII 0x13 = Device control 3 - line.replace(MaskEscapedBracket, F("\\{")); - MaskEscapedBracket = static_cast(0x14); // ASCII 0x14 = Device control 4 - line.replace(MaskEscapedBracket, F("\\}")); - } + restoreMaskedAndEscaped(line, mustReplaceMaskedChars, mustReplaceEscapedBracket, mustReplaceEscapedCurlyBracket, mustReplaceEscapedColon); } void substitute_eventvalue(String& line, const String& event) { diff --git a/src/src/Helpers/HTTPResponseParser.cpp b/src/src/Helpers/HTTPResponseParser.cpp index 8dfb6d739f..9d57f6d8af 100644 --- a/src/src/Helpers/HTTPResponseParser.cpp +++ b/src/src/Helpers/HTTPResponseParser.cpp @@ -10,13 +10,13 @@ # if FEATURE_JSON_EVENT # include "../Helpers/ESPEasy_Storage.h" +# include "../Helpers/JSON_helper.h" # include "../WebServer/LoadFromFS.h" # include # endif // if FEATURE_JSON_EVENT - void eventFromResponse(const String& host, const int& httpCode, const String& uri, ESPEasy_HTTPClient& http, const int& parseJson) { if ((httpCode == 200)) { if (parseJson == -1) { @@ -236,10 +236,13 @@ void eventFromResponse(const String& host, const int& httpCode, const String& ur // Allocate memory for root if needed if (root == nullptr) { - # ifdef USE_SECOND_HEAP - HeapSelectIram ephemeral; - # endif // ifdef USE_SECOND_HEAP - root = new (std::nothrow) DynamicJsonDocument(lastJsonMessageLength); + // Try to allocate in PSRAM or 2nd heap if possible + constexpr unsigned size = sizeof(DynamicJsonDocument); + void *ptr = special_calloc(1, size); + + if (ptr) { + root = new (ptr) DynamicJsonDocument(lastJsonMessageLength); // Dynamic allocation + } } if (root != nullptr) { @@ -319,103 +322,13 @@ void readAndProcessJsonKeys(DynamicJsonDocument *root, int numJson) { } } - // Process the key and navigate the JSON - JsonVariant value = *root; - size_t start = 0, end; - - while ((end = key.indexOf('.', start)) != (unsigned int)-1) { - String part = key.substring(start, end); - start = end + 1; - - // Look for an array e.g., "result[0]" → object "result", index 0 - int bracketStart = part.indexOf('['); - - if (bracketStart != -1) { - String objectName = part.substring(0, bracketStart); - String indexStr = part.substring(bracketStart + 1, part.indexOf(']', bracketStart)); - - if (objectName.length() > 0) { - value = value[objectName]; // Access the object - } - - if (value.is()) { - int index = indexStr.toInt(); - value = value[index]; - } else { - value = value[indexStr]; // fallback if not actually array - } - } else { - // Normal object access without array - value = value[part]; - } - - if (value.isNull()) { - break; // Key path is invalid - } - } + const String val = getJsonValue(root, key, false); // Return arrays and objects as csv, _not_ JSON formatted - if (!value.isNull()) { + if (!val.isEmpty()) { successfullyProcessedCount++; - String lastPart = key.substring(start); - int bracketStart = lastPart.indexOf('['); - - if (bracketStart != -1) { - String objectName = lastPart.substring(0, bracketStart); - String indexStr = lastPart.substring(bracketStart + 1, lastPart.indexOf(']', bracketStart)); - - if (objectName.length() > 0) { - value = value[objectName]; - } - - if (value.is()) { - value = value[indexStr.toInt()]; - } else { - value = value[indexStr]; - } - } else { - value = value[lastPart]; - } - } - - // Append the value to the CSV string if it exists - if (!value.isNull()) { - if (value.is()) { - csvOutput += String(value.as()); - } else if (value.is()) { - csvOutput += doubleToString(value.as(), nr_decimals, true); - } else if (value.is()) { - csvOutput += String(value.as()); - } else if (value.is()) { - // If the value is an array, iterate over its elements - JsonArray array = value.as(); - size_t arraySize = array.size(); // Get the total number of elements in the array - size_t currentIndex = 0; // Track the current index - - for (JsonVariant element : array) { - if (element.is()) { - csvOutput += String(element.as()); - } else if (element.is()) { - csvOutput += doubleToString(element.as(), nr_decimals, true); - } else if (element.is()) { - csvOutput += String(element.as()); - } else { - csvOutput += F("unknown"); - } - - // Add a comma unless it's the last element - currentIndex++; - - if (currentIndex < arraySize) { - csvOutput += ','; - } - } - } else { - csvOutput += F("unknown"); - } - } else { - csvOutput += F("null"); // Indicate missing value + csvOutput += val; + csvOutput += ','; } - csvOutput += ','; } keyFile.close(); @@ -429,11 +342,11 @@ void readAndProcessJsonKeys(DynamicJsonDocument *root, int numJson) { // Log the results if (loglevelActiveFor(LOG_LEVEL_INFO)) { addLog(LOG_LEVEL_INFO, strformat(F("Successfully processed %d out of %d keys"), successfullyProcessedCount, keyCount)); - eventQueue.addMove(strformat(F("JsonReply%s%s=%s"), - numJson != 0 ? "#" : "", - toStringNoZero(numJson).c_str(), - csvOutput.c_str())); } + eventQueue.addMove(strformat(F("JsonReply%s%s=%s"), + numJson != 0 ? "#" : "", + toStringNoZero(numJson).c_str(), + csvOutput.c_str())); } } diff --git a/src/src/Helpers/JSON_helper.cpp b/src/src/Helpers/JSON_helper.cpp new file mode 100644 index 0000000000..357cb39c01 --- /dev/null +++ b/src/src/Helpers/JSON_helper.cpp @@ -0,0 +1,167 @@ +#include "../Helpers/JSON_helper.h" + +/** Changelog: + * 2026-08-01 tonhuisman: Extracted JSON value parser from HTTPResponseParser by @chromoxdor + * Support bool type by returning 1/0 for true/false + */ + +#if FEATURE_JSON_PARSE +# include "../Helpers/StringConverter.h" + +// Private for now +String getJsonValue(JsonVariant element, + bool asJson); + +String getJsonValue(DynamicJsonDocument *root, + String key, + bool asJson) { + // Process the key and navigate the JSON + JsonVariant value = *root; + size_t start = 0; + size_t end; + String result; + + while ((end = key.indexOf('.', start)) != -1) { + const String part = key.substring(start, end); + start = end + 1; + + // Look for an array e.g., "result[0]" → object "result", index 0 + const int bracketStart = part.indexOf('['); + + if (bracketStart != -1) { + const String objectName = part.substring(0, bracketStart); + const String indexStr = part.substring(bracketStart + 1, part.indexOf(']', bracketStart)); + + if (objectName.length() > 0) { + value = value[objectName]; // Access the object + } + + if (value.is()) { + value = value[indexStr.toInt()]; + } else { + value = value[indexStr]; // fallback if not actually array + } + } else { + // Normal object access without array + value = value[part]; + } + + if (value.isNull()) { + break; // Key path is invalid + } + } + + if (!value.isNull()) { + const String lastPart = key.substring(start); + const int bracketStart = lastPart.indexOf('['); + + if (bracketStart != -1) { + const String objectName = lastPart.substring(0, bracketStart); + const String indexStr = lastPart.substring(bracketStart + 1, lastPart.indexOf(']', bracketStart)); + + if (objectName.length() > 0) { + value = value[objectName]; + } + + if (value.is()) { + value = value[indexStr.toInt()]; + } else { + value = value[indexStr]; + } + } else { + value = value[lastPart]; + } + } + + // Append the value to the CSV string if it exists + if (!value.isNull()) { + result += getJsonValue(value, asJson); + + } + return result; +} + +String getJsonValue(JsonVariant value, bool asJson) { + # if FEATURE_USE_DOUBLE_AS_ESPEASY_RULES_FLOAT_TYPE + const int nr_decimals = ESPEASY_DOUBLE_NR_DECIMALS; + # else // ifdef FEATURE_USE_DOUBLE_AS_ESPEASY_RULES_FLOAT_TYPE + const int nr_decimals = ESPEASY_FLOAT_NR_DECIMALS; + # endif // ifdef FEATURE_USE_DOUBLE_AS_ESPEASY_RULES_FLOAT_TYPE + + String result; + + if (value.is()) { + result += String(value.as()); + } else if (value.is()) { + result += doubleToString(value.as(), nr_decimals, true); + } else if (value.is()) { + if (asJson) { + result += wrap_String(String(value.as()), '\"'); // JSON quotes are always " + } else { + result += String(value.as()); + } + } else if (value.is()) { + if (asJson) { + result += value.as() ? F("true") : F("false"); + } else { + result += String(value.as() ? 1 : 0); + } + } else if (value.is()) { + // If the value is an array, iterate over its elements + JsonArray array = value.as(); + size_t arraySize = array.size(); // Get the total number of elements in the array + size_t currentIndex = 0; // Track the current index + + if (asJson) { + result += '['; + } + + for (JsonVariant element : array) { + result += getJsonValue(element, asJson); + + // Add a comma unless it's the last element + currentIndex++; + + if (currentIndex < arraySize) { + result += ','; + } + } + + if (asJson) { + result += ']'; + } + } else if (value.is()) { + // if the value is a JSON Object, iterate over the attributes and return , pair(s), recursive + auto it = value.as().begin(); + int objectSize = value.as().size(); + int currentIndex = 0; + + while (it != value.as().end()) { + if (asJson) { + result += '{'; + result += wrap_String(it->key().c_str(), '\"'); // JSON quotes are always " + } else { + result += String(it->key().c_str()); + } + result += ','; + result += getJsonValue(it->value(), asJson); + + if (asJson) { + result += '}'; + } + currentIndex++; + + if (currentIndex < objectSize) { + result += ','; + } + ++it; + } + + } else { + result += F("unknown"); + } + + return result; +} + +#endif // if FEATURE_JSON_PARSE diff --git a/src/src/Helpers/JSON_helper.h b/src/src/Helpers/JSON_helper.h new file mode 100644 index 0000000000..0566a8a662 --- /dev/null +++ b/src/src/Helpers/JSON_helper.h @@ -0,0 +1,12 @@ +#pragma once + +/** See */ +#if FEATURE_JSON_PARSE +# include +# include "../Helpers/StringConverter_Numerical.h" + +String getJsonValue(DynamicJsonDocument *root, + String key, + bool asJson); // Format objects and arrays as JSON with {} and [] wrappers + +#endif // if FEATURE_JSON_PARSE diff --git a/src/src/Helpers/StringParser.cpp b/src/src/Helpers/StringParser.cpp index ccf985cf7d..b95982fc9e 100644 --- a/src/src/Helpers/StringParser.cpp +++ b/src/src/Helpers/StringParser.cpp @@ -37,7 +37,7 @@ bool hasEscapedCharacter(String& str, const char EscapeChar) void stripEscapeCharacters(String& str) { - const char braces[] = { '%', '[', ']', '{', '}', '(', ')' }; + const char braces[] = { '%', '[', ']', '{', '}', '(', ')', ':' }; constexpr uint8_t nrbraces = NR_ELEMENTS(braces); for (uint8_t i = 0; i < nrbraces; ++i) { @@ -48,7 +48,7 @@ void stripEscapeCharacters(String& str) void addEscapeCharacters(String& str) { - const char braces[] = { '%', '[', ']', '{', '}', '(', ')' }; + const char braces[] = { '%', '[', ']', '{', '}', '(', ')', ':' }; constexpr uint8_t nrbraces = NR_ELEMENTS(braces); for (uint8_t i = 0; i < nrbraces; ++i) { diff --git a/src/src/Helpers/StringParser.h b/src/src/Helpers/StringParser.h index 25406e881f..807f325e78 100644 --- a/src/src/Helpers/StringParser.h +++ b/src/src/Helpers/StringParser.h @@ -12,9 +12,11 @@ bool hasEscapedCharacter(String& str, const char EscapeChar); // Cleans str from escaped characters -// So far \\% \\[ \\] \\{ \\} \\( and \\) are used (all with single backslash!) +// So far \\% \\[ \\] \\{ \\} \\( \\) and \\: are used (all with single backslash!) void stripEscapeCharacters(String& str); +// Applies str with escaped characters +// So far % [ ] { } ( ) and : are escaped (all with single backslash!) void addEscapeCharacters(String& str); #if FEATURE_STRING_VARIABLES diff --git a/src/src/PluginStructs/P037_data_struct.h b/src/src/PluginStructs/P037_data_struct.h index 974abca08f..f6b70070a8 100644 --- a/src/src/PluginStructs/P037_data_struct.h +++ b/src/src/PluginStructs/P037_data_struct.h @@ -10,6 +10,7 @@ # include "../Helpers/Misc.h" # include "../Helpers/StringParser.h" # include "../Globals/MQTT.h" +# include "../Helpers/JSON_helper.h" # include @@ -197,9 +198,13 @@ struct P037_data_struct : public PluginTaskData_base String _filterListItem; # endif // if P037_FILTER_SUPPORT # if P037_JSON_SUPPORT - DynamicJsonDocument *root = nullptr; - uint16_t lastJsonMessageLength = 512; + uint16_t lastJsonMessageLength = 512; + +public: + + DynamicJsonDocument *root = nullptr; # endif // if P037_JSON_SUPPORT + }; #endif // ifdef USED_P037 diff --git a/static/espeasy.js b/static/espeasy.js index 3bf7a53d44..edd03f9130 100644 --- a/static/espeasy.js +++ b/static/espeasy.js @@ -174,7 +174,7 @@ var AnythingElse = [ "%dns%", "%dns1%", "%dns2%", "%flash_freq%", "%flash_size%", "%flash_chip_vendor%", "%flash_chip_model%", "%fs_free%", "%fs_size%", "%cpu_id%", "%cpu_freq%", "%cpu_model%", "%cpu_rev%", "%cpu_cores%", "%board_name%", "%inttemp%", "%islimited_build%", "%isvar_double%", //String Functions - "substring", "lookup", "indexOf", "indexOf_ci", "equals", "equals_ci", "strtol", "timeToMin", "timeToSec", + "substring", "lookup", "indexOf", "indexOf_ci", "equals", "equals_ci", "strtol", "timeToMin", "timeToSec", "unescape", "escape", "parse", "json", //Ethernet "%ethwifimode%", "%ethconnected%", "%ethduplex%", "%ethspeed%", "%ethstate%", "%ethspeedstate%", //Standard Conversions diff --git a/static/espeasy.min.js b/static/espeasy.min.js index 63a16731ae..398428c45c 100644 --- a/static/espeasy.min.js +++ b/static/espeasy.min.js @@ -1 +1 @@ -var commonAtoms=["And","Or"],commonKeywords=["If","Else","Elseif","Endif"],commonCommands=["AccessInfo","Background","Build","ClearAccessBlock","ClearRTCam","Config","ControllerDisable","ControllerEnable","DateTime","Debug","Dec","DeepSleep","DisablePriorityTask","DNS","DST","EraseSDKWiFi","ExecuteRules","FactoryReset","Gateway","I2Cscanner","Inc","IP","Latitude","Let","LetStr","Load","LogEntry","LogPortStatus","Longitude","LoopTimerSet","LoopTimerSet_ms","LoopTimerSetAndRun","LoopTimerSetAndRun_ms","MemInfo","MemInfoDetail","Name","NetworkDisable","NetworkEnable","Password","PostToHTTP","PostToHTTPS","Publish","PublishR","PutToHTTP","PutToHTTPS","Reboot","Save","SendTo","SendToHTTP","SendToHTTPS","SendToUDP","SendToUDPMix","Settings","Subnet","Subscribe","TaskClear","TaskClearAll","TaskDisable","TaskEnable","TaskRun","TaskValueSet","TaskValueSetAndRun","TaskValueSetDerived","TaskValueSetPresentation","TimerPause","TimerResume","TimerSet","TimerSet_ms","TimeZone","UdpPort","UdpTest","Unit","UseNTP","WdConfig","WdRead","WiFi","WiFiAllowAP","WiFiAPMode","WiFiConnect","WiFiDisconnect","WiFiKey","WiFiKey2","WiFiMode","WiFiScan","WiFiSSID","WiFiSSID2","WiFiSTAMode","Event","AsyncEvent","GPIO","GPIOToggle","LongPulse","LongPulse_mS","Monitor","Pulse","PWM","Servo","Status","Tone","RTTTL","UnMonitor","Provision","Provision,Config","Provision,Security","Provision,Notification","Provision,Provision","Provision,Rules","Provision,CustomCdnUrl","Provision,Firmware"],commonEvents=["Clock#Time","JsonReply","JsonReply#","Login#Failed","MQTT#Connected","MQTT#Disconnected","MQTTimport#Connected","MQTTimport#Disconnected","OpenMeteo#current","OpenMeteo#daily","OpenMeteo#hourly","Rules#Timer","System#Boot","System#BootMode","System#Sleep","System#Wake","TaskExit#","TaskInit#","ThingspeakReply","Time#Initialized","Time#Set","WiFi#APmodeDisabled","WiFi#APmodeEnabled","WiFi#ChangedAccesspoint","WiFi#ChangedWiFichannel","WiFi#Connected","WiFi#Disconnected"],commonPlugins=["ResetPulseCounter","SetPulseCounterTotal","LogPulseStatistic","analogout","MCPGPIO","MCPGPIOToggle","MCPLongPulse","MCPLongPulse_ms","MCPPulse","Status,MCP","Monitor,MCP","MonitorRange,MCP","UnMonitorRange,MCP","UnMonitor,MCP","MCPGPIORange","MCPGPIOPattern","MCPMode","MCPModeRange","ExtGpio","ExtPwm","ExtPulse","ExtLongPulse","Status,EXT,","LCDCmd","LCD","PCFGPIO","PCFGPIOToggle","PCFLongPulse","PCFLongPulse_ms","PCFPulse","Status,PCF","Monitor,PCF","MonitorRange,PCF","UnMonitorRange,PCF","UnMonitor,PCF","PCFGPIORange","PCFGPIOpattern","PCFMode","PCFmodeRange","SerialSend","SerialSendMix","Ser2NetClientSend","SerialSend_test","pcapwm","pcafrq","mode2","OLED","OLEDCMD","OLEDCMD,on","OLEDCMD,off","OLEDCMD,clear","IRSEND","IRSENDAC","OledFramedCmd","OledFramedCmd,Display","OledFramedCmd,low","OledFramedCmd,med","OledFramedCmd,high","OledFramedCmd,Frame","OledFramedCmd,linecount","OledFramedCmd,leftalign","OledFramedCmd,align","OledFramedCmd,userDef1","OledFramedCmd,userDef2","NeoPixel","NeoPixelAll","NeoPixelLine","NeoPixelHSV","NeoPixelAllHSV","NeoPixelLineHSV","NeoPixelBright","MotorShieldCmd,DCMotor","MotorShieldCmd,Stepper","MHZCalibrateZero","MHZReset","MHZABCEnable","MHZABCDisable","Sensair_SetRelay","PMSX003","PMSX003,Wake","PMSX003,Sleep","PMSX003,Reset","encwrite","Play","Vol","Eq","Mode","Repeat","tareChanA","tareChanB","7dn","7dst","7dsd","7dtext","7ddt","7dt","7dtfont","7dtbin","7don","7doff","7output","HLWCalibrate","HLWReset","csecalibrate","cseclearpulses","csereset","WemosMotorShieldCMD","LolinMotorShieldCMD","GPS","GPS,Sleep","GPS,Wake","GPS#GotFix","GPS#LostFix","GPS#Travelled","homieValueSet","SerialProxy_Write","SerialProxy_WriteMix","SerialProxy_Test","HeatPumpir","MitsubishiHP","MitsubishiHP,temperature","MitsubishiHP,power","MitsubishiHP,mode","MitsubishiHP,fan","MitsubishiHP,vane","MitsubishiHP,widevane","Culreader_Write","Touch","Touch,Rot","Touch,Flip","Touch,Enable","Touch,Disable","Touch,On","Touch,Off","Touch,Toggle","Touch,Setgrp","Touch,Incgrp","Touch,Decgrp","Touch,Incpage","Touch,Decpage","Touch,Updatebutton","WakeOnLan","DotMatrix","DotMatrix,clear","DotMatrix,update","DotMatrix,size","DotMatrix,txt","DotMatrix,settxt","DotMatrix,content","DotMatrix,alignment","DotMatrix,anim.in","DotMatrix,anim.out","DotMatrix,speed","DotMatrix,pause","DotMatrix,font","DotMatrix,layout","DotMatrix,inverted","DotMatrix,specialeffect","DotMatrix,offset","DotMatrix,brightness","DotMatrix,repeat","DotMatrix,setbar","DotMatrix,bar","Thermo","Thermo,Up","Thermo,Down","Thermo,Mode","Thermo,ModeBtn","Thermo,Setpoint","Max1704xclearalert","scdgetabc","scdgetalt","scdgettmp","scdsetcalibration","scdsetfrc","scdgetinterval","multirelay","multirelay,on","multirelay,off","multirelay,set","multirelay,get","multirelay,loop","ShiftOut","ShiftOut,Set","ShiftOut,SetNoUpdate","ShiftOut,Update","ShiftOut,SetAll","ShiftOut,SetAllNoUpdate","ShiftOut,SetAllLow","ShiftOut,SetAllHigh","ShiftOut,SetChipCount","ShiftOut,SetHexBin","cdmrst","nfx","nfx,off","nfx,on","nfx,dim","nfx,line,","nfx,hsvline,","nfx,one,","nfx,hsvone,","nfx,all,","nfx,rgb,","nfx,fade,","nfx,hsv,","nfx,colorfade,","nfx,rainbow","nfx,kitt,","nfx,comet,","nfx,theatre,","nfx,scan,","nfx,dualscan,","nfx,twinkle,","nfx,twinklefade,","nfx,sparkle,","nfx,wipe,","nfx,dualwipe","nfx,fire","nfx,fireflicker","nfx,faketv","nfx,simpleclock","nfx,stop","nfx,statusrequest","nfx,fadetime,","nfx,fadedelay,","nfx,speed,","nfx,count,","nfx,bgcolor","ShiftIn","ShiftIn,PinEvent","ShiftIn,ChipEvent","ShiftIn,SetChipCount","ShiftIn,SampleFrequency","ShiftIn,EventPerPin","scd4x","scd4x,storesettings","scd4x,facoryreset","scd4x,selftest","scd4x,setfrc,","axp","axp,ldo2","axp,ldo3","axp,ldoio","axp,gpio0","axp,gpio1","axp,gpio2","axp,gpio3","axp,gpio4","axp,dcdc2","axp,dcdc3","axp,ldo2map","axp,ldo3map","axp,ldoiomap","axp,dcdc2map","axp,dcdc3map","axp,ldo2perc","axp,ldo3perc","axp,ldoioperc","axp,dcdc2perc","axp,dcdc3perc","I2CEncoder","I2CEncoder,bright","I2CEncoder,led1","I2CEncoder,led2","I2CEncoder,gain","I2CEncoder,set","cachereader","cachereader,readpos","cachereader,sendtaskinfo","cachereader,flush","tm1621","tm1621,write,","tm1621,writerow,","tm1621,voltamp,","tm1621,energy,","tm1621,celcius,","tm1621,fahrenheit,","tm1621,humidity,","tm1621,raw,","dac","dac,1","dac,2","sht4x","sht4x,startup","ld2410","ld2410,factoryreset","ld2410,logall","digipot","digipot,reset","digipot,shutdown","digipot,","7dextra","7dbefore","7dgroup","7digit","7color","7digitcolor","7groupcolor","gp8403","gp8403,volt,","gp8403,mvolt,","gp8403,range,","gp8403,preset,","gp8403,init,","sen5x","sen5x,startclean","sen5x,techlog,","as3935","as3935,clearstats","as3935,calibrate","as3935,setgain,","as3935,setnf,","as3935,setwd,","as3925,setsrej,","lu9685","lu9685,servo,","lu9685,enable,","lu9685,disable,","lu9685,setrange,","geni2c","geni2c,cmd,","geni2c,exec,","geni2c,log,"],pluginDispKind=["tft","ili9341","ili9342","ili9481","ili9486","ili9488","epd","eink","epaper","il3897","uc8151d","ssd1680","ws2in7","ws1in54","st77xx","st7735","st7789","st7796","neomatrix","neo","pcd8544"],pluginDispCmd=["cmd,on","cmd,off","cmd,clear","cmd,backlight","cmd,bright","cmd,deepsleep","cmd,seq_start","cmd,seq_end","cmd,inv","cmd,rot",",clear",",rot",",tpm",",txt",",txp",",txz",",txc",",txs",",txtfull",",asciitable",",font",",l",",lh",",lv",",lm",",lmr",",r",",rf",",c",",cf",",rf",",t",",tf",",rr",",rrf",",px",",pxh",",pxv",",bmp",",btn",",win",",defwin",",delwin"],commonTag=["On","Do","Endon"],commonNumber=["toBin","toHex","Constrain","XOR","AND:","OR:","Ord","bitRead","bitSet","bitClear","bitWrite","urlencode"],commonMath=["Log","Ln","Abs","Exp","Sqrt","Sq","Round","Sin","Cos","Tan","aSin","aCos","aTan","aTan2","Sin_d","Cos_d","Tan_d","aSin_d","aCos_d","aTan_d","aTan2_d","map","mapc","fmod"],commonWarning=["delay","Delay","ResetFlashWriteCounter"],taskSpecifics=["settings.Enabled","settings.Interval","settings.ValueCount","settings.Controller1.Enabled","settings.Controller2.Enabled","settings.Controller3.Enabled","settings.Controller1.Idx","settings.Controller2.Idx","settings.Controller3.Idx"],AnythingElse=["%eventvalue%","%eventpar%","%eventname%","%sysname%","%bootcause%","%systime%","%systm_hm%","%systm_hm_0%","%systm_hm_sp%","%systime_am%","%systime_am_0%","%systime_am_sp%","%systm_hm_am%","%systm_hm_am_0%","%systm_hm_am_sp%","%lcltime%","%sunrise%","%s_sunrise%","%m_sunrise%","%sunset%","%s_sunset%","%m_sunset%","%lcltime_am%","%latitude%","%longitude%","%syshour%","%syshour_0%","%sysmin%","%sysmin_0%","%syssec%","%syssec_0%","%sysday%","%sysday_0%","%sysmonth%","%sysmonth_0%","%systzoffset%","%systzoffset_s%","%sysyear%","%sysyear_0%","%sysyears%","%sysweekday%","%sysweekday_s%","%unixtime%","%unixtime_lcl%","%uptime%","%uptime_ms%","%rssi%","%ip%","%unit%","%unit_0%","%ssid%","%bssid%","%wi_ch%","%iswifi%","%vcc%","%mac%","%mac_int%","%isntp%","%ismqtt%","%dns%","%dns1%","%dns2%","%flash_freq%","%flash_size%","%flash_chip_vendor%","%flash_chip_model%","%fs_free%","%fs_size%","%cpu_id%","%cpu_freq%","%cpu_model%","%cpu_rev%","%cpu_cores%","%board_name%","%inttemp%","%islimited_build%","%isvar_double%","substring","lookup","indexOf","indexOf_ci","equals","equals_ci","strtol","timeToMin","timeToSec","%ethwifimode%","%ethconnected%","%ethduplex%","%ethspeed%","%ethstate%","%ethspeedstate%","%c_w_dir%","%c_c2f%","%c_ms2Bft%","%c_dew_th%","%c_alt_pres_sea%","%c_sea_pres_alt%","%c_cm2imp%","%c_isnum%","%c_mm2imp%","%c_m2day%","%c_m2dh%","%c_m2dhm%","%c_s2dhms%","%c_ts2date%","%c_ts2isodate%","%c_ts2wday%","%c_random%","%c_2hex%","%c_u2ip%","%c_uname%","%c_uage%","%c_ubuild%","%c_ubuildstr%","%c_uload%","%c_utype%","%c_utypestr%","%c_strf%","%c_d2r%","%c_r2d%","%SP%","%CR%","%LF%","%N%","%R%","%e%","%pi%","var","int","str","length"];for(const e of pluginDispKind)commonPlugins=commonPlugins.concat(e);for(const e of pluginDispKind)for(const t of pluginDispCmd){let n=e+t;commonPlugins=commonPlugins.concat(n)}var rEdit,EXTRAWORDS=commonAtoms.concat(commonPlugins,commonKeywords,commonCommands,commonEvents,commonTag,commonNumber,commonMath,commonWarning,taskSpecifics,AnythingElse),confirmR=!0,android=/Android/.test(navigator.userAgent);function initCM(){function e(e){}android&&(confirmR=!!confirm("Do you want to enable colored rules on your Android device?\nThis feature hasn't been fully tested yet and may still have some issues.\nIt is currently expected to work with Chrome, Firefox, and Vivaldi.\nPlease report any problems you encounter.")),confirmR&&(CodeMirror.commands.autocomplete=function(e){e.showHint({hint:CodeMirror.hint.anyword})},(rEdit=CodeMirror.fromTextArea(document.getElementById("rules"),{tabSize:2,indentWithTabs:!1,lineNumbers:!0,autoCloseBrackets:!0,extraKeys:{"Ctrl-Space":"autocomplete",Tab:e=>{"null"===e.getMode().name?e.execCommand("insertTab"):e.somethingSelected()?e.execCommand("indentMore"):e.execCommand("insertSoftTab")},"Shift-Tab":e=>e.execCommand("indentLess")}})).on("change",(function(){rEdit.save()})),android||rEdit.on("inputRead",(function(e,t){var n=e.getCursor(),o=e.getTokenAt(n);/[\w%,.]/.test(t.text)&&"comment"!=o.type&&e.showHint({completeSingle:!1})})),CodeMirror.keyMap.default["Ctrl-F"]=function(e){openFind()},CodeMirror.keyMap.default["Cmd-F"]=function(e){openFind()},CodeMirror.keyMap.default["Ctrl-G"]=e,CodeMirror.keyMap.default["Cmd-G"]=e,CodeMirror.keyMap.default["Shift-Ctrl-G"]=e,CodeMirror.keyMap.default["Shift-Cmd-G"]=e,CodeMirror.keyMap.default["Ctrl-H"]=e,CodeMirror.keyMap.default["Cmd-H"]=e,CodeMirror.keyMap.default["Shift-Ctrl-F"]=e,CodeMirror.keyMap.default["Shift-Cmd-F"]=e,CodeMirror.keyMap.default["Ctrl-Shift-R"]=e,CodeMirror.keyMap.default["Cmd-Shift-R"]=e)}function closeSearchDialog(){const e=document.querySelectorAll(".CodeMirror-dialog");e.length>0&&(e.forEach((e=>e.remove())),document.body.classList.remove("dialog-opened")),rEdit.execCommand("clearSearch")}function removeHighlight(){requestAnimationFrame((()=>{document.querySelectorAll(".search-next-highlight").forEach((e=>e.classList.remove("search-next-highlight")))}))}let findDialogObserver=null;function openFind(){findDialogObserver&&(document.querySelectorAll(".CodeMirror-dialog").forEach((e=>e.remove())),findDialogObserver.disconnect(),findDialogObserver=null),findDialogObserver=new MutationObserver((()=>{document.querySelector(".CodeMirror-dialog")||(removeHighlight(),findDialogObserver.disconnect(),findDialogObserver=null)})),findDialogObserver.observe(document.body,{childList:!0,subtree:!0}),clearSearchNextHighlight(rEdit),rEdit.execCommand("findPersistent"),addFindButtons()}function clearSearchNextHighlight(e){removeHighlight(),e.__searchNextHighlight&&(e.__searchNextHighlight.clear(),e.__searchNextHighlight=null)}function addFindButtons(){document.querySelector(".CodeMirror-selected");const e=document.querySelector(".CodeMirror-dialog");if(!e||e.querySelector(".search-button-group"))return;[{title:"Find Previous",symbol:"▲",action:()=>rEdit.execCommand("findPersistentPrev")},{title:"Find Next",symbol:"▼",action:()=>rEdit.execCommand("findPersistentNext")},{title:"Replace",symbol:"Replace",action:()=>{closeSearchDialog(),rEdit.execCommand("replace"),addFindButtons()}},{title:"Close",symbol:"❌",action:closeSearchDialog},{title:"Help",symbol:"?",action:()=>{alert("Available shortcuts:\n• Ctrl+F / Cmd+F: Open search\n• Enter: Find next\n• Shift+Enter: Find previous\n• Use /re/ syntax for regex search")}}].forEach((({title:t,symbol:n,action:o})=>{const i=document.createElement("span");i.title=t,i.className="help"===t.toLowerCase()?"button help":"button",i.innerHTML=n,i.style.cssText="\n cursor: pointer;\n user-select: none;\n ",i.addEventListener("click",(e=>{e.preventDefault(),o()})),e.appendChild(i)}))}function triggerFormatting(){let e,t,n,o,i;if(confirmR){const r=rEdit.getDoc();e=rEdit.getScrollInfo(),t=r.getCursor(),n=0===t.ch?t.line-1:t.line,o=rEdit.getLine(n)||"",i=rEdit.getValue()}else i=document.getElementById("rules").value;if(i=initalAutocorrection(i),i=formatLogic(i),confirmR){rEdit.setValue(i);const r=n,s=0===t.ch&&o.length>0?o.length:t.ch;rEdit.setCursor({line:r,ch:s}),setTimeout((()=>{rEdit.scrollTo(e.left,e.top),rEdit.focus()}),0),rEdit.save()}else document.getElementById("rules").value=i}function initalAutocorrection(e){for(const t of EXTRAWORDS)if("Do"===t){const t=/(^|\s)(do)(\s*)(\/\/.*)?$/gim;e=e.replace(t,((e,t,n,o,i)=>`${t}Do${o}${i??""}`))}else{const n=new RegExp(`^\\s*\\b${t}\\b`,"gmi");e=e.replace(n,(e=>e.replace(new RegExp(t,"i"),t)))}return e}function formatLogic(e){const t=" ",n=e.split("\n").map((e=>{const t=e.trimStart();return t.startsWith("//")?e:t})),o=[],i=[];let r=!1,s=null,a=[],l=[];function c(e){return e.trim().startsWith("//")}function d(e){return""===e.trim()}function m(e){return e.trim().toLowerCase().startsWith("on")}function u(e){return e.trim().toLowerCase().endsWith("do")}function f(e){return"endon"===e.trim().toLowerCase()}function h(e){return e.trim().toLowerCase().startsWith("if")}function p(e){return"else"===e.trim().toLowerCase()}function g(e){return e.trim().toLowerCase().startsWith("elseif")}function C(e){return"endif"===e.trim().toLowerCase()}let x=0;function S(){a.length>0&&(i.push(`• Missing ${a.length} Endif(s):`),i.push(` - Unclosed If block(s) starting at line(s): ${l.join(", ")}`)),a=[],l=[]}for(let e=0;e0){const e=extractFirstErrorLine(i);if(alert("Errors found:\n"+i.join("\n")),!isNaN(e))if(confirmR)setTimeout((()=>{jumpToLine(e)}),50);else{const t=document.getElementById("rules");setTimeout((()=>{jumpToLineInTextarea(t,e)}),50)}}return o.join("\n")}function jumpToLine(e){const t=Math.max(0,e-1);rEdit.setCursor({line:t,ch:0}),rEdit.focus(),rEdit.scrollIntoView({line:t,ch:0},100)}function extractFirstErrorLine(e){for(const t of e){let e=t.match(/• Line (\d+)/);if(e)return parseInt(e[1]);if(e=t.match(/starting at line (\d+)/),e)return parseInt(e[1]);if(e=t.match(/starting at line\(s\):\s*(\d+)/),e)return parseInt(e[1])}return null}function jumpToLineInTextarea(e,t){const n=e.value.split("\n"),o=Math.max(1,Math.min(t,n.length));let i=0;for(let e=0;e{const e=document.getElementById("rulesselect");if(e){if(confirmR){const t=document.createElement("button");t.type="button",t.id="searchBtn",t.innerHTML="🔎︎",t.style.padding="2px 5px",t.className="button help",e.appendChild(t),t.addEventListener("click",(()=>{void 0!==rEdit&&openFind()}))}const t=document.createElement("button");t.type="button",t.id="formatBtn",t.textContent="Format",t.className="button",e.appendChild(t),t.addEventListener("click",(()=>{triggerFormatting()}))}let t="";if(document.addEventListener("keydown",(function(e){const n=e.key;(["Backspace","Delete","ArrowLeft","ArrowRight","ArrowUp","ArrowDown","Enter","Tab","Escape","Shift","Control","Alt","Meta"].includes(n)||1!==n.length)&&(t="")})),android){var n=!1;rEdit.on("keydown",((e,o)=>{["Enter","Backspace"," "].includes(o.key)&&(t=""),n=!0}));let e="";const i=rEdit.getInputField();function o(o,i=!1){if(!(rEdit.hasFocus()&&rEdit&&o.data&&n))return;n=!1;const r=o.data,s=rEdit.getDoc(),a=s.getCursor(),l=rEdit.getTokenAt(a);if(" "===r)return t="",void(e="");if(!(r===e&&t.length>0))if(/[\w%,.]/.test(r)&&"comment"!==l.type){const n=a.ch<=1?r.slice(-1):r;t+=n,e=t,t.startsWith(String(a.line+1))&&0===a.ch&&(t=t.slice(String(a.line).length));const o={line:a.line,ch:a.ch-t.length+1},l=()=>{s.replaceRange(t,o,a),rEdit.setCursor({line:o.line,ch:o.ch+t.length}),rEdit.showHint({completeSingle:!1})};i?l():setTimeout(l,0)}else t=""}const r=navigator.userAgent.toLowerCase(),s=/firefox/.test(r),a=/chrome/.test(r)&&!s;s?i.addEventListener("beforeinput",(e=>{e.preventDefault(),o(e,!0)})):a&&document.addEventListener("input",(e=>{o(e,!1)})),rEdit.on("endCompletion",(function(){setTimeout((()=>{!function(){const e=document.createElement("input");e.type="text",e.style.position="absolute",e.style.opacity="0",e.style.height="0",e.style.width="0",e.style.border="none",e.style.top="0",e.style.left="-9999",e.style.padding="0",e.style.zIndex="-1",e.style.fontSize="16px",document.body.appendChild(e),e.focus(),setTimeout((()=>{e.remove(),rEdit.focus()}),10)}()}),100)}))}})),function(e){"object"==typeof exports&&"object"==typeof module?e(require("codemirror")):"function"==typeof define&&define.amd?define(["codemirror"],e):e(CodeMirror)}((function(e){"use strict";e.defineMode("espeasy",(function(){var e={};function t(t,n){for(var o=0;oe.toLowerCase()));commonCommands=commonCommands.concat(n);var o=commonEvents.map((e=>e.toLowerCase()));commonEvents=commonEvents.concat(o);var i=commonPlugins.map((e=>e.toLowerCase()));commonPlugins=commonPlugins.concat(i);var r=commonAtoms.map((e=>e.toLowerCase()));commonAtoms=commonAtoms.concat(r);var s=commonKeywords.map((e=>e.toLowerCase()));commonKeywords=commonKeywords.concat(s);var a=commonTag.map((e=>e.toLowerCase()));commonTag=commonTag.concat(a);var l=commonNumber.map((e=>e.toLowerCase()));commonNumber=commonNumber.concat(l);var c=commonMath.map((e=>e.toLowerCase()));commonMath=commonMath.concat(c);var d=AnythingElse.map((e=>e.toLowerCase()));AnythingElse=AnythingElse.concat(d);var m=taskSpecifics.map((e=>e.toLowerCase()));function u(t,n){if(t.eatSpace())return null;t.sol();var o=t.next();if(/\d/.test(o)){if("0"==o)return"x"===t.next()?(t.eatWhile(/\w/),"number"):(t.eatWhile(/\d|\./),"number");if(t.eatWhile(/\d|\./),!t.match("d")&&!t.match("output")&&(t.eol()||/\D/.test(t.peek())))return"number"}if(/\w/.test(o))for(const e of EXTRAWORDS){let n=e.substring(1);(e.includes(":")||e.includes(",")||e.includes("."))&&t.match(n)}if(/\w/.test(o)&&(t.eatWhile(/[\w]/),t.match(".gpio")||t.match(".pulse")||t.match(".frq")||t.match(".pwm")))return"def";if("\\"===o)return t.next(),null;if("("===o||")"===o)return"bracket";if("{"===o||"}"===o||":"===o)return"number";if("/"==o)return/\//.test(t.peek())?(t.skipToEnd(),"comment"):"operator";if("'"==o&&(t.eatWhile(/[^']/),t.match("'")))return"attribute";if("+"===o||"="===o||"<"===o||">"===o||"-"===o||","===o||"*"===o||"!"===o)return"operator";if("%"==o){if(/\d/.test(t.next()))return"number";if(t.eatWhile(/[^\s\%]/),t.match("%"))return"hr"}if("["==o&&(t.eatWhile(/[^\s\]]/),t.eat("]")))return"hr";t.eatWhile(/\w/);var i=t.current();return/\w/.test(o)&&t.match("#")?(t.eatWhile(/[\w.#]/),"events"):"#"===o?(t.eatWhile(/\w/),"number"):e.hasOwnProperty(i)?e[i]:null}function f(e,t){return(t.tokens[0]||u)(e,t)}return taskSpecifics=taskSpecifics.concat(m),t("atom",commonAtoms),t("keyword",commonKeywords),t("builtin",commonCommands),t("events",commonEvents),t("def",commonPlugins),t("tag",commonTag),t("number",commonNumber),t("bracket",commonMath),t("warning",commonWarning),t("hr",AnythingElse),t("comment",taskSpecifics),{startState:function(){return{tokens:[]}},token:function(e,t){return f(e,t)},closeBrackets:"[]{}''\"\"``()",lineComment:"//",fold:"brace"}}))})),function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],mod):e(CodeMirror)}((function(e){var t={pairs:"()[]{}''\"\"",closeBefore:")]}'\":;>",triples:"",explode:"[]{}"},n=e.Pos;function o(e,n){return"pairs"==n&&"string"==typeof e?e:"object"==typeof e&&null!=e[n]?e[n]:t[n]}e.defineOption("autoCloseBrackets",!1,(function(t,n,s){s&&s!=e.Init&&(t.removeKeyMap(i),t.state.closeBrackets=null),n&&(r(o(n,"pairs")),t.state.closeBrackets=n,t.addKeyMap(i))}));var i={Backspace:function(t){var i=a(t);if(!i||t.getOption("disableInput"))return e.Pass;for(var r=o(i,"pairs"),s=t.listSelections(),l=0;l=0;l--){var m=s[l].head;t.replaceRange("",n(m.line,m.ch-1),n(m.line,m.ch+1),"+delete")}},Enter:function(t){var n=a(t),i=n&&o(n,"explode");if(!i||t.getOption("disableInput"))return e.Pass;for(var r=t.listSelections(),s=0;s1&&h.indexOf(i)>=0&&t.getRange(n(M.line,M.ch-2),M)==i+i){if(M.ch>2&&/\bstring/.test(t.getTokenTypeAt(n(M.line,M.ch-2))))return e.Pass;S="addFour"}else if(p){var b=0==M.ch?" ":t.getRange(n(M.line,M.ch-1),M);if(e.isWordChar(P)||b==i||e.isWordChar(b))return e.Pass;S="both"}else{if(!C||!(0===P.length||/\s/.test(P)||f.indexOf(P)>-1))return e.Pass;S="both"}else S=p&&m(t,M)?"both":h.indexOf(i)>=0&&t.getRange(M,n(M.line,M.ch+3))==i+i+i?"skipThree":"skip";if(u){if(u!=S)return e.Pass}else u=S}var T=d%2?s.charAt(d-1):i,v=d%2?i:s.charAt(d+1);t.operation((function(){if("skip"==u)l(t,1);else if("skipThree"==u)l(t,3);else if("surround"==u){for(var e=t.getSelections(),n=0;n0?{line:s.head.line,ch:s.head.ch+t}:{line:s.head.line-1};n.push({anchor:a,head:a})}e.setSelections(n,i)}function c(t){var o=e.cmpPos(t.anchor,t.head)>0;return{anchor:new n(t.anchor.line,t.anchor.ch+(o?-1:1)),head:new n(t.head.line,t.head.ch+(o?1:-1))}}function d(e,t){var o=e.getRange(n(t.line,t.ch-1),n(t.line,t.ch+1));return 2==o.length?o:null}function m(e,t){var o=e.getTokenAt(n(t.line,t.ch+1));return/\bstring/.test(o.type)&&o.start==t.ch&&(0==t.ch||!/\bstring/.test(e.getTokenTypeAt(t)))}r(t.pairs+"`")})); \ No newline at end of file +var commonAtoms=["And","Or"],commonKeywords=["If","Else","Elseif","Endif"],commonCommands=["AccessInfo","Background","Build","ClearAccessBlock","ClearRTCam","Config","ControllerDisable","ControllerEnable","DateTime","Debug","Dec","DeepSleep","DisablePriorityTask","DNS","DST","EraseSDKWiFi","ExecuteRules","FactoryReset","Gateway","I2Cscanner","Inc","IP","Latitude","Let","LetStr","Load","LogEntry","LogPortStatus","Longitude","LoopTimerSet","LoopTimerSet_ms","LoopTimerSetAndRun","LoopTimerSetAndRun_ms","MemInfo","MemInfoDetail","Name","NetworkDisable","NetworkEnable","Password","PostToHTTP","PostToHTTPS","Publish","PublishR","PutToHTTP","PutToHTTPS","Reboot","Save","SendTo","SendToHTTP","SendToHTTPS","SendToUDP","SendToUDPMix","Settings","Subnet","Subscribe","TaskClear","TaskClearAll","TaskDisable","TaskEnable","TaskRun","TaskValueSet","TaskValueSetAndRun","TaskValueSetDerived","TaskValueSetPresentation","TimerPause","TimerResume","TimerSet","TimerSet_ms","TimeZone","UdpPort","UdpTest","Unit","UseNTP","WdConfig","WdRead","WiFi","WiFiAllowAP","WiFiAPMode","WiFiConnect","WiFiDisconnect","WiFiKey","WiFiKey2","WiFiMode","WiFiScan","WiFiSSID","WiFiSSID2","WiFiSTAMode","Event","AsyncEvent","GPIO","GPIOToggle","LongPulse","LongPulse_mS","Monitor","Pulse","PWM","Servo","Status","Tone","RTTTL","UnMonitor","Provision","Provision,Config","Provision,Security","Provision,Notification","Provision,Provision","Provision,Rules","Provision,CustomCdnUrl","Provision,Firmware"],commonEvents=["Clock#Time","JsonReply","JsonReply#","Login#Failed","MQTT#Connected","MQTT#Disconnected","MQTTimport#Connected","MQTTimport#Disconnected","OpenMeteo#current","OpenMeteo#daily","OpenMeteo#hourly","Rules#Timer","System#Boot","System#BootMode","System#Sleep","System#Wake","TaskExit#","TaskInit#","ThingspeakReply","Time#Initialized","Time#Set","WiFi#APmodeDisabled","WiFi#APmodeEnabled","WiFi#ChangedAccesspoint","WiFi#ChangedWiFichannel","WiFi#Connected","WiFi#Disconnected"],commonPlugins=["ResetPulseCounter","SetPulseCounterTotal","LogPulseStatistic","analogout","MCPGPIO","MCPGPIOToggle","MCPLongPulse","MCPLongPulse_ms","MCPPulse","Status,MCP","Monitor,MCP","MonitorRange,MCP","UnMonitorRange,MCP","UnMonitor,MCP","MCPGPIORange","MCPGPIOPattern","MCPMode","MCPModeRange","ExtGpio","ExtPwm","ExtPulse","ExtLongPulse","Status,EXT,","LCDCmd","LCD","PCFGPIO","PCFGPIOToggle","PCFLongPulse","PCFLongPulse_ms","PCFPulse","Status,PCF","Monitor,PCF","MonitorRange,PCF","UnMonitorRange,PCF","UnMonitor,PCF","PCFGPIORange","PCFGPIOpattern","PCFMode","PCFmodeRange","SerialSend","SerialSendMix","Ser2NetClientSend","SerialSend_test","pcapwm","pcafrq","mode2","OLED","OLEDCMD","OLEDCMD,on","OLEDCMD,off","OLEDCMD,clear","IRSEND","IRSENDAC","OledFramedCmd","OledFramedCmd,Display","OledFramedCmd,low","OledFramedCmd,med","OledFramedCmd,high","OledFramedCmd,Frame","OledFramedCmd,linecount","OledFramedCmd,leftalign","OledFramedCmd,align","OledFramedCmd,userDef1","OledFramedCmd,userDef2","NeoPixel","NeoPixelAll","NeoPixelLine","NeoPixelHSV","NeoPixelAllHSV","NeoPixelLineHSV","NeoPixelBright","MotorShieldCmd,DCMotor","MotorShieldCmd,Stepper","MHZCalibrateZero","MHZReset","MHZABCEnable","MHZABCDisable","Sensair_SetRelay","PMSX003","PMSX003,Wake","PMSX003,Sleep","PMSX003,Reset","encwrite","Play","Vol","Eq","Mode","Repeat","tareChanA","tareChanB","7dn","7dst","7dsd","7dtext","7ddt","7dt","7dtfont","7dtbin","7don","7doff","7output","HLWCalibrate","HLWReset","csecalibrate","cseclearpulses","csereset","WemosMotorShieldCMD","LolinMotorShieldCMD","GPS","GPS,Sleep","GPS,Wake","GPS#GotFix","GPS#LostFix","GPS#Travelled","homieValueSet","SerialProxy_Write","SerialProxy_WriteMix","SerialProxy_Test","HeatPumpir","MitsubishiHP","MitsubishiHP,temperature","MitsubishiHP,power","MitsubishiHP,mode","MitsubishiHP,fan","MitsubishiHP,vane","MitsubishiHP,widevane","Culreader_Write","Touch","Touch,Rot","Touch,Flip","Touch,Enable","Touch,Disable","Touch,On","Touch,Off","Touch,Toggle","Touch,Setgrp","Touch,Incgrp","Touch,Decgrp","Touch,Incpage","Touch,Decpage","Touch,Updatebutton","WakeOnLan","DotMatrix","DotMatrix,clear","DotMatrix,update","DotMatrix,size","DotMatrix,txt","DotMatrix,settxt","DotMatrix,content","DotMatrix,alignment","DotMatrix,anim.in","DotMatrix,anim.out","DotMatrix,speed","DotMatrix,pause","DotMatrix,font","DotMatrix,layout","DotMatrix,inverted","DotMatrix,specialeffect","DotMatrix,offset","DotMatrix,brightness","DotMatrix,repeat","DotMatrix,setbar","DotMatrix,bar","Thermo","Thermo,Up","Thermo,Down","Thermo,Mode","Thermo,ModeBtn","Thermo,Setpoint","Max1704xclearalert","scdgetabc","scdgetalt","scdgettmp","scdsetcalibration","scdsetfrc","scdgetinterval","multirelay","multirelay,on","multirelay,off","multirelay,set","multirelay,get","multirelay,loop","ShiftOut","ShiftOut,Set","ShiftOut,SetNoUpdate","ShiftOut,Update","ShiftOut,SetAll","ShiftOut,SetAllNoUpdate","ShiftOut,SetAllLow","ShiftOut,SetAllHigh","ShiftOut,SetChipCount","ShiftOut,SetHexBin","cdmrst","nfx","nfx,off","nfx,on","nfx,dim","nfx,line,","nfx,hsvline,","nfx,one,","nfx,hsvone,","nfx,all,","nfx,rgb,","nfx,fade,","nfx,hsv,","nfx,colorfade,","nfx,rainbow","nfx,kitt,","nfx,comet,","nfx,theatre,","nfx,scan,","nfx,dualscan,","nfx,twinkle,","nfx,twinklefade,","nfx,sparkle,","nfx,wipe,","nfx,dualwipe","nfx,fire","nfx,fireflicker","nfx,faketv","nfx,simpleclock","nfx,stop","nfx,statusrequest","nfx,fadetime,","nfx,fadedelay,","nfx,speed,","nfx,count,","nfx,bgcolor","ShiftIn","ShiftIn,PinEvent","ShiftIn,ChipEvent","ShiftIn,SetChipCount","ShiftIn,SampleFrequency","ShiftIn,EventPerPin","scd4x","scd4x,storesettings","scd4x,facoryreset","scd4x,selftest","scd4x,setfrc,","axp","axp,ldo2","axp,ldo3","axp,ldoio","axp,gpio0","axp,gpio1","axp,gpio2","axp,gpio3","axp,gpio4","axp,dcdc2","axp,dcdc3","axp,ldo2map","axp,ldo3map","axp,ldoiomap","axp,dcdc2map","axp,dcdc3map","axp,ldo2perc","axp,ldo3perc","axp,ldoioperc","axp,dcdc2perc","axp,dcdc3perc","I2CEncoder","I2CEncoder,bright","I2CEncoder,led1","I2CEncoder,led2","I2CEncoder,gain","I2CEncoder,set","cachereader","cachereader,readpos","cachereader,sendtaskinfo","cachereader,flush","tm1621","tm1621,write,","tm1621,writerow,","tm1621,voltamp,","tm1621,energy,","tm1621,celcius,","tm1621,fahrenheit,","tm1621,humidity,","tm1621,raw,","dac","dac,1","dac,2","sht4x","sht4x,startup","ld2410","ld2410,factoryreset","ld2410,logall","digipot","digipot,reset","digipot,shutdown","digipot,","7dextra","7dbefore","7dgroup","7digit","7color","7digitcolor","7groupcolor","gp8403","gp8403,volt,","gp8403,mvolt,","gp8403,range,","gp8403,preset,","gp8403,init,","sen5x","sen5x,startclean","sen5x,techlog,","as3935","as3935,clearstats","as3935,calibrate","as3935,setgain,","as3935,setnf,","as3935,setwd,","as3925,setsrej,","lu9685","lu9685,servo,","lu9685,enable,","lu9685,disable,","lu9685,setrange,","geni2c","geni2c,cmd,","geni2c,exec,","geni2c,log,"],pluginDispKind=["tft","ili9341","ili9342","ili9481","ili9486","ili9488","epd","eink","epaper","il3897","uc8151d","ssd1680","ws2in7","ws1in54","st77xx","st7735","st7789","st7796","neomatrix","neo","pcd8544"],pluginDispCmd=["cmd,on","cmd,off","cmd,clear","cmd,backlight","cmd,bright","cmd,deepsleep","cmd,seq_start","cmd,seq_end","cmd,inv","cmd,rot",",clear",",rot",",tpm",",txt",",txp",",txz",",txc",",txs",",txtfull",",asciitable",",font",",l",",lh",",lv",",lm",",lmr",",r",",rf",",c",",cf",",rf",",t",",tf",",rr",",rrf",",px",",pxh",",pxv",",bmp",",btn",",win",",defwin",",delwin"],commonTag=["On","Do","Endon"],commonNumber=["toBin","toHex","Constrain","XOR","AND:","OR:","Ord","bitRead","bitSet","bitClear","bitWrite","urlencode"],commonMath=["Log","Ln","Abs","Exp","Sqrt","Sq","Round","Sin","Cos","Tan","aSin","aCos","aTan","aTan2","Sin_d","Cos_d","Tan_d","aSin_d","aCos_d","aTan_d","aTan2_d","map","mapc","fmod"],commonWarning=["delay","Delay","ResetFlashWriteCounter"],taskSpecifics=["settings.Enabled","settings.Interval","settings.ValueCount","settings.Controller1.Enabled","settings.Controller2.Enabled","settings.Controller3.Enabled","settings.Controller1.Idx","settings.Controller2.Idx","settings.Controller3.Idx"],AnythingElse=["%eventvalue%","%eventpar%","%eventname%","%sysname%","%bootcause%","%systime%","%systm_hm%","%systm_hm_0%","%systm_hm_sp%","%systime_am%","%systime_am_0%","%systime_am_sp%","%systm_hm_am%","%systm_hm_am_0%","%systm_hm_am_sp%","%lcltime%","%sunrise%","%s_sunrise%","%m_sunrise%","%sunset%","%s_sunset%","%m_sunset%","%lcltime_am%","%latitude%","%longitude%","%syshour%","%syshour_0%","%sysmin%","%sysmin_0%","%syssec%","%syssec_0%","%sysday%","%sysday_0%","%sysmonth%","%sysmonth_0%","%systzoffset%","%systzoffset_s%","%sysyear%","%sysyear_0%","%sysyears%","%sysweekday%","%sysweekday_s%","%unixtime%","%unixtime_lcl%","%uptime%","%uptime_ms%","%rssi%","%ip%","%unit%","%unit_0%","%ssid%","%bssid%","%wi_ch%","%iswifi%","%vcc%","%mac%","%mac_int%","%isntp%","%ismqtt%","%dns%","%dns1%","%dns2%","%flash_freq%","%flash_size%","%flash_chip_vendor%","%flash_chip_model%","%fs_free%","%fs_size%","%cpu_id%","%cpu_freq%","%cpu_model%","%cpu_rev%","%cpu_cores%","%board_name%","%inttemp%","%islimited_build%","%isvar_double%","substring","lookup","indexOf","indexOf_ci","equals","equals_ci","strtol","timeToMin","timeToSec","unescape","escape","parse","json","%ethwifimode%","%ethconnected%","%ethduplex%","%ethspeed%","%ethstate%","%ethspeedstate%","%c_w_dir%","%c_c2f%","%c_ms2Bft%","%c_dew_th%","%c_alt_pres_sea%","%c_sea_pres_alt%","%c_cm2imp%","%c_isnum%","%c_mm2imp%","%c_m2day%","%c_m2dh%","%c_m2dhm%","%c_s2dhms%","%c_ts2date%","%c_ts2isodate%","%c_ts2wday%","%c_random%","%c_2hex%","%c_u2ip%","%c_uname%","%c_uage%","%c_ubuild%","%c_ubuildstr%","%c_uload%","%c_utype%","%c_utypestr%","%c_strf%","%c_d2r%","%c_r2d%","%SP%","%CR%","%LF%","%N%","%R%","%e%","%pi%","var","int","str","length"];for(const e of pluginDispKind)commonPlugins=commonPlugins.concat(e);for(const e of pluginDispKind)for(const t of pluginDispCmd){let n=e+t;commonPlugins=commonPlugins.concat(n)}var rEdit,EXTRAWORDS=commonAtoms.concat(commonPlugins,commonKeywords,commonCommands,commonEvents,commonTag,commonNumber,commonMath,commonWarning,taskSpecifics,AnythingElse),confirmR=!0,android=/Android/.test(navigator.userAgent);function initCM(){function e(e){}android&&(confirmR=!!confirm("Do you want to enable colored rules on your Android device?\nThis feature hasn't been fully tested yet and may still have some issues.\nIt is currently expected to work with Chrome, Firefox, and Vivaldi.\nPlease report any problems you encounter.")),confirmR&&(CodeMirror.commands.autocomplete=function(e){e.showHint({hint:CodeMirror.hint.anyword})},(rEdit=CodeMirror.fromTextArea(document.getElementById("rules"),{tabSize:2,indentWithTabs:!1,lineNumbers:!0,autoCloseBrackets:!0,extraKeys:{"Ctrl-Space":"autocomplete",Tab:e=>{"null"===e.getMode().name?e.execCommand("insertTab"):e.somethingSelected()?e.execCommand("indentMore"):e.execCommand("insertSoftTab")},"Shift-Tab":e=>e.execCommand("indentLess")}})).on("change",(function(){rEdit.save()})),android||rEdit.on("inputRead",(function(e,t){var n=e.getCursor(),o=e.getTokenAt(n);/[\w%,.]/.test(t.text)&&"comment"!=o.type&&e.showHint({completeSingle:!1})})),CodeMirror.keyMap.default["Ctrl-F"]=function(e){openFind()},CodeMirror.keyMap.default["Cmd-F"]=function(e){openFind()},CodeMirror.keyMap.default["Ctrl-G"]=e,CodeMirror.keyMap.default["Cmd-G"]=e,CodeMirror.keyMap.default["Shift-Ctrl-G"]=e,CodeMirror.keyMap.default["Shift-Cmd-G"]=e,CodeMirror.keyMap.default["Ctrl-H"]=e,CodeMirror.keyMap.default["Cmd-H"]=e,CodeMirror.keyMap.default["Shift-Ctrl-F"]=e,CodeMirror.keyMap.default["Shift-Cmd-F"]=e,CodeMirror.keyMap.default["Ctrl-Shift-R"]=e,CodeMirror.keyMap.default["Cmd-Shift-R"]=e)}function closeSearchDialog(){const e=document.querySelectorAll(".CodeMirror-dialog");e.length>0&&(e.forEach((e=>e.remove())),document.body.classList.remove("dialog-opened")),rEdit.execCommand("clearSearch")}function removeHighlight(){requestAnimationFrame((()=>{document.querySelectorAll(".search-next-highlight").forEach((e=>e.classList.remove("search-next-highlight")))}))}let findDialogObserver=null;function openFind(){findDialogObserver&&(document.querySelectorAll(".CodeMirror-dialog").forEach((e=>e.remove())),findDialogObserver.disconnect(),findDialogObserver=null),findDialogObserver=new MutationObserver((()=>{document.querySelector(".CodeMirror-dialog")||(removeHighlight(),findDialogObserver.disconnect(),findDialogObserver=null)})),findDialogObserver.observe(document.body,{childList:!0,subtree:!0}),clearSearchNextHighlight(rEdit),rEdit.execCommand("findPersistent"),addFindButtons()}function clearSearchNextHighlight(e){removeHighlight(),e.__searchNextHighlight&&(e.__searchNextHighlight.clear(),e.__searchNextHighlight=null)}function addFindButtons(){document.querySelector(".CodeMirror-selected");const e=document.querySelector(".CodeMirror-dialog");if(!e||e.querySelector(".search-button-group"))return;[{title:"Find Previous",symbol:"▲",action:()=>rEdit.execCommand("findPersistentPrev")},{title:"Find Next",symbol:"▼",action:()=>rEdit.execCommand("findPersistentNext")},{title:"Replace",symbol:"Replace",action:()=>{closeSearchDialog(),rEdit.execCommand("replace"),addFindButtons()}},{title:"Close",symbol:"❌",action:closeSearchDialog},{title:"Help",symbol:"?",action:()=>{alert("Available shortcuts:\n• Ctrl+F / Cmd+F: Open search\n• Enter: Find next\n• Shift+Enter: Find previous\n• Use /re/ syntax for regex search")}}].forEach((({title:t,symbol:n,action:o})=>{const i=document.createElement("span");i.title=t,i.className="help"===t.toLowerCase()?"button help":"button",i.innerHTML=n,i.style.cssText="\n cursor: pointer;\n user-select: none;\n ",i.addEventListener("click",(e=>{e.preventDefault(),o()})),e.appendChild(i)}))}function triggerFormatting(){let e,t,n,o,i;if(confirmR){const r=rEdit.getDoc();e=rEdit.getScrollInfo(),t=r.getCursor(),n=0===t.ch?t.line-1:t.line,o=rEdit.getLine(n)||"",i=rEdit.getValue()}else i=document.getElementById("rules").value;if(i=initalAutocorrection(i),i=formatLogic(i),confirmR){rEdit.setValue(i);const r=n,s=0===t.ch&&o.length>0?o.length:t.ch;rEdit.setCursor({line:r,ch:s}),setTimeout((()=>{rEdit.scrollTo(e.left,e.top),rEdit.focus()}),0),rEdit.save()}else document.getElementById("rules").value=i}function initalAutocorrection(e){for(const t of EXTRAWORDS)if("Do"===t){const t=/(^|\s)(do)(\s*)(\/\/.*)?$/gim;e=e.replace(t,((e,t,n,o,i)=>`${t}Do${o}${i??""}`))}else{const n=new RegExp(`^\\s*\\b${t}\\b`,"gmi");e=e.replace(n,(e=>e.replace(new RegExp(t,"i"),t)))}return e}function formatLogic(e){const t=" ",n=e.split("\n").map((e=>{const t=e.trimStart();return t.startsWith("//")?e:t})),o=[],i=[];let r=!1,s=null,a=[],l=[];function c(e){return e.trim().startsWith("//")}function d(e){return""===e.trim()}function m(e){return e.trim().toLowerCase().startsWith("on")}function u(e){return e.trim().toLowerCase().endsWith("do")}function f(e){return"endon"===e.trim().toLowerCase()}function h(e){return e.trim().toLowerCase().startsWith("if")}function p(e){return"else"===e.trim().toLowerCase()}function g(e){return e.trim().toLowerCase().startsWith("elseif")}function C(e){return"endif"===e.trim().toLowerCase()}let x=0;function S(){a.length>0&&(i.push(`• Missing ${a.length} Endif(s):`),i.push(` - Unclosed If block(s) starting at line(s): ${l.join(", ")}`)),a=[],l=[]}for(let e=0;e0){const e=extractFirstErrorLine(i);if(alert("Errors found:\n"+i.join("\n")),!isNaN(e))if(confirmR)setTimeout((()=>{jumpToLine(e)}),50);else{const t=document.getElementById("rules");setTimeout((()=>{jumpToLineInTextarea(t,e)}),50)}}return o.join("\n")}function jumpToLine(e){const t=Math.max(0,e-1);rEdit.setCursor({line:t,ch:0}),rEdit.focus(),rEdit.scrollIntoView({line:t,ch:0},100)}function extractFirstErrorLine(e){for(const t of e){let e=t.match(/• Line (\d+)/);if(e)return parseInt(e[1]);if(e=t.match(/starting at line (\d+)/),e)return parseInt(e[1]);if(e=t.match(/starting at line\(s\):\s*(\d+)/),e)return parseInt(e[1])}return null}function jumpToLineInTextarea(e,t){const n=e.value.split("\n"),o=Math.max(1,Math.min(t,n.length));let i=0;for(let e=0;e{const e=document.getElementById("rulesselect");if(e){if(confirmR){const t=document.createElement("button");t.type="button",t.id="searchBtn",t.innerHTML="🔎︎",t.style.padding="2px 5px",t.className="button help",e.appendChild(t),t.addEventListener("click",(()=>{void 0!==rEdit&&openFind()}))}const t=document.createElement("button");t.type="button",t.id="formatBtn",t.textContent="Format",t.className="button",e.appendChild(t),t.addEventListener("click",(()=>{triggerFormatting()}))}let t="";if(document.addEventListener("keydown",(function(e){const n=e.key;(["Backspace","Delete","ArrowLeft","ArrowRight","ArrowUp","ArrowDown","Enter","Tab","Escape","Shift","Control","Alt","Meta"].includes(n)||1!==n.length)&&(t="")})),android){var n=!1;rEdit.on("keydown",((e,o)=>{["Enter","Backspace"," "].includes(o.key)&&(t=""),n=!0}));let e="";const i=rEdit.getInputField();function o(o,i=!1){if(!(rEdit.hasFocus()&&rEdit&&o.data&&n))return;n=!1;const r=o.data,s=rEdit.getDoc(),a=s.getCursor(),l=rEdit.getTokenAt(a);if(" "===r)return t="",void(e="");if(!(r===e&&t.length>0))if(/[\w%,.]/.test(r)&&"comment"!==l.type){const n=a.ch<=1?r.slice(-1):r;t+=n,e=t,t.startsWith(String(a.line+1))&&0===a.ch&&(t=t.slice(String(a.line).length));const o={line:a.line,ch:a.ch-t.length+1},l=()=>{s.replaceRange(t,o,a),rEdit.setCursor({line:o.line,ch:o.ch+t.length}),rEdit.showHint({completeSingle:!1})};i?l():setTimeout(l,0)}else t=""}const r=navigator.userAgent.toLowerCase(),s=/firefox/.test(r),a=/chrome/.test(r)&&!s;s?i.addEventListener("beforeinput",(e=>{e.preventDefault(),o(e,!0)})):a&&document.addEventListener("input",(e=>{o(e,!1)})),rEdit.on("endCompletion",(function(){setTimeout((()=>{!function(){const e=document.createElement("input");e.type="text",e.style.position="absolute",e.style.opacity="0",e.style.height="0",e.style.width="0",e.style.border="none",e.style.top="0",e.style.left="-9999",e.style.padding="0",e.style.zIndex="-1",e.style.fontSize="16px",document.body.appendChild(e),e.focus(),setTimeout((()=>{e.remove(),rEdit.focus()}),10)}()}),100)}))}})),function(e){"object"==typeof exports&&"object"==typeof module?e(require("codemirror")):"function"==typeof define&&define.amd?define(["codemirror"],e):e(CodeMirror)}((function(e){"use strict";e.defineMode("espeasy",(function(){var e={};function t(t,n){for(var o=0;oe.toLowerCase()));commonCommands=commonCommands.concat(n);var o=commonEvents.map((e=>e.toLowerCase()));commonEvents=commonEvents.concat(o);var i=commonPlugins.map((e=>e.toLowerCase()));commonPlugins=commonPlugins.concat(i);var r=commonAtoms.map((e=>e.toLowerCase()));commonAtoms=commonAtoms.concat(r);var s=commonKeywords.map((e=>e.toLowerCase()));commonKeywords=commonKeywords.concat(s);var a=commonTag.map((e=>e.toLowerCase()));commonTag=commonTag.concat(a);var l=commonNumber.map((e=>e.toLowerCase()));commonNumber=commonNumber.concat(l);var c=commonMath.map((e=>e.toLowerCase()));commonMath=commonMath.concat(c);var d=AnythingElse.map((e=>e.toLowerCase()));AnythingElse=AnythingElse.concat(d);var m=taskSpecifics.map((e=>e.toLowerCase()));function u(t,n){if(t.eatSpace())return null;t.sol();var o=t.next();if(/\d/.test(o)){if("0"==o)return"x"===t.next()?(t.eatWhile(/\w/),"number"):(t.eatWhile(/\d|\./),"number");if(t.eatWhile(/\d|\./),!t.match("d")&&!t.match("output")&&(t.eol()||/\D/.test(t.peek())))return"number"}if(/\w/.test(o))for(const e of EXTRAWORDS){let n=e.substring(1);(e.includes(":")||e.includes(",")||e.includes("."))&&t.match(n)}if(/\w/.test(o)&&(t.eatWhile(/[\w]/),t.match(".gpio")||t.match(".pulse")||t.match(".frq")||t.match(".pwm")))return"def";if("\\"===o)return t.next(),null;if("("===o||")"===o)return"bracket";if("{"===o||"}"===o||":"===o)return"number";if("/"==o)return/\//.test(t.peek())?(t.skipToEnd(),"comment"):"operator";if("'"==o&&(t.eatWhile(/[^']/),t.match("'")))return"attribute";if("+"===o||"="===o||"<"===o||">"===o||"-"===o||","===o||"*"===o||"!"===o)return"operator";if("%"==o){if(/\d/.test(t.next()))return"number";if(t.eatWhile(/[^\s\%]/),t.match("%"))return"hr"}if("["==o&&(t.eatWhile(/[^\s\]]/),t.eat("]")))return"hr";t.eatWhile(/\w/);var i=t.current();return/\w/.test(o)&&t.match("#")?(t.eatWhile(/[\w.#]/),"events"):"#"===o?(t.eatWhile(/\w/),"number"):e.hasOwnProperty(i)?e[i]:null}function f(e,t){return(t.tokens[0]||u)(e,t)}return taskSpecifics=taskSpecifics.concat(m),t("atom",commonAtoms),t("keyword",commonKeywords),t("builtin",commonCommands),t("events",commonEvents),t("def",commonPlugins),t("tag",commonTag),t("number",commonNumber),t("bracket",commonMath),t("warning",commonWarning),t("hr",AnythingElse),t("comment",taskSpecifics),{startState:function(){return{tokens:[]}},token:function(e,t){return f(e,t)},closeBrackets:"[]{}''\"\"``()",lineComment:"//",fold:"brace"}}))})),function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],mod):e(CodeMirror)}((function(e){var t={pairs:"()[]{}''\"\"",closeBefore:")]}'\":;>",triples:"",explode:"[]{}"},n=e.Pos;function o(e,n){return"pairs"==n&&"string"==typeof e?e:"object"==typeof e&&null!=e[n]?e[n]:t[n]}e.defineOption("autoCloseBrackets",!1,(function(t,n,s){s&&s!=e.Init&&(t.removeKeyMap(i),t.state.closeBrackets=null),n&&(r(o(n,"pairs")),t.state.closeBrackets=n,t.addKeyMap(i))}));var i={Backspace:function(t){var i=a(t);if(!i||t.getOption("disableInput"))return e.Pass;for(var r=o(i,"pairs"),s=t.listSelections(),l=0;l=0;l--){var m=s[l].head;t.replaceRange("",n(m.line,m.ch-1),n(m.line,m.ch+1),"+delete")}},Enter:function(t){var n=a(t),i=n&&o(n,"explode");if(!i||t.getOption("disableInput"))return e.Pass;for(var r=t.listSelections(),s=0;s1&&h.indexOf(i)>=0&&t.getRange(n(M.line,M.ch-2),M)==i+i){if(M.ch>2&&/\bstring/.test(t.getTokenTypeAt(n(M.line,M.ch-2))))return e.Pass;S="addFour"}else if(p){var b=0==M.ch?" ":t.getRange(n(M.line,M.ch-1),M);if(e.isWordChar(P)||b==i||e.isWordChar(b))return e.Pass;S="both"}else{if(!C||!(0===P.length||/\s/.test(P)||f.indexOf(P)>-1))return e.Pass;S="both"}else S=p&&m(t,M)?"both":h.indexOf(i)>=0&&t.getRange(M,n(M.line,M.ch+3))==i+i+i?"skipThree":"skip";if(u){if(u!=S)return e.Pass}else u=S}var T=d%2?s.charAt(d-1):i,v=d%2?i:s.charAt(d+1);t.operation((function(){if("skip"==u)l(t,1);else if("skipThree"==u)l(t,3);else if("surround"==u){for(var e=t.getSelections(),n=0;n0?{line:s.head.line,ch:s.head.ch+t}:{line:s.head.line-1};n.push({anchor:a,head:a})}e.setSelections(n,i)}function c(t){var o=e.cmpPos(t.anchor,t.head)>0;return{anchor:new n(t.anchor.line,t.anchor.ch+(o?-1:1)),head:new n(t.head.line,t.head.ch+(o?1:-1))}}function d(e,t){var o=e.getRange(n(t.line,t.ch-1),n(t.line,t.ch+1));return 2==o.length?o:null}function m(e,t){var o=e.getTokenAt(n(t.line,t.ch+1));return/\bstring/.test(o.type)&&o.start==t.ch&&(0==t.ch||!/\bstring/.test(e.getTokenTypeAt(t)))}r(t.pairs+"`")})); \ No newline at end of file From c39facad5c6d012a74405f6017be1fac6906e2b2 Mon Sep 17 00:00:00 2001 From: Ton Huisman Date: Tue, 4 Aug 2026 22:46:59 +0200 Subject: [PATCH 2/4] [getJsonValue] Restore missing null return, update changelog, small code improvements --- src/src/Helpers/JSON_helper.cpp | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/src/Helpers/JSON_helper.cpp b/src/src/Helpers/JSON_helper.cpp index 357cb39c01..3ca188aadb 100644 --- a/src/src/Helpers/JSON_helper.cpp +++ b/src/src/Helpers/JSON_helper.cpp @@ -1,6 +1,10 @@ #include "../Helpers/JSON_helper.h" /** Changelog: + * 2026-08-04 tonhuisman: Parse JsonObject as name,value pairs, + * Add recursive processing to retrieve the value + * Add asJson parameter to get JSON-compliant values (quoted strings, bool as true/false, + * arrays with [] and objects with {}) * 2026-08-01 tonhuisman: Extracted JSON value parser from HTTPResponseParser by @chromoxdor * Support bool type by returning 1/0 for true/false */ @@ -76,8 +80,10 @@ String getJsonValue(DynamicJsonDocument *root, // Append the value to the CSV string if it exists if (!value.isNull()) { result += getJsonValue(value, asJson); - + } else { + result += F("null"); } + return result; } @@ -96,7 +102,7 @@ String getJsonValue(JsonVariant value, bool asJson) { result += doubleToString(value.as(), nr_decimals, true); } else if (value.is()) { if (asJson) { - result += wrap_String(String(value.as()), '\"'); // JSON quotes are always " + result += wrap_String(value.as(), '\"'); // JSON quotes are always " } else { result += String(value.as()); } @@ -120,7 +126,7 @@ String getJsonValue(JsonVariant value, bool asJson) { result += getJsonValue(element, asJson); // Add a comma unless it's the last element - currentIndex++; + ++currentIndex; if (currentIndex < arraySize) { result += ','; @@ -132,9 +138,9 @@ String getJsonValue(JsonVariant value, bool asJson) { } } else if (value.is()) { // if the value is a JSON Object, iterate over the attributes and return , pair(s), recursive - auto it = value.as().begin(); - int objectSize = value.as().size(); - int currentIndex = 0; + auto it = value.as().begin(); + size_t objectSize = value.as().size(); + size_t currentIndex = 0; while (it != value.as().end()) { if (asJson) { @@ -149,7 +155,7 @@ String getJsonValue(JsonVariant value, bool asJson) { if (asJson) { result += '}'; } - currentIndex++; + ++currentIndex; if (currentIndex < objectSize) { result += ','; From 4e191fc5b5571b2dc554057e770018d87792fa63 Mon Sep 17 00:00:00 2001 From: Ton Huisman Date: Wed, 5 Aug 2026 15:46:52 +0200 Subject: [PATCH 3/4] [Rules] Exclude new features for ESP8266 by default for build-size reasons --- src/_P037_MQTTImport.ino | 33 +++++--- src/src/CustomBuild/define_plugin_sets.h | 19 ++++- src/src/ESPEasyCore/ESPEasyRules.cpp | 93 +++++++++++++++++---- src/src/Helpers/HTTPResponseParser.cpp | 102 +++++++++++++++++++++++ src/src/Helpers/JSON_helper.h | 2 +- src/src/Helpers/StringParser.cpp | 12 ++- src/src/PluginStructs/P037_data_struct.h | 9 +- 7 files changed, 235 insertions(+), 35 deletions(-) diff --git a/src/_P037_MQTTImport.ino b/src/_P037_MQTTImport.ino index d45ddf4c9f..f381df58df 100644 --- a/src/_P037_MQTTImport.ino +++ b/src/_P037_MQTTImport.ino @@ -557,7 +557,18 @@ boolean Plugin_037(uint8_t function, struct EventStruct *event, String& string) if (!jsonAttribute.isEmpty()) { key = jsonAttribute; - Payload = getJsonValue(P037_data->root, key, false); + # if FEATURE_JSON_PARSE + Payload = getJsonValue(P037_data->root, key, false); + # else + + if (key.indexOf('.') > -1) { + String part1 = parseStringKeepCase(key, 1, '.'); + String part2 = parseStringKeepCase(key, 2, '.'); + Payload = P037_data->doc[part1][part2].as(); + } else { + Payload = P037_data->doc[key].as(); + } + # endif // if FEATURE_JSON_PARSE unparsedPayload = Payload; int8_t jIndex = jsonIndex.toInt(); @@ -626,10 +637,10 @@ boolean Plugin_037(uint8_t function, struct EventStruct *event, String& string) success = false; break; } - numericPayload = false; // No, it isn't numeric - doublePayload = NAN; // Invalid value + numericPayload = false; // No, it isn't numeric + doublePayload = NAN; // Invalid value } - UserVar.setFloat(event->TaskIndex, x, doublePayload); // Save the new value + UserVar.setFloat(event->TaskIndex, x, doublePayload); // Save the new value // Generate event for rules processing - proposed by TridentTD @@ -664,9 +675,9 @@ boolean Plugin_037(uint8_t function, struct EventStruct *event, String& string) addEscapeCharacters(tmp); // Add escape characters to avoid problems with rules processing if a JSON message is received String RuleEvent = strformat(F("%s#%s=%s"), - getTaskDeviceName(event->TaskIndex).c_str(), - event->String1.c_str(), - wrapWithQuotesIfContainsParameterSeparatorChar(tmp).c_str()); + getTaskDeviceName(event->TaskIndex).c_str(), + event->String1.c_str(), + wrapWithQuotesIfContainsParameterSeparatorChar(tmp).c_str()); P037_addEventToQueue(event, RuleEvent); } @@ -675,9 +686,9 @@ boolean Plugin_037(uint8_t function, struct EventStruct *event, String& string) if (loglevelActiveFor(LOG_LEVEL_INFO)) { addLog(LOG_LEVEL_INFO, strformat(F("IMPT : [%s#%s] : %s"), - getTaskDeviceName(event->TaskIndex).c_str(), - checkJson ? key.c_str() : getTaskValueName(event->TaskIndex, x).c_str(), - toString(doublePayload, ExtraTaskSettings.TaskDeviceValueDecimals[x]).c_str())); + getTaskDeviceName(event->TaskIndex).c_str(), + checkJson ? key.c_str() : getTaskValueName(event->TaskIndex, x).c_str(), + toString(doublePayload, ExtraTaskSettings.TaskDeviceValueDecimals[x]).c_str())); } # endif // if !defined(P037_LIMIT_BUILD_SIZE) || defined(P037_OVERRIDE) @@ -694,7 +705,7 @@ boolean Plugin_037(uint8_t function, struct EventStruct *event, String& string) } else { String tmp = Payload; addEscapeCharacters(tmp); // Add escape characters to avoid problems with rules processing if a JSON message is received - + RuleEvent += wrapWithQuotesIfContainsParameterSeparatorChar(tmp); } P037_addEventToQueue(event, RuleEvent); diff --git a/src/src/CustomBuild/define_plugin_sets.h b/src/src/CustomBuild/define_plugin_sets.h index 054f6ac4fb..f901eb489d 100644 --- a/src/src/CustomBuild/define_plugin_sets.h +++ b/src/src/CustomBuild/define_plugin_sets.h @@ -4395,13 +4395,26 @@ To create/register a plugin, you have to : //-------------------End of HTTPResponseParser Section---------- #ifndef FEATURE_JSON_PARSE - #if defined(USES_P037) || FEATURE_JSON_EVENT // and other JSON-parsing features - #define FEATURE_JSON_PARSE 1 + #if defined(ESP32) && (defined(USES_P037) || FEATURE_JSON_EVENT) // and other JSON-parsing features + #define FEATURE_JSON_PARSE 1 // By default only on ESP32 #else #define FEATURE_JSON_PARSE 0 #endif // if defined(USES_P037) || FEATURE_JSON_EVENT #endif // ifndef FEATURE_JSON_PARSE +#ifndef FEATURE_EXTENDED_STRING_FUNCTIONS + #ifdef ESP32 + #define FEATURE_EXTENDED_STRING_FUNCTIONS 1 + #endif // ifdef ESP32 + #ifdef ESP8266 + #define FEATURE_EXTENDED_STRING_FUNCTIONS 0 + #endif // ifdef ESP8266 +#endif // ifndef FEATURE_EXTENDED_STRING_FUNCTIONS +#if defined(ESP8266) && FEATURE_JSON_PARSE && !FEATURE_EXTENDED_STRING_FUNCTIONS + #undef FEATURE_JSON_PARSE + #define FEATURE_JSON_PARSE 0 +#endif // if FEATURE_JSON_PARSE && !FEATURE_EXTENDED_STRING_FUNCTIONS + #if !(defined(SOC_DAC_SUPPORTED) && SOC_DAC_SUPPORTED) #ifdef USES_P152 #undef USES_P152 @@ -4422,7 +4435,7 @@ To create/register a plugin, you have to : #if FEATURE_WIFI #ifndef USES_NW001 #define USES_NW001 - #endif + #endif #ifndef USES_NW002 #define USES_NW002 #endif diff --git a/src/src/ESPEasyCore/ESPEasyRules.cpp b/src/src/ESPEasyCore/ESPEasyRules.cpp index 91e9554135..176dac9016 100644 --- a/src/src/ESPEasyCore/ESPEasyRules.cpp +++ b/src/src/ESPEasyCore/ESPEasyRules.cpp @@ -29,10 +29,10 @@ #include #include -#if FEATURE_JSON_PARSE +#if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS #include #include "../Helpers/JSON_helper.h" -#endif // if FEATURE_JSON_PARSE +#endif // if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS #ifdef WEBSERVER_NEW_RULES String EventToFileName(const String& eventName) { @@ -471,12 +471,14 @@ bool parse_math_functions(const String& cmd_s_lower, const String& arg1, const S const char string_commands[] PROGMEM = "substring|indexof|indexof_ci|equals|equals_ci|timetomin|timetosec|strtol|tobin|tohex|ord|urlencode" #if FEATURE_STRING_VARIABLES "|lookup" + #if FEATURE_EXTENDED_STRING_FUNCTIONS "|unescape|escape" "|parse" + #endif // if FEATURE_EXTENDED_STRING_FUNCTIONS #endif // if FEATURE_STRING_VARIABLES - #if FEATURE_JSON_PARSE + #if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS "|json" - #endif // if FEATURE_JSON_PARSE + #endif // if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS ; enum class string_commands_e { substring, @@ -493,16 +495,19 @@ enum class string_commands_e { urlencode, #if FEATURE_STRING_VARIABLES lookup, + #if FEATURE_EXTENDED_STRING_FUNCTIONS unescape, escape, parse, + #endif // if FEATURE_EXTENDED_STRING_FUNCTIONS #endif // if FEATURE_STRING_VARIABLES - #if FEATURE_JSON_PARSE + #if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS json, - #endif // if FEATURE_JSON_PARSE + #endif // if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS }; + #if FEATURE_EXTENDED_STRING_FUNCTIONS void hideEscaped(String &line, bool &mustReplaceEscapedBracket, bool &mustReplaceEscapedCurlyBracket, bool &mustReplaceEscapedColon) { String MaskEscapedBracket; @@ -577,12 +582,13 @@ void restoreMaskedAndEscaped(String &line, bool mustReplaceMaskedChars, bool mus line.replace(MaskEscapedBracket, F("\\:")); } } +#endif // if FEATURE_EXTENDED_STRING_FUNCTIONS void parse_string_commands(String& line) { unsigned int startIndex = 0; int closingIndex; - #if FEATURE_JSON_PARSE + #if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS DynamicJsonDocument*root = nullptr; uint16_t lastJsonMessageLength = 512; @@ -594,14 +600,37 @@ void parse_string_commands(String& line) { root = nullptr; } }; - #endif // if FEATURE_JSON_PARSE + #endif // if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS bool mustReplaceMaskedChars = false; bool mustReplaceEscapedBracket = false; bool mustReplaceEscapedCurlyBracket = false; - bool mustReplaceEscapedColon = false; String MaskEscapedBracket; + + #if FEATURE_EXTENDED_STRING_FUNCTIONS + bool mustReplaceEscapedColon = false; hideEscaped(line, mustReplaceEscapedBracket, mustReplaceEscapedCurlyBracket, mustReplaceEscapedColon); + #else // if FEATURE_EXTENDED_STRING_FUNCTIONS + if (hasEscapedCharacter(line,'(') || hasEscapedCharacter(line,')')) { + // replace the \( and \) with other characters to mask the escaped brackets so we can continue parsing. + // We have to unmask then after we're finished. + MaskEscapedBracket = static_cast(0x11); // ASCII 0x11 = Device control 1 + line.replace(F("\\("), MaskEscapedBracket); + MaskEscapedBracket = static_cast(0x12); // ASCII 0x12 = Device control 2 + line.replace(F("\\)"), MaskEscapedBracket); + mustReplaceEscapedBracket = true; + } + + if (hasEscapedCharacter(line,'{') || hasEscapedCharacter(line,'}')) { + // replace the \{ and \} with other characters to mask the escaped curly brackets so we can continue parsing. + // We have to unmask then after we're finished. + MaskEscapedBracket = static_cast(0x13); // ASCII 0x13 = Device control 3 + line.replace(F("\\{"), MaskEscapedBracket); + MaskEscapedBracket = static_cast(0x14); // ASCII 0x14 = Device control 4 + line.replace(F("\\}"), MaskEscapedBracket); + mustReplaceEscapedCurlyBracket = true; + } + #endif // if FEATURE_EXTENDED_STRING_FUNCTIONS while (get_next_inner_bracket(line, startIndex, closingIndex, '}')) { // Command without opening and closing brackets. @@ -657,6 +686,7 @@ void parse_string_commands(String& line) { replacement = arg3.substring(startpos * endpos, (startpos + 1) * endpos); } break; + #if FEATURE_EXTENDED_STRING_FUNCTIONS case string_commands_e::unescape: case string_commands_e::escape: replacement = parseStringToEndKeepCaseNoTrim(fullCommand, 2, ':'); @@ -673,6 +703,7 @@ void parse_string_commands(String& line) { hideMasked(replacement, mustReplaceMaskedChars); break; + #endif // if FEATURE_EXTENDED_STRING_FUNCTIONS #endif // if FEATURE_STRING_VARIABLES case string_commands_e::indexof: case string_commands_e::indexof_ci: @@ -770,7 +801,7 @@ void parse_string_commands(String& line) { replacement = URLEncode(arg1); } break; - #if FEATURE_STRING_VARIABLES + #if FEATURE_STRING_VARIABLES && FEATURE_EXTENDED_STRING_FUNCTIONS case string_commands_e::parse: // parse: a function to retrieve nth param (1-based), with optional separator, default: comma // {parse::[]:} @@ -786,8 +817,8 @@ void parse_string_commands(String& line) { hideMasked(replacement, mustReplaceMaskedChars); // re-apply } break; - #endif // if FEATURE_STRING_VARIABLES - #if FEATURE_JSON_PARSE + #endif // if FEATURE_STRING_VARIABLES && FEATURE_EXTENDED_STRING_FUNCTIONS + #if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS case string_commands_e::json: // json: get a value from a (valid) JSON string // {json::[]:} @@ -838,7 +869,7 @@ void parse_string_commands(String& line) { } break; } - #endif // if FEATURE_JSON_PARSE + #endif // if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS } } } @@ -850,7 +881,12 @@ void parse_string_commands(String& line) { // See: https://github.com/letscontrolit/ESPEasy/issues/2932#issuecomment-596139096 replacement = line.substring(startIndex, closingIndex + 1); mustReplaceMaskedChars = true; + #if FEATURE_EXTENDED_STRING_FUNCTIONS hideMasked(replacement, mustReplaceMaskedChars); // apply + #else // if FEATURE_EXTENDED_STRING_FUNCTIONS + line.replace('{', static_cast(0x02)); + line.replace('}', static_cast(0x03)); + #endif // if FEATURE_EXTENDED_STRING_FUNCTIONS } // Replace the full command including opening and closing brackets. @@ -864,14 +900,41 @@ void parse_string_commands(String& line) { } } - #if FEATURE_JSON_PARSE + #if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS if (nullptr != root) { cleanupJSON(); } - #endif // if FEATURE_JSON_PARSE + #endif // if FEATURE_JSON_PARSE && FEATURE_EXTENDED_STRING_FUNCTIONS + #if FEATURE_EXTENDED_STRING_FUNCTIONS restoreMaskedAndEscaped(line, mustReplaceMaskedChars, mustReplaceEscapedBracket, mustReplaceEscapedCurlyBracket, mustReplaceEscapedColon); + #else // if FEATURE_EXTENDED_STRING_FUNCTIONS + if (mustReplaceMaskedChars) { + // We now have to check if we did mask some parts and unmask them. + // Let's hope we don't mess up any Unicode here. + line.replace(static_cast(0x02), '{'); + line.replace(static_cast(0x03), '}'); + } + + if (mustReplaceEscapedBracket) { + // We now have to check if we did mask some escaped bracket and unmask them. + // Let's hope we don't mess up any Unicode here. + MaskEscapedBracket = static_cast(0x11); // ASCII 0x11 = Device control 1 + line.replace(MaskEscapedBracket, F("\\(")); + MaskEscapedBracket = static_cast(0x12); // ASCII 0x12 = Device control 2 + line.replace(MaskEscapedBracket, F("\\)")); + } + + if (mustReplaceEscapedCurlyBracket) { + // We now have to check if we did mask some escaped curly bracket and unmask them. + // Let's hope we don't mess up any Unicode here. + MaskEscapedBracket = static_cast(0x13); // ASCII 0x13 = Device control 3 + line.replace(MaskEscapedBracket, F("\\{")); + MaskEscapedBracket = static_cast(0x14); // ASCII 0x14 = Device control 4 + line.replace(MaskEscapedBracket, F("\\}")); + } + #endif // if FEATURE_EXTENDED_STRING_FUNCTIONS } void substitute_eventvalue(String& line, const String& event) { diff --git a/src/src/Helpers/HTTPResponseParser.cpp b/src/src/Helpers/HTTPResponseParser.cpp index 9d57f6d8af..30cc93215f 100644 --- a/src/src/Helpers/HTTPResponseParser.cpp +++ b/src/src/Helpers/HTTPResponseParser.cpp @@ -322,6 +322,7 @@ void readAndProcessJsonKeys(DynamicJsonDocument *root, int numJson) { } } + # if FEATURE_JSON_PARSE const String val = getJsonValue(root, key, false); // Return arrays and objects as csv, _not_ JSON formatted if (!val.isEmpty()) { @@ -329,6 +330,107 @@ void readAndProcessJsonKeys(DynamicJsonDocument *root, int numJson) { csvOutput += val; csvOutput += ','; } + # else // if FEATURE_JSON_PARSE + // Process the key and navigate the JSON + JsonVariant value = *root; + size_t start = 0, end; + + while ((end = key.indexOf('.', start)) != (unsigned int)-1) { + String part = key.substring(start, end); + start = end + 1; + + // Look for an array e.g., "result[0]" → object "result", index 0 + int bracketStart = part.indexOf('['); + + if (bracketStart != -1) { + String objectName = part.substring(0, bracketStart); + String indexStr = part.substring(bracketStart + 1, part.indexOf(']', bracketStart)); + + if (objectName.length() > 0) { + value = value[objectName]; // Access the object + } + + if (value.is()) { + int index = indexStr.toInt(); + value = value[index]; + } else { + value = value[indexStr]; // fallback if not actually array + } + } else { + // Normal object access without array + value = value[part]; + } + + if (value.isNull()) { + break; // Key path is invalid + } + } + + if (!value.isNull()) { + successfullyProcessedCount++; + String lastPart = key.substring(start); + int bracketStart = lastPart.indexOf('['); + + if (bracketStart != -1) { + String objectName = lastPart.substring(0, bracketStart); + String indexStr = lastPart.substring(bracketStart + 1, lastPart.indexOf(']', bracketStart)); + + if (objectName.length() > 0) { + value = value[objectName]; + } + + if (value.is()) { + value = value[indexStr.toInt()]; + } else { + value = value[indexStr]; + } + } else { + value = value[lastPart]; + } + } + + // Append the value to the CSV string if it exists + if (!value.isNull()) { + if (value.is()) { + csvOutput += String(value.as()); + } else if (value.is()) { + csvOutput += doubleToString(value.as(), nr_decimals, true); + } else if (value.is()) { + csvOutput += String(value.as()); + } else if (value.is()) { + // If the value is an array, iterate over its elements + JsonArray array = value.as(); + size_t arraySize = array.size(); // Get the total number of elements in the array + size_t currentIndex = 0; // Track the current index + + for (JsonVariant element : array) { + if (element.is()) { + csvOutput += String(element.as()); + } else if (element.is()) { + csvOutput += doubleToString(element.as(), nr_decimals, true); + } else if (element.is()) { + csvOutput += String(element.as()); + } else { + csvOutput += F("unknown"); + } + + // Add a comma unless it's the last element + currentIndex++; + + if (currentIndex < arraySize) { + csvOutput += ','; + } + } + } else { + csvOutput += F("unknown"); + } + } else { + csvOutput += F("null"); // Indicate missing value + csvOutput += val; + csvOutput += ','; + } + csvOutput += ','; + # endif // if FEATURE_JSON_PARSE } keyFile.close(); diff --git a/src/src/Helpers/JSON_helper.h b/src/src/Helpers/JSON_helper.h index 0566a8a662..a6b8e6520b 100644 --- a/src/src/Helpers/JSON_helper.h +++ b/src/src/Helpers/JSON_helper.h @@ -1,6 +1,6 @@ #pragma once -/** See */ +/** See JSON_helper.cpp for changelog */ #if FEATURE_JSON_PARSE # include # include "../Helpers/StringConverter_Numerical.h" diff --git a/src/src/Helpers/StringParser.cpp b/src/src/Helpers/StringParser.cpp index b95982fc9e..533870176c 100644 --- a/src/src/Helpers/StringParser.cpp +++ b/src/src/Helpers/StringParser.cpp @@ -37,7 +37,11 @@ bool hasEscapedCharacter(String& str, const char EscapeChar) void stripEscapeCharacters(String& str) { - const char braces[] = { '%', '[', ']', '{', '}', '(', ')', ':' }; + const char braces[] = { '%', '[', ']', '{', '}', '(', ')' + #if FEATURE_EXTENDED_STRING_FUNCTIONS + , ':' + #endif // if FEATURE_EXTENDED_STRING_FUNCTIONS + }; constexpr uint8_t nrbraces = NR_ELEMENTS(braces); for (uint8_t i = 0; i < nrbraces; ++i) { @@ -48,7 +52,11 @@ void stripEscapeCharacters(String& str) void addEscapeCharacters(String& str) { - const char braces[] = { '%', '[', ']', '{', '}', '(', ')', ':' }; + const char braces[] = { '%', '[', ']', '{', '}', '(', ')' + #if FEATURE_EXTENDED_STRING_FUNCTIONS + , ':' + #endif // if FEATURE_EXTENDED_STRING_FUNCTIONS + }; constexpr uint8_t nrbraces = NR_ELEMENTS(braces); for (uint8_t i = 0; i < nrbraces; ++i) { diff --git a/src/src/PluginStructs/P037_data_struct.h b/src/src/PluginStructs/P037_data_struct.h index f6b70070a8..5dc10c6d84 100644 --- a/src/src/PluginStructs/P037_data_struct.h +++ b/src/src/PluginStructs/P037_data_struct.h @@ -10,9 +10,6 @@ # include "../Helpers/Misc.h" # include "../Helpers/StringParser.h" # include "../Globals/MQTT.h" -# include "../Helpers/JSON_helper.h" - -# include // # define PLUGIN_037_DEBUG // Additional debugging information @@ -85,6 +82,12 @@ # endif // ifndef P037_FILTER_PER_TOPIC # endif // if P037_FILTER_SUPPORT && P037_MAX_FILTERS == VARS_PER_TASK +#if P037_JSON_SUPPORT +# include "../Helpers/JSON_helper.h" + +# include +#endif // if P037_JSON_SUPPORT + # define P037_ARRAY_SIZE (P037_MAX_MAPPINGS + P037_MAX_FILTERS) // Storage layout definitions # define P037_START_MAPPINGS 0 # define P037_END_MAPPINGS (P037_MAX_MAPPINGS - 1) From dd07ba69288146b8cf0d5625fcd71c522956ff0c Mon Sep 17 00:00:00 2001 From: Ton Huisman Date: Wed, 5 Aug 2026 15:55:47 +0200 Subject: [PATCH 4/4] [Rules] Update Custom-sample.h --- src/Custom-sample.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Custom-sample.h b/src/Custom-sample.h index 97e6daad1f..451b79a30b 100644 --- a/src/Custom-sample.h +++ b/src/Custom-sample.h @@ -30,6 +30,8 @@ #define FEATURE_JSON_EVENT 0 // Generates an event with the values of a JSON repsonse of an HTTP call. Keys are stored in json.keys one key per line (e.g.: Body.Data.DAY_ENERGY.Values.1) // #define FEATURE_SD 1 // Enable SD card support // #define FEATURE_DOWNLOAD 1 // Enable downloading a file from an url +// #define FEATURE_JSON_PARSE 1 // Enable extended JSON parsing, disabled by default on ESP8266 +// #define FEATURE_EXTENDED_STRING_FUNCTIONS 1 // Enable extra Rules string functions. Also required to enable FEATURE_JSON_PARSE on ESP8266, as it's disabled by default for ESP8266 #ifdef BUILD_GIT # undef BUILD_GIT