diff --git a/README.md b/README.md index 1dc0118..804c6a8 100644 --- a/README.md +++ b/README.md @@ -160,8 +160,11 @@ Need to look at sub panel (combined panels) when serial port is wrong, can't edit config. --> -## Release 3.0.5 (dev) +## Release 3.1.0 * added device power to watts in MQTT discovery for power monitoring +* Updated timers to be finer grained, (seconds vs minutes). - ie support for dosing pumps. +* Added config options for button (circuit) runtimes to set default runtimes for a device. +* Added support for onetouch macro's to OneTouch protocol. ## Release 3.0.4 (March 2026) * Fixed AqualinkD not starting when IP not assigned and MQTT enabled. diff --git a/RELEASE.md b/RELEASE.md index e6ecf10..0e1437b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,6 +4,14 @@ All notable changes to AqualinkD are documented here. Releases are listed in rev --- +## Release 3.1.0 (April 2026) +* added device power to watts in MQTT discovery for power monitoring +* Updated timers to be finer grained, (seconds vs minutes). - ie support for dosing pumps. +* Added config options for button (circuit) runtimes to set default runtimes for a device. +* Added support for onetouch macro's to OneTouch protocol. + +--- + ## Release 3.0.4 (March 2026) * Fixed AqualinkD not starting when IP not assigned and MQTT enabled. * Fixed aqmanager config editor issue when adding pumpType. diff --git a/release/aqualinkd-arm64 b/release/aqualinkd-arm64 index ed6fe8d..00572dd 100755 Binary files a/release/aqualinkd-arm64 and b/release/aqualinkd-arm64 differ diff --git a/release/aqualinkd-armhf b/release/aqualinkd-armhf index cd58efa..412f18f 100755 Binary files a/release/aqualinkd-armhf and b/release/aqualinkd-armhf differ diff --git a/release/rs485mon-arm64 b/release/rs485mon-arm64 index 5a5c71e..04f51e4 100755 Binary files a/release/rs485mon-arm64 and b/release/rs485mon-arm64 differ diff --git a/release/rs485mon-armhf b/release/rs485mon-armhf index c9bb94f..f454cfe 100755 Binary files a/release/rs485mon-armhf and b/release/rs485mon-armhf differ diff --git a/source/aq_panel.c b/source/aq_panel.c index 5e618c1..da20705 100644 --- a/source/aq_panel.c +++ b/source/aq_panel.c @@ -1337,6 +1337,8 @@ const char* getActionName(action_type type) break; case TIMER: return "Timer"; + case TIMER_SEC: + return "Timer (Seconds)"; break; case LIGHT_MODE: return "Light Mode"; @@ -1451,7 +1453,13 @@ bool setDeviceState(struct aqualinkdata *aqdata, int deviceIndex, bool isON, req } LOG(PANL_LOG, LOG_INFO, "received '%s' for '%s', turning '%s'\n", (isON == false ? "OFF" : "ON"), button->name, (isON == false ? "OFF" : "ON")); -#ifdef AQ_PDA + + if ( button->runtime_sec > 0) { + LOG(PANL_LOG, LOG_INFO, "'%s' is has runtime %u seconds, setting timer\n", button->name, button->runtime_sec); + start_timer(aqdata, deviceIndex, 0, button->runtime_sec); + } + + #ifdef AQ_PDA if (isPDA_PANEL) { if (button->special_mask & PROGRAM_LIGHT/* && isPDA_IAQT*/) { //if ( isPDA_IAQT && isIAQL_ACTIVE) { @@ -1525,9 +1533,12 @@ bool setDeviceState(struct aqualinkdata *aqdata, int deviceIndex, bool isON, req } else { aq_programmer(AQ_SET_IAQTOUCH_DEVICE_ON_OFF, button, (isON == false ? OFF : ON), deviceIndex, aqdata); set_pre_state = false; - } + } + } else if (isONET_ENABLED){ + aq_programmer(AQ_SET_ONETOUCH_MACRO, button, (isON == false ? OFF : ON), deviceIndex, aqdata); + set_pre_state = false; } else { - LOG(PANL_LOG, LOG_ERR, "Can only use Aqualink Touch protocol for Virtual Buttons"); + LOG(PANL_LOG, LOG_ERR, "Can only use Aqualink Touch for Virtual Buttons"); } } else { // Everything else, simply send the button code. @@ -1831,7 +1842,7 @@ bool panel_device_request(struct aqualinkdata *aqdata, action_type type, int dev deviceIndex, value, getRequestName(source)); - } else if (type == ON_OFF || type == TIMER || type == LIGHT_BRIGHTNESS || type == LIGHT_MODE){ + } else if (type == ON_OFF || type == TIMER ||type == TIMER_SEC || type == LIGHT_BRIGHTNESS || type == LIGHT_MODE){ LOG(PANL_LOG,LOG_INFO, "Device request type '%s' for deviceindex %d '%s' of value %d from '%s'\n", getActionName(type), deviceIndex, @@ -1856,10 +1867,12 @@ bool panel_device_request(struct aqualinkdata *aqdata, action_type type, int dev } break; case TIMER: - //setDeviceState(&aqdata->aqbuttons[deviceIndex], true); setDeviceState(aqdata, deviceIndex, true, source); - //start_timer(aqdata, &aqdata->aqbuttons[deviceIndex], deviceIndex, value); - start_timer(aqdata, deviceIndex, value); + start_timer(aqdata, deviceIndex, value, 0); + break; + case TIMER_SEC: + setDeviceState(aqdata, deviceIndex, true, source); + start_timer(aqdata, deviceIndex, 0, value); break; case LIGHT_BRIGHTNESS: // Allow value=0 here (unlike LIGHT_MODE) since we could get multiple requests from a slider. (aka HomeKit) diff --git a/source/aq_programmer.c b/source/aq_programmer.c index 0e38e10..aed6267 100644 --- a/source/aq_programmer.c +++ b/source/aq_programmer.c @@ -100,6 +100,7 @@ const func_ptr _prog_functions[AQP_RSSADAPTER_MAX] = { [AQ_SET_IAQTOUCH_PUMP_VS_PROGRAM] = set_aqualink_iaqtouch_pump_vs_program, [AQ_SET_IAQTOUCH_LIGHTCOLOR_MODE] = set_aqualink_iaqtouch_light_colormode, [AQ_SET_IAQTOUCH_DEVICE_ON_OFF] = set_aqualink_iaqtouch_device_on_off, + [AQ_SET_ONETOUCH_MACRO] = set_aqualink_onetouch_macro, //[AQ_SET_IAQTOUCH_ONETOUCH_ON_OFF] = set_aqualink_iaqtouch_onetouch_on_off, // Not finished and not needed [AQ_PDA_INIT] = set_aqualink_PDA_init, [AQ_PDA_WAKE_INIT] = set_aqualink_PDA_wakeinit, @@ -734,7 +735,7 @@ void _aq_programmer_(program_type r_type, char *args, aqkey *button, int value, return; // No need to create this as thread. break; default: - // Should check that _prog_functions[type] is valid. + // NSF REALLY Should check that _prog_functions[type] is valid. if( pthread_create( &programmingthread->thread_id , NULL , _prog_functions[type], (void*)programmingthread) < 0) { LOG(PROG_LOG, LOG_ERR, "could not create thread\n"); return; diff --git a/source/aq_programmer.h b/source/aq_programmer.h index 5ba619c..8a9aa8c 100644 --- a/source/aq_programmer.h +++ b/source/aq_programmer.h @@ -188,7 +188,7 @@ typedef enum pump_type { PT_UNKNOWN = -1, EPUMP, // = ePump AC & Jandy ePUMP VSPUMP, // = Intelliflo VS - VFPUMP // = Intelliflo VF (GPM) + VFPUMP, // = Intelliflo VF (GPM) } pump_type; diff --git a/source/aq_timer.c b/source/aq_timer.c index 6747828..5b23781 100644 --- a/source/aq_timer.c +++ b/source/aq_timer.c @@ -19,6 +19,7 @@ struct timerthread { int deviceIndex; struct aqualinkdata *aqdata; int duration_min; + u_int32_t duration_sec; struct timespec timeout; time_t started_at; struct timerthread *next; @@ -57,7 +58,25 @@ int get_timer_left(aqkey *button) return 0; } -void clear_timer(struct aqualinkdata *aqdata, /*aqkey *button,*/ int deviceIndex) +uint32_t get_timer_left_sec(aqkey *button) +{ + struct timerthread *t_ptr = find_timerthread(button); + + if (t_ptr != NULL) { + time_t now = time(0); + long total_duration_sec = (t_ptr->duration_min * 60) + t_ptr->duration_sec; + long elapsed_sec = (long)difftime(now, t_ptr->started_at); + if (elapsed_sec >= total_duration_sec) { + return 0; + } + + return (uint32_t)(total_duration_sec - elapsed_sec); + } + + return 0; +} + +void clear_timer(struct aqualinkdata *aqdata, int deviceIndex) { //struct timerthread *t_ptr = find_timerthread(button); struct timerthread *t_ptr = find_timerthread(&aqdata->aqbuttons[deviceIndex]); @@ -65,18 +84,21 @@ void clear_timer(struct aqualinkdata *aqdata, /*aqkey *button,*/ int deviceIndex if (t_ptr != NULL) { LOG(TIMR_LOG, LOG_INFO, "Clearing timer for '%s'\n",t_ptr->button->name); t_ptr->duration_min = 0; + t_ptr->duration_sec = 0; pthread_cond_broadcast(&t_ptr->thread_cond); } } -void start_timer(struct aqualinkdata *aqdata, /*aqkey *button,*/ int deviceIndex, int duration) + +void start_timer(struct aqualinkdata *aqdata, int deviceIndex, int duration_min, u_int32_t duration_sec) { aqkey *button = &aqdata->aqbuttons[deviceIndex]; struct timerthread *t_ptr = find_timerthread(button); if (t_ptr != NULL) { LOG(TIMR_LOG, LOG_INFO, "Timer already active for '%s', resetting\n",t_ptr->button->name); - t_ptr->duration_min = duration; + t_ptr->duration_min = duration_min; + t_ptr->duration_sec = duration_sec; pthread_cond_broadcast(&t_ptr->thread_cond); return; } @@ -86,7 +108,8 @@ void start_timer(struct aqualinkdata *aqdata, /*aqkey *button,*/ int deviceIndex tmthread->button = button; tmthread->deviceIndex = deviceIndex; tmthread->thread_id = 0; - tmthread->duration_min = duration; + tmthread->duration_min = duration_min; + tmthread->duration_sec = duration_sec; 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 @@ -158,8 +181,9 @@ void *timer_worker( void *ptr ) } } + /* + // Wait the entire duration. pthread_mutex_lock(&tmthread->thread_mutex); - do { if (retval != 0) { LOG(TIMR_LOG, LOG_ERR, "pthread_cond_timedwait failed for '%s', error %d %s\n",tmthread->button->name,retval,strerror(retval)); @@ -169,15 +193,72 @@ void *timer_worker( void *ptr ) break; } clock_gettime(CLOCK_REALTIME, &tmthread->timeout); - tmthread->timeout.tv_sec += (tmthread->duration_min * 60); + tmthread->timeout.tv_sec += (tmthread->duration_min * 60) + tmthread->duration_sec; tmthread->started_at = time(0); - LOG(TIMR_LOG, LOG_INFO, "Will turn off '%s' in %d minutes\n",tmthread->button->name, tmthread->duration_min); + LOG(TIMR_LOG, LOG_INFO, "Will turn off '%s' in %d:%d\n",tmthread->button->name, tmthread->duration_min, tmthread->duration_sec); } while ((retval = pthread_cond_timedwait(&tmthread->thread_cond, &tmthread->thread_mutex, &tmthread->timeout)) != ETIMEDOUT); + pthread_mutex_unlock(&tmthread->thread_mutex); + */ + // Waake every minute (or second) and set the dirty flag. + pthread_mutex_lock(&tmthread->thread_mutex); + + // 1. Calculate the absolute end time once + struct timespec end_time; + clock_gettime(CLOCK_REALTIME, &end_time); + end_time.tv_sec += (tmthread->duration_min * 60) + tmthread->duration_sec; + + tmthread->started_at = time(0); + LOG(TIMR_LOG, LOG_INFO, "Timer started for '%s': %d:%02d total duration\n", tmthread->button->name, tmthread->duration_min, tmthread->duration_sec); + + while (1) { + struct timespec now; + clock_gettime(CLOCK_REALTIME, &now); + + // 2. Calculate remaining time + long remaining_sec = end_time.tv_sec - now.tv_sec; + + if (remaining_sec <= 0) { + break; // Timer finished + } + + SET_DIRTY(tmthread->aqdata->is_dirty); + // 3. Print time left + if (remaining_sec >= 60) { + LOG(TIMR_LOG, LOG_INFO, "Time left for '%s': %ldm %lds\n", tmthread->button->name, remaining_sec / 60, remaining_sec % 60); + } else { + LOG(TIMR_LOG, LOG_INFO, "Time left for '%s': %ld seconds\n", tmthread->button->name, remaining_sec); + } + + // Set next wake time + tmthread->timeout = now; + tmthread->timeout.tv_sec += (remaining_sec > 60) ? 60 : 1; + + // Wait for timeout or signal + retval = pthread_cond_timedwait(&tmthread->thread_cond, &tmthread->thread_mutex, &tmthread->timeout); + + if (retval == 0) { + // We were signaled! Someone changed tmthread->duration_min or duration_sec + if (tmthread->duration_min <= 0 && tmthread->duration_sec <= 0) { + break; // Cancelled + } + + LOG(TIMR_LOG, LOG_INFO, "Timer update received for '%s'. Recalculating...\n", tmthread->button->name); + + // Update the end_time based on the NEW duration + clock_gettime(CLOCK_REALTIME, &end_time); + end_time.tv_sec += (tmthread->duration_min * 60) + tmthread->duration_sec; + // Also update started_at so get_timer_left_sec() remains accurate + tmthread->started_at = time(0); + } + else if (retval != ETIMEDOUT) { + LOG(TIMR_LOG, LOG_ERR, "pthread_cond_timedwait error: %d\n", retval); + break; + } + } pthread_mutex_unlock(&tmthread->thread_mutex); - - LOG(TIMR_LOG, LOG_NOTICE, "End timer for '%s'\n",tmthread->button->name); + LOG(TIMR_LOG, LOG_NOTICE, "End timer for '%s'\n", tmthread->button->name); // We need to detect if we ended on time or were killed. // If killed the device is probable off (or being set to off), so we should probably poll a few times before turning off. @@ -187,7 +268,7 @@ void *timer_worker( void *ptr ) // if duration_min is 0 we were killed, if not we got here on timeout, so turn off device. - if (tmthread->duration_min != 0 && tmthread->button->led->state != OFF) { + if ( (tmthread->duration_min != 0 || tmthread->duration_sec != 0) && tmthread->button->led->state != OFF) { LOG(TIMR_LOG, LOG_INFO, "Timer waking turning '%s' off\n",tmthread->button->name); panel_device_request(tmthread->aqdata, ON_OFF, tmthread->deviceIndex, false, NET_TIMER); } else if (tmthread->button->led->state == OFF) { diff --git a/source/aq_timer.h b/source/aq_timer.h index 90b7588..61c081c 100644 --- a/source/aq_timer.h +++ b/source/aq_timer.h @@ -4,9 +4,10 @@ #include "aqualink.h" -void start_timer(struct aqualinkdata *aq_data, /*aqkey *button,*/ int deviceIndex, int duration); +void start_timer(struct aqualinkdata *aqdata, int deviceIndex, int duration_min, uint32_t duration_sec); int get_timer_left(aqkey *button); -void clear_timer(struct aqualinkdata *aq_data, /*aqkey *button,*/ int deviceIndex); +u_int32_t get_timer_left_sec(aqkey *button); +void clear_timer(struct aqualinkdata *aq_data, int deviceIndex); // Not best place for this, but leave it here so all requests are in net services, this is forward decleration of function in net_services.c #ifdef AQ_PDA void create_PDA_on_off_request(aqkey *button, bool isON); diff --git a/source/aqualink.h b/source/aqualink.h index 7107dea..a62ee7a 100644 --- a/source/aqualink.h +++ b/source/aqualink.h @@ -100,18 +100,14 @@ typedef struct aqualinkled typedef struct aqualinkkey { - //int number; - //aqledstate ledstate; // In the future there is no need to aqled struct so move code over to this. aqled *led; char *label; char *name; -//#ifdef AQ_PDA -// char *pda_label; -//#endif unsigned char code; unsigned char rssd_code; uint8_t special_mask; void *special_mask_ptr; + uint32_t runtime_sec; } aqkey; @@ -176,6 +172,7 @@ typedef enum action_type { SPA_HTR_INCREMENT, // Setpoint add value ON_OFF, TIMER, + TIMER_SEC, LIGHT_MODE, LIGHT_BRIGHTNESS, DATE_TIME diff --git a/source/config.c b/source/config.c index b38cfa4..40508c3 100644 --- a/source/config.c +++ b/source/config.c @@ -1154,12 +1154,10 @@ if (strlen(cleanwhitespace(value)) <= 0) { rtn=true; */ } else if (strncasecmp(param + 9, "_light", 6) == 0) { - if ( ! populateLightData(aqdata, param + 10, &aqdata->aqbuttons[num], value) ) { LOG(AQUA_LOG,LOG_ERR, "Config error, %s Ignored!",param,value); } - rtn=true; /* @@ -1175,14 +1173,15 @@ if (strlen(cleanwhitespace(value)) <= 0) { LOG(AQUA_LOG,LOG_ERR, "Config error, Couldn't find light for '%s'\n",value); }*/ } 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; - } + } else if (strncasecmp(param + 9, "_runtime", 8) == 0) { + aqdata->aqbuttons[num].runtime_sec = time_string_to_seconds(value); + rtn=true; + } //#if defined AQ_IAQTOUCH } else if (strncasecmp(param, "virtual_button_", 15) == 0) { @@ -1270,12 +1269,18 @@ if (strlen(cleanwhitespace(value)) <= 0) { vbutton->rssd_code = NUL; break; } - + } else { LOG(AQUA_LOG,LOG_ERR, "Config error, could not find vitrual button for `%s`",param); } rtn=true; - } + } else if (strncasecmp(param + 17, "_runtime", 8) == 0) { + aqkey *vbutton = getVirtualButton(aqdata, num); + if (vbutton != NULL) { + vbutton->runtime_sec = time_string_to_seconds(value); + rtn=true; + } + } } else if (strncasecmp(param, "sensor_", 7) == 0) { int num = strtoul(param + 7, NULL, 10) - 1; if (num + 1 > MAX_SENSORS || num < 0) { @@ -1644,7 +1649,7 @@ char *errorlevel2text(int level) #define ERR_VBUTTON_NO_EXTENDEDID (1<<8) #define ERR_VBUTTON_4_BOOSTON (1<<9) #define ERR_HEATPUMP_CHILLER (1<<10) - +#define ERR_VBUTTON_ONET_NOID (1<<11) void check_print_config (struct aqualinkdata *aqdata) @@ -1988,6 +1993,14 @@ void check_print_config (struct aqualinkdata *aqdata) //char ext[] = " VSP ID None | AL ID 0 "; char ext[120]; ext[0] = '\0'; + + char runtime[11]; + runtime[0] = '\0'; + if (aqdata->aqbuttons[i].runtime_sec > 0) { + seconds_to_time_string(aqdata->aqbuttons[i].runtime_sec, runtime, sizeof(runtime)); + sprintf(runtime+8, " |"); + } + for (j = 0; j < aqdata->num_pumps; j++) { if (aqdata->pumps[j].button == &aqdata->aqbuttons[i]) { sprintf(ext, "VSP ID 0x%02hhx | PumpID %-1d | %s", @@ -2008,15 +2021,23 @@ void check_print_config (struct aqualinkdata *aqdata) sprintf(ext,"%-12s|", ((altlabel_detail *)aqdata->aqbuttons[i].special_mask_ptr)->altlabel); } - LOG(AQUA_LOG,LOG_NOTICE, "Button %-13s = label %-15.15s | %s\n", - aqdata->aqbuttons[i].name, aqdata->aqbuttons[i].label, ext); + LOG(AQUA_LOG,LOG_NOTICE, "Button %-13s = label %-15.15s | %s%s\n", + aqdata->aqbuttons[i].name, aqdata->aqbuttons[i].label, runtime,ext); - if ( ((aqdata->aqbuttons[i].special_mask & VIRTUAL_BUTTON) == VIRTUAL_BUTTON) && - ((aqdata->aqbuttons[i].special_mask & VS_PUMP ) != VS_PUMP) && - ( ! is_aqualink_touch_id(_aqconfig_.extended_device_id))) { - //(_aqconfig_.extended_device_id < 0x30 || _aqconfig_.extended_device_id > 0x33 ) ){ - setMASK(errors, ERR_VBUTTON_NO_EXTENDEDID); + if ( isVBUTTON(aqdata->aqbuttons[i].special_mask) && ( ! is_aqualink_touch_id(_aqconfig_.extended_device_id))) + { + // Onetouch protocol is good for if vbutton is pump or has onetouchID's + if (is_onetouch_id(_aqconfig_.extended_device_id)) { + //removeMASK(errors, ERR_VBUTTON_NO_EXTENDEDID); + if ( !isVS_PUMP(aqdata->aqbuttons[i].special_mask) && (aqdata->aqbuttons[i].rssd_code == NUL)) { + setMASK(errors, ERR_VBUTTON_ONET_NOID); + } //else if (!isVS_PUMP(aqdata->aqbuttons[i].special_mask)) { + //removeMASK(errors, ERR_VBUTTON_NO_EXTENDEDID); + //} + } else { + setMASK(errors, ERR_VBUTTON_NO_EXTENDEDID); + } //LOG(AQUA_LOG,LOG_WARNING, "Config error, extended_device_id must be one of the folowing (0x30,0x31,0x32,0x33) to use virtual button : '%s'",aqdata->aqbuttons[i].label); } @@ -2068,6 +2089,9 @@ void check_print_config (struct aqualinkdata *aqdata) if (isMASK_SET(errors, ERR_HEATPUMP_CHILLER)) { LOG(AQUA_LOG,LOG_ERR, "Config error, Heat pump / Chiller was not configured correctly\n"); } + if (isMASK_SET(errors, ERR_VBUTTON_ONET_NOID)) { + LOG(AQUA_LOG,LOG_ERR, "Config error, for Virtual Buttons using OneTouch protocol, 'onetouchID' must be set\n"); + } } @@ -2149,6 +2173,7 @@ int save_config_js(const char* inBuf, int inSize, char* outBuf, int outSize, str aqdata->aqbuttons[i].special_mask_ptr = NULL; aqdata->aqbuttons[i].code = NUL; aqdata->aqbuttons[i].rssd_code = NUL; + aqdata->aqbuttons[i].runtime_sec = 0; } aqdata->num_pumps = 0; aqdata->num_lights = 0; @@ -2459,6 +2484,12 @@ bool writeCfg (struct aqualinkdata *aqdata) if (isVBUTTON_ALTLABEL(aqdata->aqbuttons[i].special_mask)) { fprintf(fp,"%s_altLabel=%s\n", prefix, ((altlabel_detail *)aqdata->aqbuttons[i].special_mask_ptr)->altlabel); } + + if (aqdata->aqbuttons[i].runtime_sec > 0) { + char tmstr[30]; + seconds_to_time_string(aqdata->aqbuttons[i].runtime_sec, tmstr, sizeof(tmstr)); + fprintf(fp,"%s_runtime=%s\n", prefix, tmstr); + } } diff --git a/source/json_messages.c b/source/json_messages.c index 0a8a679..8075d2f 100644 --- a/source/json_messages.c +++ b/source/json_messages.c @@ -323,7 +323,8 @@ char *get_aux_information(aqkey *button, struct aqualinkdata *aqdata, char *buff //printf("Button %s is Switch\n", button->name); length += sprintf(buffer+length, ",\"type_ext\": \"switch_timer\", \"timer_active\":\"%s\"", (((button->special_mask & TIMER_ACTIVE) == TIMER_ACTIVE)?JSON_ON:JSON_OFF) ); if ((button->special_mask & TIMER_ACTIVE) == TIMER_ACTIVE) { - length += sprintf(buffer+length,",\"timer_duration\":\"%d\"", get_timer_left(button)); + //length += sprintf(buffer+length,",\"timer_duration\":\"%d\"", get_timer_left(button)); + length += sprintf(buffer+length,",\"timer_duration\":\"%u\"", get_timer_left_sec(button)); } return buffer; } @@ -835,7 +836,7 @@ printf("Pump GPM %d\n",aqdata->pumps[i].watts); printf("Pump Type %d\n",aqdata->pumps[i].pumpType); */ //if (aqdata->pumps[i].pumpType != PT_UNKNOWN && (aqdata->pumps[i].rpm != TEMP_UNKNOWN || aqdata->pumps[i].gpm != TEMP_UNKNOWN || aqdata->pumps[i].watts != TEMP_UNKNOWN)) { - if (aqdata->pumps[i].pumpType != PT_UNKNOWN ) { + if (aqdata->pumps[i].pumpType != PT_UNKNOWN) { length += sprintf(buffer+length, "\"Pump_%d\":{\"name\":\"%s\",\"id\":\"%s\",\"RPM\":\"%d\",\"GPM\":\"%d\",\"Watts\":\"%d\",\"Pump_Type\":\"%s\",\"Status\":\"%d\"},", i+1,aqdata->pumps[i].button->label,aqdata->pumps[i].button->name,aqdata->pumps[i].rpm,aqdata->pumps[i].gpm,aqdata->pumps[i].watts, (aqdata->pumps[i].pumpType==VFPUMP?"vfPump":(aqdata->pumps[i].pumpType==VSPUMP?"vsPump":"ePump")), @@ -861,7 +862,8 @@ printf("Pump Type %d\n",aqdata->pumps[i].pumpType); for (i=0; i < aqdata->total_buttons; i++) { if ((aqdata->aqbuttons[i].special_mask & TIMER_ACTIVE) == TIMER_ACTIVE) { - length += sprintf(buffer+length, "\"%s\": \"%d\",", aqdata->aqbuttons[i].name, get_timer_left(&aqdata->aqbuttons[i]) ); + //length += sprintf(buffer+length, "\"%s\": \"%d\",", aqdata->aqbuttons[i].name, get_timer_left(&aqdata->aqbuttons[i]) ); + length += sprintf(buffer+length, "\"%s\": \"%u\",", aqdata->aqbuttons[i].name, get_timer_left_sec(&aqdata->aqbuttons[i]) ); } } if (buffer[length-1] == ',') @@ -1454,6 +1456,19 @@ int build_aqualink_config_JSON(char* buffer, int size, struct aqualinkdata *aqda } else length += result; + if (aqdata->aqbuttons[i].runtime_sec > 0) { + sprintf(buf,"%s_runtime", prefix); + char buf1[10]; + buf1[0] = '\0'; + seconds_to_time_string(aqdata->aqbuttons[i].runtime_sec, buf1, sizeof(buf1)); + stringptr = buf1; // Create a pointer to the buffer + if ((result = json_cfg_element(buffer+length, size-length, buf, &stringptr, CFG_STRING, 0, NULL, 0)) <= 0) { + LOG(NET_LOG,LOG_ERR, "Config json buffer full in, result truncated! size=%d curently used=%d\n",size,length); + return length; + } else + length += result; + } + if (isVS_PUMP(aqdata->aqbuttons[i].special_mask)) { if (((pump_detail *)aqdata->aqbuttons[i].special_mask_ptr)->pumpIndex > 0) { diff --git a/source/net_services.c b/source/net_services.c index 013290e..fed97e6 100644 --- a/source/net_services.c +++ b/source/net_services.c @@ -1406,11 +1406,11 @@ uriAtype action_URI(request_source from, const char *URI, int uri_length, float if ( strncasecmp(ri2, "ORP", 3) == 0 ) { SET_IF_CHANGED(_aqualink_data->orp, round(value), _aqualink_data->is_dirty); rtn = uActioned; - LOG(NET_LOG,LOG_NOTICE, "%s: request to set ORP to %d\n",actionName[from],_aqualink_data->orp); + LOG(NET_LOG,LOG_INFO, "%s: request to set ORP to %d\n",actionName[from],_aqualink_data->orp); } else if ( strncasecmp(ri2, "Ph", 2) == 0 ) { SET_IF_CHANGED(_aqualink_data->ph, value, _aqualink_data->is_dirty); rtn = uActioned; - LOG(NET_LOG,LOG_NOTICE, "%s: request to set Ph to %.2f\n",actionName[from],_aqualink_data->ph); + LOG(NET_LOG,LOG_INFO, "%s: request to set Ph to %.2f\n",actionName[from],_aqualink_data->ph); } else { LOG(NET_LOG,LOG_WARNING,"%s: ignoring, unknown URI %.*s\n",actionName[from],uri_length,URI); rtn = uBad; @@ -1424,12 +1424,11 @@ uriAtype action_URI(request_source from, const char *URI, int uri_length, float //bool istimer = false; action_type atype = ON_OFF; //int timer=0; - if (strncasecmp(ri2, "timer", 5) == 0) { - //istimer = true; - atype = TIMER; - //timer = value; // Save off timer - //value = 1; // Make sure we turn device on if timer. - } else if ( value > 1 || value < 0) { + if (strncasecmp(ri2, "timer_sec", 9) == 0) { + atype = TIMER_SEC; + } else if (strncasecmp(ri2, "timer", 5) == 0) { + atype = TIMER; + }else if ( value > 1 || value < 0) { LOG(NET_LOG,LOG_WARNING, "%s: URI %s has invalid value %.2f\n",actionName[from], URI, value); *rtnmsg = INVALID_VALUE; rtn = uBad; diff --git a/source/onetouch.c b/source/onetouch.c index 7e16236..2b3cb0c 100644 --- a/source/onetouch.c +++ b/source/onetouch.c @@ -43,7 +43,7 @@ static struct ot_macro _macros[3]; bool _panel_version_P2 = false; // Older panels REV 0.1 and 0.2 -void set_macro_status(); +void set_macro_status(struct aqualinkdata *aqdata); void pump_update(struct aqualinkdata *aqdata, int updated); bool log_heater_setpoints(struct aqualinkdata *aqdata); @@ -789,7 +789,7 @@ bool new_menu(struct aqualinkdata *aqdata) switch (menu_type) { case OTM_ONETOUCH: - set_macro_status(); + set_macro_status(aqdata); break; case OTM_EQUIPTMENT_STATUS: if (initRS == false) { @@ -835,7 +835,8 @@ bool new_menu(struct aqualinkdata *aqdata) return rtn; } -void set_macro_status() + +void set_macro_status(struct aqualinkdata *aqdata) { // OneTouch Menu Line 2 = SPA MODE OFF // OneTouch Menu Line 5 = CLEAN MODE ON @@ -860,6 +861,18 @@ void set_macro_status() LOG(ONET_LOG,LOG_DEBUG, "Macro #2 '%s' is %s\n",_macros[1].name,_macros[1].ison?"On":"Off"); LOG(ONET_LOG,LOG_DEBUG, "Macro #3 '%s' is %s\n",_macros[2].name,_macros[2].ison?"On":"Off"); + for (int i = aqdata->virtual_button_start; i < aqdata->total_buttons; i++) { + if (aqdata->aqbuttons[i].rssd_code - 15 == 1) { + SET_IF_CHANGED(aqdata->aqbuttons[i].led->state, _macros[0].ison?ON:OFF, aqdata->is_dirty); + LOG(ONET_LOG,LOG_DEBUG, "Setting Macro #1 '%s' to %s for button '%s'\n",_macros[0].name,_macros[0].ison?"On":"Off",aqdata->aqbuttons[i].label); + } else if (aqdata->aqbuttons[i].rssd_code - 15 == 2) { + SET_IF_CHANGED(aqdata->aqbuttons[i].led->state, _macros[1].ison?ON:OFF, aqdata->is_dirty); + LOG(ONET_LOG,LOG_DEBUG, "Setting Macro #1 '%s' to %s for button '%s'\n",_macros[1].name,_macros[1].ison?"On":"Off",aqdata->aqbuttons[i].label); + } else if (aqdata->aqbuttons[i].rssd_code - 15 == 3) { + SET_IF_CHANGED(aqdata->aqbuttons[i].led->state, _macros[2].ison?ON:OFF, aqdata->is_dirty); + LOG(ONET_LOG,LOG_DEBUG, "Setting Macro #1 '%s' to %s for button '%s'\n",_macros[2].name,_macros[2].ison?"On":"Off",aqdata->aqbuttons[i].label); + } + } } } diff --git a/source/onetouch_aq_programmer.c b/source/onetouch_aq_programmer.c index bd0f99e..1c24522 100644 --- a/source/onetouch_aq_programmer.c +++ b/source/onetouch_aq_programmer.c @@ -262,6 +262,41 @@ bool goto_onetouch_system_menu(struct aqualinkdata *aqdata) return true; } +bool goto_onetouch_macros_menu(struct aqualinkdata *aqdata) +{ + + for(int i=0; i < 4; i++) { + + if (get_onetouch_menu_type() == OTM_ONETOUCH) { + return true; + } + + if (get_onetouch_menu_type() == OTM_SYSTEM) { + select_onetouch_menu_item(aqdata, "ONETOUCH ON/OFF"); + waitfor_ot_queue2empty(); + //LOG(ONET_LOG,LOG_DEBUG, "goto_onetouch_macros_menu queue empty\n"); + waitForNextOT_Menu(aqdata); + //LOG(ONET_LOG,LOG_DEBUG, "goto_onetouch_macros_menu next menu\n"); + //waitForOT_MessageTypes(aqdata,CMD_PDA_HIGHLIGHT,CMD_PDA_HIGHLIGHT,15); + //LOG(ONET_LOG,LOG_DEBUG, "goto_onetouch_macros_menu CMD_PDA_HIGHLIGHT\n"); + //delay(500); + //LOG(ONET_LOG,LOG_DEBUG, "goto_onetouch_macros_menu delay\n"); + //waitForOT_MessageTypes(aqdata,CMD_PDA_CLEAR,CMD_PDA_HIGHLIGHTCHARS,15); + //waitForOT_MessageTypes(aqdata,CMD_PDA_HIGHLIGHT,CMD_PDA_HIGHLIGHT,15); + //waitForOT_MessageType(aqdata,CMD_PDA_HIGHLIGHT,CMD_PDA_0x04,10); + } else if (get_onetouch_menu_type() != OTM_ONETOUCH){ + send_ot_cmd(KEY_ONET_BACK); + waitfor_ot_queue2empty(); + waitForNextOT_Menu(aqdata); + } + + } + + LOG(ONET_LOG,LOG_ERR, "OneTouch device programmer couldn't get to ONETOUCH menu\n"); + return false; +} + + bool goto_onetouch_menu(struct aqualinkdata *aqdata, ot_menu_type menu) { bool equErr = false; @@ -270,6 +305,10 @@ bool goto_onetouch_menu(struct aqualinkdata *aqdata, ot_menu_type menu) LOG(ONET_LOG,LOG_DEBUG, "OneTouch device programmer request for menu %d\n",menu); + if (menu == OTM_ONETOUCH){ + return goto_onetouch_macros_menu(aqdata); + } + if ( ! goto_onetouch_system_menu(aqdata) ) { LOG(ONET_LOG,LOG_ERR, "OneTouch device programmer failed to get system menu\n"); return false; @@ -569,9 +608,10 @@ void *set_aqualink_onetouch_freezeprotect( void *ptr ) void *set_aqualink_onetouch_macro( void *ptr ) { + /* struct programmingThreadCtrl *threadCtrl; threadCtrl = (struct programmingThreadCtrl *) ptr; - //struct aqualinkdata *aqdata = threadCtrl->aqdata; + struct aqualinkdata *aqdata = threadCtrl->aqdata; //sprintf(msg, "%-5d%-5d",index, (strcmp(value, "on") == 0)?ON:OFF); // Use above to set @@ -585,13 +625,58 @@ void *set_aqualink_onetouch_macro( void *ptr ) unsigned int device = atoi(&buf[0]); unsigned int state = atoi(&buf[5]); #endif +*/ + struct programmingThreadCtrl *threadCtrl; + threadCtrl = (struct programmingThreadCtrl *) ptr; + struct aqualinkdata *aqdata = threadCtrl->aqdata; + +#ifdef NEW_AQ_PROGRAMMER + struct programmerArgs *pargs = &threadCtrl->pArgs; + aqkey *button = threadCtrl->pArgs.button; + int value = pargs->value; +#else + //int val = atoi((char*)threadCtrl->thread_args); +#endif + + if (! isVBUTTON(button->special_mask)){ + LOG(ONET_LOG,LOG_ERR, "OneTouch macro programmer only supports VBUTTON macros\n"); + return ptr; + } waitForSingleThreadOrTerminate(threadCtrl, AQ_SET_ONETOUCH_MACRO); LOG(ONET_LOG,LOG_DEBUG, "OneTouch Marco\n"); - LOG(ONET_LOG,LOG_ERR, "OneTouch Macro not implimented (device=%d|state=%d)\n",button->label,state); + //LOG(ONET_LOG,LOG_ERR, "OneTouch Macro not implimented (device=%d|state=%d)\n",button->label,state); + if ( !goto_onetouch_menu(aqdata, OTM_ONETOUCH) ){ + LOG(ONET_LOG,LOG_ERR, "OneTouch device programmer failed to get heater temp menu\n"); + } + + // Check button is not= value. (don't do this before as the menu command above may change the state of the button) + if (button->led->state == value) { + LOG(ONET_LOG,LOG_DEBUG, "OneTouch Macro '%s' already in desired state, no change needed\n", button->label); + cleanAndTerminateThread(threadCtrl); + return ptr; + } + + switch(button->rssd_code - 15) { + case 1: + send_ot_cmd(KEY_ONET_SELECT_1); + SET_IF_CHANGED(button->led->state, !button->led->state, aqdata->is_dirty); + break; + case 2: + send_ot_cmd(KEY_ONET_SELECT_2); + SET_IF_CHANGED(button->led->state, !button->led->state, aqdata->is_dirty); + break; + case 3: + send_ot_cmd(KEY_ONET_SELECT_3); + SET_IF_CHANGED(button->led->state, !button->led->state, aqdata->is_dirty) ; + break; + default: + LOG(ONET_LOG,LOG_ERR, "OneTouch Macro programmer only has 3 buttons OneTouchID %d is invalid\n",button->rssd_code - 15); + } + cleanAndTerminateThread(threadCtrl); // just stop compiler error, ptr is not valid as it's just been freed diff --git a/source/utils.c b/source/utils.c index 73bbdaa..dac2fb8 100644 --- a/source/utils.c +++ b/source/utils.c @@ -968,6 +968,69 @@ char *prittyString(char *str) return str; } +/* +#include +#include +#include +*/ +/** + * Converts a time string to total seconds. + * Supported formats: "HH:MM:SS", "MM:SS", or "SS" + */ +uint32_t time_string_to_seconds(const char *time_str) { + if (time_str == NULL) return 0; + + int parts[3] = {0, 0, 0}; + int count = 0; + + // Count the number of colons to determine the format + const char *p = time_str; + int colons = 0; + while (*p) { + if (*p == ':') colons++; + p++; + } + + if (colons == 2) { + // Format: HH:MM:SS + count = sscanf(time_str, "%d:%d:%d", &parts[0], &parts[1], &parts[2]); + if (count == 3) { + return (uint32_t)((parts[0] * 3600) + (parts[1] * 60) + parts[2]); + } + } else if (colons == 1) { + // Format: MM:SS + count = sscanf(time_str, "%d:%d", &parts[0], &parts[1]); + if (count == 2) { + return (uint32_t)((parts[0] * 60) + parts[1]); + } + } else { + // Format: SS + count = sscanf(time_str, "%d", &parts[0]); + if (count == 1) { + return (uint32_t)parts[0]; + } + } + + return 0; // Return 0 if parsing failed +} + + +/** + * Converts total seconds into a string with format "HH:MM:SS". + * Ensure the output buffer is at least 9 bytes (8 chars + null terminator). + */ +void seconds_to_time_string(uint32_t total_seconds, char *output_buffer, size_t buffer_size) { + if (output_buffer == NULL || buffer_size < 9) { + return; + } + + uint32_t h = total_seconds / 3600; + uint32_t m = (total_seconds % 3600) / 60; + uint32_t s = total_seconds % 60; + + // %02u ensures two digits with leading zeros + snprintf(output_buffer, buffer_size, "%02u:%02u:%02u", h, m, s); +} temperatureUOM getTemperatureUOM(const char *uom) { diff --git a/source/utils.h b/source/utils.h index b940e85..62615bb 100644 --- a/source/utils.h +++ b/source/utils.h @@ -140,7 +140,8 @@ char *prittyString(char *str); //void closePacketLog(); float timespec2float(const struct timespec *elapsed); bool isUomTemperature( const char *uom); - +uint32_t time_string_to_seconds(const char *time_str); +void seconds_to_time_string(uint32_t total_seconds, char *output_buffer, size_t buffer_size); temperatureUOM getTemperatureUOM(const char *uom); diff --git a/source/version.h b/source/version.h index ad1f36b..0be5b93 100644 --- a/source/version.h +++ b/source/version.h @@ -4,5 +4,5 @@ #define AQUALINKD_SHORT_NAME "AqualinkD" // Use Magor . Minor . Patch -#define AQUALINKD_VERSION "3.0.5 (dev)" +#define AQUALINKD_VERSION "3.1.0" \ No newline at end of file diff --git a/web/aqmanager.html b/web/aqmanager.html index c9d21dc..4d40048 100644 --- a/web/aqmanager.html +++ b/web/aqmanager.html @@ -955,8 +955,8 @@ // } // key = 1 //} - const buttonTypelist = ["pumpIndex", "pumpID", "pumpType", "pumpName", "pumpMaxSpeed", "pumpMinSpeed", "lightMode"]; - const virtButtonTypelist = ["pumpIndex", "pumpID", "pumpType", "pumpName", "pumpMaxSpeed", "pumpMinSpeed", "lightMode", "onetouchID", "altLabel"]; + const buttonTypelist = ["pumpIndex", "pumpID", "pumpType", "pumpName", "pumpMaxSpeed", "pumpMinSpeed", "lightMode", "runtime"]; + const virtButtonTypelist = ["pumpIndex", "pumpID", "pumpType", "pumpName", "pumpMaxSpeed", "pumpMinSpeed", "lightMode", "runtime", "onetouchID", "altLabel"]; const configtable = document.getElementById("config_table"); const newRow = configtable.insertRow(); var cell1 = newRow.insertCell(); @@ -1036,7 +1036,7 @@ _addedBlankVirtualButton = true; return; } - const virtButtonTypelist = ["pumpIndex", "pumpID", "pumpType", "pumpName", "pumpMaxSpeed", "pumpMinSpeed", "lightMode", "onetouchID", "altLabel"]; + const virtButtonTypelist = ["pumpIndex", "pumpID", "pumpType", "pumpName", "pumpMaxSpeed", "pumpMinSpeed", "lightMode", "runtime", "onetouchID", "altLabel"]; const configtable = document.getElementById("config_table"); const vbname = "virtual_button_" + String(num).padStart(2, '0'); const vblabel = vbname + "_label"; @@ -1156,6 +1156,8 @@ } else if(deleted && key.startsWith("light_program_")) { //console.log(key.slice(0, 9)); delete _config[key]; + } else { + //console.log("delete - Unknown delete option " + key); } if(rebuild) { resetConfig(null); @@ -1288,6 +1290,7 @@ break; case "pumpName": case "altLabel": + case "runtime": js.type = "string"; break; } diff --git a/web/index.html b/web/index.html index 376a3f4..2cf165b 100644 --- a/web/index.html +++ b/web/index.html @@ -2017,6 +2017,31 @@ const minutes = totalMinutes % 60; return `${padToTwoDigits(hours)}:${padToTwoDigits(minutes)}`; } + +/** + * Formats seconds into a human-readable string. + * Returns HH:MM if duration is 1 minute or more. + * Returns MM:SS if duration is less than 1 minute. + */ +function formatDuration(totalSeconds) { +if (totalSeconds < 0) totalSeconds = 0; + + // If we are at 1 minute or more, round to the nearest minute + // (e.g., 1:30 becomes 2:00, 1:29 stays 1:00) + if (totalSeconds >= 60) { + const roundedSeconds = Math.round(totalSeconds / 60) * 60; + const h = Math.floor(roundedSeconds / 3600); + const m = Math.floor((roundedSeconds % 3600) / 60); + + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; + } + + // If under 60 seconds, show actual seconds + const s = Math.floor(totalSeconds % 60); + return `00:${s.toString().padStart(2, '0')}s`; +} + + function padToTwoDigits(num) { return num.toString().padStart(2, "0"); } @@ -3186,7 +3211,9 @@ //console.log("TIMER "+obj.toString()); } for (var obj in data.timer_durations) { - setTileOnText(obj.toString(),"Timer "+toHoursAndMinutes(data.timer_durations[obj])); + //setTileOnText(obj.toString(),"Timer "+toHoursAndMinutes(data.timer_durations[obj])); + setTileOnText(obj.toString(),"Timer "+formatDuration(data.timer_durations[obj])); + //setTileOnTextLine2(obj.toString(),"Timer "+toHoursAndMinutes(data.timer_durations[obj])); //console.log("TIMER "+obj.toString()+" duration "+data.timer_durations[obj]); }