diff --git a/.gitignore b/.gitignore
index 852084b8..a385d97b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
################################################################################
# This .gitignore file was automatically created by Microsoft(R) Visual Studio.
################################################################################
-
+.idea
.vs
.pio
pio
diff --git a/HeishaMon/HeishaMon.ino b/HeishaMon/HeishaMon.ino
index 8af40fa0..9d337367 100644
--- a/HeishaMon/HeishaMon.ino
+++ b/HeishaMon/HeishaMon.ino
@@ -517,6 +517,10 @@ void mqtt_reconnect()
sprintf_P(mqtt_topic, PSTR("%s/%s/WatthourTotal/2"), heishamonSettings.mqtt_topic_base, mqtt_topic_s0);
mqtt_client.subscribe(mqtt_topic);
}
+ if (heishamonSettings.use_1wire) { // allow setting the per-sensor calibration offset over mqtt: /1wire/
/offset/set
+ sprintf_P(mqtt_topic, PSTR("%s/%s/+/offset/set"), heishamonSettings.mqtt_topic_base, mqtt_topic_1wire);
+ mqtt_client.subscribe(mqtt_topic);
+ }
if (heishamonSettings.use_1wire && mqttReconnects == 1) { // only on first connect: retrieve previously known 1wire sensors from their retained mqtt topics
sprintf_P(mqtt_topic, PSTR("%s/%s/+"), heishamonSettings.mqtt_topic_base, mqtt_topic_1wire);
mqtt_client.subscribe(mqtt_topic);
@@ -1030,8 +1034,12 @@ void mqtt_callback(char* topic, byte* payload, unsigned int length) {
mqttGPIOCallback(topic_gpiocommand, msg);
} 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
+ char* topic_1wire_sub = strchr(topic_1wire_address, '/');
+ if ((topic_1wire_sub == NULL) && (length > 0)) { //only handle the address topic itself (not .../alias) and skip empty (cleared) retained messages
restoreDallasFromMqtt(topic_1wire_address, String(msg).toFloat(), log_message);
+ } else if ((topic_1wire_sub != NULL) && (strcmp_P(topic_1wire_sub, PSTR("/offset/set")) == 0) && (length > 0)) { //set the calibration offset of a sensor
+ *topic_1wire_sub = '\0'; //terminate the address (topiccopy is a private copy of the topic)
+ changeDallasOffset(mqtt_client, heishamonSettings.mqtt_topic_base, topic_1wire_address, msg, log_message);
}
}
free(topiccopy);
@@ -1090,6 +1098,8 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) {
client->route = 60;
} else if (strcmp((char *)dat, "/removedallas") == 0) {
client->route = 190;
+ } else if (strcmp((char *)dat, "/dallasoffset") == 0) {
+ client->route = 195;
} else if (strcmp((char *)dat, "/togglelog") == 0) {
client->route = 1;
log_message(_F("Toggled mqtt log flag"));
@@ -1192,6 +1202,12 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) {
removeDallasSensor(mqtt_client, heishamonSettings.mqtt_topic_base, (char *)args->name, log_message);
return 0;
} break;
+ case 195: {
+ char value[16];
+ snprintf(value, sizeof(value), "%.*s", (int)args->len, (char *)args->value);
+ changeDallasOffset(mqtt_client, heishamonSettings.mqtt_topic_base, (char *)args->name, value, log_message);
+ return 0;
+ } break;
case 100: {
unsigned char cmd[256] = { 0 };
char cpy[args->len + 1];
@@ -1336,6 +1352,9 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) {
case 190: {
return 0;
} break;
+ case 195: {
+ return 0;
+ } break;
case 80: {
if (client->content == 0) {
webserver_send(client, 302, (char *)"text/html", 0);
@@ -1481,7 +1500,8 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) {
} break;
case 60:
case 70:
- case 190: {
+ case 190:
+ case 195: {
header->ptr += sprintf_P((char *)header->buffer, PSTR("Location: /"));
return -1;
} break;
diff --git a/HeishaMon/dallas.cpp b/HeishaMon/dallas.cpp
index a26a6e8c..8e53490c 100644
--- a/HeishaMon/dallas.cpp
+++ b/HeishaMon/dallas.cpp
@@ -30,6 +30,8 @@ unsigned int updateAllDallasTime = 30000; // will be set using heishmonSettings
unsigned int dallasTimerWait = 30000; // will be set using heishmonSettings
void loadDallasAlias();
void saveDallasAliasFile();
+void loadDallasOffset();
+void saveDallasOffsetFile();
static int findDallasSlot(const char* address) {
for (int i = 0; i < MAX_DALLAS_SENSORS; i++) {
@@ -87,6 +89,7 @@ void rescanDallasSensors(void (*log_message)(char*), unsigned int dallasResoluti
actDallasData[slot].temperature = -127.0;
actDallasData[slot].lastgoodtime = 0;
strlcpy(actDallasData[slot].alias, "NOT SET", sizeof(actDallasData[slot].alias));
+ actDallasData[slot].offset = 0.0;
sprintf_P(log_msg, PSTR("Found new 1wire sensor: %s"), addrStr); log_message(log_msg);
sprintf_P(log_msg, PSTR("{\"data\": {\"dallasRescan\": true}}")); websocket_write_all(log_msg, strlen(log_msg)); // tell open browser tabs to reload the sensor table
}
@@ -103,6 +106,7 @@ void rescanDallasSensors(void (*log_message)(char*), unsigned int dallasResoluti
if (DALLASASYNC) DS18B20.setWaitForConversion(false); //async 1wire during next loops
loadDallasAlias();
+ loadDallasOffset();
}
void initDallasSensors(void (*log_message)(char*), unsigned int updateAllDallasTimeSettings, unsigned int dallasTimerWaitSettings, unsigned int dallasResolution) {
@@ -151,7 +155,9 @@ void readNewDallasTemp(PubSubClient &mqtt_client, void (*log_message)(char*), ch
websocket_write_all(log_msg, strlen(log_msg));
}
float allowedtempdiff = (((millis() - actDallasData[i].lastgoodtime)) / 1000.0) * MAXTEMPDIFFPERSEC;
- if (fabs(temp - 85.0) < 0.0001) { // 85.0C is the DS18B20 power-on reset default, not a real reading; sensor is online, just not converted yet
+ bool powerOnReset = (fabs(temp - 85.0) < 0.0001); // check the raw value, before the calibration offset is applied
+ temp += actDallasData[i].offset; // apply the per-sensor calibration offset; everything below (filter, mqtt, json, rules) uses the corrected value
+ if (powerOnReset) { // 85.0C is the DS18B20 power-on reset default, not a real reading; sensor is online, just not converted yet
sprintf_P(log_msg, PSTR("Ignoring 1wire sensor power-on-reset value (85.00): %s"), actDallasData[i].address);
log_message(log_msg);
} else if ((actDallasData[i].temperature != -127.0) and ((temp > (actDallasData[i].temperature + allowedtempdiff)) or (temp < (actDallasData[i].temperature - allowedtempdiff)))) {
@@ -168,6 +174,8 @@ void readNewDallasTemp(PubSubClient &mqtt_client, void (*log_message)(char*), ch
sprintf_P(mqtt_topic, PSTR("%s/%s/%s"), mqtt_topic_base, mqtt_topic_1wire, actDallasData[i].address); mqtt_client.publish(mqtt_topic, valueStr, MQTT_RETAIN_VALUES);
sprintf_P(valueStr, PSTR("%s"), actDallasData[i].alias);
sprintf_P(mqtt_topic, PSTR("%s/%s/%s/alias"), mqtt_topic_base, mqtt_topic_1wire, actDallasData[i].address); mqtt_client.publish(mqtt_topic, valueStr, MQTT_RETAIN_VALUES);
+ sprintf_P(valueStr, PSTR("%.1f"), actDallasData[i].offset);
+ sprintf_P(mqtt_topic, PSTR("%s/%s/%s/offset"), mqtt_topic_base, mqtt_topic_1wire, actDallasData[i].address); mqtt_client.publish(mqtt_topic, valueStr, MQTT_RETAIN_VALUES);
} else {
sprintf_P(valueStr, PSTR("{\"Temperature\":%.2f,\"Alias\":\"%s\"}"), actDallasData[i].temperature, actDallasData[i].alias);
sprintf_P(mqtt_topic, PSTR("%s/%s/%s"), mqtt_topic_base, mqtt_topic_1wire, actDallasData[i].address); mqtt_client.publish(mqtt_topic, valueStr, MQTT_RETAIN_VALUES);
@@ -214,7 +222,10 @@ void dallasJsonOutput(struct webserver_t *client) {
webserver_send_content(client, str, strlen(str));
webserver_send_content_P(client, PSTR(",\"Alias\":\""), 10);
webserver_send_content(client, actDallasData[i].alias, strlen(actDallasData[i].alias));
- webserver_send_content_P(client, PSTR("\",\"Present\":"), 12);
+ webserver_send_content_P(client, PSTR("\",\"Offset\":"), 11);
+ dtostrf(actDallasData[i].offset, 0, 1, str);
+ webserver_send_content(client, str, strlen(str));
+ webserver_send_content_P(client, PSTR(",\"Present\":"), 11);
if (actDallasData[i].present) {
webserver_send_content_P(client, PSTR("true"), 4);
} else {
@@ -260,11 +271,13 @@ void removeDallasSensor(PubSubClient &mqtt_client, char* mqtt_topic_base, char*
// publishing an empty retained payload clears the previously retained message on the broker
sprintf_P(mqtt_topic, PSTR("%s/%s/%s"), mqtt_topic_base, mqtt_topic_1wire, address); mqtt_client.publish(mqtt_topic, "", true);
sprintf_P(mqtt_topic, PSTR("%s/%s/%s/alias"), mqtt_topic_base, mqtt_topic_1wire, address); mqtt_client.publish(mqtt_topic, "", true);
+ sprintf_P(mqtt_topic, PSTR("%s/%s/%s/offset"), mqtt_topic_base, mqtt_topic_1wire, address); mqtt_client.publish(mqtt_topic, "", true);
sprintf_P(log_msg, PSTR("Removed 1wire sensor: %s"), address); log_message(log_msg);
actDallasData[slot] = dallasDataStruct();
saveDallasAliasFile();
+ saveDallasOffsetFile();
sprintf_P(log_msg, PSTR("{\"data\": {\"dallasRescan\": true}}")); websocket_write_all(log_msg, strlen(log_msg)); // tell open browser tabs to reload the sensor table
}
@@ -281,6 +294,7 @@ void restoreDallasFromMqtt(char* address, float temperature, void (*log_message)
strlcpy(actDallasData[slot].alias, "NOT SET", sizeof(actDallasData[slot].alias));
sprintf_P(log_msg, PSTR("Restored previously known 1wire sensor from mqtt: %s"), address); log_message(log_msg);
loadDallasAlias();
+ loadDallasOffset();
sprintf_P(log_msg, PSTR("{\"data\": {\"dallasRescan\": true}}")); websocket_write_all(log_msg, strlen(log_msg)); // tell open browser tabs to reload the sensor table
}
if (actDallasData[slot].lastgoodtime == 0) { //only backfill if we haven't taken a real reading yet this boot
@@ -308,3 +322,101 @@ void loadDallasAlias() {
}
}
}
+
+// Calibration offsets are stored in their own file instead of being added to dallas.json,
+// so a downgrade to an older firmware (which expects plain alias strings in dallas.json) keeps working.
+void saveDallasOffsetFile() {
+ JsonDocument jsonDoc;
+ for (int i = 0 ; i < dallasDevicecount; i++) {
+ if (!actDallasData[i].known) continue;
+ if (actDallasData[i].offset == 0.0) continue; // only store sensors that actually have an offset
+ jsonDoc[actDallasData[i].address] = actDallasData[i].offset;
+ }
+ if (LittleFS.begin()) {
+ File configFile = LittleFS.open("/dallasoffset.json", "w");
+ if (configFile) {
+ serializeJson(jsonDoc, configFile);
+ configFile.close();
+ }
+ }
+}
+
+// rounds to 0.1 degree steps and clamps to +/- DALLAS_OFFSET_MAX
+static float sanitizeDallasOffset(float offset) {
+ offset = roundf(offset * 10.0) / 10.0;
+ if (offset > DALLAS_OFFSET_MAX) offset = DALLAS_OFFSET_MAX;
+ if (offset < -DALLAS_OFFSET_MAX) offset = -DALLAS_OFFSET_MAX;
+ if (offset == 0.0) offset = 0.0; // normalize -0.0
+ return offset;
+}
+
+void loadDallasOffset() {
+ if (LittleFS.begin()) {
+ if (LittleFS.exists("/dallasoffset.json")) {
+ File configFile = LittleFS.open("/dallasoffset.json", "r");
+ if (configFile) {
+ JsonDocument jsonDoc;
+ DeserializationError error = deserializeJson(jsonDoc, configFile);
+ configFile.close();
+ if (!error) {
+ for (int i = 0 ; i < dallasDevicecount; i++) {
+ if (!actDallasData[i].known) continue;
+ if (jsonDoc[actDallasData[i].address].is()) {
+ actDallasData[i].offset = sanitizeDallasOffset(jsonDoc[actDallasData[i].address].as());
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+bool changeDallasOffset(PubSubClient &mqtt_client, char* mqtt_topic_base, char* address, const char* value, void (*log_message)(char*)) {
+ char log_msg[256];
+ char mqtt_topic[256];
+ char valueStr[16];
+
+ int slot = findDallasSlot(address);
+ if (slot < 0) {
+ sprintf_P(log_msg, PSTR("Cannot set offset, unknown 1wire sensor: %s"), address); log_message(log_msg);
+ return false;
+ }
+ // from here on use the stored address: the caller's buffer may be reused by an mqtt publish
+
+ // accept both "0.3" and "0,3"
+ char buf[16];
+ strlcpy(buf, value, sizeof(buf));
+ for (char* p = buf; *p; p++) {
+ if (*p == ',') *p = '.';
+ }
+ char* end = NULL;
+ float newOffset = strtof(buf, &end);
+ while (end && (*end == ' ')) end++;
+ if ((end == buf) || (end && *end != '\0') || isnan(newOffset) || isinf(newOffset)) {
+ sprintf_P(log_msg, PSTR("Invalid 1wire offset value for %s: %s"), actDallasData[slot].address, value); log_message(log_msg);
+ return false;
+ }
+ newOffset = sanitizeDallasOffset(newOffset);
+
+ float diff = newOffset - actDallasData[slot].offset;
+ actDallasData[slot].offset = newOffset;
+ saveDallasOffsetFile();
+ sprintf_P(log_msg, PSTR("1wire sensor %s offset set to %.1f"), actDallasData[slot].address, newOffset); log_message(log_msg);
+
+ sprintf_P(valueStr, PSTR("%.1f"), newOffset);
+ sprintf_P(mqtt_topic, PSTR("%s/%s/%s/offset"), mqtt_topic_base, mqtt_topic_1wire, actDallasData[slot].address); mqtt_client.publish(mqtt_topic, valueStr, MQTT_RETAIN_VALUES);
+
+ if (actDallasData[slot].temperature != -127.0) {
+ // shift the last known value right away, so mqtt, web and rules don't have to wait for the next reading
+ actDallasData[slot].temperature += diff;
+ sprintf_P(valueStr, PSTR("%.2f"), actDallasData[slot].temperature);
+ sprintf_P(mqtt_topic, PSTR("%s/%s/%s"), mqtt_topic_base, mqtt_topic_1wire, actDallasData[slot].address); mqtt_client.publish(mqtt_topic, valueStr, MQTT_RETAIN_VALUES);
+ sprintf_P(log_msg, PSTR("{\"data\": {\"dallasvalues\": {\"sensorID\": \"%s\", \"value\": %.2f, \"offset\": %.1f}}}"), actDallasData[slot].address, actDallasData[slot].temperature, newOffset);
+ websocket_write_all(log_msg, strlen(log_msg));
+ rules_event_cb(_F("ds18b20#"), actDallasData[slot].address);
+ } else {
+ sprintf_P(log_msg, PSTR("{\"data\": {\"dallasvalues\": {\"sensorID\": \"%s\", \"offset\": %.1f}}}"), actDallasData[slot].address, newOffset);
+ websocket_write_all(log_msg, strlen(log_msg));
+ }
+ return true;
+}
diff --git a/HeishaMon/dallas.h b/HeishaMon/dallas.h
index cb578f1a..b06ff84f 100644
--- a/HeishaMon/dallas.h
+++ b/HeishaMon/dallas.h
@@ -7,6 +7,7 @@
#include "src/common/webserver.h"
#define MAX_DALLAS_SENSORS 15
+#define DALLAS_OFFSET_MAX 10.0 // max absolute calibration offset in degrees Celsius (set in steps of 0.1)
#if defined(ESP8266)
#define ONE_WIRE_BUS 4
#elif defined(ESP32)
@@ -21,6 +22,7 @@ struct dallasDataStruct {
DeviceAddress sensor;
char address[17] = "";
char alias[32] = "NOT SET";
+ float offset = 0.0; // calibration offset (degrees Celsius) added to every raw reading of this sensor
};
void resetlastalldatatime_dallas();
@@ -29,6 +31,7 @@ void initDallasSensors(void (*log_message)(char*), unsigned int updataAllDallasT
void rescanDallasSensors(void (*log_message)(char*), unsigned int dallasResolution);
void dallasJsonOutput(struct webserver_t *client);
void changeDallasAlias(char* address, char* alias);
+bool changeDallasOffset(PubSubClient &mqtt_client, char* mqtt_topic_base, char* address, const char* value, void (*log_message)(char*));
void removeDallasSensor(PubSubClient &mqtt_client, char* mqtt_topic_base, char* address, void (*log_message)(char*));
void restoreDallasFromMqtt(char* address, float temperature, void (*log_message)(char*));
diff --git a/HeishaMon/htmlcode.h b/HeishaMon/htmlcode.h
index 09299d66..0131725d 100644
--- a/HeishaMon/htmlcode.h
+++ b/HeishaMon/htmlcode.h
@@ -334,6 +334,16 @@ input:disabled + .theme-slider-compact {
background:var(--bg-elevated);
}
.alias-edit:focus{border-color:var(--border-focus);box-shadow:0 0 0 2px var(--accent-glow)}
+.offset-edit{
+ width:64px;
+ padding:3px 6px;
+ font-size:12.5px;
+ color:var(--text-secondary);
+ background:transparent;
+ border:1px solid var(--border);
+ border-radius:var(--radius-sm);
+}
+.offset-edit:focus{outline:none;border-color:var(--border-focus);box-shadow:0 0 0 2px var(--accent-glow)}
.tab-pane{display:none}
.tab-pane.active{display:block}
.settings-grid{
@@ -884,6 +894,10 @@ function startWebsockets(){
var dID=j.data.dallasvalues.sensorID;
if(j.data.dallasvalues.value!==undefined)updCell('SensorID-'+dID+'-Temperature',j.data.dallasvalues.value);
if(j.data.dallasvalues.present!==undefined)updDallasPresence(dID,j.data.dallasvalues.present);
+ if(j.data.dallasvalues.offset!==undefined){
+ var oi=document.getElementById('SensorID-'+dID+'-OffsetInput');
+ if(oi&&document.activeElement!==oi)oi.value=Number(j.data.dallasvalues.offset).toFixed(1);
+ }
} else if(j.data.s0values){
updCell('s0port-'+j.data.s0values.s0port+'-Watt',j.data.s0values.Watt);
updCell('s0port-'+j.data.s0values.s0port+'-Watthour',j.data.s0values.Watthour);
@@ -1036,6 +1050,17 @@ var dallasAliasEdit=function(){
xhr.open('GET','/dallasalias?'+addr+'='+alias,true);
xhr.send();
};
+var dallasOffsetEdit=function(){
+ isEditing=false;
+ var addr=this.getAttribute('data-address');
+ var v=parseFloat(String(this.value).replace(',','.'));
+ if(isNaN(v))return;
+ v=Math.max(-10,Math.min(10,Math.round(v*10)/10));
+ this.value=v.toFixed(1);
+ var xhr=new XMLHttpRequest();
+ xhr.open('GET','/dallasoffset?'+addr+'='+encodeURIComponent(v.toFixed(1)),true);
+ xhr.send();
+};
function rescanDallas(){
var xhr=new XMLHttpRequest();
xhr.open('GET','/scandallas',true);
@@ -1056,7 +1081,7 @@ function renderDallasTable(d){
var row=document.createElement('tr');
var sID=item['Sensor'];
if(!item.Present)row.style.opacity='0.6';
- ['Sensor','Temperature','Alias'].forEach(function(k){
+ ['Sensor','Temperature','Offset','Alias'].forEach(function(k){
var cell=document.createElement('td');
cell.id='SensorID-'+sID+'-'+k;
if(k==='Alias'){
@@ -1068,6 +1093,18 @@ function renderDallasTable(d){
div.addEventListener('focus',function(){isEditing=true;});
div.addEventListener('blur',dallasAliasEdit);
cell.appendChild(div);
+ } else if(k==='Offset'){
+ var inp=document.createElement('input');
+ inp.type='number';inp.step='0.1';inp.min='-10';inp.max='10';
+ inp.value=(item.Offset!==undefined?Number(item.Offset):0).toFixed(1);
+ inp.className='offset-edit';
+ inp.id='SensorID-'+sID+'-OffsetInput';
+ inp.title='Calibration offset in °C, added to the raw sensor reading';
+ inp.setAttribute('data-address',item.Sensor);
+ inp.addEventListener('focus',function(){isEditing=true;});
+ inp.addEventListener('blur',function(){isEditing=false;});
+ inp.addEventListener('change',dallasOffsetEdit);
+ cell.appendChild(inp);
} else {cell.textContent=item[k];}
row.appendChild(cell);
});
@@ -1320,9 +1357,9 @@ static const char webBodyRootDallasValues[] FLASHPROG = R"====(
- | Sensor | Temperature | Alias | Status | |
+ Sensor | Temperature | Offset (°C) | Alias | Status | |
- | Loading… |
+ | Loading… |
)====";
diff --git a/README.md b/README.md
index df882856..38156246 100644
--- a/README.md
+++ b/README.md
@@ -367,6 +367,16 @@ As until today we don't know how the commands sent to the heatpump are processed
## DS18b20 1-wire support
The software also supports ds18b20 1-wire temperature sensors reading. A proper 1-wire configuration (with 4.7kohm pull-up resistor) connected to GPIO4 will be read each configured secs (minimal 5) and send at the panasonic_heat_pump/1wire/"sensor-hex-address" topic. On the pre-made boards this 4.7kohm resistor is already installed.
+### Calibration offset
+
+Each sensor can get a calibration offset (in °C, steps of 0.1, range -10.0 to +10.0) which is added to every raw reading. The corrected value is what is published to MQTT, shown on the web page, returned in the JSON output and used by the rules (`ds18b20#...`). The offset can be set:
+
+- in the web interface, in the "Offset" column of the 1-wire sensor table
+- over HTTP: `http://heishamon.local/dallasoffset?=0.3`
+- over MQTT: publish the value to `panasonic_heat_pump/1wire//offset/set` (both `0.3` and `0,3` are accepted)
+
+The active offset is published (retained) on `panasonic_heat_pump/1wire//offset` and returned as `Offset` in the `/json` output. Offsets are stored in `/dallasoffset.json` on the device and survive a reboot. A typical calibration: put all sensors together in one well-stirred container of water, compare them against a reference thermometer (or against their average) and set the difference as offset.
+
## 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.