diff --git a/HeishaMon/HeishaMon.ino b/HeishaMon/HeishaMon.ino index 8af40fa0..0a6bcd32 100644 --- a/HeishaMon/HeishaMon.ino +++ b/HeishaMon/HeishaMon.ino @@ -526,6 +526,7 @@ void mqtt_reconnect() if (mqttReconnects == 1) { //only resend all data on first connect to mqtt so a data bomb like and bad mqtt server will not cause a reconnect bomb everytime if (heishamonSettings.use_1wire) resetlastalldatatime_dallas(); //resend all 1wire values to mqtt resetlastalldatatime(); //resend all heatpump values to mqtt + publishGPIOStates(mqtt_client, heishamonSettings.gpioSettings, heishamonSettings.mqtt_topic_base, true, heishamonSettings.opentherm, heishamonSettings.listenonly); //publish all GPIO states on first connect } //use this to receive valid heishamon raw data from other heishamon to debug this OT code //#define RAWDEBUG @@ -1027,7 +1028,7 @@ void mqtt_callback(char* topic, byte* payload, unsigned int length) { mqttOTCallback(topic_otcommand, msg); } else if (strncmp(topic_command, mqtt_topic_gpio, strlen(mqtt_topic_gpio)) == 0) { char* topic_gpiocommand = topic_command + strlen(mqtt_topic_gpio) + 1; //strip the gpio subtopic from the topic - mqttGPIOCallback(topic_gpiocommand, msg); + mqttGPIOCallback(topic_gpiocommand, msg, heishamonSettings.gpioSettings, heishamonSettings.opentherm, heishamonSettings.listenonly); } else if (strncmp(topic_command, mqtt_topic_1wire, strlen(mqtt_topic_1wire)) == 0) { //this is a 1wire address topic, restore its retained value at boot char* topic_1wire_address = topic_command + strlen(mqtt_topic_1wire) + 1; //strip the "1wire/" from the topic to get the sensor address if ((strchr(topic_1wire_address, '/') == NULL) && (length > 0)) { //only handle the address topic itself (not .../alias) and skip empty (cleared) retained messages @@ -1110,6 +1111,8 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) { client->route = 81; //for Apple devices } else if (strcmp_P((char *)dat, PSTR("/factoryreset")) == 0) { client->route = 90; + } else if (strcmp_P((char *)dat, PSTR("/gpio")) == 0) { + client->route = 191; } else if (strcmp_P((char *)dat, PSTR("/command")) == 0) { if ((client->userdata = malloc(1)) == NULL) { loggingSerial.printf(PSTR("Out of memory %s:#%d\n"), __FUNCTION__, __LINE__); @@ -1246,6 +1249,34 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) { case 110: { return cacheSettings(client, args); } break; + case 191: { + // GPIO web API: args "pin" (physical GPIO pin number) and "value" (0/1/on/off/true/false) + // Store the physical pin number in userdata between arg callbacks + if (strcmp_P((char *)args->name, PSTR("pin")) == 0) { + char pin_str[8]; + snprintf(pin_str, sizeof(pin_str), "%.*s", args->len, args->value); + int phys_pin = atoi(pin_str); + client->userdata = (void*)(intptr_t)phys_pin; + } else if (strcmp_P((char *)args->name, PSTR("value")) == 0) { + int phys_pin = (int)(intptr_t)client->userdata; + char val_str[8]; + snprintf(val_str, sizeof(val_str), "%.*s", args->len, args->value); + for (int i = 0; i < NUMGPIO; i++) { + if ((int)heishamonSettings.gpioSettings.gpioPin[i] == phys_pin && heishamonSettings.gpioSettings.gpioMode[i] == OUTPUT) { + char topic[16]; + if (i < NUMGPIO_RELAY) { + snprintf_P(topic, sizeof(topic), i == 0 ? PSTR("relay/one") : PSTR("relay/two")); + } else { + snprintf_P(topic, sizeof(topic), PSTR("extra/%d"), i - NUMGPIO_RELAY + 1); + } + mqttGPIOCallback(topic, val_str, heishamonSettings.gpioSettings, heishamonSettings.opentherm, heishamonSettings.listenonly); + break; + } + } + client->userdata = NULL; + } + return 0; + } break; case 150: { if (Update.isRunning() && (!Update.hasError())) { if ((strcmp((char *)args->name, "md5") == 0) && (args->len > 0)) { @@ -1371,6 +1402,7 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) { } break; case 110: { int ret = saveSettings(client, &heishamonSettings); + setupGPIO(heishamonSettings.gpioSettings, heishamonSettings.opentherm, heishamonSettings.listenonly); //re-apply GPIO pin modes if they changed #ifdef ESP8266 if ((!heishamonSettings.opentherm) && (heishamonSettings.listenonly)) { //make sure we disable TX to heatpump-RX using the mosfet so this line is floating and will not disturb cz-taw1 @@ -1466,6 +1498,16 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) { case 180: { if (heishamonSettings.use_1wire) rescanDallasSensors(log_message, heishamonSettings.dallasResolution); } break; + case 191: { + if (client->content == 0) { + webserver_send(client, 200, (char *)"application/json", 0); + return 0; + } else if (client->content == 1) { + gpioJsonOutput(client, heishamonSettings.gpioSettings, heishamonSettings.opentherm, heishamonSettings.listenonly); + return 0; + } + return -1; + } break; default: { webserver_send(client, 301, (char *)"text/plain", 0); } break; @@ -1639,7 +1681,7 @@ void switchSerial() { proxySerial.flush(); #endif - setupGPIO(heishamonSettings.gpioSettings); //switch extra GPIOs to configured mode + setupGPIO(heishamonSettings.gpioSettings, heishamonSettings.opentherm, heishamonSettings.listenonly); //switch extra GPIOs to configured mode //mosfet output enable pinMode(ENABLEPIN, OUTPUT); @@ -2015,7 +2057,13 @@ void loop() { checkBootButton(); //webserver function + // Diagnostic: a slow synchronous HTTP pass delays MQTT, OpenTherm and heat pump processing. + const uint32_t webStart = millis(); webserver_loop(); + const uint32_t webElapsed = millis() - webStart; + if (webElapsed >= 100) { + loggingSerial.printf("DIAG WEB loop=%lu ms\n", (unsigned long)webElapsed); + } // check wifi check_wifi(); @@ -2058,6 +2106,8 @@ void loop() { if (heishamonSettings.use_s0) s0Loop(mqtt_client, log_message, heishamonSettings.mqtt_topic_base, heishamonSettings.s0Settings); + publishGPIOStates(mqtt_client, heishamonSettings.gpioSettings, heishamonSettings.mqtt_topic_base, false, heishamonSettings.opentherm, heishamonSettings.listenonly); + #ifdef ESP8266 //this only runs on ESP8266, the ESP32 does this in vTask if ((!sending) && (!heishamonSettings.listenonly) && (heishamonSettings.optionalPCB) && ((unsigned long)(millis() - lastOptionalPCBRunTime) > OPTIONALPCBQUERYTIME) ) { diff --git a/HeishaMon/gpio.cpp b/HeishaMon/gpio.cpp index 1d86491a..257ba2d6 100644 --- a/HeishaMon/gpio.cpp +++ b/HeishaMon/gpio.cpp @@ -1,27 +1,147 @@ #include "gpio.h" +#include +#include "HeishaOT.h" #include "src/common/progmem.h" #include "src/common/stricmp.h" +#include "src/common/webserver.h" const char* mqtt_topic_gpio PROGMEM = "gpio"; void log_message(char* string); -void setupGPIO(gpioSettingsStruct gpioSettings) { - for (int i = 0 ; i < NUMGPIO ; i++) { +static bool toGPIOValue(char* value) { + return (stricmp((char*)"true", value) == 0) || (stricmp((char*)"on", value) == 0) || + (stricmp((char*)"enable", value) == 0) || (String(value).toInt() == 1); +} + +// Opentherm hardwires inOTPin/outOTPin for its own TX/RX (on ESP8266 these are pins 1 and 3, +// which otherwise double as extra GPIOs) - never let the extra-GPIO code touch them while it's enabled. +static bool isOpenthermPin(unsigned int pin, bool openthermEnabled) { + return openthermEnabled && (pin == inOTPin || pin == outOTPin); +} + +// On the small (ESP8266) board, listen-only mode skips digitalWrite(ENABLEPIN, HIGH) in switchSerial() +// so the shared level shifter/mosfets that the extra GPIO pins run through are never powered - using +// them would be pointless (or float) in that mode, so block them entirely rather than silently no-op. +bool extraGPIOBlockedByListenOnly(bool listenOnly) { +#if defined(ESP8266) + return listenOnly; +#else + return false; +#endif +} + +// A pin is fully unavailable (no pinMode/digitalWrite, no digitalRead, no reporting anywhere) when +// either Opentherm has claimed it, or listen-only mode leaves its level shifter unpowered. +static bool isGpioUnavailable(unsigned int pin, int pinIndex, bool openthermEnabled, bool listenOnly) { + if (isOpenthermPin(pin, openthermEnabled)) return true; + if (pinIndex >= NUMGPIO_RELAY && extraGPIOBlockedByListenOnly(listenOnly)) return true; + return false; +} + +void setupGPIO(gpioSettingsStruct &gpioSettings, bool openthermEnabled, bool listenOnly) { + // Apply user-configurable modes to the non-relay GPIO pins (relays occupy the first NUMGPIO_RELAY slots) + for (int i = 0; i < NUMGPIO_USER; i++) { + int pinIdx = NUMGPIO_RELAY + i; + if (isGpioUnavailable(gpioSettings.gpioPin[pinIdx], pinIdx, openthermEnabled, listenOnly)) continue; + switch (gpioSettings.gpioUserMode[i]) { + case GPIO_MODE_INPUT: gpioSettings.gpioMode[pinIdx] = INPUT; break; + case GPIO_MODE_OUTPUT: gpioSettings.gpioMode[pinIdx] = OUTPUT; break; + default: gpioSettings.gpioMode[pinIdx] = INPUT_PULLUP; break; + } + } + for (int i = 0; i < NUMGPIO; i++) { + if (isGpioUnavailable(gpioSettings.gpioPin[i], i, openthermEnabled, listenOnly)) continue; pinMode(gpioSettings.gpioPin[i], gpioSettings.gpioMode[i]); } } -void mqttGPIOCallback(char* topic, char* value) { +void mqttGPIOCallback(char* topic, char* value, gpioSettingsStruct &gpioSettings, bool openthermEnabled, bool listenOnly) { char log_msg[256]; sprintf_P(log_msg, PSTR("GPIO: MQTT message received on subtopic '%s' value '%s'"), topic, value); log_message(log_msg); #ifdef ESP32 - if (strcmp_P(topic, PSTR("relay/one")) == 0) { + if (strcmp_P(PSTR("relay/one"), topic) == 0) { log_message(_F("GPIO: MQTT message received 'relay/one'")); - digitalWrite(relayOnePin,((stricmp((char*)"true", value) == 0) || (stricmp((char*)"on", value) == 0) || (stricmp((char*)"enable", value) == 0)|| (String(value).toInt() == 1 ))); - } else if (strcmp_P(topic,PSTR("relay/two")) == 0) { + digitalWrite(relayOnePin, toGPIOValue(value)); + return; + } else if (strcmp_P(PSTR("relay/two"), topic) == 0) { log_message(_F("GPIO: MQTT message received 'relay/two'")); - digitalWrite(relayTwoPin,((stricmp((char*)"true", value) == 0) || (stricmp((char*)"on", value) == 0) || (stricmp((char*)"enable", value) == 0)|| (String(value).toInt() == 1 ))); + digitalWrite(relayTwoPin, toGPIOValue(value)); + return; } #endif + // Handle user-configurable output GPIOs: topic "extra/N" (1-indexed) + if (strncmp_P(topic, PSTR("extra/"), 6) == 0) { + int idx = atoi(topic + 6) - 1; // convert 1-indexed to 0-indexed + if (idx >= 0 && idx < NUMGPIO_USER) { + int pinIdx = NUMGPIO_RELAY + idx; + // Only pins actually in OUTPUT mode are ever candidates for a real write. Pins left in + // INPUT/INPUT_PULLUP (which includes every opentherm/listen-only-blocked pin, since setupGPIO() + // never promotes them to OUTPUT) commonly receive this callback too - not as a write command, + // but as the self-echo of publishGPIOStates() reporting their own read state back over MQTT. + // Checking mode first (before the opentherm/listen-only reasoning) avoids logging a misleading + // "ignoring write" message for what is actually just a harmless state echo, not a blocked switch. + if (gpioSettings.gpioMode[pinIdx] == OUTPUT) { + if (isOpenthermPin(gpioSettings.gpioPin[pinIdx], openthermEnabled)) { + log_message(_F("GPIO: ignoring extra GPIO write, pin reserved by Opentherm")); + return; + } + if (extraGPIOBlockedByListenOnly(listenOnly)) { + log_message(_F("GPIO: ignoring extra GPIO write, listen-only mode leaves the level shifter unpowered")); + return; + } + char log_msg[64]; + snprintf_P(log_msg, sizeof(log_msg), PSTR("GPIO: set extra/%d (pin %d) = %s"), idx + 1, gpioSettings.gpioPin[pinIdx], value); + log_message(log_msg); + digitalWrite(gpioSettings.gpioPin[pinIdx], toGPIOValue(value)); + } + } + } +} + +void publishGPIOStates(PubSubClient &mqtt_client, gpioSettingsStruct &gpioSettings, char* mqtt_topic_base, bool publishAll, bool openthermEnabled, bool listenOnly) { + static int lastState[NUMGPIO]; + static bool initialized = false; + if (!initialized) { + for (int i = 0; i < NUMGPIO; i++) lastState[i] = -1; + initialized = true; + } + char topic[256]; + char wsmsg[64]; + bool mqttConnected = mqtt_client.connected(); + for (int i = 0; i < NUMGPIO; i++) { + // Unavailable pins (Opentherm-claimed, or listen-only leaving the level shifter unpowered) are not + // read at all - no digitalRead, no MQTT publish, no websocket push. They're not meaningful telemetry + // (Opentherm's own bit-banging would just get echoed back over MQTT to mqttGPIOCallback) and the + // frontend GPIO tab hides/removes them entirely, so there's nothing to report them to. + if (isGpioUnavailable(gpioSettings.gpioPin[i], i, openthermEnabled, listenOnly)) continue; + int state = digitalRead(gpioSettings.gpioPin[i]); + if (publishAll || state != lastState[i]) { + lastState[i] = state; + if (i >= NUMGPIO_RELAY && mqttConnected) { + snprintf_P(topic, sizeof(topic), PSTR("%s/gpio/extra/%d"), mqtt_topic_base, i - NUMGPIO_RELAY + 1); + mqtt_client.publish(topic, state ? "1" : "0", true); + } + int len = snprintf_P(wsmsg, sizeof(wsmsg), PSTR("{\"data\": {\"gpio\": {\"pin\": %d, \"state\": %d}}}"), + gpioSettings.gpioPin[i], state); + websocket_write_all(wsmsg, len); + } + } } + +void gpioJsonOutput(struct webserver_t *client, gpioSettingsStruct &gpioSettings, bool openthermEnabled, bool listenOnly) { + char buf[64]; + webserver_send_content_P(client, PSTR("["), 1); + bool first = true; + for (int i = 0; i < NUMGPIO; i++) { + if (isGpioUnavailable(gpioSettings.gpioPin[i], i, openthermEnabled, listenOnly)) continue; + int state = digitalRead(gpioSettings.gpioPin[i]); + int mode = (i < NUMGPIO_RELAY) ? GPIO_MODE_OUTPUT : gpioSettings.gpioUserMode[i - NUMGPIO_RELAY]; + int len = snprintf_P(buf, sizeof(buf), PSTR("%s{\"pin\":%d,\"mode\":%d,\"state\":%d}"), + first ? "" : ",", gpioSettings.gpioPin[i], mode, state); + webserver_send_content(client, buf, len); + first = false; + } + webserver_send_content_P(client, PSTR("]"), 1); +} + diff --git a/HeishaMon/gpio.h b/HeishaMon/gpio.h index aea5191d..0d5a5353 100644 --- a/HeishaMon/gpio.h +++ b/HeishaMon/gpio.h @@ -1,9 +1,13 @@ #if defined(ESP8266) #define NUMGPIO 3 +#define NUMGPIO_USER 3 // all GPIOs are user-configurable on ESP8266 +#define NUMGPIO_RELAY 0 // no fixed relays on ESP8266 #include #include #elif defined(ESP32) #define NUMGPIO 7 +#define NUMGPIO_USER 5 // last 5 are user-configurable +#define NUMGPIO_RELAY 2 // first 2 (pins 21,47) are fixed relays #include #include #include @@ -11,6 +15,11 @@ #define relayTwoPin 47 #endif +// User GPIO mode values (stored in settings) +#define GPIO_MODE_INPUT_PULLUP 0 +#define GPIO_MODE_INPUT 1 +#define GPIO_MODE_OUTPUT 2 + extern const char* mqtt_topic_gpio; struct gpioSettingsStruct { @@ -18,12 +27,19 @@ struct gpioSettingsStruct { unsigned int gpioPin[NUMGPIO] = {1, 3, 16}; unsigned int gpioMode[NUMGPIO] = {INPUT_PULLUP, INPUT_PULLUP, INPUT_PULLUP}; #elif defined(ESP32) - unsigned int gpioPin[NUMGPIO] = {33, 34, 35, 36, 37, 21, 47}; - unsigned int gpioMode[NUMGPIO] = {INPUT_PULLUP, INPUT_PULLUP, INPUT_PULLUP, INPUT_PULLUP, INPUT_PULLUP, OUTPUT, OUTPUT}; + unsigned int gpioPin[NUMGPIO] = {21, 47, 33, 34, 35, 36, 37}; + unsigned int gpioMode[NUMGPIO] = {OUTPUT, OUTPUT, INPUT_PULLUP, INPUT_PULLUP, INPUT_PULLUP, INPUT_PULLUP, INPUT_PULLUP}; #endif + // User-configurable modes for the non-relay GPIOs (0=INPUT_PULLUP, 1=INPUT, 2=OUTPUT) + unsigned int gpioUserMode[NUMGPIO_USER] = {}; // all default to 0 (INPUT_PULLUP) }; -void setupGPIO(gpioSettingsStruct gpioSettings); -void mqttGPIOCallback(char* topic, char* value); +class PubSubClient; +struct webserver_t; +void setupGPIO(gpioSettingsStruct &gpioSettings, bool openthermEnabled, bool listenOnly); +void mqttGPIOCallback(char* topic, char* value, gpioSettingsStruct &gpioSettings, bool openthermEnabled, bool listenOnly); +void publishGPIOStates(PubSubClient &mqtt_client, gpioSettingsStruct &gpioSettings, char* mqtt_topic_base, bool publishAll, bool openthermEnabled, bool listenOnly); +void gpioJsonOutput(struct webserver_t *client, gpioSettingsStruct &gpioSettings, bool openthermEnabled, bool listenOnly); +bool extraGPIOBlockedByListenOnly(bool listenOnly); // true when the whole GPIO tab/panel should be hidden (ESP8266 in listen-only mode) diff --git a/HeishaMon/htmlcode.h b/HeishaMon/htmlcode.h index 09299d66..faca8e3f 100644 --- a/HeishaMon/htmlcode.h +++ b/HeishaMon/htmlcode.h @@ -31,6 +31,7 @@ static const char webCSS[] FLASHPROG = R"====( --green:#28a745; --green-glow:rgba(40,167,69,0.15); --orange:#fd7e14; + --switch-track:#c3c9d1; --radius:8px; --radius-sm:5px; --radius-lg:12px; @@ -57,6 +58,7 @@ html.dark-mode { --green:#2ecc94; --green-glow:rgba(46,204,148,0.2); --orange:#f0a500; + --switch-track:#3a4257; } /* ═══════════════════════════════════════════════════════════════════════ @@ -276,6 +278,7 @@ tbody td:first-child{color:var(--text-primary);font-family:'JetBrains Mono',mono letter-spacing:0.3px; } .theme-switch-compact { + display:inline-block; position:relative; width:32px; height:18px; @@ -292,7 +295,8 @@ tbody td:first-child{color:var(--text-primary);font-family:'JetBrains Mono',mono left:0; right:0; bottom:0; - background:var(--border); + background:var(--switch-track); + border:1px solid var(--border); transition:0.3s; border-radius:18px; } @@ -309,6 +313,7 @@ tbody td:first-child{color:var(--text-primary);font-family:'JetBrains Mono',mono } input:checked + .theme-slider-compact { background:var(--accent); + border-color:var(--accent); } input:checked + .theme-slider-compact:before { transform:translateX(14px); @@ -890,6 +895,10 @@ function startWebsockets(){ updCell('s0port-'+j.data.s0values.s0port+'-WatthourTotal',j.data.s0values.WatthourTotal); } else if(j.data.opentherm){ updCell(j.data.opentherm.name+'-value',j.data.opentherm.value); + } else if(j.data.gpio){ + var gpioCell=document.getElementById('gpio-'+j.data.gpio.pin+'-state'); + var gpioInput=gpioCell&&gpioCell.querySelector('input[type=checkbox]'); + if(gpioInput)gpioInput.checked=!!j.data.gpio.state; } else if(j.data.dallasRescan){ refreshDallasTable(); } @@ -1099,6 +1108,43 @@ async function refreshDallasTable(){ renderDallasTable(d); }catch(e){} } +var gpioModeNames={0:'Input (pull-up)',1:'Input',2:'Output'}; +function renderGpioTable(d){ + if(!(d&&d.gpio&&Array.isArray(d.gpio)))return; + var tb=document.getElementById('gpiovalues'); + if(!tb)return; // GPIO tab/pane isn't rendered at all in listen-only mode + tb.innerHTML=''; + // first 2 pins are the fixed relays on boards that have them (ESP32); the rest are user-configurable GPIOs + // Fixed ESP32 relays are identified by physical pin, not the number of visible GPIOs. + // OpenTherm may reserve pins and reduce the visible count. + var extraIdx=0; + d.gpio.forEach(function(item,i){ + var row=document.createElement('tr'); + var nameCell=document.createElement('td'); + var relayName=item.pin===21?'Relay 1':(item.pin===47?'Relay 2':null); + nameCell.textContent=relayName||('GPIO '+(++extraIdx)); + row.appendChild(nameCell); + var pinCell=document.createElement('td');pinCell.textContent=item.pin;row.appendChild(pinCell); + var modeCell=document.createElement('td');modeCell.textContent=gpioModeNames[item.mode]||item.mode;row.appendChild(modeCell); + var stateCell=document.createElement('td'); + stateCell.id='gpio-'+item.pin+'-state'; + var label=document.createElement('label');label.className='theme-switch-compact'; + var input=document.createElement('input');input.type='checkbox';input.checked=!!item.state; + if(item.mode===2){ + input.onchange=function(){toggleGpio(item.pin,this.checked);}; + } else { + input.disabled=true; + } + var slider=document.createElement('span');slider.className='theme-slider-compact'; + label.appendChild(input);label.appendChild(slider); + stateCell.appendChild(label); + row.appendChild(stateCell); + tb.appendChild(row); + }); +} +function toggleGpio(pin,on){ + fetch('/gpio?pin='+pin+'&value='+(on?1:0)).catch(function(){}); +} async function refreshTable(){ try{ if(isEditing)return; @@ -1117,6 +1163,7 @@ async function refreshTable(){ d['heatpump optional'].forEach(function(item){tb.appendChild(buildRow(item,'Topic'));}); } renderDallasTable(d); + renderGpioTable(d); if(d&&d.s0&&Array.isArray(d.s0)){ var tb=document.getElementById('s0values');tb.innerHTML=''; d.s0.forEach(function(item){ @@ -1295,6 +1342,9 @@ static const char webBodyRootS0Tab[] FLASHPROG = R"====( static const char webBodyRootOpenthermTab[] FLASHPROG = R"====( )===="; +static const char webBodyRootGpioTab[] FLASHPROG = R"====( + +)===="; static const char webTabnavClose[] FLASHPROG = R"====( @@ -1351,6 +1401,18 @@ static const char webBodyRootOpenthermValues[] FLASHPROG = R"====( )===="; +static const char webBodyRootGpioValues[] FLASHPROG = R"====( +
+
+

GPIO

Live
+ + + + +
NamePinModeState
Loading…
+
+)===="; + static const char webBodyRootConsole[] FLASHPROG = R"====(
@@ -1398,6 +1460,28 @@ function ShowHideDallasTable(cb){ function ShowHideS0Table(cb){ document.getElementById('s0settings').style.display=cb.checked?'block':'none'; } +function updateGPIORowVisibility(){ + // On ESP8266, two hardware facts make some Extra GPIO settings unusable: + // - Opentherm hardwires pins 1 and 3 (GPIO 1/GPIO 2) for its own TX/RX while it's enabled. + // - Listen-only mode skips enabling the shared level shifter/mosfets on boot, so ALL extra + // GPIO pins are left unpowered/floating while it's enabled. + var otEl=document.getElementsByName('opentherm')[0]; + var loEl=document.getElementsByName('listenonly')[0]; + var otOn=!!(otEl&&otEl.checked); + var loOn=!!(loEl&&loEl.checked); + document.querySelectorAll('.gpio-row').forEach(function(row){ + var hide=loOn||(otOn&&row.classList.contains('ot-conflict-gpio')); + row.style.display=hide?'none':''; + var sel=row.querySelector('select'); + if(sel)sel.disabled=hide; + }); + document.querySelectorAll('.ot-conflict-note').forEach(function(row){ + row.style.display=(otOn&&!loOn)?'':'none'; + }); + document.querySelectorAll('.lo-conflict-note').forEach(function(row){ + row.style.display=loOn?'':'none'; + }); +} function changeMinWatt(port){ var ppkwh=document.getElementById('s0_ppkwh_'+port).value; var interval=document.getElementById('s0_interval_'+port).value; @@ -1602,13 +1686,13 @@ static const char settingsForm2[] FLASHPROG = R"====(
-
+
Rules load normally, but skip after crashes to prevent boot loops. Enable to override.

Listen Only

-
+

1-Wire DS18B20

@@ -1661,6 +1745,15 @@ static const char settingsForm2[] FLASHPROG = R"====(
— W
+
+

Extra GPIO

+
+
+
+
+ + +
@@ -1764,6 +1857,17 @@ static const char settingsForm2[] FLASHPROG = R"====(
— W
+
+

Extra GPIO

+
+
+
+
+
+
+
+
+
@@ -1807,6 +1911,7 @@ R"====( if(el[0].type.indexOf('select')>-1){var ch=el[0].childNodes;for(var x=0;xs0Settings[1].lowerPowerInterval = jsonDoc[F("s0_2_interval")]; if (jsonDoc[F("s0_2_minpulsewidth")]) heishamonSettings->s0Settings[1].minimalPulseWidth = jsonDoc[F("s0_2_minpulsewidth")]; if (jsonDoc[F("s0_2_maxpulsewidth")]) heishamonSettings->s0Settings[1].maximalPulseWidth = jsonDoc[F("s0_2_maxpulsewidth")]; + for (int i = 0; i < NUMGPIO_USER; i++) { + char key[16]; + snprintf(key, sizeof(key), "gpio_%d_mode", i + 1); + if (jsonDoc[key]) { + unsigned int mode = jsonDoc[key]; + if (mode > GPIO_MODE_OUTPUT) mode = GPIO_MODE_INPUT_PULLUP; + heishamonSettings->gpioSettings.gpioUserMode[i] = mode; + } + } ntpReload(heishamonSettings); } else { log_message(_F("Failed to load json config, forcing config reset.")); @@ -508,7 +517,12 @@ void settingsToJson(JsonDocument &jsonDoc, settingsStruct *heishamonSettings) { jsonDoc[F("s0_2_ppkwh")] = heishamonSettings->s0Settings[1].ppkwh; jsonDoc[F("s0_2_interval")] = heishamonSettings->s0Settings[1].lowerPowerInterval; jsonDoc[F("s0_2_minpulsewidth")] = heishamonSettings->s0Settings[1].minimalPulseWidth; - jsonDoc[F("s0_2_maxpulsewidth")] = heishamonSettings->s0Settings[1].maximalPulseWidth; + jsonDoc[F("s0_2_maxpulsewidth")] = heishamonSettings->s0Settings[1].maximalPulseWidth; + for (int i = 0; i < NUMGPIO_USER; i++) { + char key[16]; + snprintf(key, sizeof(key), "gpio_%d_mode", i + 1); + jsonDoc[key] = heishamonSettings->gpioSettings.gpioUserMode[i]; + } } void saveJsonToFile(JsonDocument &jsonDoc, const char* filename) { @@ -729,6 +743,16 @@ int saveSettings(struct webserver_t *client, settingsStruct *heishamonSettings) jsonDoc[F("s0_2_minpulsewidth")] = tmp->value; } else if (strcmp(tmp->name.c_str(), "s0_2_maxpulsewidth") == 0) { jsonDoc[F("s0_2_maxpulsewidth")] = tmp->value; + } else { + // Handle gpio_N_mode keys dynamically + for (int i = 0; i < NUMGPIO_USER; i++) { + char key[16]; + snprintf(key, sizeof(key), "gpio_%d_mode", i + 1); + if (strcmp(tmp->name.c_str(), key) == 0) { + jsonDoc[key] = tmp->value; + break; + } + } } tmp = tmp->next; } @@ -1043,6 +1067,9 @@ int handleRoot(struct webserver_t *client, float readpercentage, int mqttReconne if (heishamonSettings->opentherm) { webserver_send_content_P(client, webBodyRootOpenthermTab, strlen_P(webBodyRootOpenthermTab)); } + if (!extraGPIOBlockedByListenOnly(heishamonSettings->listenonly)) { + webserver_send_content_P(client, webBodyRootGpioTab, strlen_P(webBodyRootGpioTab)); + } webserver_send_content_P(client, webTabnavClose, strlen_P(webTabnavClose)); if (heishamonSettings->listenonly) { webserver_send_content_P(client, webBodyRootStatusListenOnly, strlen_P(webBodyRootStatusListenOnly)); @@ -1060,6 +1087,9 @@ int handleRoot(struct webserver_t *client, float readpercentage, int mqttReconne if (heishamonSettings->use_s0) { webserver_send_content_P(client, webBodyRootS0Values, strlen_P(webBodyRootS0Values)); } + if (!extraGPIOBlockedByListenOnly(heishamonSettings->listenonly)) { + webserver_send_content_P(client, webBodyRootGpioValues, strlen_P(webBodyRootGpioValues)); + } } break; case 3: { webserver_send_content_P(client, webBodyRootConsole, strlen_P(webBodyRootConsole)); @@ -1253,6 +1283,8 @@ int handleJsonOutput(struct webserver_t *client, char* actData, char* actDataExt webserver_send_content_P(client, PSTR(",\"opentherm\":"), 13); openthermJsonOutput(client); } + webserver_send_content_P(client, PSTR(",\"gpio\":"), 8); + gpioJsonOutput(client, heishamonSettings->gpioSettings, heishamonSettings->opentherm, heishamonSettings->listenonly); webserver_send_content_P(client, PSTR("}"), 1); } return 0; diff --git a/README.md b/README.md index df882856..75694e63 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Prints a value to the console. Concatenates various values into a combined string. E.g.: `@SetCurves = concat('{zone1:{heat:{target:{high:', @Z1_Heat_Curve_Target_High_Temp, ',low:32}}}}');` - `gpio` -Allows setting or getting a GPIO state. When called with a single argument, a GPIO state is returned. When called with two arguments the state of a GPIO is set. This function only sets digital pins so the state can only be 0 or 1. The two relays on the large heishamon are gpio21 and gpio47. See the example to switch them each two seconds. +Allows setting or getting a GPIO state. When called with a single argument, a GPIO state is returned. When called with two arguments the state of a GPIO is set. This function only sets digital pins so the state can only be 0 or 1. The two relays on the large heishamon are gpio21 and gpio47. The user-configurable extra GPIOs (see below) can also be addressed by their pin number. See the example to switch the relays each two seconds. ``` on System#Boot then @@ -370,6 +370,44 @@ The software also supports ds18b20 1-wire temperature sensors reading. A proper ## Large board relay control The newer, large, heishamon contains two onboard relays which can be switched on and off using MQTT commands. The relays can be used for any contact switching, even 230V mains (max 5A). For example to switch the 230V contacts in the heatpump for controlling the 'external thermostat', switching a pump on or off or other lower power devices. I do not recommend to use the relay as a switch for a electric heater as they use too much power. To control the relay just send a value of 1 or 0 to the MQTT topic "panasonic_heat_pump/gpio/relay/one" for relay one or "panasonic_heat_pump/gpio/relay/two" for relay two. +## User-configurable extra GPIO +In addition to the fixed relay pins, HeishaMon exposes a number of user-configurable GPIO pins that can each be independently set to Input (pull-up), Input, or Output mode. Configure them in the settings web menu, under the **Extra GPIO** panel. + +**Available pins per platform:** +- **ESP8266:** 3 extra GPIOs (pins 1, 3, 16) +- **ESP32:** 5 extra GPIOs (pins 33–37); relay pins 21 and 47 remain fixed + +Each pin is listed in the panel as "GPIO 1", "GPIO 2", etc., with its physical pin number shown alongside (e.g. "GPIO 1 (pin 1)"). The chosen mode is saved in settings and (re-)applied on boot and whenever settings are saved, so a pin already wired as an input can be safely repurposed as an output (or vice versa) without a firmware reflash. + +**Controlling output GPIOs over MQTT:** +For pins set to Output, send `1`, `0`, `on`, `off`, `true`, or `false` to: +``` +panasonic_heat_pump/gpio/extra/N +``` +where `N` is the 1-based index of the extra GPIO shown in the settings panel (e.g. `extra/1` for "GPIO 1"), not its physical pin number. Values sent to a pin that isn't configured as Output are ignored. + +**Reading GPIO states over MQTT:** +Both input and output pin states are automatically published (retained) to the same `panasonic_heat_pump/gpio/extra/N` topics whenever a state changes, and for all extra GPIOs on first MQTT connect. + +**HTTP API:** +You can read all extra GPIO states as JSON via a GET request: +``` +GET /gpio +``` +Response example: +```json +[{"pin":33,"mode":2,"state":1},{"pin":34,"mode":0,"state":0}, ...] +``` +where `pin` is the physical pin number, and `mode` is 0 = Input (pull-up), 1 = Input, 2 = Output. + +To set an output pin via HTTP POST, use the 1-based extra-GPIO index (same `N` as the MQTT topic, not the physical pin number): +``` +POST /gpio?pin=N&value=1 +``` + +**Using extra GPIOs in rules:** +The `gpio()` rules function works with the extra GPIO pins by their physical pin number, the same as the relay pins (see the `gpio` command further up in this README). + ## Opentherm support If your heishamon board supports opentherm the software can also be used to bridge opentherm information from a compatible thermostat to your home automation over MQTT or JSON and as mentioned above it can also be connected directly in the rules to connect opentherm information to the heatpump and back, for example to display the outside temperature from the heatpump on your opentherm thermostat. If you enable opentherm support in settings there will be a new tab visible in the web page. On that tab you will see opentherm values. Some are of type R(ead) and some are W(rite), and some are both. Read means that the thermostat can read that information from the heishamon. You provide that information over MQTT (or using the rules) by updating this value on the mqtt 'opentherm/read' topic, for example 'panasonic_heat_pump/opentherm/read/outsideTemp'. The write values are information from the thermostat, like 'roomTemp'. These are available on mqtt topic 'opentherm/write'. You can use these values to change the heatpump behaviour in anyway you want using your home automation and mqtt set-commands to heishamon on using the internal rules.