diff --git a/Makefile b/Makefile
index 311824b..a7515cf 100755
--- a/Makefile
+++ b/Makefile
@@ -98,7 +98,7 @@ ifeq ($(AQ_ONETOUCH), true)
endif
ifeq ($(AQ_IAQTOUCH), true)
- SRCS := $(SRCS) iaqtouch.c iaqtouch_aq_programmer.c
+ SRCS := $(SRCS) iaqtouch.c iaqtouch_aq_programmer.c iaqualink.c
AQ_FLAGS := $(AQ_FLAGS) -D AQ_IAQTOUCH
endif
diff --git a/README.md b/README.md
index 11f4405..6012c2c 100644
--- a/README.md
+++ b/README.md
@@ -114,15 +114,22 @@ Designed to mimic AqualinkRS devices, used to fully configure the master control
# Updates in 2.4.1 (under development)
@@ -130,6 +137,10 @@ NEED TO FIX FOR THIS RELEASE.
* This is faster, more reliable and does not intefear with the physical PDA device (like existing implimentation)
* Please consider this very much BETA at the moment.
* use `device_id=0x33` in aqualinkd.conf
+* Added MQTT vsp_pump/speed/set for setting speed (RPM/GPM) by %, for automation hubs.
+* cleaned up code for spa_mode and spa for newer pannels.
+* Allow VSP to be asigned to virtual button.
+* Fixed bug with timer not starting.
# Updates in Release 2.4.0
* WARNING Breaking change if you use dimmer (please change button_??_lightMode from 6 to 10)
diff --git a/release/aqualinkd-amd64 b/release/aqualinkd-amd64
index 3dd339d..3a514e4 100755
Binary files a/release/aqualinkd-amd64 and b/release/aqualinkd-amd64 differ
diff --git a/release/aqualinkd-arm64 b/release/aqualinkd-arm64
index 9795867..671dff1 100755
Binary files a/release/aqualinkd-arm64 and b/release/aqualinkd-arm64 differ
diff --git a/release/aqualinkd-armhf b/release/aqualinkd-armhf
index 640f65a..62a1e3a 100755
Binary files a/release/aqualinkd-armhf and b/release/aqualinkd-armhf differ
diff --git a/release/aqualinkd.conf b/release/aqualinkd.conf
index 8fbbecd..da44eb5 100755
--- a/release/aqualinkd.conf
+++ b/release/aqualinkd.conf
@@ -269,7 +269,7 @@ use_panel_aux_labels=no
# Below are settings for standard buttons on RS-8 Combo panel used as example.
button_01_label=Filter Pump
-button_02_label=Spa Mode
+button_02_label=Spa
button_03_label=Cleaner
@@ -305,4 +305,4 @@ button_12_label=Solar Heater
#virtual_button_02_label=Clean Mode
#virtual_button_03_label = OneTouch 4
#virtual_button_04_label = OneTouch 5
-#virtual_button_05_label = OneTouch 6
\ No newline at end of file
+#virtual_button_05_label = OneTouch 6
diff --git a/release/install.sh b/release/install.sh
index 1856d66..fd5a771 100755
--- a/release/install.sh
+++ b/release/install.sh
@@ -132,8 +132,8 @@ fi
# V2.3.9 has kind-a breaking change for config.js, so check existing and rename if needed
# we added Aux_V? to the button list
if [ -f "$WEBLocation/config.js" ]; then
- # Test is if has AUX_V1 in file
- if ! grep -q Aux_V1 $WEBLocation/config.js; then
+ # Test is if has AUX_V1 in file AND "Spa" is in file (Spa_mode changed to Spa)
+ if ! grep -q 'Aux_V1' $WEBLocation/$file || ! grep -q '"Spa"' $WEBLocation/$file; then
dateext=`date +%Y%m%d_%H_%M_%S`
echo "AqualinkD web config is old, making copy to $WEBLocation/config.js.$dateext"
echo "Please make changes to new version $WEBLocation/config.js"
diff --git a/release/serial_logger-amd64 b/release/serial_logger-amd64
index 8712448..738a1e3 100755
Binary files a/release/serial_logger-amd64 and b/release/serial_logger-amd64 differ
diff --git a/release/serial_logger-arm64 b/release/serial_logger-arm64
index bf70e29..fcc041b 100755
Binary files a/release/serial_logger-arm64 and b/release/serial_logger-arm64 differ
diff --git a/release/serial_logger-armhf b/release/serial_logger-armhf
index f9170f2..a0724c0 100755
Binary files a/release/serial_logger-armhf and b/release/serial_logger-armhf differ
diff --git a/source/aq_mqtt.h b/source/aq_mqtt.h
index f1e115d..b693634 100644
--- a/source/aq_mqtt.h
+++ b/source/aq_mqtt.h
@@ -53,6 +53,7 @@
#define PUMP_MODE_TOPIC "/Mode"
#define PUMP_STATUS_TOPIC "/Status"
#define PUMP_PPC_TOPIC "/PPC"
+#define PUMP_SPEED_TOPIC "/Speed"
#define LIGHT_PROGRAM_TOPIC "/program"
/*
diff --git a/source/aq_panel.c b/source/aq_panel.c
index f410f11..b1d858b 100644
--- a/source/aq_panel.c
+++ b/source/aq_panel.c
@@ -360,6 +360,32 @@ aqkey *addVirtualButton(struct aqualinkdata *aqdata, char *label, int vindex) {
return button;
}
+// So the 0-100% should be 600-3450 RPM and 15-130 GPM (ie 1% would = 600 & 0%=off)
+// (value-600) / (3450-600) * 100
+// (value) / 100 * (3450-600) + 600
+
+//{{ (((value | float(0) - %d) / %d) * 100) | int }} - minspeed, (maxspeed - minspeed),
+//{{ ((value | float(0) / 100) * %d) + %d | int }} - (maxspeed - minspeed), minspeed)
+
+int getPumpSpeedAsPercent(pump_detail *pump) {
+ int pValue = pump->pumpType==VFPUMP?pump->gpm:pump->rpm;
+
+ if (pValue < pump->minSpeed) {
+ return 0;
+ }
+
+ // Below will return 0% if pump is at min, so return max (caculation, 1)
+ return AQ_MAX( (int)((float)(pValue - pump->minSpeed) / (float)(pump->maxSpeed - pump->minSpeed) * 100) + 0.5, 1);
+}
+
+int convertPumpPercentToSpeed(pump_detail *pump, int pValue) {
+ if (pValue >= 100)
+ return pump->maxSpeed;
+ else if (pValue <= 0)
+ return pump->minSpeed;
+
+ return ( ((float)(pValue / (float)100) * (pump->maxSpeed - pump->minSpeed) + pump->minSpeed)) + 0.5;
+}
// 4,6,8,10,12,14
void initPanelButtons(struct aqualinkdata *aqdata, bool rs, int size, bool combo, bool dual) {
@@ -717,6 +743,13 @@ bool setDeviceState(struct aqualinkdata *aqdata, int deviceIndex, bool isON, req
{
aqkey *button = &aqdata->aqbuttons[deviceIndex];
+ if (button->special_mask & VIRTUAL_BUTTON && button->special_mask & VS_PUMP) {
+ // Virtual Button with VSP is always on.
+ LOG(PANL_LOG, LOG_INFO, "received '%s' for '%s', virtual pump is always on, ignoring", (isON == false ? "OFF" : "ON"), button->name);
+ button->led->state = ON;
+ return false;
+ }
+
if ((button->led->state == OFF && isON == false) ||
(isON > 0 && (button->led->state == ON || button->led->state == FLASH ||
button->led->state == ENABLE))) {
diff --git a/source/aq_panel.h b/source/aq_panel.h
index f3a6e57..5bad8eb 100644
--- a/source/aq_panel.h
+++ b/source/aq_panel.h
@@ -62,6 +62,9 @@ void addPanelIAQTouchInterface();
void addPanelRSserialAdapterInterface();
void changePanelToExtendedIDProgramming();
+int getPumpSpeedAsPercent(pump_detail *pump);
+int convertPumpPercentToSpeed(pump_detail *pump, int value); // This is probable only needed internally
+
uint16_t getPanelSupport( char *rev_string, int rev_len);
aqkey *addVirtualButton(struct aqualinkdata *aqdata, char *label, int vindex);
diff --git a/source/aq_programmer.h b/source/aq_programmer.h
index 87d9d60..38f5877 100644
--- a/source/aq_programmer.h
+++ b/source/aq_programmer.h
@@ -35,6 +35,7 @@ typedef enum emulation_type{
ONETOUCH,
IAQTOUCH,
AQUAPDA, // AQUAPALM and PDA are taken as specific type.
+ IAQUALNK, // iAqualink (wifi extra ID)
JANDY_DEVICE, // Very rarley used.
SIMULATOR
} emulation_type;
diff --git a/source/aq_serial.c b/source/aq_serial.c
index 315a0d3..a480ea9 100644
--- a/source/aq_serial.c
+++ b/source/aq_serial.c
@@ -76,6 +76,8 @@ emulation_type getJandyDeviceType(unsigned char ID) {
return AQUAPDA;
if (ID >= 0x30 && ID <= 0x33)
return IAQTOUCH;
+ if (ID >= 0xa0 && ID <= 0xa3)
+ return IAQUALNK;
/*
if (ID >= 0x00 && ID <= 0x03)
@@ -286,6 +288,10 @@ const char* get_jandy_packet_type(unsigned char* packet , int length)
return "LXi status";
break;
+ case 0x53:
+ return "iAqalnk Poll";
+ break;
+
default:
sprintf(buf, "Unknown '0x%02hhx'", packet[PKT_CMD]);
return buf;
diff --git a/source/aq_serial.h b/source/aq_serial.h
index 487dfd3..b8f5b52 100644
--- a/source/aq_serial.h
+++ b/source/aq_serial.h
@@ -232,7 +232,7 @@ DEV_UNKNOWN_MASK = 0xF8; // Unknown mask, used to reset values
#endif
#define BTN_PUMP "Filter_Pump"
-#define BTN_SPA "Spa_Mode"
+#define BTN_SPA "Spa"
#define BTN_AUX1 "Aux_1"
#define BTN_AUX2 "Aux_2"
#define BTN_AUX3 "Aux_3"
diff --git a/source/aq_timer.c b/source/aq_timer.c
index 69161cd..5a315ae 100644
--- a/source/aq_timer.c
+++ b/source/aq_timer.c
@@ -88,6 +88,7 @@ void start_timer(struct aqualinkdata *aq_data, /*aqkey *button,*/ int deviceInde
tmthread->thread_id = 0;
tmthread->duration_min = duration;
tmthread->next = NULL;
+ tmthread->started_at = time(0); // This will get reset once we actually start. But need it here incase someone calls get_timer_left() before we start
if( pthread_create( &tmthread->thread_id , NULL , timer_worker, (void*)tmthread) < 0) {
LOG(TIMR_LOG, LOG_ERR, "could not create timer thread for button '%s'\n",button->name);
diff --git a/source/aqualink.h b/source/aqualink.h
index 1fddd1e..3c9810f 100644
--- a/source/aqualink.h
+++ b/source/aqualink.h
@@ -68,6 +68,12 @@ bool checkAqualinkTime(); // Only need to externalise this for PDA
bool isVirtualButtonEnabled();
+#define PUMP_RPM_MAX 3450
+#define PUMP_RPM_MIN 600
+#define PUMP_GPM_MAX 130
+#define PUMP_GPM_MIN 15
+
+
enum {
FAHRENHEIT,
CELSIUS,
@@ -179,6 +185,8 @@ typedef struct pumpd
int rpm;
int gpm;
int watts;
+ int maxSpeed; // Max rpm or gpm depending on pump
+ int minSpeed;
unsigned char pumpID;
int pumpIndex;
char pumpName[PUMP_NAME_LENGTH];
diff --git a/source/aqualinkd.c b/source/aqualinkd.c
index 6ddf64b..2eb9a91 100644
--- a/source/aqualinkd.c
+++ b/source/aqualinkd.c
@@ -49,6 +49,7 @@
#include "onetouch_aq_programmer.h"
#include "iaqtouch.h"
#include "iaqtouch_aq_programmer.h"
+#include "iaqualink.h"
#include "version.h"
#include "rs_msg_utils.h"
#include "serialadapter.h"
@@ -697,6 +698,12 @@ int startup(char *self, char *cfgFile)
LOG(AQUA_LOG,LOG_NOTICE, "Config BTN %-13s = label %-15s | %s\n",
_aqualink_data.aqbuttons[i].name, _aqualink_data.aqbuttons[i].label, ext);
}
+
+ if ( ((_aqualink_data.aqbuttons[i].special_mask & VIRTUAL_BUTTON) == VIRTUAL_BUTTON) &&
+ ((_aqualink_data.aqbuttons[i].special_mask & VS_PUMP ) != VS_PUMP) &&
+ (_aqconfig_.extended_device_id < 0x30 || _aqconfig_.extended_device_id > 0x33 ) ){
+ LOG(AQUA_LOG,LOG_WARNING, "Config error, extended_device_id must be on of the folowing (0x30,0x31,0x32,0x33) to use virtual button : '%s'",_aqualink_data.aqbuttons[i].label);
+ }
}
/*
for (i=0; i < _aqualink_data.total_buttons; i++)
@@ -766,6 +773,41 @@ void caculate_ack_packet(int rs_fd, unsigned char *packet_buffer, emulation_type
}
//DEBUG_TIMER_STOP(_rs_packet_timer,AQUA_LOG,"AquaTouch Emulation type Processed packet in");
break;
+ case IAQUALNK:
+ /*
+ Probe | HEX: 0x10|0x02|0xa3|0x00|0xb5|0x10|0x03|
+ Ack | HEX: 0x10|0x02|0x00|0x01|0x00|0x00|0x13|0x10|0x03|
+ Unknown '0x61' | HEX: 0x10|0x02|0xa3|0x61|0x00|0x00|0x00|0x04|0x00|0x27|0x41|0x10|0x03|
+ Ack | HEX: 0x10|0x02|0x00|0x01|0x61|0x00|0x74|0x10|0x03|
+ Unknown '0x50' | HEX: 0x10|0x02|0xa3|0x50|0x20|0x20|0x20|0x20|0x20|0x20|0x20|0x20|0x20|0x00|0x25|0x10|0x03|
+ Ack | HEX: 0x10|0x02|0x00|0x01|0x50|0x00|0x63|0x10|0x03|
+ Unknown '0x51' | HEX: 0x10|0x02|0xa3|0x51|0x00|0x06|0x10|0x03|
+ Ack | HEX: 0x10|0x02|0x00|0x01|0x51|0x00|0x64|0x10|0x03|
+ Unknown '0x59' | HEX: 0x10|0x02|0xa3|0x59|0x00|0x0e|0x10|0x03|
+ Ack | HEX: 0x10|0x02|0x00|0x01|0x59|0x00|0x6c|0x10|0x03|
+ Unknown '0x52' | HEX: 0x10|0x02|0xa3|0x52|0x00|0x07|0x10|0x03|
+ Ack | HEX: 0x10|0x02|0x00|0x01|0x52|0x00|0x65|0x10|0x03|
+ Unknown '0x53' | HEX: 0x10|0x02|0xa3|0x53|0x08|0x10|0x03|
+ Ack | HEX: 0x10|0x02|0x00|0x01|0x3f|0x00|0x52|0x10|0x03|
+ Use byte 3 as return ack, except for 0x53=0x3f
+ */
+ if (packet_buffer[PKT_CMD] == 0x53) {
+ /*
+ static int cnt=0;
+ if (cnt++ > 10) {
+ cnt=0;
+ LOG(IAQL_LOG,LOG_NOTICE, "Sending get bigass packet\n");
+ send_extended_ack(rs_fd, 0x3f, 0x18);
+ } else*/ {
+ // Use 0x3f
+ send_extended_ack(rs_fd, 0x3f, 0x00);
+ }
+ send_jandy_command(rs_fd, get_rssa_cmd(packet_buffer[PKT_CMD]), 4);
+ } else {
+ // Use packet_buffer[PKT_CMD]
+ send_extended_ack(rs_fd, packet_buffer[PKT_CMD], 0x00);
+ }
+ break;
#endif
#ifdef AQ_PDA
case AQUAPDA:
@@ -879,7 +921,8 @@ void main_loop()
pthread_mutex_init(&_aqualink_data.active_thread.thread_mutex, NULL);
pthread_cond_init(&_aqualink_data.active_thread.thread_cond, NULL);
- for (i=0; i < MAX_PUMPS; i++) {
+ //for (i=0; i < MAX_PUMPS; i++) {
+ for (i=0; i < _aqualink_data.num_pumps; i++) {
_aqualink_data.pumps[i].rpm = TEMP_UNKNOWN;
_aqualink_data.pumps[i].gpm = TEMP_UNKNOWN;
_aqualink_data.pumps[i].watts = TEMP_UNKNOWN;
@@ -888,6 +931,15 @@ void main_loop()
_aqualink_data.pumps[i].status = TEMP_UNKNOWN;
_aqualink_data.pumps[i].pStatus = PS_OFF;
_aqualink_data.pumps[i].pressureCurve = TEMP_UNKNOWN;
+
+ if (_aqualink_data.pumps[i].maxSpeed <= 0) {
+ _aqualink_data.pumps[i].maxSpeed = (_aqualink_data.pumps[i].pumpType==VFPUMP?PUMP_GPM_MAX:PUMP_RPM_MAX);
+ }
+ if (_aqualink_data.pumps[i].minSpeed <= 0) {
+ _aqualink_data.pumps[i].minSpeed = (_aqualink_data.pumps[i].pumpType==VFPUMP?PUMP_GPM_MIN:PUMP_RPM_MIN);
+ }
+
+ //printf("arrayindex=%d, pump=%d, min=%d, max=%d\n",i,_aqualink_data.pumps[i].pumpIndex, _aqualink_data.pumps[i].minSpeed ,_aqualink_data.pumps[i].maxSpeed);
}
for (i=0; i < MAX_LIGHTS; i++) {
@@ -1241,10 +1293,14 @@ void main_loop()
}
// Process and packets of devices we are acting as
- if (packet_length > 0 && getProtocolType(packet_buffer) == JANDY &&
+ if (packet_length > 0 && getProtocolType(packet_buffer) == JANDY && packet_buffer[PKT_DEST] != 0x00 &&
(packet_buffer[PKT_DEST] == _aqconfig_.device_id ||
packet_buffer[PKT_DEST] == _aqconfig_.rssa_device_id ||
- packet_buffer[PKT_DEST] == _aqconfig_.extended_device_id ))
+#if defined AQ_ONETOUCH || defined AQ_IAQTOUCH
+ packet_buffer[PKT_DEST] == _aqconfig_.extended_device_id ||
+ packet_buffer[PKT_DEST] == _aqconfig_.extended_device_id2
+#endif
+ ))
{
switch(getJandyDeviceType(packet_buffer[PKT_DEST])){
case ALLBUTTON:
@@ -1267,6 +1323,10 @@ void main_loop()
_aqualink_data.updated = process_pda_packet(packet_buffer, packet_length);
caculate_ack_packet(rs_fd, packet_buffer, AQUAPDA);
break;
+ case IAQUALNK:
+ _aqualink_data.updated = process_iaqualink_packet(packet_buffer, packet_length, &_aqualink_data);
+ caculate_ack_packet(rs_fd, packet_buffer, IAQUALNK);
+ break;
default:
break;
}
diff --git a/source/config.c b/source/config.c
index 88e8ea2..dcd10ac 100644
--- a/source/config.c
+++ b/source/config.c
@@ -47,6 +47,8 @@
char *generate_mqtt_id(char *buf, int len);
pump_detail *getpump(struct aqualinkdata *aqdata, int button);
+bool populatePumpData(struct aqualinkdata *aqdata, char *pumpcfg ,aqkey *button, char *value);
+pump_detail *getPumpFromButtonID(struct aqualinkdata *aqdata, aqkey *button);
struct tmpPanelInfo {
int size;
@@ -403,9 +405,11 @@ bool setConfigValue(struct aqualinkdata *aqdata, char *param, char *value) {
// Has to be before the below.
_aqconfig_.extended_device_id_programming = text2bool(value);
rtn=true;
- } else if (strncasecmp(param, "extended_device_id", 9) == 0) {
+ } else if (strncasecmp(param, "extended_device_id", 18) == 0) {
_aqconfig_.extended_device_id = strtoul(cleanalloc(value), NULL, 16);
- //_config_parameters.onetouch_device_id != 0x00
+ rtn=true;
+ } else if (strncasecmp(param, "enable_iaqualink", 16) == 0) {
+ _aqconfig_.enable_iaqualink = text2bool(value);
rtn=true;
#endif
} else if (strncasecmp(param, "panel_type_size", 15) == 0) {
@@ -682,63 +686,25 @@ bool setConfigValue(struct aqualinkdata *aqdata, char *param, char *value) {
LOG(AQUA_LOG,LOG_ERR, "Config error, (colored|programmable) Lights limited to %d, ignoring %s'\n",MAX_LIGHTS,param);
}
rtn=true;
- } else if (strncasecmp(param + 9, "_pumpID", 7) == 0) {
- pump_detail *pump = getpump(aqdata, num);
- if (pump != NULL) {
- pump->pumpID = strtoul(cleanalloc(value), NULL, 16);
- //if ( (int)pump->pumpID <= PENTAIR_DEC_PUMP_MAX) {
- if ( (int)pump->pumpID >= PENTAIR_DEC_PUMP_MIN && (int)pump->pumpID <= PENTAIR_DEC_PUMP_MAX) {
- pump->prclType = PENTAIR;
- } else {
- pump->prclType = JANDY;
- //pump->pumpType = EPUMP; // For testing let the interface set this
- }
- } else {
- LOG(AQUA_LOG,LOG_ERR, "Config error, VSP Pumps limited to %d, ignoring : %s",MAX_PUMPS,param);
- }
- rtn=true;
- } else if (strncasecmp(param + 9, "_pumpIndex", 10) == 0) { //button_01_pumpIndex=1
- pump_detail *pump = getpump(aqdata, num);
- if (pump != NULL) {
- pump->pumpIndex = strtoul(value, NULL, 10);
- } else {
- LOG(AQUA_LOG,LOG_ERR, "Config error, VSP Pumps limited to %d, ignoring : %s",MAX_PUMPS,param);
- }
- rtn=true;
- } else if (strncasecmp(param + 9, "_pumpType", 9) == 0) {
- // This is not documented, as it's prefered for AqualinkD to find the pump type.
- pump_detail *pump = getpump(aqdata, num);
- if (pump != NULL) {
- if ( stristr(value, "Pentair VS") != 0)
- pump->pumpType = VSPUMP;
- else if ( stristr(value, "Pentair VF") != 0)
- pump->pumpType = VFPUMP;
- else if ( stristr(value, "Jandy ePump") != 0)
- pump->pumpType = EPUMP;
- } else {
- LOG(AQUA_LOG,LOG_ERR, "Config error, VSP Pumps limited to %d, ignoring : %s",MAX_PUMPS,param);
- }
- rtn=true;
- } else if (strncasecmp(param + 9, "_pumpName", 9) == 0) { //button_01_pumpIndex=1
- pump_detail *pump = getpump(aqdata, num);
- if (pump != NULL) {
- //pump->pumpName = cleanalloc(value);
- strncpy(pump->pumpName ,cleanwhitespace(value), PUMP_NAME_LENGTH-1);
- } else {
+ } else if (strncasecmp(param + 9, "_pump", 5) == 0) {
+
+ if ( ! populatePumpData(aqdata, param + 10, &aqdata->aqbuttons[num], value) )
+ {
LOG(AQUA_LOG,LOG_ERR, "Config error, VSP Pumps limited to %d, ignoring : %s",MAX_PUMPS,param);
}
+
rtn=true;
- }
-#if defined AQ_IAQTOUCH
+ }
+//#if defined AQ_IAQTOUCH
} else if (strncasecmp(param, "virtual_button_", 15) == 0) {
- rtn=true;
+ rtn=true;
+ int num = strtoul(param + 15, NULL, 10);
if (_aqconfig_.paneltype_mask == 0) {
// ERROR the vbutton will be irnored.
LOG(AQUA_LOG,LOG_WARNING, "Config error, Panel type mush be definied before adding a virtual_button, ignored setting : %s",param);
- } else if (_aqconfig_.extended_device_id < 0x30 || _aqconfig_.extended_device_id > 0x33 ) {
- LOG(AQUA_LOG,LOG_WARNING, "Config error, extended_device_id must on of the folowing (0x30,0x31,0x32,0x33), ignored setting : %s",param);
+ //} else if (_aqconfig_.extended_device_id < 0x30 || _aqconfig_.extended_device_id > 0x33 ) {
+ // LOG(AQUA_LOG,LOG_WARNING, "Config error, extended_device_id must on of the folowing (0x30,0x31,0x32,0x33), ignored setting : %s",param);
} else if (strncasecmp(param + 17, "_label", 6) == 0) {
- int num = strtoul(param + 15, NULL, 10);
char *label = cleanalloc(value);
aqkey *button = addVirtualButton(aqdata, label, num);
if (button != NULL) {
@@ -746,15 +712,102 @@ bool setConfigValue(struct aqualinkdata *aqdata, char *param, char *value) {
} else {
LOG(AQUA_LOG,LOG_WARNING, "Error with '%s', total buttons=%d, config has %d already, ignoring!\n",param, TOTAL_BUTTONS, aqdata->total_buttons+1);
}
+ } else if (strncasecmp(param + 17, "_pump", 5) == 0) {
+ char *vbname = malloc(sizeof(char*) * 10);
+ snprintf(vbname, 9, "%s%d", BTN_VAUX, num);
+ aqkey *vbutton = NULL;
+ for (int i = aqdata->virtual_button_start; i < aqdata->total_buttons; i++) {
+ //printf("Checking %s agasinsdt %s\n",aqdata->aqbuttons[i].name, vbname);
+ if ( strcmp( aqdata->aqbuttons[i].name, vbname) == 0 ) {
+ vbutton = &aqdata->aqbuttons[i];
+ vbutton->led->state = ON; //Virtual pump is always on
+ if ( ! populatePumpData(aqdata, param + 18, vbutton, value) )
+ {
+ LOG(AQUA_LOG,LOG_ERR, "Config error, VSP Pumps limited to %d, ignoring : %s",MAX_PUMPS,param);
+ }
+ break;
+ }
+ }
+ if (vbutton == NULL) {
+ LOG(AQUA_LOG,LOG_ERR, "Config error, could not find vitrual button for `%s`",param);
+ }
}
}
-#endif
+//#endif
return rtn;
}
+// pumpcfg is pointer to pumpIndex, pumpName, pumpType pumpID, (ie pull off button_??_ or vurtual_button_??_)
+bool populatePumpData(struct aqualinkdata *aqdata, char *pumpcfg ,aqkey *button, char *value)
+{
+
+ pump_detail *pump = getPumpFromButtonID(aqdata, button);
+ if (pump == NULL) {
+ return false;
+ }
+
+ if (strncasecmp(pumpcfg, "pumpIndex", 9) == 0) {
+ pump->pumpIndex = strtoul(value, NULL, 10);
+ } else if (strncasecmp(pumpcfg, "pumpType", 8) == 0) {
+ if ( stristr(value, "Pentair VS") != 0)
+ pump->pumpType = VSPUMP;
+ else if ( stristr(value, "Pentair VF") != 0)
+ pump->pumpType = VFPUMP;
+ else if ( stristr(value, "Jandy ePump") != 0)
+ pump->pumpType = EPUMP;
+ } else if (strncasecmp(pumpcfg, "pumpName", 8) == 0) {
+ strncpy(pump->pumpName ,cleanwhitespace(value), PUMP_NAME_LENGTH-1);
+ } else if (strncasecmp(pumpcfg, "pumpID", 6) == 0) {
+ pump->pumpID = strtoul(cleanalloc(value), NULL, 16);
+ if ( (int)pump->pumpID >= PENTAIR_DEC_PUMP_MIN && (int)pump->pumpID <= PENTAIR_DEC_PUMP_MAX) {
+ pump->prclType = PENTAIR;
+ } else {
+ pump->prclType = JANDY;
+ }
+ } else if (strncasecmp(pumpcfg, "pumpMaxSpeed", 12) == 0) {
+ pump->maxSpeed = strtoul(value, NULL, 10);
+ } else if (strncasecmp(pumpcfg, "pumpMinSpeed", 12) == 0) {
+ pump->minSpeed = strtoul(value, NULL, 10);
+ }
+
+ return true;
+}
+
+pump_detail *getPumpFromButtonID(struct aqualinkdata *aqdata, aqkey *button)
+{
+ int pi;
+ // Does it exist
+ for (pi=0; pi < aqdata->num_pumps; pi++) {
+ if (aqdata->pumps[pi].button == button) {
+ return &aqdata->pumps[pi];
+ }
+ }
+ // Create new entry
+ if (aqdata->num_pumps < MAX_PUMPS) {
+ //printf ("Creating pump %d\n",button);
+ button->special_mask |= VS_PUMP;
+ aqdata->pumps[aqdata->num_pumps].button = button;
+ aqdata->pumps[aqdata->num_pumps].pumpType = PT_UNKNOWN;
+ aqdata->pumps[aqdata->num_pumps].rpm = TEMP_UNKNOWN;
+ aqdata->pumps[aqdata->num_pumps].watts = TEMP_UNKNOWN;
+ aqdata->pumps[aqdata->num_pumps].gpm = TEMP_UNKNOWN;
+ aqdata->pumps[aqdata->num_pumps].pStatus = PS_OFF;
+ aqdata->pumps[aqdata->num_pumps].pumpIndex = 0;
+ aqdata->pumps[aqdata->num_pumps].maxSpeed = TEMP_UNKNOWN;
+ aqdata->pumps[aqdata->num_pumps].minSpeed = TEMP_UNKNOWN;
+ //pumpType
+ aqdata->pumps[aqdata->num_pumps].pumpName[0] = '\0';
+ aqdata->num_pumps++;
+ return &aqdata->pumps[aqdata->num_pumps-1];
+ }
+
+ return NULL;
+}
+
+/*
pump_detail *getpump(struct aqualinkdata *aqdata, int button)
{
//static int _pumpindex = 0;
@@ -788,7 +841,7 @@ pump_detail *getpump(struct aqualinkdata *aqdata, int button)
return NULL;
}
-
+*/
void init_config()
{
diff --git a/source/config.h b/source/config.h
index 460e9d2..2560756 100644
--- a/source/config.h
+++ b/source/config.h
@@ -44,7 +44,9 @@ struct aqconfig
int16_t paneltype_mask;
#if defined AQ_ONETOUCH || defined AQ_IAQTOUCH
unsigned char extended_device_id;
+ unsigned char extended_device_id2;
bool extended_device_id_programming;
+ bool enable_iaqualink;
#endif
bool deamonize;
#ifndef AQ_MANAGER // Need to uncomment and clean up referances in future.
diff --git a/source/hassio.c b/source/hassio.c
index c99a0ae..3932596 100644
--- a/source/hassio.c
+++ b/source/hassio.c
@@ -127,9 +127,8 @@ const char *HASSIO_VSP_DISCOVER = "{"
"\"payload_off\": \"0\","
"\"percentage_command_topic\": \"%s/%s/%s/set\"," // aqualinkd,filter_pump , RPM|GPM
"\"percentage_state_topic\": \"%s/%s/%s\"," // aqualinkd,filter_pump , RPM|GPM
- // "\"percentage_value_template\": \"{{ (((value | float(0) - %d) / %d) * 100) | int }}\"," // 600, (3450-600)
- "\"percentage_value_template\": \"{%% if value | float(0) > %d %%} {{ (((value | float(0) - %d) / %d) * 100) | int }}{%% else %%} 1{%% endif %%}\"," // min,min,(max-min)
- "\"percentage_command_template\": \"{{ ((value | float(0) / 100) * %d) + %d | int }}\"," // (3450-130), 600
+ //"\"percentage_value_template\": \"{%% if value | float(0) > %d %%} {{ (((value | float(0) - %d) / %d) * 100) | int }}{%% else %%} 1{%% endif %%}\"," // min,min,(max-min)
+ //"\"percentage_command_template\": \"{{ ((value | float(0) / 100) * %d) + %d | int }}\"," // (3450-130), 600
"\"speed_range_max\": 100,"
"\"speed_range_min\": 1," // 18|12 600rpm|15gpm
"\"qos\": 1,"
@@ -469,20 +468,9 @@ void publish_mqtt_hassio_discover(struct aqualinkdata *aqdata, struct mg_connect
// VSP Pumps
for (i=0; i < aqdata->num_pumps; i++) {
- int maxspeed=3450; // Min is 600
- int minspeed=600; // 600 as % of max
- char units[4];
- sprintf(units, "RPM");
-
- if ( aqdata->pumps[i].pumpType == VFPUMP ) {
- maxspeed=130; // Min is 15
- minspeed=15; // 15 as % of max
- sprintf(units, "GPM");
- }
+ char units[] = "Speed";
// Create a FAN for pump against the button it' assigned to
// In the future maybe change this to the pump# or change the sensors to button???
- // Need to change the max / min. These do NOT lomit the slider in hassio, only the MQTT limits.
- // So the 0-100% should be 600-3450 RPM and 15-130 GPM (ie 1% would = 600 & 0%=off)
sprintf(msg, HASSIO_VSP_DISCOVER,
_aqconfig_.mqtt_aq_topic,
aqdata->pumps[i].button->name,units,
@@ -491,9 +479,7 @@ void publish_mqtt_hassio_discover(struct aqualinkdata *aqdata, struct mg_connect
_aqconfig_.mqtt_aq_topic,aqdata->pumps[i].button->name,
_aqconfig_.mqtt_aq_topic,aqdata->pumps[i].button->name,
_aqconfig_.mqtt_aq_topic,aqdata->pumps[i].button->name,units,
- _aqconfig_.mqtt_aq_topic,aqdata->pumps[i].button->name,units,
- minspeed, minspeed, (maxspeed - minspeed),
- (maxspeed - minspeed), minspeed);
+ _aqconfig_.mqtt_aq_topic,aqdata->pumps[i].button->name,units);
sprintf(topic, "%s/fan/aqualinkd/aqualinkd_%s_%s/config", _aqconfig_.mqtt_hass_discover_topic, aqdata->pumps[i].button->name, units);
send_mqtt(nc, topic, msg);
diff --git a/source/iaqtouch.c b/source/iaqtouch.c
index 1245786..057266e 100644
--- a/source/iaqtouch.c
+++ b/source/iaqtouch.c
@@ -1000,6 +1000,13 @@ bool process_iaqtouch_packet(unsigned char *packet, int length, struct aqualinkd
//printf("***** iAqualink Touch STARTUP Message ******* \n");
if (gotInit == false) {
LOG(IAQT_LOG,LOG_DEBUG, "STARTUP Message\n");
+
+ if (_aqconfig_.enable_iaqualink) {
+ LOG(IAQT_LOG,LOG_DEBUG, "Enabling iAqualink Protocol\n");
+ // Below will not send 0x29 since queueGetProgramData takes presidance,
+ //iaqt_queue_cmd(0x29);
+ _aqconfig_.extended_device_id2 = _aqconfig_.extended_device_id + 112; // 0x70 in dec
+ }
//LOG(IAQT_LOG,LOG_ERR, "STARTUP REMOVED GET PANEL DATA FOR TESTING\n");
queueGetProgramData(IAQTOUCH, aq_data);
gotInit = true;
diff --git a/source/iaqualink.c b/source/iaqualink.c
new file mode 100644
index 0000000..e62e982
--- /dev/null
+++ b/source/iaqualink.c
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2017 Shaun Feakes - All rights reserved
+ *
+ * You may use redistribute and/or modify this code under the terms of
+ * the GNU General Public License version 2 as published by the
+ * Free Software Foundation. For the terms of this license,
+ * see .
+ *
+ * You are free to use this software under the terms of the GNU General
+ * Public License, but WITHOUT ANY WARRANTY; without even the implied
+ * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ * See the GNU General Public License for more details.
+ *
+ * https://github.com/sfeakes/aqualinkd
+ */
+
+#include
+#include
+#include
+
+#include "aq_serial.h"
+#include "aqualink.h"
+#include "packetLogger.h"
+
+bool process_iaqualink_packet(unsigned char *packet, int length, struct aqualinkdata *aq_data)
+{
+
+ //debuglogPacket(IAQL_LOG, packet, length, true, true);
+
+ return true;
+}
\ No newline at end of file
diff --git a/source/iaqualink.h b/source/iaqualink.h
index 8f0b4eb..e255549 100644
--- a/source/iaqualink.h
+++ b/source/iaqualink.h
@@ -1,7 +1,16 @@
+
+bool process_iaqualink_packet(unsigned char *packet, int length, struct aqualinkdata *aq_data);
+
+
/*
Read Jandy packet To 0xa3 of type Unknown '0x53' | HEX: 0x10|0x02|0xa3|0x53|0x08|0x10|0x03|
Read Jandy packet To 0x00 of type Ack | HEX: 0x10|0x02|0x00|0x01|0x3f|0x00|0x52|0x10|0x03|
+
+Below get's sent to AqualinkTouch with iAqualink is enabled.
+End of message is board cpu and panel type.
+Read Jandy packet To 0x33 of type Unknown '0x70' | HEX: 0x10|0x02|0x33|0x70|0x0d|0x00|0x01|0x02|0x03|0x05|0x06|0x07|0x0e|0x0f|0x1a|0x1d|0x20|0x21|0x00|0x00|0x00|0x00|0x00|0x48|0x00|0x66|0x00|0x50|0x00|0x00|0x00|0xff|0x42|0x30|0x33|0x31|0x36|0x38|0x32|0x33|0x20|0x52|0x53|0x2d|0x34|0x20|0x43|0x6f|0x6d|0x62|0x6f|0x00|0x00|0x4b|0x10|0x03|
+
*/
\ No newline at end of file
diff --git a/source/net_services.c b/source/net_services.c
index 7329b34..4130f25 100644
--- a/source/net_services.c
+++ b/source/net_services.c
@@ -75,6 +75,7 @@ void mqtt_broadcast_aqualinkstate(struct mg_connection *nc);
#endif
void reset_last_mqtt_status();
+bool uri_strcmp(const char *uri, const char *string);
//static const char *s_http_port = "8080";
static struct mg_serve_http_opts _http_server_opts;
@@ -914,11 +915,17 @@ void mqtt_broadcast_aqualinkstate(struct mg_connection *nc)
_last_mqtt_aqualinkdata.pumps[i].rpm = _aqualink_data->pumps[i].rpm;
//send_mqtt_aux_msg(nc, PUMP_TOPIC, i+1, PUMP_RPM_TOPIC, _aqualink_data->pumps[i].rpm);
send_mqtt_aux_msg(nc, _aqualink_data->pumps[i].button->name, PUMP_RPM_TOPIC, _aqualink_data->pumps[i].rpm);
+ if (_aqualink_data->pumps[i].pumpType == EPUMP || _aqualink_data->pumps[i].pumpType == VSPUMP) {
+ send_mqtt_aux_msg(nc, _aqualink_data->pumps[i].button->name, PUMP_SPEED_TOPIC, getPumpSpeedAsPercent(&_aqualink_data->pumps[i]));
+ }
}
if (_aqualink_data->pumps[i].gpm != TEMP_UNKNOWN && _aqualink_data->pumps[i].gpm != _last_mqtt_aqualinkdata.pumps[i].gpm) {
_last_mqtt_aqualinkdata.pumps[i].gpm = _aqualink_data->pumps[i].gpm;
//send_mqtt_aux_msg(nc, PUMP_TOPIC, i+1, PUMP_GPH_TOPIC, _aqualink_data->pumps[i].gph);
send_mqtt_aux_msg(nc, _aqualink_data->pumps[i].button->name, PUMP_GPM_TOPIC, _aqualink_data->pumps[i].gpm);
+ if (_aqualink_data->pumps[i].pumpType == VFPUMP) {
+ send_mqtt_aux_msg(nc, _aqualink_data->pumps[i].button->name, PUMP_SPEED_TOPIC, getPumpSpeedAsPercent(&_aqualink_data->pumps[i]));
+ }
}
if (_aqualink_data->pumps[i].watts != TEMP_UNKNOWN && _aqualink_data->pumps[i].watts != _last_mqtt_aqualinkdata.pumps[i].watts) {
_last_mqtt_aqualinkdata.pumps[i].watts = _aqualink_data->pumps[i].watts;
@@ -1254,7 +1261,7 @@ uriAtype action_URI(request_source from, const char *URI, int uri_length, float
rtn = uBad;
}
// Action a pump RPM/GPM message
- } else if ((ri3 != NULL && ((strncasecmp(ri2, "RPM", 3) == 0) || (strncasecmp(ri2, "GPM", 3) == 0) || (strncasecmp(ri2, "VSP", 3) == 0)) && (strncasecmp(ri3, "set", 3) == 0))) {
+ } else if ((ri3 != NULL && ((strncasecmp(ri2, "RPM", 3) == 0) || (strncasecmp(ri2, "GPM", 3) == 0) || (strncasecmp(ri2, "Speed", 5) == 0) || (strncasecmp(ri2, "VSP", 3) == 0)) && (strncasecmp(ri3, "set", 3) == 0))) {
found = false;
// Is it a pump index or pump name
if (strncmp(ri1, "Pump_", 5) == 0) { // Pump by number
@@ -1274,9 +1281,15 @@ uriAtype action_URI(request_source from, const char *URI, int uri_length, float
return uBad;
}
} else {
- LOG(NET_LOG,LOG_NOTICE, "%s: request to change pump %d %s to %d\n",actionName[from],pumpIndex+1, (strncasecmp(ri2, "GPM", 3) == 0)?"GPM":"RPM", round(value));
+ if (strncasecmp(ri2, "Speed", 5) == 0) {
+ int val = convertPumpPercentToSpeed(&_aqualink_data->pumps[i], round(value));
+ LOG(NET_LOG,LOG_NOTICE, "%s: request to change pump %d Speed to %d%%, using %s of %d\n",actionName[from],pumpIndex+1, round(value), (_aqualink_data->pumps[i].pumpType==VFPUMP?"GPM":"RPM" ) ,val);
+ panel_device_request(_aqualink_data, PUMP_RPM, pumpIndex, val, from);
+ } else {
+ LOG(NET_LOG,LOG_NOTICE, "%s: request to change pump %d %s to %d\n",actionName[from],pumpIndex+1, (strncasecmp(ri2, "GPM", 3) == 0)?"GPM":"RPM", round(value));
//create_program_request(from, PUMP_RPM, round(value), pumpIndex);
- panel_device_request(_aqualink_data, PUMP_RPM, pumpIndex, round(value), from);
+ panel_device_request(_aqualink_data, PUMP_RPM, pumpIndex, round(value), from);
+ }
}
//_aqualink_data->unactioned.type = PUMP_RPM;
//_aqualink_data->unactioned.value = round(value);
@@ -1287,7 +1300,8 @@ uriAtype action_URI(request_source from, const char *URI, int uri_length, float
}
} else { // Pump by button name
for (i=0; i < _aqualink_data->total_buttons ; i++) {
- if (strncmp(ri1, _aqualink_data->aqbuttons[i].name, strlen(_aqualink_data->aqbuttons[i].name)) == 0 ){
+ //if (strncmp(ri1, _aqualink_data->aqbuttons[i].name, strlen(_aqualink_data->aqbuttons[i].name)) == 0 ){
+ if ( uri_strcmp(ri1, _aqualink_data->aqbuttons[i].name)) {
int pi;
for (pi=0; pi < _aqualink_data->num_pumps; pi++) {
if (_aqualink_data->pumps[pi].button == &_aqualink_data->aqbuttons[i]) {
@@ -1304,9 +1318,15 @@ uriAtype action_URI(request_source from, const char *URI, int uri_length, float
return uBad;
}
} else {
- LOG(NET_LOG,LOG_NOTICE, "%s: request to change pump %d %s to %d\n",actionName[from], pi+1, (strncasecmp(ri2, "GPM", 3) == 0)?"GPM":"RPM", round(value));
+ if (strncasecmp(ri2, "Speed", 5) == 0) {
+ int val = convertPumpPercentToSpeed(&_aqualink_data->pumps[pi], round(value));
+ LOG(NET_LOG,LOG_NOTICE, "%s: request to change pump %d Speed to %d%%, using %s of %d\n",actionName[from],_aqualink_data->pumps[pi].pumpIndex, round(value), (_aqualink_data->pumps[i].pumpType==VFPUMP?"GPM":"RPM" ) ,val);
+ panel_device_request(_aqualink_data, PUMP_RPM, _aqualink_data->pumps[pi].pumpIndex, val, from);
+ } else {
+ LOG(NET_LOG,LOG_NOTICE, "%s: request to change pump %d %s to %d\n",actionName[from], pi+1, (strncasecmp(ri2, "GPM", 3) == 0)?"GPM":"RPM", round(value));
//create_program_request(from, PUMP_RPM, round(value), _aqualink_data->pumps[pi].pumpIndex);
- panel_device_request(_aqualink_data, PUMP_RPM, _aqualink_data->pumps[pi].pumpIndex, round(value), from);
+ panel_device_request(_aqualink_data, PUMP_RPM, _aqualink_data->pumps[pi].pumpIndex, round(value), from);
+ }
}
//_aqualink_data->unactioned.type = PUMP_RPM;
//_aqualink_data->unactioned.value = round(value);
@@ -1363,13 +1383,15 @@ uriAtype action_URI(request_source from, const char *URI, int uri_length, float
for (i=0; i < _aqualink_data->total_buttons && found==false; i++) {
// If Label = "Spa", "Spa_Heater" will turn on "Spa", so need to check '/' on label as next character
- if (strncmp(ri1, _aqualink_data->aqbuttons[i].name, strlen(_aqualink_data->aqbuttons[i].name)) == 0 ||
- (strncmp(ri1, _aqualink_data->aqbuttons[i].label, strlen(_aqualink_data->aqbuttons[i].label)) == 0 && ri1[strlen(_aqualink_data->aqbuttons[i].label)] == '/'))
+ //if (strncmp(ri1, _aqualink_data->aqbuttons[i].name, strlen(_aqualink_data->aqbuttons[i].name)) == 0 ||
+ // (strncmp(ri1, _aqualink_data->aqbuttons[i].label, strlen(_aqualink_data->aqbuttons[i].label)) == 0 && ri1[strlen(_aqualink_data->aqbuttons[i].label)] == '/'))
+ if ( uri_strcmp(ri1, _aqualink_data->aqbuttons[i].name) || uri_strcmp(ri1, _aqualink_data->aqbuttons[i].label) )
{
found = true;
//create_panel_request(from, i, value, istimer);
+ LOG(NET_LOG,LOG_INFO, "%d: MATCH %s to topic %.*s\n",from,_aqualink_data->aqbuttons[i].name,uri_length, URI);
+ LOG(NET_LOG,LOG_INFO, "ri1=%s, length=%d char@=%c\n",ri1,strlen(_aqualink_data->aqbuttons[i].label),ri1[strlen(_aqualink_data->aqbuttons[i].label)] );
panel_device_request(_aqualink_data, atype, i, value, from);
- //LOG(NET_LOG,LOG_INFO, "%s: MATCH %s to topic %.*s\n",from,_aqualink_data->aqbuttons[i].name,uri_length, URI);
}
}
if(!found) {
@@ -1387,6 +1409,28 @@ uriAtype action_URI(request_source from, const char *URI, int uri_length, float
return rtn;
}
+/*
+ Quicker and more accurate for us than normal strncmp, since we check for the trailing / at right position
+ check Spa against uri /Spa/set /Spa_mode/set / Spa_heater/set
+*/
+bool uri_strcmp(const char *uri, const char *string) {
+ int i;
+ int len = strlen(string);
+
+ // Check the trailing / on length first.
+ if (uri[len] != '/') {
+ return false;
+ }
+
+ // Now check all characters
+ for (i=0; i < len; i++) {
+ if ( uri[i] != string[i] ){
+ return false;
+ }
+ }
+
+ return true;
+}
void action_mqtt_message(struct mg_connection *nc, struct mg_mqtt_message *msg) {
char *rtnmsg;
diff --git a/source/utils.c b/source/utils.c
index 149e7cc..f7faf71 100644
--- a/source/utils.c
+++ b/source/utils.c
@@ -334,6 +334,9 @@ const char* logmask2name(logmask_t from)
case SLOG_LOG:
return "Serial Log:";
break;
+ case IAQL_LOG:
+ return "iAqualink :";
+ break;
case AQUA_LOG:
default:
return "AqualinkD: ";
diff --git a/source/utils.h b/source/utils.h
index a472b62..2f1821e 100644
--- a/source/utils.h
+++ b/source/utils.h
@@ -52,6 +52,7 @@ typedef int32_t logmask_t;
#define DBGT_LOG (1 << 14) // Debug Timer
#define SLOG_LOG (1 << 15) // Serial_Logger
+#define IAQL_LOG (1 << 16) // iAqualink
#define TIMR_LOG SCHD_LOG
#define PANL_LOG PROG_LOG
diff --git a/web/config.js b/web/config.js
index a2bf0a1..b23dc95 100644
--- a/web/config.js
+++ b/web/config.js
@@ -6,7 +6,7 @@
// http://aqualink.ip.address/api/devices
var devices = [
"Filter_Pump",
- "Spa_Mode",
+ "Spa",
"Aux_1",
"Aux_2",
"Aux_3",
diff --git a/web/hk/Spa-off.png b/web/hk/Spa-off.png
new file mode 120000
index 0000000..5f60636
--- /dev/null
+++ b/web/hk/Spa-off.png
@@ -0,0 +1 @@
+./switch-off.png
\ No newline at end of file
diff --git a/web/hk/Spa-on.png b/web/hk/Spa-on.png
new file mode 120000
index 0000000..6afbc0b
--- /dev/null
+++ b/web/hk/Spa-on.png
@@ -0,0 +1 @@
+./switch-on.png
\ No newline at end of file