Added reconnecting-websocket.js to automatic reconnect the websocket if the connection is lost. Usefull for mobile devices that go to sleep after a while. Changed mon_fermenters to use websockets instead of polling. Fixed wrong temperature color ranges on the fermenter monior. Increased the websocket receive buffer to 2048. In cannot overflow, but larger messages are chunked and the application does not handle these split messages. Needs termferm 0.9.9 or newer.

Mon, 18 May 2020 11:00:59 +0200

author
Michiel Broek <mbroek@mbse.eu>
date
Mon, 18 May 2020 11:00:59 +0200
changeset 679
48f8f3fce7c0
parent 678
14322825cb3d
child 680
0bb48333d133

Added reconnecting-websocket.js to automatic reconnect the websocket if the connection is lost. Usefull for mobile devices that go to sleep after a while. Changed mon_fermenters to use websockets instead of polling. Fixed wrong temperature color ranges on the fermenter monior. Increased the websocket receive buffer to 2048. In cannot overflow, but larger messages are chunked and the application does not handle these split messages. Needs termferm 0.9.9 or newer.

bmsd/Makefile file | annotate | diff | comparison | revisions
bmsd/fermenters.c file | annotate | diff | comparison | revisions
bmsd/fermenters.h file | annotate | diff | comparison | revisions
bmsd/mqtt.c file | annotate | diff | comparison | revisions
bmsd/mqtt.h file | annotate | diff | comparison | revisions
bmsd/websocket.c file | annotate | diff | comparison | revisions
www/Makefile file | annotate | diff | comparison | revisions
www/cmd_fermenter.php file | annotate | diff | comparison | revisions
www/includes/global.inc.php file | annotate | diff | comparison | revisions
www/js/global.js file | annotate | diff | comparison | revisions
www/js/mon_fermenter.js file | annotate | diff | comparison | revisions
www/js/reconnecting-websocket.js file | annotate | diff | comparison | revisions
www/js/reconnecting-websocket.min.js file | annotate | diff | comparison | revisions
www/mon_fermenter.php file | annotate | diff | comparison | revisions
--- a/bmsd/Makefile	Thu May 14 14:38:20 2020 +0200
+++ b/bmsd/Makefile	Mon May 18 11:00:59 2020 +0200
@@ -59,12 +59,12 @@
 lock.o: lock.h bms.h futil.h
 nodes.o: bms.h xutil.h nodes.h mysql.h websocket.h
 futil.o: bms.h futil.h
-fermenters.o: bms.h xutil.h fermenters.h mysql.h websocket.h
+fermenters.o: bms.h xutil.h fermenters.h mysql.h websocket.h mqtt.h
 co2meters.o: bms.h xutil.h co2meters.h mysql.h websocket.h
 ispindels.o: bms.h xutil.h ispindels.h mysql.h nodes.h websocket.h
 bms.o: bms.h xutil.h futil.h rdconfig.h lock.h mqtt.h mysql.h nodes.h websocket.h
 xutil.o: bms.h xutil.h
 rdconfig.o: bms.h xutil.h futil.h rdconfig.h
 mysql.o: bms.h xutil.h mysql.h nodes.h
-websocket.o: bms.h xutil.h websocket.h co2meters.h
+websocket.o: bms.h xutil.h websocket.h fermenters.h co2meters.h
 # End of generated dependencies
--- a/bmsd/fermenters.c	Thu May 14 14:38:20 2020 +0200
+++ b/bmsd/fermenters.c	Mon May 18 11:00:59 2020 +0200
@@ -27,6 +27,7 @@
 #include "xutil.h"
 #include "fermenters.h"
 #include "mysql.h"
+#include "mqtt.h"
 #include "websocket.h"
 
 
@@ -36,15 +37,401 @@
 extern sys_config       Config;
 
 
+void fermenter_ws_send(sys_fermenter_list *fermenter)
+{
+    char	*msg = NULL, buf[65];
+
+    msg = xstrcpy((char *)"{\"device\":\"fermenters\",\"node\":\"");
+    msg = xstrcat(msg, fermenter->node);
+    msg = xstrcat(msg, (char *)"\",\"unit\":\"");
+    msg = xstrcat(msg, fermenter->alias);
+    msg = xstrcat(msg, (char *)"\",\"online\":");
+    msg = xstrcat(msg, fermenter->online ? (char *)"1":(char *)"0");
+    msg = xstrcat(msg, (char *)",\"mode\":\"");
+    msg = xstrcat(msg, fermenter->mode);
+    msg = xstrcat(msg, (char *)"\",\"beeruuid\":\"");
+    msg = xstrcat(msg, fermenter->beeruuid);
+    msg = xstrcat(msg, (char *)"\",\"beercode\":\"");
+    msg = xstrcat(msg, fermenter->beercode);
+    msg = xstrcat(msg, (char *)"\",\"beername\":\"");
+    msg = xstrcat(msg, fermenter->beername);
+    msg = xstrcat(msg, (char *)"\",\"yeast_lo\":");
+    snprintf(buf, 64, "%.3f", fermenter->yeast_lo);
+    msg = xstrcat(msg, buf);
+    msg = xstrcat(msg, (char *)",\"yeast_hi\":");
+    snprintf(buf, 64, "%.3f", fermenter->yeast_hi);
+    msg = xstrcat(msg, buf);
+    if (fermenter->air_address) {
+	msg = xstrcat(msg, (char *)",\"air_state\":\"");
+	msg = xstrcat(msg, fermenter->air_state);
+        msg = xstrcat(msg, (char *)"\",\"air_temperature\":");
+        snprintf(buf, 64, "%.3f", fermenter->air_temperature);
+        msg = xstrcat(msg, buf);
+    }
+    if (fermenter->beer_address) {
+	msg = xstrcat(msg, (char *)",\"beer_state\":\"");
+	msg = xstrcat(msg, fermenter->beer_state);
+        msg = xstrcat(msg, (char *)"\",\"beer_temperature\":");
+        snprintf(buf, 64, "%.3f", fermenter->beer_temperature);
+        msg = xstrcat(msg, buf);
+    }
+    if (fermenter->chiller_address) {
+	msg = xstrcat(msg, (char *)",\"chiller_state\":\"");
+	msg = xstrcat(msg, fermenter->chiller_state);
+        msg = xstrcat(msg, (char *)"\",\"chiller_temperature\":");
+        snprintf(buf, 64, "%.3f", fermenter->chiller_temperature);
+        msg = xstrcat(msg, buf);
+    }
+    if (fermenter->heater_address) {
+        msg = xstrcat(msg, (char *)",\"heater_state\":");
+        snprintf(buf, 64, "%d", fermenter->heater_state);
+        msg = xstrcat(msg, buf);
+    }
+    if (fermenter->cooler_address) {
+        msg = xstrcat(msg, (char *)",\"cooler_state\":");
+        snprintf(buf, 64, "%d", fermenter->cooler_state);
+        msg = xstrcat(msg, buf);
+    }
+    if (fermenter->fan_address) {
+        msg = xstrcat(msg, (char *)",\"fan_state\":");
+        snprintf(buf, 64, "%d", fermenter->fan_state);
+        msg = xstrcat(msg, buf);
+    }
+    if (fermenter->light_address) {
+	msg = xstrcat(msg, (char *)",\"light_address\":\"");
+	msg = xstrcat(msg, fermenter->light_address);
+        msg = xstrcat(msg, (char *)"\",\"light_state\":");
+        snprintf(buf, 64, "%d", fermenter->light_state);
+        msg = xstrcat(msg, buf);
+    }
+    if (fermenter->door_address) {
+	msg = xstrcat(msg, (char *)",\"door_address\":\"");
+        msg = xstrcat(msg, fermenter->door_address);
+        msg = xstrcat(msg, (char *)"\",\"door_state\":");
+        snprintf(buf, 64, "%d", fermenter->door_state);
+        msg = xstrcat(msg, buf);
+    }
+    if (fermenter->psu_address) {
+        msg = xstrcat(msg, (char *)",\"psu_address\":\"");
+        msg = xstrcat(msg, fermenter->psu_address);
+        msg = xstrcat(msg, (char *)"\",\"psu_state\":");
+        snprintf(buf, 64, "%d", fermenter->psu_state);
+        msg = xstrcat(msg, buf);
+    }
+    msg = xstrcat(msg, (char *)",\"setpoint_low\":");
+    snprintf(buf, 64, "%.3f", fermenter->setpoint_low);
+    msg = xstrcat(msg, buf);
+    msg = xstrcat(msg, (char *)",\"setpoint_high\":");
+    snprintf(buf, 64, "%.3f", fermenter->setpoint_high);
+    msg = xstrcat(msg, buf);
+    msg = xstrcat(msg, (char *)",\"alarm\":");
+    snprintf(buf, 64, "%d", fermenter->alarm);
+    msg = xstrcat(msg, buf);
+    msg = xstrcat(msg, (char *)",\"stage\":\"");
+    msg = xstrcat(msg, fermenter->stage);
+    msg = xstrcat(msg, (char *)"\"");
+
+    if (fermenter->profile_uuid) {
+	msg = xstrcat(msg, (char *)",\"profile_uuid\":\"");
+	msg = xstrcat(msg, fermenter->profile_uuid);
+	msg = xstrcat(msg, (char *)"\",\"profile_name\":\"");
+	msg = xstrcat(msg, fermenter->profile_name);
+	msg = xstrcat(msg, (char *)"\",\"profile_state\":\"");
+	msg = xstrcat(msg, fermenter->profile_state);
+	msg = xstrcat(msg, (char *)"\",\"profile_percent\":");
+	snprintf(buf, 64, "%d", fermenter->profile_percent);
+	msg = xstrcat(msg, buf);
+	msg = xstrcat(msg, (char *)",\"profile_inittemp_high\":");
+	snprintf(buf, 64, "%.3f", fermenter->profile_inittemp_high);
+	msg = xstrcat(msg, buf);
+	msg = xstrcat(msg, (char *)",\"profile_inittemp_low\":");
+	snprintf(buf, 64, "%.3f", fermenter->profile_inittemp_low);
+	msg = xstrcat(msg, buf);
+	msg = xstrcat(msg, (char *)",\"profile_steps\":");
+	msg = xstrcat(msg, fermenter->profile_steps);
+    }
+
+    msg = xstrcat(msg, (char *)",\"webcam_url\":\"");
+    msg = xstrcat(msg, fermenter->webcam_url);
+    msg = xstrcat(msg, (char *)"\",\"webcam_light\":");
+    snprintf(buf, 64, "%d", fermenter->webcam_light);
+    msg = xstrcat(msg, buf);
+    msg = xstrcat(msg, (char *)"}");
+    ws_broadcast(msg);
+    free(msg);
+    msg = NULL;
+}
+
+
+
+char *fermenter_paybase(void)
+{
+    static char *tmp;
+    char	buf[33];
+
+    tmp = xstrcpy((char *)"{\"timestamp\":");
+    snprintf(buf, 32, "%ld", time(NULL));
+    tmp = xstrcat(tmp, buf);
+    tmp = xstrcat(tmp, (char *)",\"metric\":");
+    return tmp;
+}
+
+
+void fermenter_ws_receive(char *payload)
+{
+    struct json_object  *jobj, *pobj, *iobj, *val;
+    char                *node = NULL, *alias = NULL, *beeruuid = NULL, *beercode = NULL, *beername = NULL;
+    char		*mode = NULL, *stage = NULL, *profile = NULL, *profile_uuid = NULL, *profile_name = NULL, *profile_steps = NULL;
+    char		*topic = NULL, *pay = NULL, buf[75], *profile_command = NULL;
+    float		setpoint_low = 0, setpoint_high = 0, yeast_lo = 0, yeast_hi = 0, inittemp_lo = 0, inittemp_hi = 0;
+    int			heater_state = -1, cooler_state = -1, fan_state = -1, profile_fridgemode = -1;
+
+    syslog(LOG_NOTICE, "fermenter_ws_receive(%s)", payload);
+
+    /*
+     * Process the JSON formatted payload.
+     */
+    jobj = json_tokener_parse(payload);
+    if (json_object_object_get_ex(jobj, "node", &val))
+        node = xstrcpy((char *)json_object_get_string(val));
+    if (json_object_object_get_ex(jobj, "unit", &val))
+        alias = xstrcpy((char *)json_object_get_string(val));
+    if (json_object_object_get_ex(jobj, "beeruuid", &val))
+        beeruuid = xstrcpy((char *)json_object_get_string(val));
+    if (json_object_object_get_ex(jobj, "beercode", &val))
+        beercode = xstrcpy((char *)json_object_get_string(val));
+    if (json_object_object_get_ex(jobj, "beername", &val))
+        beername = xstrcpy((char *)json_object_get_string(val));
+    if (json_object_object_get_ex(jobj, "mode", &val))
+	mode = xstrcpy((char *)json_object_get_string(val));
+    if (json_object_object_get_ex(jobj, "stage", &val))
+	stage = xstrcpy((char *)json_object_get_string(val));
+    if (json_object_object_get_ex(jobj, "setpoint_low", &val))
+        setpoint_low = json_object_get_double(val);
+    if (json_object_object_get_ex(jobj, "setpoint_high", &val))
+        setpoint_high = json_object_get_double(val);
+    if (json_object_object_get_ex(jobj, "yeast_lo", &val))
+        yeast_lo = json_object_get_double(val);
+    if (json_object_object_get_ex(jobj, "yeast_hi", &val))
+        yeast_hi = json_object_get_double(val);
+    if (json_object_object_get_ex(jobj, "heater_state", &val))
+	heater_state = json_object_get_int(val);
+    if (json_object_object_get_ex(jobj, "cooler_state", &val))
+        cooler_state = json_object_get_int(val);
+    if (json_object_object_get_ex(jobj, "fan_state", &val))
+        fan_state = json_object_get_int(val);
+    if (json_object_object_get_ex(jobj, "profile", &pobj)) {
+	profile = xstrcpy((char *)json_object_get_string(pobj));
+	if (profile == NULL) { // clear profile request
+	    profile = xstrcpy((char *)"null");
+	}
+	if (json_object_object_get_ex(pobj, "uuid", &val)) {
+	    profile_uuid = xstrcpy((char *)json_object_get_string(val));
+	    syslog(LOG_NOTICE, "profile uuid");
+	}
+	if (json_object_object_get_ex(pobj, "name", &val))
+	    profile_name = xstrcpy((char *)json_object_get_string(val));
+	if (json_object_object_get_ex(pobj, "inittemp", &iobj)) {
+
+	    if (json_object_object_get_ex(iobj, "low", &val))
+		inittemp_lo = json_object_get_double(val);
+	    if (json_object_object_get_ex(iobj, "high", &val))
+		inittemp_hi = json_object_get_double(val);
+	}
+	if (json_object_object_get_ex(pobj, "fridgemode", &val))
+	    profile_fridgemode = json_object_get_int(val);
+	if (json_object_object_get_ex(pobj, "steps", &val))
+	    profile_steps = xstrcpy((char *)json_object_get_string(val));
+	if (json_object_object_get_ex(pobj, "command", &val)) {
+	    profile_command = xstrcpy((char *)json_object_get_string(val));
+	    syslog(LOG_NOTICE, "profile command %s", profile_command);
+	}
+    }
+    json_object_put(jobj);
+
+    /*
+     * Prepare MQTT topic
+     */
+    topic = xstrcpy((char *)"mbv1.0/fermenters/DCMD/");
+    topic = xstrcat(topic, node);
+    topic = xstrcat(topic, (char *)"/");
+    topic = xstrcat(topic, alias);
+
+    if (node && alias) {
+    	if (mode) {
+	    syslog(LOG_NOTICE, "Set fermenter %s/%s mode %s", node, alias, mode);
+	    pay = fermenter_paybase();
+	    pay = xstrcat(pay, (char *)"{\"mode\":\"");
+	    pay = xstrcat(pay, mode);
+	    pay = xstrcat(pay, (char *)"\"}}");
+	    mqtt_publish(topic, pay);
+	    free(pay);
+	    pay = NULL;
+    	}
+
+    	if (stage) {
+	    syslog(LOG_NOTICE, "Set fermenter %s/%s stage %s", node, alias, stage);
+	    pay = fermenter_paybase();
+            pay = xstrcat(pay, (char *)"{\"stage\":\"");
+            pay = xstrcat(pay, stage);
+            pay = xstrcat(pay, (char *)"\"}}");
+            mqtt_publish(topic, pay);
+            free(pay);
+            pay = NULL;
+    	}
+
+    	if (setpoint_low > 0 && setpoint_high > 0 && setpoint_high >= setpoint_low) {
+	    syslog(LOG_NOTICE, "Set fermenter %s/%s setpoint %.1f %.1f", node, alias, setpoint_low, setpoint_high);
+	    pay = fermenter_paybase();
+	    pay = xstrcat(pay, (char *)"{\"setpoint\":{\"low\":");
+	    snprintf(buf, 64, "%.1f", setpoint_low);
+	    pay = xstrcat(pay, buf);
+	    pay = xstrcat(pay, (char *)",\"high\":");
+	    snprintf(buf, 64, "%.1f", setpoint_high);
+	    pay = xstrcat(pay, buf);
+	    pay = xstrcat(pay, (char *)"}}}");
+            mqtt_publish(topic, pay);
+            free(pay);
+            pay = NULL;
+    	}
+
+        if (heater_state >= 0) {
+            syslog(LOG_NOTICE, "Set fermenter %s/%s heater %d", node, alias, heater_state);
+            pay = fermenter_paybase();
+            pay = xstrcat(pay, (char *)"{\"heater\":{\"state\":");
+	    snprintf(buf, 64, "%d", heater_state);
+	    pay = xstrcat(pay, buf);
+            pay = xstrcat(pay, (char *)"}}}");
+            mqtt_publish(topic, pay);
+            free(pay);
+            pay = NULL;
+        }
+
+        if (cooler_state >= 0) {
+            syslog(LOG_NOTICE, "Set fermenter %s/%s cooler %d", node, alias, cooler_state);
+            pay = fermenter_paybase();
+            pay = xstrcat(pay, (char *)"{\"cooler\":{\"state\":");
+            snprintf(buf, 64, "%d", cooler_state);
+            pay = xstrcat(pay, buf);
+            pay = xstrcat(pay, (char *)"}}}");
+            mqtt_publish(topic, pay);
+            free(pay);
+            pay = NULL;
+        }
+
+        if (fan_state >= 0) {
+            syslog(LOG_NOTICE, "Set fermenter %s/%s fan %d", node, alias, fan_state);
+            pay = fermenter_paybase();
+            pay = xstrcat(pay, (char *)"{\"fan\":{\"state\":");
+            snprintf(buf, 64, "%d", fan_state);
+            pay = xstrcat(pay, buf);
+            pay = xstrcat(pay, (char *)"}}}");
+            mqtt_publish(topic, pay);
+            free(pay);
+            pay = NULL;
+        }
+
+	if (beeruuid && beercode && beername && (yeast_hi > yeast_lo) && (yeast_lo > 0)) {
+	    syslog(LOG_NOTICE, "Set fermenter %s/%s beer %s %s", node, alias, beercode, beername);
+            pay = fermenter_paybase();
+            pay = xstrcat(pay, (char *)"{\"product\":{\"uuid\":\"");
+	    pay = xstrcat(pay, beeruuid);
+	    pay = xstrcat(pay, (char *)"\",\"code\":\"");
+	    pay = xstrcat(pay, beercode);
+	    pay = xstrcat(pay, (char *)"\",\"name\":\"");
+	    pay = xstrcat(pay, beername);
+	    pay = xstrcat(pay, (char *)"\",\"yeast_lo\":");
+	    snprintf(buf, 64, "%.1f", yeast_lo);
+	    pay = xstrcat(pay, buf);
+	    pay = xstrcat(pay, (char *)",\"yeast_hi\":");
+            snprintf(buf, 64, "%.1f", yeast_hi);
+            pay = xstrcat(pay, buf);
+	    pay = xstrcat(pay, (char *)"}}}");
+            mqtt_publish(topic, pay);
+            free(pay);
+            pay = NULL;
+	}
+
+	if (profile) {
+	    syslog(LOG_NOTICE, "%s", profile);
+	    if (strcmp(profile, (char *)"null") == 0) {
+		syslog(LOG_NOTICE, "Set fermenter %s/%s profile null", node, alias);
+            	pay = fermenter_paybase();
+            	pay = xstrcat(pay, (char *)"{\"profile\":null}}");
+            	mqtt_publish(topic, pay);
+            	free(pay);
+            	pay = NULL;
+	    } else if (profile_uuid && profile_name && profile_steps) {
+		syslog(LOG_NOTICE, "Set fermenter %s/%s profile %s", node, alias, profile_name);
+		pay = fermenter_paybase();
+                pay = xstrcat(pay, (char *)"{\"profile\":{\"uuid\":\"");
+		pay = xstrcat(pay, profile_uuid);
+		pay = xstrcat(pay, (char *)"\",\"name\":\"");
+		pay = xstrcat(pay, profile_name);
+		pay = xstrcat(pay, (char *)"\",\"inittemp\":{\"low\":");
+		snprintf(buf, 64, "%.1f", inittemp_lo);
+		pay = xstrcat(pay, buf);
+		pay = xstrcat(pay, (char *)",\"high\":");
+		snprintf(buf, 64, "%.1f", inittemp_hi);
+                pay = xstrcat(pay, buf);
+		pay = xstrcat(pay, (char *)"},\"fridgemode\":");
+		snprintf(buf, 64, "%d", profile_fridgemode);
+		pay = xstrcat(pay, buf);
+		pay = xstrcat(pay, (char *)",\"steps\":");
+		pay = xstrcat(pay, profile_steps);
+		pay = xstrcat(pay, (char *)"}}}");
+		mqtt_publish(topic, pay);
+                free(pay);
+                pay = NULL;
+	    } else if (profile_command) {
+		syslog(LOG_NOTICE, "Set fermenter %s/%s profile command %s", node, alias, profile_command);
+		pay = fermenter_paybase();
+                pay = xstrcat(pay, (char *)"{\"profile\":{\"command\":\"");
+		pay = xstrcat(pay, profile_command);
+		pay = xstrcat(pay, (char *)"\"}}}");
+                mqtt_publish(topic, pay);
+                free(pay);
+                pay = NULL;
+	    }
+	}
+    }
+
+    free(topic);
+    if (node)
+        free(node);
+    if (alias)
+        free(alias);
+    if (beeruuid)
+        free(beeruuid);
+    if (beercode)
+        free(beercode);
+    if (beername)
+        free(beername);
+    if (mode)
+	free(mode);
+    if (stage)
+	free(stage);
+    if (profile)
+	free(profile);
+    if (profile_uuid)
+	free(profile_uuid);
+    if (profile_name)
+	free(profile_name);
+    if (profile_steps)
+	free(profile_steps);
+    if (profile_command)
+	free(profile_command);
+}
+
+
 
 void fermenter_set(char *edge_node, char *alias, bool birth, char *payload)
 {
     struct json_object	*jobj, *val, *sensor, *temp;
     sys_fermenter_list	*fermenter, *tmpp;
     bool		new_fermenter = true;
-    char		*msg = NULL, buf[65];
 
-//    fprintf(stdout, "fermenter_set: %s/%s %s %s\n", edge_node, alias, birth ? "BIRTH":"DATA", payload);
+    //syslog(LOG_NOTICE, "fermenter_set: %s/%s %s %s", edge_node, alias, birth ? "BIRTH":"DATA", payload);
 
     /*
      * Search fermenter record in the memory array and use it if found.
@@ -267,8 +654,6 @@
     }
     if (json_object_object_get_ex(jobj, "profile", &sensor)) {
 	if (strcmp(json_object_to_json_string_ext(sensor, 0), "null")) {
-//	    printf("profile: %s\n", json_object_to_json_string_ext(sensor, 0));
-
 	    if (json_object_object_get_ex(sensor, "uuid", &val)) {
 		if (fermenter->profile_uuid)
 		    free(fermenter->profile_uuid);
@@ -314,82 +699,11 @@
 	    fermenter->profile_uuid = fermenter->profile_name = fermenter->profile_state = fermenter->profile_steps = NULL;
 	    fermenter->profile_percent = 0;
 	    fermenter->profile_inittemp_high = fermenter->profile_inittemp_low = 0.0;
-	    fermenter->yeast_lo = 12;
-	    fermenter->yeast_hi = 24;
 	}
     }
     json_object_put(jobj);
 
-    msg = xstrcpy((char *)"{\"device\":\"fermenters\",\"node\":\"");
-    msg = xstrcat(msg, edge_node);
-    msg = xstrcat(msg, (char *)"\",\"unit\":\"");
-    msg = xstrcat(msg, alias);
-    msg = xstrcat(msg, (char *)"\",\"online\":");
-    msg = xstrcat(msg, fermenter->online ? (char *)"1":(char *)"0");
-    msg = xstrcat(msg, (char *)",\"mode\":\"");
-    msg = xstrcat(msg, fermenter->mode);
-    msg = xstrcat(msg, (char *)"\",\"yeast_lo\":");
-    snprintf(buf, 64, "%.3f", fermenter->yeast_lo);
-    msg = xstrcat(msg, buf);
-    msg = xstrcat(msg, (char *)",\"yeast_hi\":");
-    snprintf(buf, 64, "%.3f", fermenter->yeast_hi);
-    msg = xstrcat(msg, buf);
-    if (fermenter->air_address) {
-    	msg = xstrcat(msg, (char *)",\"air\":");
-	snprintf(buf, 64, "%.3f", fermenter->air_temperature);
-	msg = xstrcat(msg, buf);
-    }
-    if (fermenter->beer_address) {
-        msg = xstrcat(msg, (char *)",\"beer\":");
-        snprintf(buf, 64, "%.3f", fermenter->beer_temperature);
-        msg = xstrcat(msg, buf);
-    }
-    if (fermenter->chiller_address) {
-        msg = xstrcat(msg, (char *)",\"chiller\":");
-        snprintf(buf, 64, "%.3f", fermenter->chiller_temperature);
-        msg = xstrcat(msg, buf);
-    }
-    if (fermenter->heater_address) {
-        msg = xstrcat(msg, (char *)",\"heater\":");
-        snprintf(buf, 64, "%d", fermenter->heater_state);
-        msg = xstrcat(msg, buf);
-    }
-    if (fermenter->cooler_address) {
-        msg = xstrcat(msg, (char *)",\"cooler\":");
-        snprintf(buf, 64, "%d", fermenter->cooler_state);
-        msg = xstrcat(msg, buf);
-    }
-    if (fermenter->fan_address) {
-        msg = xstrcat(msg, (char *)",\"fan\":");
-        snprintf(buf, 64, "%d", fermenter->fan_state);
-        msg = xstrcat(msg, buf);
-    }
-    if (fermenter->light_address) {
-        msg = xstrcat(msg, (char *)",\"light\":");
-        snprintf(buf, 64, "%d", fermenter->light_state);
-        msg = xstrcat(msg, buf);
-    }
-    if (fermenter->door_address) {
-        msg = xstrcat(msg, (char *)",\"door\":");
-        snprintf(buf, 64, "%d", fermenter->door_state);
-        msg = xstrcat(msg, buf);
-    }
-    msg = xstrcat(msg, (char *)",\"sp_lo\":");
-    snprintf(buf, 64, "%.3f", fermenter->setpoint_low);
-    msg = xstrcat(msg, buf);
-    msg = xstrcat(msg, (char *)",\"sp_hi\":");
-    snprintf(buf, 64, "%.3f", fermenter->setpoint_high);
-    msg = xstrcat(msg, buf);
-    msg = xstrcat(msg, (char *)",\"alarm\":");
-    snprintf(buf, 64, "%d", fermenter->alarm);
-    msg = xstrcat(msg, buf);
-    msg = xstrcat(msg, (char *)",\"stage\":\"");
-    msg = xstrcat(msg, fermenter->stage);
-    msg = xstrcat(msg, (char *)"\"}");
-    ws_broadcast(msg);
-    free(msg);
-    msg = NULL;
-
+    fermenter_ws_send(fermenter);
 //    fermenter_dump(fermenter);
 
     if (new_fermenter) {
--- a/bmsd/fermenters.h	Thu May 14 14:38:20 2020 +0200
+++ b/bmsd/fermenters.h	Mon May 18 11:00:59 2020 +0200
@@ -8,6 +8,12 @@
 void fermenter_dump(sys_fermenter_list *fermenter);
 
 /**
+ * @brief Process received command from a websocket.
+ * @param payload The received data in JSON format.
+ */
+void fermenter_ws_receive(char *payload);
+
+/**
  * @brief Birth of a fermenter or data update. Create it in the database if 
  *        never seen before, else just update the database entry.
  * @param topic The MQTT topic string, contains the fermenter type and name.
--- a/bmsd/mqtt.c	Thu May 14 14:38:20 2020 +0200
+++ b/bmsd/mqtt.c	Mon May 18 11:00:59 2020 +0200
@@ -222,6 +222,13 @@
 
 
 
+void mqtt_publish(char *topic, char *payload)
+{
+    mosquitto_publish(mosq, &mqtt_mid_sent, topic, strlen(payload), payload, mqtt_qos, false);
+}
+
+
+
 void publishNData(bool birth, int flag)
 {
     char                *topic = NULL, *payload = NULL;
--- a/bmsd/mqtt.h	Thu May 14 14:38:20 2020 +0200
+++ b/bmsd/mqtt.h	Mon May 18 11:00:59 2020 +0200
@@ -7,6 +7,13 @@
 
 
 /**
+ * @brief Publish MQTT message.
+ * @param topic The topic part of the message.
+ * @param payload The payload part of the message.
+ */
+void mqtt_publish(char *topic, char *payload);
+
+/**
  * @brief Connect to the MQTT server.
  * @return 0 if success, else the connection failed.
  */
--- a/bmsd/websocket.c	Thu May 14 14:38:20 2020 +0200
+++ b/bmsd/websocket.c	Mon May 18 11:00:59 2020 +0200
@@ -27,6 +27,7 @@
 #include "bms.h"
 #include "xutil.h"
 #include "websocket.h"
+#include "fermenters.h"
 #include "co2meters.h"
 #include <libwebsockets.h>
 
@@ -48,6 +49,9 @@
  */
 #define MAX_MESSAGE_QUEUE 512
 
+#define WS_INBUF	  2048
+
+
 /*
  * one of these created for each message
  */
@@ -66,7 +70,7 @@
 {
     struct per_session_data__lws_mirror *pss = (struct per_session_data__lws_mirror *)user;
     int		n, m;
-    char	buf[513];
+    char	buf[WS_INBUF + 1];
 
     switch (reason) {
 
@@ -116,7 +120,9 @@
 		memcpy(buf, in, len);
 		buf[len] = '\0';
 		syslog(LOG_NOTICE, "ws: reveived %ld bytes %s", len, buf);
-		if (strncmp(buf, (char *)"{\"device\":\"co2meters\",", 22) == 0) {
+		if (strncmp(buf, (char *)"{\"device\":\"fermenters\",", 23) == 0) {
+		    fermenter_ws_receive(buf);
+		} else if (strncmp(buf, (char *)"{\"device\":\"co2meters\",", 22) == 0) {
 		    co2meter_ws_receive(buf);
 		}
 
@@ -137,7 +143,7 @@
 
 
 static struct lws_protocols protocols[] = {
-	{ "bmsd-protocol", callback_ws, sizeof(struct per_session_data__lws_mirror), 512 },
+	{ "bmsd-protocol", callback_ws, sizeof(struct per_session_data__lws_mirror), WS_INBUF },
         { NULL, NULL, 0, 0 } /* terminator */
 };
 
--- a/www/Makefile	Thu May 14 14:38:20 2020 +0200
+++ b/www/Makefile	Mon May 18 11:00:59 2020 +0200
@@ -3,7 +3,7 @@
 
 include ../Makefile.global
 
-SRC		= cmd_fermenter.php cmd_ispindel.php config.php.dist crontasks.php \
+SRC		= cmd_ispindel.php config.php.dist crontasks.php \
 		  export_equipments.php export_fermentables.php export_hops.php export_mashs.php \
 		  export_miscs.php export_styles.php export_suppliers.php export_waters.php \
 		  export_yeasts.php favicon.ico gen_about.php \
--- a/www/cmd_fermenter.php	Thu May 14 14:38:20 2020 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,55 +0,0 @@
-<?php
-require_once('config.php');
-
-/*
- * Sequence number file. Must be mode 666 in directory mode 777
- */
-$sfile = "run/sequence";
-if (fopen($sfile, "r")) {
-	$data = file_get_contents($sfile);
-	$nr = intval($data);
-} else {
-	$nr = 1;
-}
-$nr++;
-if ($fp = fopen($sfile, "w")) {
-	file_put_contents($sfile, $nr);
-	fclose($fp);
-}
-
-
-if (isset($_POST['node'])) {
-	$node = $_POST['node'];
-} else {
-	return 1;
-}
-if (isset($_POST['alias'])) {
-	$alias = $_POST['alias'];
-} else {
-	return 1;
-}
-if (isset($_POST['payload'])) {
-	$payload = $_POST['payload'];
-} else {
-	return 1;
-}
-
-
-/*
- * topic:    mbv1.0/fermenters/DCMD/$node/$alias
- * payload:  {"timestamp":1546956819,"seq":688,"metric":$payload}
- * $payload: {"product":{"code":"HUP001","name":"Hop Hup"}}
- *           {"stage":"PRIMARY","mode":"FRIDGE","setpoint":{"low":17.8,"high":18.2}}
- *           {"heater":{"state":0},"cooler":{"state":0},"fan":{"state":0},"light":{"state":0}}
- *           {"profile":{"uuid":"...","name":"profielnaam","inittemp":{"low":18.2,"high":20.5},"fridgemode":0,
- *              "steps":[{"resttime":12,"steptime":36,"target":{"low":20.8,"high":23.1},"fridgemode":0},
- *              	 {"resttime":48,"steptime":48,"target":{"low":27.5,"high":28.0},"fridgemode":0}]}}
- *           {"profile":{"command":"start/stop/pause/abort"}
- */
-$topic = "mbv1.0/fermenters/DCMD/" . $node . "/" . $alias;
-$payload = '\'{"timestamp":' . time() . ',"seq":' . $nr . ',"metric":' . $payload . '}\'';
-
-syslog(LOG_NOTICE, $topic . ' ' . $payload);
-shell_exec('mosquitto_pub -h '.MQTT_HOST.' -q 0 -t '.$topic.' -m '.$payload);
-
-?>
--- a/www/includes/global.inc.php	Thu May 14 14:38:20 2020 +0200
+++ b/www/includes/global.inc.php	Mon May 18 11:00:59 2020 +0200
@@ -143,6 +143,7 @@
 	my_grain_absorbtion = "<?php echo $my_grain_absorbtion; ?>", my_default_water = "<?php echo $my_default_water; ?>";
   </script>
   <script src="js/jquery-1.11.1.js"></script>
+  <script src="js/reconnecting-websocket.min.js"></script>
   <script src="jqwidgets/jqxcore.js"></script>
   <script src="jqwidgets/jqxwindow.js"></script>
   <script src="jqwidgets/jqxmenu.js"></script>
--- a/www/js/global.js	Thu May 14 14:38:20 2020 +0200
+++ b/www/js/global.js	Mon May 18 11:00:59 2020 +0200
@@ -751,7 +751,8 @@
 mashlist = new $.jqx.dataAdapter(mashProfileSource);
 
 /* Websocket interface. Place "websocket.onmessage = function(evt) {}" in the user script. */
-var websocket = new WebSocket('ws://'+location.hostname+'/ws');
+//var websocket = new WebSocket('ws://'+location.hostname+'/ws');
+var websocket = new ReconnectingWebSocket('ws://'+location.hostname+'/ws');
 
 websocket.onopen = function(evt) {
  console.log('WebSocket connection opened');
--- a/www/js/mon_fermenter.js	Thu May 14 14:38:20 2020 +0200
+++ b/www/js/mon_fermenter.js	Mon May 18 11:00:59 2020 +0200
@@ -46,11 +46,6 @@
  var record = {},
  blank = {},
  ppayload = '',
- newBase = false,
- newProduct = false,
- newSwitch = false,
- newProfile = false,
- schedule = 0,
  yl = 12, // Normal yeast temp range
  yh = 24,
 
@@ -85,9 +80,7 @@
    }
    return data;
   },
-  loadError: function(jqXHR, status, error) {
-   $('#err').text(status + ' ' + error);
-  },
+  loadError: function(jqXHR, status, error) { console.log(status + ' ' + error); },
  }),
  profileSource = {
   datatype: 'json',
@@ -114,8 +107,8 @@
    empty['record'] = -1;
    empty['uuid'] = '';
    empty['name'] = 'Wis profiel';
-   empty['inittemp_lo'] = 20;
-   empty['inittemp_hi'] = 20;
+   empty['inittemp_lo'] = 20.0;
+   empty['inittemp_hi'] = 20.2;
    empty['fridgemode'] = 0;
    empty['totalsteps'] = 0;
    empty['duration'] = 0;
@@ -220,166 +213,192 @@
  dataAdapter = new $.jqx.dataAdapter(source, {
   loadComplete: function(records) {
    record = dataAdapter.records[0];
-   var range, oline = (record.online) ? 'On-line' : 'Off-line';
-   $('#info_uuid').html(record.uuid);
-   $('#info_system').html(record.node + '/' + record.alias);
-   $('#info_online').html(oline);
-   $('#info_beer').html(record.beercode + ' - ' + record.beername);
-   $('#info_mode').jqxDropDownList('selectItem', record.mode);
-   $('#info_stage').jqxDropDownList('selectItem', record.stage);
-   $('#info_profile').html(record.profile_name);
+   updateScreen();
    blank['name'] = record.alias;
    blank['code'] = record.alias.toUpperCase();
    blank['uuid'] = record.uuid;
-   if (record.profile_name == '')
-    $('#info_mode').jqxDropDownList('disableItem', 'PROFILE');
-   else
-    $('#info_mode').jqxDropDownList('enableItem', 'PROFILE');
-   $('#target_lo').val(record.setpoint_low);
-   $('#target_hi').val(record.setpoint_high);
-   if (record.online && ((record.mode == 'FRIDGE') || (record.mode == 'BEER'))) {
-    $('#target_lo').jqxNumberInput({ readOnly: false, Width: 70, spinButtons: true });
-    $('#target_hi').jqxNumberInput({ readOnly: false, Width: 70, spinButtons: true });
-   } else {
-    $('#target_lo').jqxNumberInput({ readOnly: true, Width: 50, spinButtons: false });
-    $('#target_hi').jqxNumberInput({ readOnly: true, Width: 50, spinButtons: false });
-   }
+  }
+ });
+
+ function updateScreen() {
+   $('#info_uuid').html(record.uuid);
+   $('#info_system').html(record.node + '/' + record.alias);
 
-   if (record.online && record.door_address && (record.door_state != '0')) {
-    $('#fermenter_doorled').html('<div class="LEDyellow_on"></div>Door');
-   } else {
-    $('#fermenter_doorled').html('<div class="LEDyellow_off"></div>Door');
-   }
-   if (record.online && record.light_address && (record.light_state != '0')) {
-    $('#fermenter_lightled').html('<div class="LEDyellow_on"></div>Light');
-   } else {
-    $('#fermenter_lightled').html('<div class="LEDyellow_off"></div>Light');
-   }
+   if (record.online) {
+    $('#info_online').html('On-line');
+    $('#info_beer').html(record.beercode + ' - ' + record.beername);
+    $('#info_mode').jqxDropDownList('selectItem', record.mode);
+    $('#info_stage').jqxDropDownList('selectItem', record.stage);
+    if (record.door_address) {
+     if (record.door_state != '0') {
+      $('#fermenter_doorled').html('<div class="LEDyellow_on"></div>Door');
+     } else {
+      $('#fermenter_doorled').html('<div class="LEDyellow_off"></div>Door');
+     }
+    }
+    if (record.light_address) {
+     if (record.light_state != '0') {
+      $('#fermenter_lightled').html('<div class="LEDyellow_on"></div>Light');
+     } else {
+      $('#fermenter_lightled').html('<div class="LEDyellow_off"></div>Light');
+     }
+    }
+    if (record.mode != 'OFF') {
+     $('#fermenter_powerled').html('<div class="LEDblue_on"></div>Power');
+     $('#select_beer').jqxDropDownList({ disabled: true });
+     $('#select_beer').jqxDropDownList('clearSelection');
+     $('#select_beer').hide();
+    } else {
+     $('#fermenter_powerled').html('<div class="LEDblue_off"></div>Power');
+     $('#select_beer').show();
+     $('#select_beer').jqxDropDownList({ disabled: false });
+    }
+    if (record.alarm != '0') {
+     $('#fermenter_alarmled').html('<div class="LEDred_on"></div>Alarm');
+    } else {
+     $('#fermenter_alarmled').html('<div class="LEDred_off"></div>Alarm');
+    }
+
+    $('#target_lo').val(record.setpoint_low);
+    $('#target_hi').val(record.setpoint_high);
+    if ((record.mode == 'FRIDGE') || (record.mode == 'BEER')) {
+     $('#target_lo').jqxNumberInput({ readOnly: false, Width: 70, spinButtons: true });
+     $('#target_hi').jqxNumberInput({ readOnly: false, Width: 70, spinButtons: true });
+    } else {
+     $('#target_lo').jqxNumberInput({ readOnly: true, Width: 50, spinButtons: false });
+     $('#target_hi').jqxNumberInput({ readOnly: true, Width: 50, spinButtons: false });
+    }
 
-   if (record.online && (record.mode != 'OFF')) {
-    $('#fermenter_powerled').html('<div class="LEDblue_on"></div>Power');
-    $('#select_beer').jqxDropDownList({ disabled: true });
-    $('#select_beer').jqxDropDownList('clearSelection');
-    $('#select_beer').hide();
-   } else {
-    $('#fermenter_powerled').html('<div class="LEDblue_off"></div>Power');
-    $('#select_beer').show();
-    $('#select_beer').jqxDropDownList({ disabled: false });
-   }
-   if (record.online && (record.alarm != '0')) {
-    $('#fermenter_alarmled').html('<div class="LEDred_on"></div>Alarm');
-   } else {
-    $('#fermenter_alarmled').html('<div class="LEDred_off"></div>Alarm');
-   }
+    $('.f_control_leds').show();
+    if (record.heater_state != '0') {
+     $('#fermenter_led1').html('<div class="LEDgreen_on"></div>Heat');
+    } else {
+     $('#fermenter_led1').html('<div class="LEDgreen_off"></div>Heat');
+    }
+    if (record.cooler_state != '0') {
+     $('#fermenter_led2').html('<div class="LEDgreen_on"></div>Cool');
+    } else {
+     $('#fermenter_led2').html('<div class="LEDgreen_off"></div>Cool');
+    }
+    if (record.fan_state != '0') {
+     $('#fermenter_led3').html('<div class="LEDgreen_on"></div>Fan');
+    } else {
+     $('#fermenter_led3').html('<div class="LEDgreen_off"></div>Fan');
+    }
+
+    if (record.mode == 'NONE') {
+     $('.f_control_switches').show();
+    } else {
+     $('.f_control_switches').hide();
+    }
+    if ((record.heater_state != '0') != $('#fermenter_toggle1').jqxSwitchButton('val'))
+     $('#fermenter_toggle1').val(record.heater_state != '0');
+    if ((record.cooler_state != '0') != $('#fermenter_toggle2').jqxSwitchButton('val'))
+     $('#fermenter_toggle2').val(record.cooler_state != '0');
+    if ((record.fan_state != '0') != $('#fermenter_toggle3').jqxSwitchButton('val'))
+     $('#fermenter_toggle3').val(record.fan_state != '0');
+
+    $('#info_profile').html(record.profile_name);
+    if (record.profile_name == '')
+     $('#info_mode').jqxDropDownList('disableItem', 'PROFILE');
+    else
+     $('#info_mode').jqxDropDownList('enableItem', 'PROFILE');
 
-   if (record.online && (record.heater_state != '0')) {
-    $('#fermenter_led1').html('<div class="LEDgreen_on"></div>Heat');
-   } else {
-    $('#fermenter_led1').html('<div class="LEDgreen_off"></div>Heat');
-   }
-   if (record.online && (record.cooler_state != '0')) {
-    $('#fermenter_led2').html('<div class="LEDgreen_on"></div>Cool');
-   } else {
-    $('#fermenter_led2').html('<div class="LEDgreen_off"></div>Cool');
-   }
-   if (record.online && (record.fan_state != '0')) {
-    $('#fermenter_led3').html('<div class="LEDgreen_on"></div>Fan');
-   } else {
-    $('#fermenter_led3').html('<div class="LEDgreen_off"></div>Fan');
-   }
-   if (record.online && (record.mode == 'NONE')) {
-    $('#fermenter_toggle1').jqxSwitchButton('enable');
-    $('#fermenter_toggle2').jqxSwitchButton('enable');
-    $('#fermenter_toggle3').jqxSwitchButton('enable');
-   } else {
-    $('#fermenter_toggle1').jqxSwitchButton('disable');
-    $('#fermenter_toggle2').jqxSwitchButton('disable');
-    $('#fermenter_toggle3').jqxSwitchButton('disable');
-    $('#fermenter_toggle1').val(record.heater_state != '0');
-    $('#fermenter_toggle2').val(record.cooler_state != '0');
-    $('#fermenter_toggle3').val(record.fan_state != '0');
-   }
-
-   if (record.online && (record.mode == 'PROFILE')) {
-    if (record.profile_state == 'OFF') {
+    if (record.mode == 'PROFILE') {
+     if (record.profile_state == 'OFF') {
+      $('#select_profile').show();
+      $('#select_profile').jqxDropDownList({ disabled: false });
+      $('#info_mode').jqxDropDownList({ disabled: false });
+      $('#Profile1').jqxButton({ template: 'success', value: 'Starten' });
+      $('#Profile1').show();
+      $('#Profile2').hide();
+      $('#status_profile').html('');
+     } else if (record.profile_state == 'RUN') {
+      $('#select_profile').jqxDropDownList({ disabled: true });
+      $('#select_profile').hide();
+      $('#info_mode').jqxDropDownList({ disabled: true });
+      $('#Profile1').jqxButton({ template: 'danger', value: 'Afbreken' });
+      $('#Profile2').jqxButton({ template: 'primary', value: 'Pauze' });
+      $('#Profile1').show();
+      $('#Profile2').show();
+      $('#status_profile').html('Profiel actief, ' + record.profile_percent + '% gereed');
+     } else if (record.profile_state == 'PAUSE') {
+      $('#select_profile').jqxDropDownList({ disabled: true });
+      $('#select_profile').hide();
+      $('#info_mode').jqxDropDownList({ disabled: true });
+      $('#Profile1').jqxButton({ template: 'danger', value: 'Afbreken' });
+      $('#Profile2').jqxButton({ template: 'success', value: 'Doorgaan' });
+      $('#Profile1').show();
+      $('#Profile2').show();
+      $('#status_profile').html('Profiel pauze, ' + record.profile_percent + '% gereed');
+     } else if (record.profile_state == 'DONE') {
+      $('#select_profile').jqxDropDownList({ disabled: true });
+      $('#select_profile').hide();
+      $('#info_mode').jqxDropDownList({ disabled: true });
+      $('#Profile1').jqxButton({ template: 'primary', value: 'Profiel Ok' });
+      $('#Profile1').show();
+      $('#Profile2').hide();
+      $('#status_profile').html('Profiel is gereed');
+     }
+    } else {
      $('#select_profile').show();
      $('#select_profile').jqxDropDownList({ disabled: false });
      $('#info_mode').jqxDropDownList({ disabled: false });
-     $('#Profile1').jqxButton({ template: 'success', value: 'Starten' });
-     $('#Profile1').show();
+     $('#Profile1').hide();
      $('#Profile2').hide();
      $('#status_profile').html('');
-    } else if (record.profile_state == 'RUN') {
-     $('#select_profile').jqxDropDownList({ disabled: true });
-     $('#select_profile').hide();
-     $('#info_mode').jqxDropDownList({ disabled: true });
-     $('#Profile1').jqxButton({ template: 'danger', value: 'Afbreken' });
-     $('#Profile2').jqxButton({ template: 'primary', value: 'Pauze' });
-     $('#Profile1').show();
-     $('#Profile2').show();
-     $('#status_profile').html('Profiel actief, ' + record.profile_percent + '% gereed');
-    } else if (record.profile_state == 'PAUSE') {
-     $('#select_profile').jqxDropDownList({ disabled: true });
-     $('#select_profile').hide();
-     $('#info_mode').jqxDropDownList({ disabled: true });
-     $('#Profile1').jqxButton({ template: 'danger', value: 'Afbreken' });
-     $('#Profile2').jqxButton({ template: 'success', value: 'Doorgaan' });
-     $('#Profile1').show();
-     $('#Profile2').show();
-     $('#status_profile').html('Profiel pauze, ' + record.profile_percent + '% gereed');
-    } else if (record.profile_state == 'DONE') {
-     $('#select_profile').jqxDropDownList({ disabled: true });
-     $('#select_profile').hide();
-     $('#info_mode').jqxDropDownList({ disabled: true });
-     $('#Profile1').jqxButton({ template: 'primary', value: 'Profiel Ok' });
-     $('#Profile1').show();
-     $('#Profile2').hide();
-     $('#status_profile').html('Profiel is gereed');
+    }
+
+    if (record.webcam_url != '') {
+     $('#Camera').show();
+    } else {
+     $('#Camera').hide();
+    }
+
+    var yl = record.yeast_lo;
+    var yh = record.yeast_hi;
+    var range = { ranges: [{ startValue: 0, endValue: yl, style: { fill: '#3399FF', stroke: '#3399FF' }, endWidth: 10, startWidth: 10 },
+                      { startValue: yl, endValue: yh, style: { fill: '#00CC33', stroke: '#00CC33' }, endWidth: 10, startWidth: 10 },
+                      { startValue: yh, endValue: 45, style: { fill: '#FC6A6A', stroke: '#FC6A6A' }, endWidth: 10, startWidth: 10 }]};
+    $('#gaugeContainer_air').jqxGauge(range);
+    $('#gaugeContainer_beer').jqxGauge(range);
+
+    $('#gaugeContainer_air').jqxGauge({ caption: { value: 'Lucht: ' + record.air_temperature.toFixed(3) }});
+    $('#gaugeContainer_air').jqxGauge({ value: record.air_temperature });
+    if (record.air_state == 'OK') {
+     $('#gaugeContainer_air').jqxGauge({ disabled: false });
+    } else {
+     $('#gaugeContainer_air').jqxGauge({ disabled: true });
     }
-   } else {
-    $('#select_profile').show();
-    $('#select_profile').jqxDropDownList({ disabled: false });
-    $('#info_mode').jqxDropDownList({ disabled: false });
-    $('#Profile1').hide();
-    $('#Profile2').hide();
-    $('#status_profile').html('');
-   }
-   if (record.online && (record.webcam_url != '')) {
-    $('#Camera').show();
-   } else {
+    $('#gaugeContainer_beer').jqxGauge({ caption: { value: 'Bier: ' + record.beer_temperature.toFixed(3) }});
+    $('#gaugeContainer_beer').jqxGauge({ value: record.beer_temperature });
+    if (record.beer_state == 'OK') {
+     $('#gaugeContainer_beer').jqxGauge({ disabled: false });
+    } else {
+     $('#gaugeContainer_beer').jqxGauge({ disabled: true });
+    }
+    $('#gaugeContainer_chiller').jqxGauge({ value: record.chiller_temperature });
+    if (record.chiller_state == 'OK') {
+     $('#gaugeContainer_chiller').jqxGauge({ disabled: false });
+    } else {
+     $('#gaugeContainer_chiller').jqxGauge({ disabled: true });
+    }
+   } else { // offline
+    $('#info_online').html('Off-line');
+    $('#info_beer').html('');
+    $('#info_mode').hide();
+    $('#info_stage').hide();
+    $('#select_beer').hide();
+    $('#select_profile').hide();
+    $('.f_display,.f_control_switches,.f_control_leds').hide();
+    $('#fermenter_powerled').html('<div class="LEDblue_off"></div>Power');
+    $('#fermenter_alarmled').html('<div class="LEDred_on"></div>Alarm');
+    $('#gaugeContainer_air').jqxGauge({ disabled: true });
+    $('#gaugeContainer_beer').jqxGauge({ disabled: true });
+    $('#gaugeContainer_chiller').jqxGauge({ disabled: true });
     $('#Camera').hide();
    }
-
-   yl = record.yeast_lo;
-   yh = record.yeast_hi;
-   range = { ranges: [{ startValue: 0, endValue: yl, style: { fill: '#3399FF', stroke: '#3399FF' }, endWidth: 10, startWidth: 10 },
-                      { startValue: yl, endValue: yh, style: { fill: '#00CC33', stroke: '#00CC33' }, endWidth: 10, startWidth: 10 },
-                      { startValue: yh, endValue: 45, style: { fill: '#FC6A6A', stroke: '#FC6A6A' }, endWidth: 10, startWidth: 10 }]};
-   $('#gaugeContainer_air').jqxGauge(range);
-   $('#gaugeContainer_beer').jqxGauge(range);
-
-   $('#gaugeContainer_air').jqxGauge({ caption: { value: 'Lucht: ' + record.air_temperature.toFixed(3) }});
-   $('#gaugeContainer_air').jqxGauge({ value: record.air_temperature });
-   if (record.air_state == 'OK') {
-    $('#gaugeContainer_air').jqxGauge({ disabled: false });
-   } else {
-    $('#gaugeContainer_air').jqxGauge({ disabled: true });
-   }
-   $('#gaugeContainer_beer').jqxGauge({ caption: { value: 'Bier: ' + record.beer_temperature.toFixed(3) }});
-   $('#gaugeContainer_beer').jqxGauge({ value: record.beer_temperature });
-   if (record.beer_state == 'OK') {
-    $('#gaugeContainer_beer').jqxGauge({ disabled: false });
-   } else {
-    $('#gaugeContainer_beer').jqxGauge({ disabled: true });
-   }
-   $('#gaugeContainer_chiller').jqxGauge({ value: record.chiller_temperature });
-   if (record.chiller_state == 'OK') {
-    $('#gaugeContainer_chiller').jqxGauge({ disabled: false });
-   } else {
-    $('#gaugeContainer_chiller').jqxGauge({ disabled: true });
-   }
-  }
- });
+ }
 
  $('#select_beer').jqxDropDownList({
   placeHolder: 'Kies bier:',
@@ -429,114 +448,24 @@
  $('#Profile1').hide(); // Hide these until they are needed.
  $('#Profile2').hide();
 
- function sendBase(stage, mode, tlo, thi) {
-
-  console.log('sendBase(' + stage + ', ' + mode + ', ' + tlo + ', ' + thi + ')');
-  var data = 'node=' + record.node + '&alias=' + record.alias + '&payload={"stage":"' + stage;
-  data += '","mode":"' + mode + '","setpoint":{"low":' + tlo + ',"high":' + thi + '}}';
-  $.ajax({
-   url: 'cmd_fermenter.php',
-   data: data,
-   type: 'POST',
-   success: function(data) {},
-   error: function(jqXHR, textStatus, errorThrown) { console.log('sendBase() error'); }
-  });
- }
-
- function sendSwitch(sw1, sw2, sw3, sw4) {
-
-  console.log('sendSwitch(' + sw1 + ', ' + sw2 + ', ' + sw3 + ', ' + sw4 + ')');
-  var data = 'node=' + record.node + '&alias=' + record.alias + '&payload=';
-  data += '{"heater":{"state":' + sw1 + '},"cooler":{"state":' + sw2 + '},"fan":{"state":' + sw3 + '},"light":{"state":' + sw4 + '}}';
-  $.ajax({
-   url: 'cmd_fermenter.php',
-   data: data,
-   type: 'POST',
-   success: function(data) {},
-   error: function(jqXHR, textStatus, errorThrown) { console.log('sendSwitch() error'); }
-  });
- }
-
- function sendProduct(code, name, uuid, yeast_lo, yeast_hi) {
-
-  console.log('sendProduct(' + code + ', ' + name + ', ' + uuid + ', ' + yeast_lo + ', ' + yeast_hi + ')');
-  var data = 'node=' + record.node + '&alias=' + record.alias + '&payload=';
-  data += '{"product":{"code":"' + code + '","name":"' + name + '","uuid":"' + uuid + '","yeast_lo":' + yeast_lo + ',"yeast_hi":' + yeast_hi + '}}';
-  $.ajax({
-   url: 'cmd_fermenter.php',
-   data: data,
-   type: 'POST',
-   success: function(data) {},
-   error: function(jqXHR, textStatus, errorThrown) { console.log('sendProduct() error'); }
-  });
- }
-
- function sendProfile(payload) {
-
-  console.log('sendProfile(' + payload + ')');
-  var data = 'node=' + record.node + '&alias=' + record.alias + '&payload=' + payload;
-  $.ajax({
-   url: 'cmd_fermenter.php',
-   data: data,
-   type: 'POST',
-   success: function(data) {},
-   error: function(jqXHR, textStatus, errorThrown) { console.log('sendProfile() error'); }
-  });
- }
-
-
  // Get the data immediatly and then at regular intervals to refresh.
  dataAdapter.dataBind();
- setInterval(function() {
-  var skip = false;
-  if (newBase) {
-   sendBase(record.stage, record.mode, record.setpoint_low, record.setpoint_high);
-   newBase = false;
-   skip = true;
-  }
-  if (newSwitch) {
-   sendSwitch(record.heater_state, record.cooler_state, record.fan_state, record.light_state);
-   newSwitch = false;
-   skip = true;
-  }
-  if (newProduct) {
-   sendProduct(record.beercode, record.beername, record.beeruuid, record.yeast_lo, record.yeast_hi);
-   newProduct = false;
-   skip = true;
-  }
-  if (newProfile) {
-   sendProfile(ppayload);
-   newProfile = false;
-   skip = true;
-  }
-  if (skip) {
-   schedule = 4; // 2 seconds wait to get the results
-  } else {
-   if (schedule > 0)
-    schedule--;
-  }
-
-  if (schedule <= 0) {
-   dataAdapter.dataBind();
-   schedule = 20;
-  }
- }, 500);
 
  $('#info_mode').on('select', function(event) {
-  var args = event.args;
-  if (args) {
-   record.mode = args.item.value;
-   $('#fermenter_toggle1').val(0);
-   $('#fermenter_toggle2').val(0);
-   $('#fermenter_toggle3').val(0);
+  if (event.args && event.args.item.value != record.mode) {
+   record.mode = event.args.item.value;
+   console.log('set mode ' + record.mode);
+   var msg = '{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","mode":"' + record.mode + '"}';
+   websocket.send(msg);
   }
-  newBase = true;
  });
  $('#info_stage').on('select', function(event) {
-  var args = event.args;
-  if (args)
-   record.stage = args.item.value;
-  newBase = true;
+  if (event.args && event.args.item.value != record.stage) {
+   record.stage = event.args.item.value;
+   console.log('set stage ' + record.stage);
+   var msg = '{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","stage":"' + record.stage + '"}';
+   websocket.send(msg);
+  }
  });
  $('#select_beer').on('select', function(event) {
   if (event.args) {
@@ -547,7 +476,11 @@
    record.beeruuid = datarecord.uuid;
    record.yeast_lo = datarecord.yeast_lo;
    record.yeast_hi = datarecord.yeast_hi;
-   newProduct = true;
+   console.log('set beer ' + record.beercode + ' ' + record.beername);
+   var msg = '{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias +
+             '","beeruuid":"' + record.beeruuid + '","beercode":"' + record.beercode + '","beername":"' + record.beername +
+             '","yeast_lo":' + record.yeast_lo + ',"yeast_hi":' + record.yeast_hi + '}';
+   websocket.send(msg);
   }
  });
  $('#select_profile').on('select', function(event) {
@@ -556,9 +489,9 @@
    datarecord = profilelist.records[index],
    row, i;
    if (datarecord.record == -1) {
-    ppayload = '{"profile":null}';
+    ppayload = '","profile":null}';
    } else {
-    ppayload = '{"profile":{"uuid":"' + datarecord.uuid + '","name":"' + datarecord.name + '",';
+    ppayload = '","profile":{"uuid":"' + datarecord.uuid + '","name":"' + datarecord.name + '",';
     ppayload += '"inittemp":{"low":' + datarecord.inittemp_lo + ',"high":' + datarecord.inittemp_hi + '},';
     ppayload += '"fridgemode":' + datarecord.fridgemode + ',"steps":[';
     for (i = 0; i < datarecord.steps.length; i++) {
@@ -571,7 +504,8 @@
     }
     ppayload += ']}}';
    }
-   newProfile = true;
+   var msg = '{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + ppayload;
+   websocket.send(msg);
   }
  });
 
@@ -582,7 +516,9 @@
    record.setpoint_high = record.setpoint_low;
    $('#target_hi').val(record.setpoint_high);
   }
-  newBase = true;
+  console.log('set setpoints ' + record.setpoint_low + ' ' + record.setpoint_high);
+  websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias +
+            '","setpoint_low":' + record.setpoint_low + ',"setpoint_high":' + record.setpoint_high + '}');
  });
  $('#target_hi').on('change', function(event) {
   record.setpoint_high = parseFloat(event.args.value);
@@ -591,72 +527,64 @@
    record.setpoint_low = record.setpoint_high;
    $('#target_lo').val(record.setpoint_low);
   }
-  newBase = true;
+  console.log('set setpoints ' + record.setpoint_low + ' ' + record.setpoint_high);
+  websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias +
+            '","setpoint_low":' + record.setpoint_low + ',"setpoint_high":' + record.setpoint_high + '}');
  });
 
  $('#fermenter_toggle1').on('checked', function(event) {
-  if (record.mode == 'NONE') {
-   record.heater_state = 0;
-   newSwitch = true;
+  if (record.mode == 'NONE' && record.heater_state != 0) {
+   console.log('set heater ' + $("#fermenter_toggle1").jqxSwitchButton('val'));
+   websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","heater_state":0}');
   }
  });
  $('#fermenter_toggle1').on('unchecked', function(event) {
-  if (record.mode == 'NONE') {
-   record.heater_state = 100;
-   record.cooler_state = 0;
-   $('#fermenter_toggle2').val(0);
-   newSwitch = true;
+  if (record.mode == 'NONE' && record.heater_state == 0) {
+   console.log('set heater ' + $("#fermenter_toggle1").jqxSwitchButton('val'));
+   websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","heater_state":100,"cooler_state":0}');
   }
  });
  $('#fermenter_toggle2').on('checked', function(event) {
-  if (record.mode == 'NONE') {
-   record.cooler_state = 0;
-   newSwitch = true;
+  if (record.mode == 'NONE' && record.cooler_state != 0) {
+   console.log('set cooler ' + $("#fermenter_toggle2").jqxSwitchButton('val'));
+   websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","cooler_state":0}');
   }
  });
  $('#fermenter_toggle2').on('unchecked', function(event) {
-  if (record.mode == 'NONE') {
-   record.cooler_state = 100;
-   record.heater_state = 0;
-   $('#fermenter_toggle1').val(0);
-   newSwitch = true;
+  if (record.mode == 'NONE' & record.cooler_state == 0) {
+   console.log('set cooler ' + $("#fermenter_toggle2").jqxSwitchButton('val'));
+   websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","cooler_state":100,"heater_state":0}');
   }
  });
  $('#fermenter_toggle3').on('checked', function(event) {
-  if (record.mode == 'NONE') {
-   record.fan_state = 0;
-   newSwitch = true;
+  if (record.mode == 'NONE' && record.fan_state != 0) {
+   websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","fan_state":0}');
   }
  });
  $('#fermenter_toggle3').on('unchecked', function(event) {
-  if (record.mode == 'NONE') {
-   record.fan_state = 100;
-   newSwitch = true;
+  if (record.mode == 'NONE' && record.fan_state == 0) {
+   websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","fan_state":100}');
   }
  });
  $('#Profile1').click(function() {
   if (record.mode == 'PROFILE') {
    if (record.profile_state == 'OFF') {
-    ppayload = '{"profile":{"command":"start"}}';
-    newProfile = true;
+    websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","profile":{"command":"start"}}');
    } else if ((record.profile_state == 'RUN') || (record.profile_state == 'PAUSE')) {
     // Open a popup to confirm this action.
     $('#eventWindow').jqxWindow('open');
     $('#delOk').click(function() {
-     ppayload = '{"profile":{"command":"abort"}}';
-     newProfile = true;
+     websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","profile":{"command":"abort"}}');
     });
    } else if (record.profile_state == 'DONE') {
-    ppayload = '{"profile":{"command":"done"}}';
-    newProfile = true;
+    websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","profile":{"command":"done"}}');
    }
   }
  });
  $('#Profile2').click(function() {
   if (record.mode == 'PROFILE') {
    if ((record.profile_state == 'RUN') || (record.profile_state == 'PAUSE')) {
-    ppayload = '{"profile":{"command":"pause"}}';
-    newProfile = true;
+    websocket.send('{"device":"fermenters","node":"' + record.node + '","unit":"' + record.alias + '","profile":{"command":"pause"}}');
    }
   }
  });
@@ -669,8 +597,61 @@
  $('#Camera').jqxButton({ template: 'primary', width: '150px', theme: theme });
  $('#Camera').click(function() {
   record.light_state = 100;
-  newSwitch = true;
   window.open(record.webcam_url);
  });
  createAbortElements();
+
+ websocket.onmessage = function(evt) {
+  var msg = evt.data;
+  var obj = JSON.parse(msg);
+
+  if (obj.device == "fermenters" && obj.node == record.node && obj.unit == record.alias) {
+   console.log('ws got this device ' + msg);
+   record.online = obj.online;
+   if (obj.online) {
+    record.beeruuid = obj.beeruuid;
+    record.beercode = obj.beercode;
+    record.beername = obj.beername;
+    record.yeast_lo = obj.yeast_lo;
+    record.yeast_hi = obj.yeast_hi;
+    record.air_state = obj.air_state;
+    record.air_temperature = obj.air_temperature;
+    record.beer_state = obj.beer_state;
+    record.beer_temperature = obj.beer_temperature;
+    record.chiller_state = obj.chiller_state;
+    record.chiller_temperature = obj.chiller_temperature;
+    record.heater_state = obj.heater_state;
+    record.cooler_state = obj.cooler_state;
+    record.fan_state = obj.fan_state;
+    if (obj.door_address)
+     record.door_state = obj.door_state;
+    if (obj.light_address)
+     record.light_state = obj.light_state;
+    if (obj.psu_address)
+     record.psu_state = obj.psu_state;
+    record.mode = obj.mode;
+    record.stage = obj.stage;
+    record.alarm = obj.alarm;
+    record.setpoint_low = obj.setpoint_low;
+    record.setpoint_high = obj.setpoint_high;
+    record.webcam_url = obj.webcam_url;
+    record.webcam_light = obj.webcam_light;
+    if (obj.profile_name) {
+     record.profile_uuid = obj.profile_uuid;
+     record.profile_name = obj.profile_name;
+     record.profile_state = obj.profile_state;
+     record.profile_percent = obj.profile_percent;
+     record.profile_inittemp_high = obj.profile_inittemp_high;
+     record.profile_inittemp_low = obj.profile_inittemp_low;
+    } else {
+     record.profile_uuid = '';
+     record.profile_name = '';
+     record.profile_state = '';
+     record.profile_percent = 0;
+    }
+   }
+   updateScreen();
+  }
+  ws_global(msg);
+ }
 });
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/www/js/reconnecting-websocket.js	Mon May 18 11:00:59 2020 +0200
@@ -0,0 +1,365 @@
+// MIT License:
+//
+// Copyright (c) 2010-2012, Joe Walnes
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+/**
+ * This behaves like a WebSocket in every way, except if it fails to connect,
+ * or it gets disconnected, it will repeatedly poll until it successfully connects
+ * again.
+ *
+ * It is API compatible, so when you have:
+ *   ws = new WebSocket('ws://....');
+ * you can replace with:
+ *   ws = new ReconnectingWebSocket('ws://....');
+ *
+ * The event stream will typically look like:
+ *  onconnecting
+ *  onopen
+ *  onmessage
+ *  onmessage
+ *  onclose // lost connection
+ *  onconnecting
+ *  onopen  // sometime later...
+ *  onmessage
+ *  onmessage
+ *  etc...
+ *
+ * It is API compatible with the standard WebSocket API, apart from the following members:
+ *
+ * - `bufferedAmount`
+ * - `extensions`
+ * - `binaryType`
+ *
+ * Latest version: https://github.com/joewalnes/reconnecting-websocket/
+ * - Joe Walnes
+ *
+ * Syntax
+ * ======
+ * var socket = new ReconnectingWebSocket(url, protocols, options);
+ *
+ * Parameters
+ * ==========
+ * url - The url you are connecting to.
+ * protocols - Optional string or array of protocols.
+ * options - See below
+ *
+ * Options
+ * =======
+ * Options can either be passed upon instantiation or set after instantiation:
+ *
+ * var socket = new ReconnectingWebSocket(url, null, { debug: true, reconnectInterval: 4000 });
+ *
+ * or
+ *
+ * var socket = new ReconnectingWebSocket(url);
+ * socket.debug = true;
+ * socket.reconnectInterval = 4000;
+ *
+ * debug
+ * - Whether this instance should log debug messages. Accepts true or false. Default: false.
+ *
+ * automaticOpen
+ * - Whether or not the websocket should attempt to connect immediately upon instantiation. The socket can be manually opened or closed at any time using ws.open() and ws.close().
+ *
+ * reconnectInterval
+ * - The number of milliseconds to delay before attempting to reconnect. Accepts integer. Default: 1000.
+ *
+ * maxReconnectInterval
+ * - The maximum number of milliseconds to delay a reconnection attempt. Accepts integer. Default: 30000.
+ *
+ * reconnectDecay
+ * - The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. Accepts integer or float. Default: 1.5.
+ *
+ * timeoutInterval
+ * - The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. Accepts integer. Default: 2000.
+ *
+ */
+(function (global, factory) {
+    if (typeof define === 'function' && define.amd) {
+        define([], factory);
+    } else if (typeof module !== 'undefined' && module.exports){
+        module.exports = factory();
+    } else {
+        global.ReconnectingWebSocket = factory();
+    }
+})(this, function () {
+
+    if (!('WebSocket' in window)) {
+        return;
+    }
+
+    function ReconnectingWebSocket(url, protocols, options) {
+
+        // Default settings
+        var settings = {
+
+            /** Whether this instance should log debug messages. */
+            debug: false,
+
+            /** Whether or not the websocket should attempt to connect immediately upon instantiation. */
+            automaticOpen: true,
+
+            /** The number of milliseconds to delay before attempting to reconnect. */
+            reconnectInterval: 1000,
+            /** The maximum number of milliseconds to delay a reconnection attempt. */
+            maxReconnectInterval: 30000,
+            /** The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. */
+            reconnectDecay: 1.5,
+
+            /** The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. */
+            timeoutInterval: 2000,
+
+            /** The maximum number of reconnection attempts to make. Unlimited if null. */
+            maxReconnectAttempts: null,
+
+            /** The binary type, possible values 'blob' or 'arraybuffer', default 'blob'. */
+            binaryType: 'blob'
+        }
+        if (!options) { options = {}; }
+
+        // Overwrite and define settings with options if they exist.
+        for (var key in settings) {
+            if (typeof options[key] !== 'undefined') {
+                this[key] = options[key];
+            } else {
+                this[key] = settings[key];
+            }
+        }
+
+        // These should be treated as read-only properties
+
+        /** The URL as resolved by the constructor. This is always an absolute URL. Read only. */
+        this.url = url;
+
+        /** The number of attempted reconnects since starting, or the last successful connection. Read only. */
+        this.reconnectAttempts = 0;
+
+        /**
+         * The current state of the connection.
+         * Can be one of: WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED
+         * Read only.
+         */
+        this.readyState = WebSocket.CONNECTING;
+
+        /**
+         * A string indicating the name of the sub-protocol the server selected; this will be one of
+         * the strings specified in the protocols parameter when creating the WebSocket object.
+         * Read only.
+         */
+        this.protocol = null;
+
+        // Private state variables
+
+        var self = this;
+        var ws;
+        var forcedClose = false;
+        var timedOut = false;
+        var eventTarget = document.createElement('div');
+
+        // Wire up "on*" properties as event handlers
+
+        eventTarget.addEventListener('open',       function(event) { self.onopen(event); });
+        eventTarget.addEventListener('close',      function(event) { self.onclose(event); });
+        eventTarget.addEventListener('connecting', function(event) { self.onconnecting(event); });
+        eventTarget.addEventListener('message',    function(event) { self.onmessage(event); });
+        eventTarget.addEventListener('error',      function(event) { self.onerror(event); });
+
+        // Expose the API required by EventTarget
+
+        this.addEventListener = eventTarget.addEventListener.bind(eventTarget);
+        this.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);
+        this.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);
+
+        /**
+         * This function generates an event that is compatible with standard
+         * compliant browsers and IE9 - IE11
+         *
+         * This will prevent the error:
+         * Object doesn't support this action
+         *
+         * http://stackoverflow.com/questions/19345392/why-arent-my-parameters-getting-passed-through-to-a-dispatched-event/19345563#19345563
+         * @param s String The name that the event should use
+         * @param args Object an optional object that the event will use
+         */
+        function generateEvent(s, args) {
+        	var evt = document.createEvent("CustomEvent");
+        	evt.initCustomEvent(s, false, false, args);
+        	return evt;
+        };
+
+        this.open = function (reconnectAttempt) {
+            ws = new WebSocket(self.url, protocols || []);
+            ws.binaryType = this.binaryType;
+
+            if (reconnectAttempt) {
+                if (this.maxReconnectAttempts && this.reconnectAttempts > this.maxReconnectAttempts) {
+                    return;
+                }
+            } else {
+                eventTarget.dispatchEvent(generateEvent('connecting'));
+                this.reconnectAttempts = 0;
+            }
+
+            if (self.debug || ReconnectingWebSocket.debugAll) {
+                console.debug('ReconnectingWebSocket', 'attempt-connect', self.url);
+            }
+
+            var localWs = ws;
+            var timeout = setTimeout(function() {
+                if (self.debug || ReconnectingWebSocket.debugAll) {
+                    console.debug('ReconnectingWebSocket', 'connection-timeout', self.url);
+                }
+                timedOut = true;
+                localWs.close();
+                timedOut = false;
+            }, self.timeoutInterval);
+
+            ws.onopen = function(event) {
+                clearTimeout(timeout);
+                if (self.debug || ReconnectingWebSocket.debugAll) {
+                    console.debug('ReconnectingWebSocket', 'onopen', self.url);
+                }
+                self.protocol = ws.protocol;
+                self.readyState = WebSocket.OPEN;
+                self.reconnectAttempts = 0;
+                var e = generateEvent('open');
+                e.isReconnect = reconnectAttempt;
+                reconnectAttempt = false;
+                eventTarget.dispatchEvent(e);
+            };
+
+            ws.onclose = function(event) {
+                clearTimeout(timeout);
+                ws = null;
+                if (forcedClose) {
+                    self.readyState = WebSocket.CLOSED;
+                    eventTarget.dispatchEvent(generateEvent('close'));
+                } else {
+                    self.readyState = WebSocket.CONNECTING;
+                    var e = generateEvent('connecting');
+                    e.code = event.code;
+                    e.reason = event.reason;
+                    e.wasClean = event.wasClean;
+                    eventTarget.dispatchEvent(e);
+                    if (!reconnectAttempt && !timedOut) {
+                        if (self.debug || ReconnectingWebSocket.debugAll) {
+                            console.debug('ReconnectingWebSocket', 'onclose', self.url);
+                        }
+                        eventTarget.dispatchEvent(generateEvent('close'));
+                    }
+
+                    var timeout = self.reconnectInterval * Math.pow(self.reconnectDecay, self.reconnectAttempts);
+                    setTimeout(function() {
+                        self.reconnectAttempts++;
+                        self.open(true);
+                    }, timeout > self.maxReconnectInterval ? self.maxReconnectInterval : timeout);
+                }
+            };
+            ws.onmessage = function(event) {
+                if (self.debug || ReconnectingWebSocket.debugAll) {
+                    console.debug('ReconnectingWebSocket', 'onmessage', self.url, event.data);
+                }
+                var e = generateEvent('message');
+                e.data = event.data;
+                eventTarget.dispatchEvent(e);
+            };
+            ws.onerror = function(event) {
+                if (self.debug || ReconnectingWebSocket.debugAll) {
+                    console.debug('ReconnectingWebSocket', 'onerror', self.url, event);
+                }
+                eventTarget.dispatchEvent(generateEvent('error'));
+            };
+        }
+
+        // Whether or not to create a websocket upon instantiation
+        if (this.automaticOpen == true) {
+            this.open(false);
+        }
+
+        /**
+         * Transmits data to the server over the WebSocket connection.
+         *
+         * @param data a text string, ArrayBuffer or Blob to send to the server.
+         */
+        this.send = function(data) {
+            if (ws) {
+                if (self.debug || ReconnectingWebSocket.debugAll) {
+                    console.debug('ReconnectingWebSocket', 'send', self.url, data);
+                }
+                return ws.send(data);
+            } else {
+                throw 'INVALID_STATE_ERR : Pausing to reconnect websocket';
+            }
+        };
+
+        /**
+         * Closes the WebSocket connection or connection attempt, if any.
+         * If the connection is already CLOSED, this method does nothing.
+         */
+        this.close = function(code, reason) {
+            // Default CLOSE_NORMAL code
+            if (typeof code == 'undefined') {
+                code = 1000;
+            }
+            forcedClose = true;
+            if (ws) {
+                ws.close(code, reason);
+            }
+        };
+
+        /**
+         * Additional public API method to refresh the connection if still open (close, re-open).
+         * For example, if the app suspects bad data / missed heart beats, it can try to refresh.
+         */
+        this.refresh = function() {
+            if (ws) {
+                ws.close();
+            }
+        };
+    }
+
+    /**
+     * An event listener to be called when the WebSocket connection's readyState changes to OPEN;
+     * this indicates that the connection is ready to send and receive data.
+     */
+    ReconnectingWebSocket.prototype.onopen = function(event) {};
+    /** An event listener to be called when the WebSocket connection's readyState changes to CLOSED. */
+    ReconnectingWebSocket.prototype.onclose = function(event) {};
+    /** An event listener to be called when a connection begins being attempted. */
+    ReconnectingWebSocket.prototype.onconnecting = function(event) {};
+    /** An event listener to be called when a message is received from the server. */
+    ReconnectingWebSocket.prototype.onmessage = function(event) {};
+    /** An event listener to be called when an error occurs. */
+    ReconnectingWebSocket.prototype.onerror = function(event) {};
+
+    /**
+     * Whether all instances of ReconnectingWebSocket should log debug messages.
+     * Setting this to true is the equivalent of setting all instances of ReconnectingWebSocket.debug to true.
+     */
+    ReconnectingWebSocket.debugAll = false;
+
+    ReconnectingWebSocket.CONNECTING = WebSocket.CONNECTING;
+    ReconnectingWebSocket.OPEN = WebSocket.OPEN;
+    ReconnectingWebSocket.CLOSING = WebSocket.CLOSING;
+    ReconnectingWebSocket.CLOSED = WebSocket.CLOSED;
+
+    return ReconnectingWebSocket;
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/www/js/reconnecting-websocket.min.js	Mon May 18 11:00:59 2020 +0200
@@ -0,0 +1,1 @@
+!function(a,b){"function"==typeof define&&define.amd?define([],b):"undefined"!=typeof module&&module.exports?module.exports=b():a.ReconnectingWebSocket=b()}(this,function(){function a(b,c,d){function l(a,b){var c=document.createEvent("CustomEvent");return c.initCustomEvent(a,!1,!1,b),c}var e={debug:!1,automaticOpen:!0,reconnectInterval:1e3,maxReconnectInterval:3e4,reconnectDecay:1.5,timeoutInterval:2e3};d||(d={});for(var f in e)this[f]="undefined"!=typeof d[f]?d[f]:e[f];this.url=b,this.reconnectAttempts=0,this.readyState=WebSocket.CONNECTING,this.protocol=null;var h,g=this,i=!1,j=!1,k=document.createElement("div");k.addEventListener("open",function(a){g.onopen(a)}),k.addEventListener("close",function(a){g.onclose(a)}),k.addEventListener("connecting",function(a){g.onconnecting(a)}),k.addEventListener("message",function(a){g.onmessage(a)}),k.addEventListener("error",function(a){g.onerror(a)}),this.addEventListener=k.addEventListener.bind(k),this.removeEventListener=k.removeEventListener.bind(k),this.dispatchEvent=k.dispatchEvent.bind(k),this.open=function(b){h=new WebSocket(g.url,c||[]),b||k.dispatchEvent(l("connecting")),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","attempt-connect",g.url);var d=h,e=setTimeout(function(){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","connection-timeout",g.url),j=!0,d.close(),j=!1},g.timeoutInterval);h.onopen=function(){clearTimeout(e),(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onopen",g.url),g.protocol=h.protocol,g.readyState=WebSocket.OPEN,g.reconnectAttempts=0;var d=l("open");d.isReconnect=b,b=!1,k.dispatchEvent(d)},h.onclose=function(c){if(clearTimeout(e),h=null,i)g.readyState=WebSocket.CLOSED,k.dispatchEvent(l("close"));else{g.readyState=WebSocket.CONNECTING;var d=l("connecting");d.code=c.code,d.reason=c.reason,d.wasClean=c.wasClean,k.dispatchEvent(d),b||j||((g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onclose",g.url),k.dispatchEvent(l("close")));var e=g.reconnectInterval*Math.pow(g.reconnectDecay,g.reconnectAttempts);setTimeout(function(){g.reconnectAttempts++,g.open(!0)},e>g.maxReconnectInterval?g.maxReconnectInterval:e)}},h.onmessage=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onmessage",g.url,b.data);var c=l("message");c.data=b.data,k.dispatchEvent(c)},h.onerror=function(b){(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","onerror",g.url,b),k.dispatchEvent(l("error"))}},1==this.automaticOpen&&this.open(!1),this.send=function(b){if(h)return(g.debug||a.debugAll)&&console.debug("ReconnectingWebSocket","send",g.url,b),h.send(b);throw"INVALID_STATE_ERR : Pausing to reconnect websocket"},this.close=function(a,b){"undefined"==typeof a&&(a=1e3),i=!0,h&&h.close(a,b)},this.refresh=function(){h&&h.close()}}return a.prototype.onopen=function(){},a.prototype.onclose=function(){},a.prototype.onconnecting=function(){},a.prototype.onmessage=function(){},a.prototype.onerror=function(){},a.debugAll=!1,a.CONNECTING=WebSocket.CONNECTING,a.OPEN=WebSocket.OPEN,a.CLOSING=WebSocket.CLOSING,a.CLOSED=WebSocket.CLOSED,a});
--- a/www/mon_fermenter.php	Thu May 14 14:38:20 2020 +0200
+++ b/www/mon_fermenter.php	Mon May 18 11:00:59 2020 +0200
@@ -60,23 +60,27 @@
      </div> <!-- fermenter_panel_top -->
 
      <div id="fermenter_panel_display">
-      <div id="fermenter_display">
+      <div id="fermenter_display" class="f_display">
        <div id="target_lo" style="margin-left: 40px; margin-top: 15px;"></div>
        <div style="margin-top: 5px;">&deg;C laag</div>
       </div>
-      <div id="fermenter_display">
+      <div id="fermenter_display" class="f_display">
        <div id="target_hi" style="margin-left: 40px; margin-top: 15px;"></div>
        <div style="margin-top: 5px;">&deg;C hoog</div>
       </div>
      </div> <!-- fermenter_panel_display -->
 
      <div id="fermenter_panel_control">
-      <div id="fermenter_led1"></div>
-      <div id="fermenter_led2"></div>
-      <div id="fermenter_led3"></div>
-      <div id="fermenter_toggle1"></div>
-      <div id="fermenter_toggle2"></div>
-      <div id="fermenter_toggle3"></div>
+      <div class="f_control_leds">
+       <div id="fermenter_led1"></div>
+       <div id="fermenter_led2"></div>
+       <div id="fermenter_led3"></div>
+      </div>
+      <div class="f_control_switches">
+       <div id="fermenter_toggle1"></div>
+       <div id="fermenter_toggle2"></div>
+       <div id="fermenter_toggle3"></div>
+      </div>
      </div> <!-- fermenter_panel_control -->
 
      <div id="fermenter_panel_buttons">

mercurial