diff --git a/examples/IRMQTTServer/IRMQTTServer.h b/examples/IRMQTTServer/IRMQTTServer.h index 9d9e18a3d..ef6cd5bcf 100644 --- a/examples/IRMQTTServer/IRMQTTServer.h +++ b/examples/IRMQTTServer/IRMQTTServer.h @@ -253,6 +253,9 @@ const uint16_t kMinUnknownSize = 2 * 10; #define KEY_JSON "json" #define KEY_RESEND "resend" #define KEY_VCC "vcc" +#define KEY_COMMAND "command" +#define KEY_SENSORTEMP "sensortemp" +#define KEY_IFEEL "ifeel" // HTML arguments we will parse for IR code information. #define KEY_TYPE "type" // KEY_PROTOCOL is also checked too. @@ -260,11 +263,17 @@ const uint16_t kMinUnknownSize = 2 * 10; #define KEY_BITS "bits" #define KEY_REPEAT "repeats" #define KEY_CHANNEL "channel" // Which IR TX channel to send on. +#define KEY_SENSORTEMP_DISABLED "sensortemp_disabled" // For HTML form only, + // not sent via MQTT + // nor JSON // GPIO html/config keys #define KEY_TX_GPIO "tx" #define KEY_RX_GPIO "rx" +// Miscellaneous constants +#define TOGGLE_JS_FN_NAME "ToggleInputBasedOnCheckbox" + // Text for Last Will & Testament status messages. const char* const kLwtOnline = "Online"; const char* const kLwtOffline = "Offline"; @@ -290,7 +299,7 @@ const uint16_t kJsonAcStateMaxSize = 1024; // Bytes // ----------------- End of User Configuration Section ------------------------- // Constants -#define _MY_VERSION_ "v1.7.2" +#define _MY_VERSION_ "v1.8.0" const uint8_t kRebootTime = 15; // Seconds const uint8_t kQuickDisplayTime = 2; // Seconds @@ -358,7 +367,8 @@ static const char kClimateTopics[] PROGMEM = "(" KEY_PROTOCOL "|" KEY_MODEL "|" KEY_POWER "|" KEY_MODE "|" KEY_TEMP "|" KEY_FANSPEED "|" KEY_SWINGV "|" KEY_SWINGH "|" KEY_QUIET "|" KEY_TURBO "|" KEY_LIGHT "|" KEY_BEEP "|" KEY_ECONO "|" KEY_SLEEP "|" - KEY_FILTER "|" KEY_CLEAN "|" KEY_CELSIUS "|" KEY_RESEND + KEY_FILTER "|" KEY_CLEAN "|" KEY_CELSIUS "|" KEY_RESEND "|" KEY_COMMAND "|" + "|" KEY_SENSORTEMP "|" KEY_IFEEL #if MQTT_CLIMATE_JSON "|" KEY_JSON #endif // MQTT_CLIMATE_JSON @@ -367,6 +377,7 @@ static const char* const kMqttTopics[] = { KEY_PROTOCOL, KEY_MODEL, KEY_POWER, KEY_MODE, KEY_TEMP, KEY_FANSPEED, KEY_SWINGV, KEY_SWINGH, KEY_QUIET, KEY_TURBO, KEY_LIGHT, KEY_BEEP, KEY_ECONO, KEY_SLEEP, KEY_FILTER, KEY_CLEAN, KEY_CELSIUS, KEY_RESEND, + KEY_COMMAND, KEY_SENSORTEMP, KEY_IFEEL KEY_JSON}; // KEY_JSON needs to be the last one. @@ -410,7 +421,8 @@ int8_t getDefaultTxGpio(void); String genStatTopic(const uint16_t channel = 0); String listOfTxGpios(void); bool hasUnsafeHTMLChars(String input); -String htmlHeader(const String title, const String h1_text = ""); +String htmlHeader(const String title, const String h1_text = "", + const String headScriptsJS = ""); String htmlEnd(void); String htmlButton(const String url, const String button, const String text = ""); @@ -418,9 +430,13 @@ String htmlMenu(void); void handleRoot(void); String addJsReloadUrl(const String url, const uint16_t timeout_s, const bool notify); +String getJsToggleCheckbox(const String functionName = TOGGLE_JS_FN_NAME); void handleExamples(void); String htmlOptionItem(const String value, const String text, bool selected); String htmlSelectBool(const String name, const bool def); +String htmlDisableCheckbox(const String name, const String targetControlId, + const bool checked, + const String toggleJsFnName = TOGGLE_JS_FN_NAME); String htmlSelectClimateProtocol(const String name, const decode_type_t def); String htmlSelectAcStateProtocol(const String name, const decode_type_t def, const bool simple); diff --git a/examples/IRMQTTServer/IRMQTTServer.ino b/examples/IRMQTTServer/IRMQTTServer.ino index ca0fa019c..57e94676d 100644 --- a/examples/IRMQTTServer/IRMQTTServer.ino +++ b/examples/IRMQTTServer/IRMQTTServer.ino @@ -947,7 +947,7 @@ void handleExamples(void) { #endif // EXAMPLES_ENABLE String htmlSelectBool(const String name, const bool def) { - String html = F(""); for (uint16_t i = 0; i < 2; i++) html += htmlOptionItem(IRac::boolToString(i), IRac::boolToString(i), i == def); @@ -955,8 +955,20 @@ String htmlSelectBool(const String name, const bool def) { return html; } +String htmlDisableCheckbox(const String name, const String targetControlId, + const bool checked, const String toggleJsFnName) { + String html = String(F("Disabled"); + return html; +} + String htmlSelectClimateProtocol(const String name, const decode_type_t def) { - String html = F(""); for (uint8_t i = 1; i <= decode_type_t::kLastDecodeType; i++) { if (IRac::isProtocolSupported((decode_type_t)i)) { html += htmlOptionItem(String(i), typeToString((decode_type_t)i), @@ -968,7 +980,7 @@ String htmlSelectClimateProtocol(const String name, const decode_type_t def) { } String htmlSelectModel(const String name, const int16_t def) { - String html = F(""); for (int16_t i = -1; i <= 6; i++) { String num = String(i); String text; @@ -984,9 +996,21 @@ String htmlSelectModel(const String name, const int16_t def) { return html; } +String htmlSelectCommandType(const String name, const stdAc::ac_command_t def) { + String html = String(F(""); + return html; +} + String htmlSelectUint(const String name, const uint16_t max, const uint16_t def) { - String html = F(""); for (uint16_t i = 0; i < max; i++) { String num = String(i); html += htmlOptionItem(num, num, i == def); @@ -997,7 +1021,7 @@ String htmlSelectUint(const String name, const uint16_t max, String htmlSelectGpio(const String name, const int16_t def, const int8_t list[], const int16_t length) { - String html = F(": "); for (int16_t i = 0; i < length; i++) { String num = String(list[i]); html += htmlOptionItem(num, list[i] == kGpioUnused ? F("Unused") : num, @@ -1009,7 +1033,7 @@ String htmlSelectGpio(const String name, const int16_t def, } String htmlSelectMode(const String name, const stdAc::opmode_t def) { - String html = F(""); for (int8_t i = -1; i <= (int8_t)stdAc::opmode_t::kLastOpmodeEnum; i++) { String mode = IRac::opmodeToString((stdAc::opmode_t)i); html += htmlOptionItem(mode, mode, (stdAc::opmode_t)i == def); @@ -1019,7 +1043,7 @@ String htmlSelectMode(const String name, const stdAc::opmode_t def) { } String htmlSelectFanspeed(const String name, const stdAc::fanspeed_t def) { - String html = F(""); for (int8_t i = 0; i <= (int8_t)stdAc::fanspeed_t::kLastFanspeedEnum; i++) { String speed = IRac::fanspeedToString((stdAc::fanspeed_t)i); html += htmlOptionItem(speed, speed, (stdAc::fanspeed_t)i == def); @@ -1029,7 +1053,7 @@ String htmlSelectFanspeed(const String name, const stdAc::fanspeed_t def) { } String htmlSelectSwingv(const String name, const stdAc::swingv_t def) { - String html = F(""); for (int8_t i = -1; i <= (int8_t)stdAc::swingv_t::kLastSwingvEnum; i++) { String swing = IRac::swingvToString((stdAc::swingv_t)i); html += htmlOptionItem(swing, swing, (stdAc::swingv_t)i == def); @@ -1039,7 +1063,7 @@ String htmlSelectSwingv(const String name, const stdAc::swingv_t def) { } String htmlSelectSwingh(const String name, const stdAc::swingh_t def) { - String html = F(""); for (int8_t i = -1; i <= (int8_t)stdAc::swingh_t::kLastSwinghEnum; i++) { String swing = IRac::swinghToString((stdAc::swingh_t)i); html += htmlOptionItem(swing, swing, (stdAc::swingh_t)i == def); @@ -1048,14 +1072,20 @@ String htmlSelectSwingh(const String name, const stdAc::swingh_t def) { return html; } -String htmlHeader(const String title, const String h1_text) { +String htmlHeader(const String title, const String h1_text, + const String headScriptsJS) { String html = F(""); html += title; html += F("" "" - "

"); + "initial-scale=1.0,minimum-scale=1.0,maximum-scale=5.0\">"); + if (headScriptsJS.length()) { + html += F("\n"); + } + html += F("

"); if (h1_text.length()) html += h1_text; else @@ -1078,15 +1108,26 @@ String htmlButton(const String url, const String button, const String text) { return html; } +String getJsToggleCheckbox(const String functionName) { + const String javascript = + String(F(" function ")) + functionName + F("(checkbox, targetInputId) {\n" + " var targetControl = document.getElementById(targetInputId);\n" + " targetControl.disabled = checkbox.checked;\n" + " if (!targetControl.disabled) { targetControl.focus(); }\n" + " }\n"); + return javascript; +} + // Admin web page void handleAirCon(void) { - String html = htmlHeader(F("Air Conditioner Control")); + String html = htmlHeader(F("Air Conditioner Control"), "", + getJsToggleCheckbox()); html += htmlMenu(); if (kNrOfIrTxGpios > 1) { - html += F("
" "" - "" @@ -1095,11 +1136,12 @@ void handleAirCon(void) { "
"); } if (climate[chan] != NULL) { - html += F("

Current Settings

" + bool noSensorTemp = (climate[chan]->next.sensorTemperature == kNoTempValue); + html += String(F("

Current Settings

" "" - "") + + "") + F("
Climate #") + + "
Climate #")) + htmlSelectUint(KEY_CHANNEL, kNrOfIrTxGpios, chan) + F("" "
" "" + "" "" @@ -1126,6 +1171,16 @@ void handleAirCon(void) { (!climate[chan]->next.celsius ? " selected='selected'" : "") + F(">F" "" + "" "" @@ -1138,6 +1193,9 @@ void handleAirCon(void) { "" + "" "" @@ -1286,8 +1344,8 @@ void handleInfo(void) { String html = htmlHeader(F("IR MQTT server info")); html += htmlMenu(); html += - F("

General

" - "

Hostname: ") + String(Hostname) + F("
" + String(F("

General

" + "

Hostname: ")) + String(Hostname) + F("
" "IP address: ") + WiFi.localIP().toString() + F("
" "MAC address: ") + WiFi.macAddress() + F("
" "Booted: ") + timeSince(1) + F("
") + @@ -2643,8 +2701,8 @@ void mqttCallback(char* topic, byte* payload, unsigned int length) { void sendMQTTDiscovery(const char *topic) { if (mqtt_client.publish( topic, String( - F("{" - "\"~\":\"") + MqttClimate + F("\"," + String(F("{" + "\"~\":\"")) + MqttClimate + F("\"," "\"name\":\"") + MqttHAName + F("\"," #if (!MQTT_CLIMATE_HA_MODE) // Typically we don't need or use the power command topic if we are using @@ -2720,8 +2778,8 @@ void loop(void) { boot = false; } else { mqttLog(String( - F("IRMQTTServer just (re)connected to MQTT. " - "Lost connection about ") + String(F("IRMQTTServer just (re)connected to MQTT. " + "Lost connection about ")) + timeSince(lastConnectedTime)).c_str()); } lastConnectedTime = now; @@ -2976,6 +3034,7 @@ void sendJsonState(const stdAc::state_t state, const String topic, DynamicJsonDocument json(kJsonAcStateMaxSize); json[KEY_PROTOCOL] = typeToString(state.protocol); json[KEY_MODEL] = state.model; + json[KEY_COMMAND] = IRac::commandToString(state.command); json[KEY_POWER] = IRac::boolToString(state.power); json[KEY_MODE] = IRac::opmodeToString(state.mode, ha_mode); // Home Assistant wants mode to be off if power is also off & vice-versa. @@ -2985,10 +3044,12 @@ void sendJsonState(const stdAc::state_t state, const String topic, } json[KEY_CELSIUS] = IRac::boolToString(state.celsius); json[KEY_TEMP] = state.degrees; + json[KEY_SENSORTEMP] = state.sensorTemperature; json[KEY_FANSPEED] = IRac::fanspeedToString(state.fanspeed); json[KEY_SWINGV] = IRac::swingvToString(state.swingv); json[KEY_SWINGH] = IRac::swinghToString(state.swingh); json[KEY_QUIET] = IRac::boolToString(state.quiet); + json[KEY_IFEEL] = IRac::boolToString(state.iFeel); json[KEY_TURBO] = IRac::boolToString(state.turbo); json[KEY_ECONO] = IRac::boolToString(state.econo); json[KEY_LIGHT] = IRac::boolToString(state.light); @@ -3026,6 +3087,10 @@ stdAc::state_t jsonToState(const stdAc::state_t current, const char *str) { result.model = IRac::strToModel(json[KEY_MODEL].as()); else if (validJsonInt(json, KEY_MODEL)) result.model = json[KEY_MODEL]; + if (validJsonStr(json, KEY_COMMAND)) + result.command = IRac::strToCommand(json[KEY_COMMAND].as()); + else if (validJsonInt(json, KEY_COMMAND)) + result.command = json[KEY_COMMAND]; if (validJsonStr(json, KEY_MODE)) result.mode = IRac::strToOpmode(json[KEY_MODE]); if (validJsonStr(json, KEY_FANSPEED)) @@ -3036,10 +3101,14 @@ stdAc::state_t jsonToState(const stdAc::state_t current, const char *str) { result.swingh = IRac::strToSwingH(json[KEY_SWINGH]); if (json.containsKey(KEY_TEMP)) result.degrees = json[KEY_TEMP]; + if (json.containsKey(KEY_SENSORTEMP)) + result.sensorTemperature = json[KEY_SENSORTEMP]; if (validJsonInt(json, KEY_SLEEP)) result.sleep = json[KEY_SLEEP]; if (validJsonStr(json, KEY_POWER)) result.power = IRac::strToBool(json[KEY_POWER]); + if (validJsonStr(json, KEY_IFEEL)) + result.iFeel = IRac::strToBool(json[KEY_IFEEL]); if (validJsonStr(json, KEY_QUIET)) result.quiet = IRac::strToBool(json[KEY_QUIET]); if (validJsonStr(json, KEY_TURBO)) @@ -3071,6 +3140,8 @@ void updateClimate(stdAc::state_t *state, const String str, state->protocol = strToDecodeType(payload.c_str()); } else if (str.equals(prefix + F(KEY_MODEL))) { state->model = IRac::strToModel(payload.c_str()); + } else if (str.equals(prefix + F(KEY_COMMAND))) { + state->command = IRac::strToCommandType(payload.c_str()); } else if (str.equals(prefix + F(KEY_POWER))) { state->power = IRac::strToBool(payload.c_str()); #if MQTT_CLIMATE_HA_MODE @@ -3089,12 +3160,24 @@ void updateClimate(stdAc::state_t *state, const String str, #endif // MQTT_CLIMATE_HA_MODE } else if (str.equals(prefix + F(KEY_TEMP))) { state->degrees = payload.toFloat(); + } else if (str.equals(prefix + F(KEY_SENSORTEMP))) { + state->sensorTemperature = payload.toFloat(); + } else if (str.equals(prefix + F(KEY_SENSORTEMP_DISABLED))) { + // The "disabled" html form field appears after the actual sensorTemp field + // and the spec guarantees the form POST field order preserves body order + // => this will always execute after KEY_SENSORTEMP has been parsed already + if (IRac::strToBool(payload.c_str())) { + // UI control was disabled, ignore the value + state->sensorTemperature = kNoTempValue; + } } else if (str.equals(prefix + F(KEY_FANSPEED))) { state->fanspeed = IRac::strToFanspeed(payload.c_str()); } else if (str.equals(prefix + F(KEY_SWINGV))) { state->swingv = IRac::strToSwingV(payload.c_str()); } else if (str.equals(prefix + F(KEY_SWINGH))) { state->swingh = IRac::strToSwingH(payload.c_str()); + } else if (str.equals(prefix + F(KEY_IFEEL))) { + state->iFeel = IRac::strToBool(payload.c_str()); } else if (str.equals(prefix + F(KEY_QUIET))) { state->quiet = IRac::strToBool(payload.c_str()); } else if (str.equals(prefix + F(KEY_TURBO))) { @@ -3132,6 +3215,11 @@ bool sendClimate(const String topic_prefix, const bool retain, diff = true; success &= sendInt(topic_prefix + KEY_MODEL, next.model, retain); } + if (prev.command != next.command || forceMQTT) { + String command_str = IRac::commandTypeToString(next.command); + diff = true; + success &= sendString(topic_prefix + KEY_COMMAND, command_str, retain); + } #ifdef MQTT_CLIMATE_HA_MODE String mode_str = IRac::opmodeToString(next.mode, MQTT_CLIMATE_HA_MODE); #else // MQTT_CLIMATE_HA_MODE @@ -3165,6 +3253,11 @@ bool sendClimate(const String topic_prefix, const bool retain, diff = true; success &= sendBool(topic_prefix + KEY_CELSIUS, next.celsius, retain); } + if (prev.sensorTemperature != next.sensorTemperature || forceMQTT) { + diff = true; + success &= sendFloat(topic_prefix + KEY_SENSORTEMP, + next.sensorTemperature, retain); + } if (prev.fanspeed != next.fanspeed || forceMQTT) { diff = true; success &= sendString(topic_prefix + KEY_FANSPEED, @@ -3180,6 +3273,10 @@ bool sendClimate(const String topic_prefix, const bool retain, success &= sendString(topic_prefix + KEY_SWINGH, IRac::swinghToString(next.swingh), retain); } + if (prev.iFeel != next.iFeel || forceMQTT) { + diff = true; + success &= sendBool(topic_prefix + KEY_IFEEL, next.iFeel, retain); + } if (prev.quiet != next.quiet || forceMQTT) { diff = true; success &= sendBool(topic_prefix + KEY_QUIET, next.quiet, retain);

" D_STR_PROTOCOL "") + htmlSelectClimateProtocol(KEY_PROTOCOL, @@ -1108,6 +1150,9 @@ void handleAirCon(void) { "
" D_STR_MODEL "") + htmlSelectModel(KEY_MODEL, climate[chan]->next.model) + F("
" D_STR_COMMAND "") + + htmlSelectCommandType(KEY_COMMAND, climate[chan]->next.command) + + F("
" D_STR_POWER "") + htmlSelectBool(KEY_POWER, climate[chan]->next.power) + F("
" D_STR_SENSORTEMP "" + "") + + htmlDisableCheckbox(KEY_SENSORTEMP_DISABLED, KEY_SENSORTEMP, + noSensorTemp) + + F("
" D_STR_FAN "") + htmlSelectFanspeed(KEY_FANSPEED, climate[chan]->next.fanspeed) + F("
" D_STR_QUIET "") + htmlSelectBool(KEY_QUIET, climate[chan]->next.quiet) + F("
" D_STR_IFEEL "") + + htmlSelectBool(KEY_IFEEL, climate[chan]->next.iFeel) + + F("
" D_STR_TURBO "") + htmlSelectBool(KEY_TURBO, climate[chan]->next.turbo) + F("