diff --git a/components/ts_api/src/ts_api_automation.c b/components/ts_api/src/ts_api_automation.c index 21e3351..0d41bb5 100644 --- a/components/ts_api/src/ts_api_automation.c +++ b/components/ts_api/src/ts_api_automation.c @@ -21,6 +21,7 @@ #include "ts_variable.h" #include "ts_rule_engine.h" #include "ts_source_manager.h" +#include "ts_temp_source.h" #include "ts_action_manager.h" #include "ts_config_pack.h" #include "ts_cert.h" @@ -29,6 +30,7 @@ #include "esp_heap_caps.h" #include "esp_crt_bundle.h" #include "esp_log.h" +#include "esp_timer.h" #include #include #include @@ -219,15 +221,53 @@ static esp_err_t api_automation_variables_list(const cJSON *params, ts_api_resul { result->data = cJSON_CreateObject(); cJSON *vars_array = cJSON_AddArrayToObject(result->data, "variables"); + int64_t now_ms = esp_timer_get_time() / 1000; + const char *prefix = NULL; + const char *source_id = NULL; + size_t prefix_len = 0; + bool include_value = true; + bool include_meta = true; + + if (params) { + cJSON *prefix_param = cJSON_GetObjectItem(params, "prefix"); + if (prefix_param && cJSON_IsString(prefix_param) && prefix_param->valuestring) { + prefix = prefix_param->valuestring; + prefix_len = strlen(prefix); + } + + cJSON *source_id_param = cJSON_GetObjectItem(params, "source_id"); + if (source_id_param && cJSON_IsString(source_id_param) && source_id_param->valuestring) { + source_id = source_id_param->valuestring; + } + + cJSON *include_value_param = cJSON_GetObjectItem(params, "include_value"); + if (include_value_param && cJSON_IsBool(include_value_param)) { + include_value = cJSON_IsTrue(include_value_param); + } + + cJSON *include_meta_param = cJSON_GetObjectItem(params, "include_meta"); + if (include_meta_param && cJSON_IsBool(include_meta_param)) { + include_meta = cJSON_IsTrue(include_meta_param); + } + } // 遍历所有变量(使用内部迭代器) ts_variable_iterate_ctx_t ctx = {0}; ts_auto_variable_t var; while (ts_variable_iterate(&ctx, &var) == ESP_OK) { + if (prefix_len > 0 && strncmp(var.name, prefix, prefix_len) != 0) { + continue; + } + if (source_id && strcmp(var.source_id, source_id) != 0) { + continue; + } + cJSON *var_obj = cJSON_CreateObject(); cJSON_AddStringToObject(var_obj, "name", var.name); - cJSON_AddItemToObject(var_obj, "value", value_to_json(&var.value)); + if (include_value) { + cJSON_AddItemToObject(var_obj, "value", value_to_json(&var.value)); + } // 类型字符串 const char *type_str = "null"; @@ -241,6 +281,20 @@ static esp_err_t api_automation_variables_list(const cJSON *params, ts_api_resul cJSON_AddStringToObject(var_obj, "type", type_str); cJSON_AddBoolToObject(var_obj, "persistent", (var.flags & TS_AUTO_VAR_PERSISTENT) != 0); cJSON_AddBoolToObject(var_obj, "readonly", (var.flags & TS_AUTO_VAR_READONLY) != 0); + if (include_meta) { + cJSON_AddNumberToObject(var_obj, "last_change_ms", (double)var.last_change_ms); + cJSON_AddNumberToObject(var_obj, "last_update_ms", (double)var.last_update_ms); + if (var.last_update_ms > 0) { + int64_t age_ms = now_ms - var.last_update_ms; + bool stale = (age_ms < 0 || age_ms > TS_TEMP_DATA_TIMEOUT_MS); + if (age_ms < 0) age_ms = 0; + cJSON_AddNumberToObject(var_obj, "age_ms", (double)age_ms); + cJSON_AddBoolToObject(var_obj, "stale", stale); + } else { + cJSON_AddNullToObject(var_obj, "age_ms"); + cJSON_AddBoolToObject(var_obj, "stale", true); + } + } if (var.source_id[0] != '\0') { cJSON_AddStringToObject(var_obj, "source_id", var.source_id); diff --git a/components/ts_api/src/ts_api_device.c b/components/ts_api/src/ts_api_device.c index 9c71230..dcd5002 100644 --- a/components/ts_api/src/ts_api_device.c +++ b/components/ts_api/src/ts_api_device.c @@ -48,6 +48,19 @@ static const char *fan_mode_to_str(ts_fan_mode_t mode) case TS_FAN_MODE_OFF: return "off"; case TS_FAN_MODE_MANUAL: return "manual"; case TS_FAN_MODE_AUTO: return "auto"; + case TS_FAN_MODE_CURVE: return "curve"; + default: return "unknown"; + } +} + +static const char *fan_auto_state_to_str(ts_fan_auto_state_t state) +{ + switch (state) { + case TS_FAN_AUTO_STATE_IDLE: return "idle"; + case TS_FAN_AUTO_STATE_BASELINE: return "baseline"; + case TS_FAN_AUTO_STATE_ACTIVE: return "active"; + case TS_FAN_AUTO_STATE_GUARD: return "guard"; + case TS_FAN_AUTO_STATE_STALE: return "stale"; default: return "unknown"; } } @@ -215,6 +228,15 @@ static esp_err_t api_device_fan_status(const cJSON *params, ts_api_result_t *res cJSON_AddNumberToObject(fan, "rpm", status.rpm); cJSON_AddNumberToObject(fan, "temp", status.temp / 10.0); cJSON_AddBoolToObject(fan, "running", status.is_running); + cJSON_AddNumberToObject(fan, "control_temperature", status.control_temp / 10.0); + cJSON_AddNumberToObject(fan, "guard_temperature", status.guard_temp / 10.0); + cJSON_AddNumberToObject(fan, "predicted_temperature", status.predicted_temp / 10.0); + cJSON_AddNumberToObject(fan, "slope_c_per_min", status.slope_c_per_min); + cJSON_AddNumberToObject(fan, "controller_gain", status.controller_gain); + cJSON_AddNumberToObject(fan, "cooling_response", status.cooling_response); + cJSON_AddStringToObject(fan, "auto_state", fan_auto_state_to_str(status.auto_state)); + cJSON_AddBoolToObject(fan, "guard_active", status.guard_active); + cJSON_AddBoolToObject(fan, "temp_stale", status.temp_stale); cJSON_AddItemToArray(fans, fan); } } @@ -226,7 +248,7 @@ static esp_err_t api_device_fan_status(const cJSON *params, ts_api_result_t *res /** * @brief device.fan.set - Set fan parameters * @param fan: fan id - * @param mode: "off", "manual", "auto" + * @param mode: "off", "manual", "auto", "curve" * @param duty: duty cycle (0-100) for manual mode */ static esp_err_t api_device_fan_set(const cJSON *params, ts_api_result_t *result) @@ -254,6 +276,8 @@ static esp_err_t api_device_fan_set(const cJSON *params, ts_api_result_t *result fan_mode = TS_FAN_MODE_MANUAL; } else if (strcmp(mode->valuestring, "auto") == 0) { fan_mode = TS_FAN_MODE_AUTO; + } else if (strcmp(mode->valuestring, "curve") == 0) { + fan_mode = TS_FAN_MODE_CURVE; } else { ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Invalid mode"); return ESP_ERR_INVALID_ARG; @@ -264,7 +288,8 @@ static esp_err_t api_device_fan_set(const cJSON *params, ts_api_result_t *result cJSON *duty = cJSON_GetObjectItem(params, "duty"); if (ret == ESP_OK && duty && cJSON_IsNumber(duty)) { int duty_val = (int)cJSON_GetNumberValue(duty); - if (duty_val >= 0 && duty_val <= 100) { + if ((!mode || !cJSON_IsString(mode) || strcmp(mode->valuestring, "manual") == 0) && + duty_val >= 0 && duty_val <= 100) { ret = ts_fan_set_duty(fan_id, duty_val); } } diff --git a/components/ts_api/src/ts_api_fan.c b/components/ts_api/src/ts_api_fan.c index 930f7de..af8210f 100644 --- a/components/ts_api/src/ts_api_fan.c +++ b/components/ts_api/src/ts_api_fan.c @@ -31,6 +31,18 @@ static const char *mode_to_string(ts_fan_mode_t mode) } } +static const char *auto_state_to_string(ts_fan_auto_state_t state) +{ + switch (state) { + case TS_FAN_AUTO_STATE_IDLE: return "idle"; + case TS_FAN_AUTO_STATE_BASELINE: return "baseline"; + case TS_FAN_AUTO_STATE_ACTIVE: return "active"; + case TS_FAN_AUTO_STATE_GUARD: return "guard"; + case TS_FAN_AUTO_STATE_STALE: return "stale"; + default: return "unknown"; + } +} + static ts_fan_mode_t string_to_mode(const char *str) { if (!str) return TS_FAN_MODE_MANUAL; @@ -53,6 +65,15 @@ static cJSON *status_to_json(ts_fan_id_t fan_id, const ts_fan_status_t *status) cJSON_AddBoolToObject(obj, "enabled", status->enabled); cJSON_AddBoolToObject(obj, "running", status->is_running); cJSON_AddBoolToObject(obj, "fault", status->fault); + cJSON_AddNumberToObject(obj, "control_temperature", status->control_temp / 10.0); + cJSON_AddNumberToObject(obj, "guard_temperature", status->guard_temp / 10.0); + cJSON_AddNumberToObject(obj, "predicted_temperature", status->predicted_temp / 10.0); + cJSON_AddNumberToObject(obj, "slope_c_per_min", status->slope_c_per_min); + cJSON_AddNumberToObject(obj, "controller_gain", status->controller_gain); + cJSON_AddNumberToObject(obj, "cooling_response", status->cooling_response); + cJSON_AddStringToObject(obj, "auto_state", auto_state_to_string(status->auto_state)); + cJSON_AddBoolToObject(obj, "guard_active", status->guard_active); + cJSON_AddBoolToObject(obj, "temp_stale", status->temp_stale); return obj; } @@ -102,9 +123,8 @@ static esp_err_t api_fan_status(const cJSON *params, ts_api_result_t *result) /* 添加全局温度信息(从绑定变量读取) */ ts_temp_status_t temp_status; if (ts_temp_get_status(&temp_status) == ESP_OK) { - float temp_c = temp_status.current_temp / 10.0f; - cJSON_AddNumberToObject(data, "temperature", temp_c); - cJSON_AddBoolToObject(data, "temp_valid", temp_status.current_temp > -400); // > -40°C 表示有效 + cJSON_AddNumberToObject(data, "temperature", temp_status.current_temp / 10.0f); + cJSON_AddBoolToObject(data, "temp_valid", temp_status.current_valid); if (temp_status.bound_variable[0] != '\0') { cJSON_AddStringToObject(data, "temp_source", temp_status.bound_variable); } diff --git a/components/ts_api/src/ts_api_temp.c b/components/ts_api/src/ts_api_temp.c index 5ad9c2c..3fb1d88 100644 --- a/components/ts_api/src/ts_api_temp.c +++ b/components/ts_api/src/ts_api_temp.c @@ -11,10 +11,29 @@ #include "ts_temp_source.h" #include "ts_variable.h" #include "ts_log.h" +#include "esp_timer.h" #include #define TAG "api_temp" +static bool variable_info_to_float(const ts_variable_info_t *var, double *value) +{ + if (!var || !value) { + return false; + } + + switch (var->value.type) { + case TS_AUTO_VAL_FLOAT: + *value = var->value.float_val; + return true; + case TS_AUTO_VAL_INT: + *value = (double)var->value.int_val; + return true; + default: + return false; + } +} + /*===========================================================================*/ /* API Handlers */ /*===========================================================================*/ @@ -39,20 +58,54 @@ static esp_err_t api_temp_sources(const cJSON *params, ts_api_result_t *result) cJSON_AddStringToObject(data, "active_source", ts_temp_source_type_to_str(status.active_source)); cJSON_AddNumberToObject(data, "current_temp_01c", status.current_temp); cJSON_AddNumberToObject(data, "current_temp_c", status.current_temp / 10.0); + cJSON_AddNumberToObject(data, "current_timestamp_ms", (double)status.current_timestamp_ms); + cJSON_AddBoolToObject(data, "current_valid", status.current_valid); cJSON_AddBoolToObject(data, "manual_mode", status.manual_mode); + cJSON_AddBoolToObject(data, "variable_partial_stale", status.variable_partial_stale); + cJSON_AddNumberToObject(data, "variable_valid_count", status.variable_valid_count); + cJSON_AddNumberToObject(data, "variable_total_count", status.variable_total_count); + cJSON_AddNumberToObject(data, "variable_valid_weight", status.variable_valid_weight); + cJSON_AddNumberToObject(data, "variable_total_weight", status.variable_total_weight); cJSON_AddNumberToObject(data, "provider_count", status.provider_count); + int64_t now_ms = esp_timer_get_time() / 1000; /* Provider list */ cJSON *providers = cJSON_AddArrayToObject(data, "providers"); for (uint32_t i = 0; i < status.provider_count; i++) { ts_temp_provider_info_t *p = &status.providers[i]; + int64_t age_ms = -1; + if (p->last_update_ms > 0) { + age_ms = now_ms - p->last_update_ms; + if (age_ms < 0) age_ms = 0; + } + bool stale = false; + bool valid = false; + if (p->type == TS_TEMP_SOURCE_DEFAULT) { + stale = false; + valid = false; + } else if (p->type == TS_TEMP_SOURCE_MANUAL) { + stale = false; + valid = p->active; + } else { + stale = !p->active || p->last_update_ms <= 0 || + (now_ms - p->last_update_ms) < 0 || + (now_ms - p->last_update_ms) > TS_TEMP_DATA_TIMEOUT_MS; + valid = !stale; + } cJSON *provider = cJSON_CreateObject(); cJSON_AddStringToObject(provider, "name", p->name ? p->name : "unknown"); cJSON_AddStringToObject(provider, "type", ts_temp_source_type_to_str(p->type)); cJSON_AddBoolToObject(provider, "active", p->active); + cJSON_AddBoolToObject(provider, "valid", valid); + cJSON_AddBoolToObject(provider, "stale", stale); cJSON_AddNumberToObject(provider, "last_value_01c", p->last_value); cJSON_AddNumberToObject(provider, "last_value_c", p->last_value / 10.0); - cJSON_AddNumberToObject(provider, "last_update_ms", p->last_update_ms); + cJSON_AddNumberToObject(provider, "last_update_ms", (double)p->last_update_ms); + if (age_ms >= 0) { + cJSON_AddNumberToObject(provider, "age_ms", (double)age_ms); + } else { + cJSON_AddNullToObject(provider, "age_ms"); + } cJSON_AddNumberToObject(provider, "update_count", p->update_count); cJSON_AddItemToArray(providers, provider); } @@ -83,6 +136,8 @@ static esp_err_t api_temp_read(const cJSON *params, ts_api_result_t *result) source_type = TS_TEMP_SOURCE_SENSOR_LOCAL; } else if (strcmp(src_str, "agx_auto") == 0 || strcmp(src_str, "agx") == 0) { source_type = TS_TEMP_SOURCE_AGX_AUTO; + } else if (strcmp(src_str, "variable") == 0) { + source_type = TS_TEMP_SOURCE_VARIABLE; } else if (strcmp(src_str, "manual") == 0) { source_type = TS_TEMP_SOURCE_MANUAL; } else { @@ -107,6 +162,13 @@ static esp_err_t api_temp_read(const cJSON *params, ts_api_result_t *result) cJSON_AddStringToObject(json, "source", ts_temp_source_type_to_str(data.source)); cJSON_AddNumberToObject(json, "timestamp_ms", data.timestamp_ms); cJSON_AddBoolToObject(json, "valid", data.valid); + if (data.source == TS_TEMP_SOURCE_VARIABLE) { + cJSON_AddBoolToObject(json, "partial_stale", data.partial_stale); + cJSON_AddNumberToObject(json, "bound_valid_count", data.bound_valid_count); + cJSON_AddNumberToObject(json, "bound_total_count", data.bound_total_count); + cJSON_AddNumberToObject(json, "bound_valid_weight", data.bound_valid_weight); + cJSON_AddNumberToObject(json, "bound_total_weight", data.bound_total_weight); + } ts_api_result_ok(result, json); return ESP_OK; @@ -179,17 +241,20 @@ static esp_err_t api_temp_status(const cJSON *params, ts_api_result_t *result) cJSON *data = cJSON_CreateObject(); - /* Get full status for bound_variable */ ts_temp_status_t status; - ts_temp_get_status(&status); + esp_err_t ret = ts_temp_get_status(&status); + if (ret != ESP_OK) { + cJSON_Delete(data); + ts_api_result_error(result, TS_API_ERR_INTERNAL, "Failed to get temp status"); + return ret; + } - /* Basic status */ - cJSON_AddBoolToObject(data, "initialized", ts_temp_source_is_initialized()); - cJSON_AddBoolToObject(data, "manual_mode", ts_temp_is_manual_mode()); - cJSON_AddStringToObject(data, "active_source", ts_temp_source_type_to_str(ts_temp_get_active_source())); + cJSON_AddBoolToObject(data, "initialized", status.initialized); + cJSON_AddBoolToObject(data, "manual_mode", status.manual_mode); + cJSON_AddStringToObject(data, "active_source", ts_temp_source_type_to_str(status.active_source)); /* Preferred source */ - ts_temp_source_type_t preferred = ts_temp_get_preferred_source(); + ts_temp_source_type_t preferred = status.preferred_source; cJSON_AddStringToObject(data, "preferred_source", preferred == TS_TEMP_SOURCE_DEFAULT ? "auto" : ts_temp_source_type_to_str(preferred)); @@ -201,13 +266,10 @@ static esp_err_t api_temp_status(const cJSON *params, ts_api_result_t *result) cJSON_AddNullToObject(data, "bound_variable"); } - /* Current effective temperature */ - ts_temp_data_t temp_data; - int16_t temp = ts_temp_get_effective(&temp_data); - cJSON_AddNumberToObject(data, "temperature_01c", temp); - cJSON_AddNumberToObject(data, "temperature_c", temp / 10.0); - cJSON_AddBoolToObject(data, "valid", temp_data.valid); - cJSON_AddNumberToObject(data, "timestamp_ms", temp_data.timestamp_ms); + cJSON_AddNumberToObject(data, "temperature_01c", status.current_temp); + cJSON_AddNumberToObject(data, "temperature_c", status.current_temp / 10.0); + cJSON_AddBoolToObject(data, "valid", status.current_valid); + cJSON_AddNumberToObject(data, "timestamp_ms", (double)status.current_timestamp_ms); ts_api_result_ok(result, data); return ESP_OK; @@ -318,6 +380,10 @@ static esp_err_t api_temp_bind(const cJSON *params, ts_api_result_t *result) ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Each variable needs a name"); return ESP_ERR_INVALID_ARG; } + if (strlen(jname->valuestring) >= TS_TEMP_MAX_VARNAME_LEN) { + ts_api_result_error(result, TS_API_ERR_INVALID_ARG, "Variable name too long"); + return ESP_ERR_INVALID_ARG; + } strncpy(bind_arr[i].name, jname->valuestring, TS_TEMP_MAX_VARNAME_LEN - 1); bind_arr[i].weight = (jweight && cJSON_IsNumber(jweight)) ? (float)jweight->valuedouble : 1.0f; @@ -387,37 +453,85 @@ static esp_err_t api_temp_bind(const cJSON *params, ts_api_result_t *result) cJSON *bv_arr = cJSON_CreateArray(); double weighted_sum = 0.0; double total_weight = 0.0; + double configured_weight = 0.0; + uint8_t positive_count = 0; + uint8_t valid_count = 0; + int64_t now_ms = esp_timer_get_time() / 1000; for (uint8_t i = 0; i < bound_count; i++) { cJSON *bv_item = cJSON_CreateObject(); + float weight = bound[i].weight; cJSON_AddStringToObject(bv_item, "name", bound[i].name); - cJSON_AddNumberToObject(bv_item, "weight", bound[i].weight); + cJSON_AddNumberToObject(bv_item, "weight", weight); + if (weight > 0.001f) { + positive_count++; + configured_weight += weight; + } double val = 0; - bool readable = (ts_variable_get_float(bound[i].name, &val) == ESP_OK); + ts_variable_info_t var_info = {0}; + bool has_info = (ts_variable_get_info(bound[i].name, &var_info) == ESP_OK); + bool readable = has_info && variable_info_to_float(&var_info, &val); if (readable) { cJSON_AddNumberToObject(bv_item, "value", val); - weighted_sum += val * bound[i].weight; - total_weight += bound[i].weight; } else { cJSON_AddNullToObject(bv_item, "value"); } + if (has_info) { + cJSON_AddNumberToObject(bv_item, "last_change_ms", (double)var_info.last_change_ms); + cJSON_AddNumberToObject(bv_item, "last_update_ms", (double)var_info.last_update_ms); + if (var_info.last_update_ms > 0) { + int64_t age_ms = now_ms - var_info.last_update_ms; + bool stale = (age_ms < 0 || age_ms > TS_TEMP_DATA_TIMEOUT_MS); + if (age_ms < 0) age_ms = 0; + cJSON_AddNumberToObject(bv_item, "age_ms", (double)age_ms); + cJSON_AddBoolToObject(bv_item, "stale", stale); + bool in_range = (val >= (TS_TEMP_MIN_VALID / 10.0) && + val <= (TS_TEMP_MAX_VALID / 10.0)); + bool valid = readable && !stale && in_range; + cJSON_AddBoolToObject(bv_item, "valid", valid); + if (valid && weight > 0.001f) { + weighted_sum += val * weight; + total_weight += weight; + valid_count++; + } + } else { + cJSON_AddNullToObject(bv_item, "age_ms"); + cJSON_AddBoolToObject(bv_item, "stale", true); + cJSON_AddBoolToObject(bv_item, "valid", false); + } + } else { + cJSON_AddNullToObject(bv_item, "last_change_ms"); + cJSON_AddNullToObject(bv_item, "last_update_ms"); + cJSON_AddNullToObject(bv_item, "age_ms"); + cJSON_AddBoolToObject(bv_item, "stale", true); + cJSON_AddBoolToObject(bv_item, "valid", false); + } cJSON_AddItemToArray(bv_arr, bv_item); } cJSON_AddItemToObject(data, "bound_variables", bv_arr); + cJSON_AddNumberToObject(data, "bound_valid_count", valid_count); + cJSON_AddNumberToObject(data, "bound_total_count", positive_count); + cJSON_AddNumberToObject(data, "bound_valid_weight", total_weight); + cJSON_AddNumberToObject(data, "bound_total_weight", configured_weight); + cJSON_AddBoolToObject(data, "partial_stale", + positive_count > 0 && valid_count < positive_count); /* Weighted temperature */ - if (bound_count > 0 && total_weight > 0.001) { + if (positive_count > 0 && valid_count == positive_count && total_weight > 0.001) { cJSON_AddNumberToObject(data, "weighted_temp_c", weighted_sum / total_weight); } - ts_temp_source_type_t active = ts_temp_get_active_source(); - cJSON_AddStringToObject(data, "active_source", ts_temp_source_type_to_str(active)); - - ts_temp_data_t temp_data; - ts_temp_get_effective(&temp_data); - cJSON_AddNumberToObject(data, "temperature_c", temp_data.value / 10.0); - cJSON_AddBoolToObject(data, "valid", temp_data.valid); + ts_temp_status_t temp_status = {0}; + if (ts_temp_get_status(&temp_status) == ESP_OK) { + cJSON_AddStringToObject(data, "active_source", + ts_temp_source_type_to_str(temp_status.active_source)); + cJSON_AddNumberToObject(data, "temperature_c", temp_status.current_temp / 10.0); + cJSON_AddBoolToObject(data, "valid", temp_status.current_valid); + } else { + cJSON_AddStringToObject(data, "active_source", "unknown"); + cJSON_AddBoolToObject(data, "valid", false); + } ts_api_result_ok(result, data); return ESP_OK; diff --git a/components/ts_automation/include/ts_automation_types.h b/components/ts_automation/include/ts_automation_types.h index c3aefde..55f1d39 100644 --- a/components/ts_automation/include/ts_automation_types.h +++ b/components/ts_automation/include/ts_automation_types.h @@ -221,6 +221,7 @@ typedef struct { ts_auto_value_t default_value; /**< Default value */ uint32_t flags; /**< Variable flags */ int64_t last_change_ms; /**< Last change timestamp */ + int64_t last_update_ms; /**< Last update timestamp */ } ts_auto_variable_t; /*===========================================================================*/ diff --git a/components/ts_automation/include/ts_variable.h b/components/ts_automation/include/ts_variable.h index d9e5eb7..e5e388f 100644 --- a/components/ts_automation/include/ts_variable.h +++ b/components/ts_automation/include/ts_variable.h @@ -37,6 +37,18 @@ typedef struct { ts_auto_value_t new_value; /**< New value */ } ts_variable_change_event_t; +/** + * @brief Public variable metadata snapshot + */ +typedef struct { + char name[TS_AUTO_NAME_MAX_LEN]; /**< Variable name */ + char source_id[TS_AUTO_NAME_MAX_LEN]; /**< Owning source ID */ + ts_auto_value_t value; /**< Current value */ + uint32_t flags; /**< Variable flags */ + int64_t last_change_ms; /**< Last value-change timestamp */ + int64_t last_update_ms; /**< Last real update timestamp */ +} ts_variable_info_t; + /*===========================================================================*/ /* Initialization */ /*===========================================================================*/ @@ -74,6 +86,17 @@ bool ts_variable_is_initialized(void); */ esp_err_t ts_variable_register(const ts_auto_variable_t *var); +/** + * @brief Create or update a variable with a real observed value + * + * Unlike ts_variable_register(), this marks last_update_ms because the caller + * is publishing current source data, not just declaring a variable. + * + * @param var Variable definition and current value + * @return ESP_OK on success + */ +esp_err_t ts_variable_upsert(const ts_auto_variable_t *var); + /** * @brief Unregister a variable * @@ -148,6 +171,24 @@ esp_err_t ts_variable_get_float(const char *name, double *value); */ esp_err_t ts_variable_get_string(const char *name, char *buffer, size_t buffer_size); +/** + * @brief Get public variable metadata and value + * + * @param name Variable name + * @param info Output metadata snapshot + * @return ESP_OK on success + */ +esp_err_t ts_variable_get_info(const char *name, ts_variable_info_t *info); + +/** + * @brief Try to get public variable metadata and value without waiting + * + * @param name Variable name + * @param info Output metadata snapshot + * @return ESP_OK on success, ESP_ERR_TIMEOUT if storage is busy + */ +esp_err_t ts_variable_get_info_try(const char *name, ts_variable_info_t *info); + /*===========================================================================*/ /* Value Modification */ /*===========================================================================*/ diff --git a/components/ts_automation/src/ts_source_manager.c b/components/ts_automation/src/ts_source_manager.c index da0c8e3..7f71526 100644 --- a/components/ts_automation/src/ts_source_manager.c +++ b/components/ts_automation/src/ts_source_manager.c @@ -1057,7 +1057,7 @@ static int auto_discover_json_fields(ts_auto_source_t *src, cJSON *json_data, new_var.value = value; new_var.flags = 0; - ret = ts_variable_register(&new_var); + ret = ts_variable_upsert(&new_var); if (ret == ESP_OK) { ESP_LOGD(TAG, "Auto-discovered variable: %s", var_name); count++; @@ -1093,7 +1093,7 @@ static int auto_discover_json_fields(ts_auto_source_t *src, cJSON *json_data, new_var.value = value; new_var.flags = 0; - ret = ts_variable_register(&new_var); + ret = ts_variable_upsert(&new_var); if (ret == ESP_OK) { count++; } @@ -1167,7 +1167,7 @@ static int process_source_mappings(ts_auto_source_t *src, cJSON *json_data) new_var.value = value; new_var.flags = 0; // 可读写 - ret = ts_variable_register(&new_var); + ret = ts_variable_upsert(&new_var); if (ret == ESP_OK) { ESP_LOGD(TAG, "Auto-created variable '%s' from mapping", mapping->var_name); processed++; diff --git a/components/ts_automation/src/ts_variable.c b/components/ts_automation/src/ts_variable.c index bb9014f..93425d1 100644 --- a/components/ts_automation/src/ts_variable.c +++ b/components/ts_automation/src/ts_variable.c @@ -218,13 +218,21 @@ esp_err_t ts_variable_register(const ts_auto_variable_t *var) } xSemaphoreTake(s_var_ctx.mutex, portMAX_DELAY); + int64_t now_ms = esp_timer_get_time() / 1000; // 检查是否已存在 int idx = find_variable_index(var->name); if (idx >= 0) { // 更新现有变量 + int64_t last_change_ms = s_var_ctx.variables[idx].last_change_ms; + int64_t last_update_ms = s_var_ctx.variables[idx].last_update_ms; + if (!value_equal(&s_var_ctx.variables[idx].value, &var->value)) { + last_change_ms = now_ms; + last_update_ms = 0; + } memcpy(&s_var_ctx.variables[idx], var, sizeof(ts_auto_variable_t)); - s_var_ctx.variables[idx].last_change_ms = esp_timer_get_time() / 1000; + s_var_ctx.variables[idx].last_change_ms = last_change_ms; + s_var_ctx.variables[idx].last_update_ms = last_update_ms; xSemaphoreGive(s_var_ctx.mutex); ESP_LOGD(TAG, "Updated variable: %s (type=%d)", var->name, var->value.type); return ESP_OK; @@ -240,7 +248,8 @@ esp_err_t ts_variable_register(const ts_auto_variable_t *var) // 添加新变量 memcpy(&s_var_ctx.variables[s_var_ctx.count], var, sizeof(ts_auto_variable_t)); - s_var_ctx.variables[s_var_ctx.count].last_change_ms = esp_timer_get_time() / 1000; + s_var_ctx.variables[s_var_ctx.count].last_change_ms = now_ms; + s_var_ctx.variables[s_var_ctx.count].last_update_ms = 0; s_var_ctx.count++; xSemaphoreGive(s_var_ctx.mutex); @@ -250,6 +259,56 @@ esp_err_t ts_variable_register(const ts_auto_variable_t *var) return ESP_OK; } +esp_err_t ts_variable_upsert(const ts_auto_variable_t *var) +{ + if (!var || !var->name[0]) { + return ESP_ERR_INVALID_ARG; + } + + if (!s_var_ctx.initialized) { + return ESP_ERR_INVALID_STATE; + } + + ts_auto_value_t old_value = {0}; + bool changed = false; + bool existed = false; + int64_t now_ms = esp_timer_get_time() / 1000; + + xSemaphoreTake(s_var_ctx.mutex, portMAX_DELAY); + + int idx = find_variable_index(var->name); + if (idx >= 0) { + existed = true; + old_value = s_var_ctx.variables[idx].value; + changed = !value_equal(&old_value, &var->value); + + int64_t last_change_ms = changed + ? now_ms + : s_var_ctx.variables[idx].last_change_ms; + memcpy(&s_var_ctx.variables[idx], var, sizeof(ts_auto_variable_t)); + s_var_ctx.variables[idx].last_change_ms = last_change_ms; + s_var_ctx.variables[idx].last_update_ms = now_ms; + } else { + if (s_var_ctx.count >= s_var_ctx.capacity) { + xSemaphoreGive(s_var_ctx.mutex); + return ESP_ERR_NO_MEM; + } + + memcpy(&s_var_ctx.variables[s_var_ctx.count], var, sizeof(ts_auto_variable_t)); + s_var_ctx.variables[s_var_ctx.count].last_change_ms = now_ms; + s_var_ctx.variables[s_var_ctx.count].last_update_ms = now_ms; + s_var_ctx.count++; + } + + xSemaphoreGive(s_var_ctx.mutex); + + if (existed && changed) { + notify_change(var->name, &old_value, &var->value); + } + + return ESP_OK; +} + esp_err_t ts_variable_unregister(const char *name) { if (!name) { @@ -460,6 +519,49 @@ esp_err_t ts_variable_get_string(const char *name, char *buffer, size_t buffer_s return ESP_OK; } +static esp_err_t variable_get_info_impl(const char *name, ts_variable_info_t *info, + TickType_t ticks_to_wait) +{ + if (!name || !info) { + return ESP_ERR_INVALID_ARG; + } + + if (!s_var_ctx.initialized) { + return ESP_ERR_INVALID_STATE; + } + + if (xSemaphoreTake(s_var_ctx.mutex, ticks_to_wait) != pdTRUE) { + return ESP_ERR_TIMEOUT; + } + + int idx = find_variable_index(name); + if (idx < 0) { + xSemaphoreGive(s_var_ctx.mutex); + return ESP_ERR_NOT_FOUND; + } + + memset(info, 0, sizeof(ts_variable_info_t)); + strncpy(info->name, s_var_ctx.variables[idx].name, sizeof(info->name) - 1); + strncpy(info->source_id, s_var_ctx.variables[idx].source_id, sizeof(info->source_id) - 1); + info->value = s_var_ctx.variables[idx].value; + info->flags = s_var_ctx.variables[idx].flags; + info->last_change_ms = s_var_ctx.variables[idx].last_change_ms; + info->last_update_ms = s_var_ctx.variables[idx].last_update_ms; + + xSemaphoreGive(s_var_ctx.mutex); + return ESP_OK; +} + +esp_err_t ts_variable_get_info(const char *name, ts_variable_info_t *info) +{ + return variable_get_info_impl(name, info, portMAX_DELAY); +} + +esp_err_t ts_variable_get_info_try(const char *name, ts_variable_info_t *info) +{ + return variable_get_info_impl(name, info, 0); +} + /*===========================================================================*/ /* 值修改 */ /*===========================================================================*/ @@ -494,11 +596,14 @@ static esp_err_t variable_set_impl(const char *name, const ts_auto_value_t *valu return ESP_ERR_NOT_ALLOWED; } + int64_t now_ms = esp_timer_get_time() / 1000; + var->last_update_ms = now_ms; + // 检查值是否变化 if (!value_equal(&var->value, value)) { ts_auto_value_t old_value = var->value; var->value = *value; - var->last_change_ms = esp_timer_get_time() / 1000; + var->last_change_ms = now_ms; xSemaphoreGive(s_var_ctx.mutex); diff --git a/components/ts_console/commands/ts_cmd_temp.c b/components/ts_console/commands/ts_cmd_temp.c index bc26680..9b42430 100644 --- a/components/ts_console/commands/ts_cmd_temp.c +++ b/components/ts_console/commands/ts_cmd_temp.c @@ -51,6 +51,7 @@ static const char *source_to_str(ts_temp_source_type_t source) case TS_TEMP_SOURCE_DEFAULT: return "default"; case TS_TEMP_SOURCE_SENSOR_LOCAL: return "sensor_local"; case TS_TEMP_SOURCE_AGX_AUTO: return "agx_auto"; + case TS_TEMP_SOURCE_VARIABLE: return "variable"; case TS_TEMP_SOURCE_MANUAL: return "manual"; default: return "unknown"; } @@ -99,7 +100,9 @@ static int do_temp_status(bool json) ts_console_printf(" Valid: %s\n", data.valid ? "Yes" : "No"); if (data.valid && data.timestamp_ms > 0) { - ts_console_printf(" Updated: %lu ms ago\n", data.timestamp_ms); + int64_t age_ms = (esp_timer_get_time() / 1000) - data.timestamp_ms; + if (age_ms < 0) age_ms = 0; + ts_console_printf(" Updated: %lld ms ago\n", (long long)age_ms); } return 0; @@ -114,7 +117,7 @@ static int do_temp_providers(bool json) /* JSON 模式通过 API 获取 */ if (json) { ts_api_result_t result; - esp_err_t ret = ts_api_call("temp.providers", NULL, &result); + esp_err_t ret = ts_api_call("temp.sources", NULL, &result); if (ret == ESP_OK && result.code == TS_API_OK && result.data) { char *json_str = cJSON_PrintUnformatted(result.data); @@ -134,6 +137,7 @@ static int do_temp_providers(bool json) TS_TEMP_SOURCE_DEFAULT, TS_TEMP_SOURCE_SENSOR_LOCAL, TS_TEMP_SOURCE_AGX_AUTO, + TS_TEMP_SOURCE_VARIABLE, TS_TEMP_SOURCE_MANUAL }; @@ -165,10 +169,10 @@ static int do_temp_set(double temp_c) { /* 通过 API 设置温度 */ cJSON *params = cJSON_CreateObject(); - cJSON_AddNumberToObject(params, "value", temp_c); + cJSON_AddNumberToObject(params, "temperature_c", temp_c); ts_api_result_t result; - esp_err_t ret = ts_api_call("temp.set", params, &result); + esp_err_t ret = ts_api_call("temp.manual", params, &result); cJSON_Delete(params); if (ret != ESP_OK || result.code != TS_API_OK) { @@ -194,12 +198,18 @@ static int do_temp_mode(const char *mode_str) return 1; } - /* 通过 API 设置模式 */ cJSON *params = cJSON_CreateObject(); - cJSON_AddStringToObject(params, "mode", mode_str); + const char *api_name = NULL; + if (strcmp(mode_str, "manual") == 0) { + api_name = "temp.manual"; + cJSON_AddBoolToObject(params, "enable", true); + } else { + api_name = "temp.select"; + cJSON_AddStringToObject(params, "source", "auto"); + } ts_api_result_t result; - esp_err_t ret = ts_api_call("temp.mode", params, &result); + esp_err_t ret = ts_api_call(api_name, params, &result); cJSON_Delete(params); if (ret != ESP_OK || result.code != TS_API_OK) { diff --git a/components/ts_drivers/include/ts_fan.h b/components/ts_drivers/include/ts_fan.h index 3b59853..1bec82c 100644 --- a/components/ts_drivers/include/ts_fan.h +++ b/components/ts_drivers/include/ts_fan.h @@ -46,10 +46,19 @@ typedef enum { typedef enum { TS_FAN_MODE_OFF, /**< 风扇关闭 */ TS_FAN_MODE_MANUAL, /**< 手动模式(固定占空比) */ - TS_FAN_MODE_AUTO, /**< 自动模式(简单温度控制) */ + TS_FAN_MODE_AUTO, /**< 自动模式(自适应温度控制) */ TS_FAN_MODE_CURVE, /**< 曲线模式(自定义温度曲线) */ } ts_fan_mode_t; +/** Adaptive auto state */ +typedef enum { + TS_FAN_AUTO_STATE_IDLE = 0, /**< Not running adaptive control */ + TS_FAN_AUTO_STATE_BASELINE, /**< Using curve baseline only */ + TS_FAN_AUTO_STATE_ACTIVE, /**< Adaptive control active */ + TS_FAN_AUTO_STATE_GUARD, /**< Hard guard active */ + TS_FAN_AUTO_STATE_STALE, /**< Temperature input stale/invalid */ +} ts_fan_auto_state_t; + /** Temperature curve point */ typedef struct { int16_t temp; /**< Temperature in 0.1°C (e.g., 350 = 35.0°C) */ @@ -80,6 +89,15 @@ typedef struct { bool is_running; /**< 是否运行中 */ bool enabled; /**< 是否启用 */ bool fault; /**< 故障标志 */ + int16_t control_temp; /**< AUTO 控制温度 0.1°C */ + int16_t guard_temp; /**< AUTO 保护温度 0.1°C */ + int16_t predicted_temp; /**< AUTO 预测温度 0.1°C */ + float slope_c_per_min; /**< AUTO 温度斜率 °C/min */ + float controller_gain; /**< AUTO 自适应增益 */ + float cooling_response; /**< AUTO 最近散热响应 °C/min */ + ts_fan_auto_state_t auto_state; /**< AUTO 控制状态 */ + bool guard_active; /**< AUTO 硬保护是否激活 */ + bool temp_stale; /**< AUTO 温度输入是否失效/过期 */ } ts_fan_status_t; /*===========================================================================*/ diff --git a/components/ts_drivers/include/ts_temp_source.h b/components/ts_drivers/include/ts_temp_source.h index 90000a6..fce3e92 100644 --- a/components/ts_drivers/include/ts_temp_source.h +++ b/components/ts_drivers/include/ts_temp_source.h @@ -77,8 +77,15 @@ typedef enum { typedef struct { int16_t value; /**< 温度值(0.1°C 单位)*/ ts_temp_source_type_t source; /**< 数据来源 */ - uint32_t timestamp_ms; /**< 更新时间戳 */ + int64_t timestamp_ms; /**< 更新时间戳 */ bool valid; /**< 数据有效性 */ + int16_t guard_value; /**< 保护温度(0.1°C 单位),绑定变量最高 fresh 温度 */ + bool guard_valid; /**< 保护温度是否有效 */ + bool partial_stale; /**< 绑定变量是否只有部分有效 */ + uint8_t bound_valid_count; /**< 有效正权重绑定数量 */ + uint8_t bound_total_count; /**< 正权重绑定数量 */ + float bound_valid_weight; /**< 有效权重和 */ + float bound_total_weight; /**< 正权重和 */ } ts_temp_data_t; /** @@ -98,7 +105,7 @@ typedef struct { ts_temp_source_type_t type; /**< Provider 类型 */ const char *name; /**< Provider 名称 */ int16_t last_value; /**< 最后报告的温度 */ - uint32_t last_update_ms; /**< 最后更新时间 */ + int64_t last_update_ms; /**< 最后更新时间 */ uint32_t update_count; /**< 更新计数 */ bool active; /**< 是否激活 */ } ts_temp_provider_info_t; @@ -128,7 +135,14 @@ typedef struct { ts_temp_bound_var_t bound_vars[TS_TEMP_MAX_BOUND_VARS]; /**< 加权绑定变量数组 */ uint8_t bound_var_count; /**< 绑定变量数量 */ int16_t current_temp; /**< 当前温度 */ + int64_t current_timestamp_ms; /**< 当前温度更新时间 */ + bool current_valid; /**< 当前温度是否有效 */ bool manual_mode; /**< 手动模式启用 */ + bool variable_partial_stale; /**< 变量温度源是否部分失效 */ + uint8_t variable_valid_count; /**< 变量温度源有效绑定数量 */ + uint8_t variable_total_count; /**< 变量温度源正权重绑定数量 */ + float variable_valid_weight; /**< 变量温度源有效权重和 */ + float variable_total_weight; /**< 变量温度源正权重和 */ uint32_t provider_count; /**< 注册的 provider 数量 */ ts_temp_provider_info_t providers[TS_TEMP_MAX_PROVIDERS]; /**< Provider 信息 */ } ts_temp_status_t; @@ -202,6 +216,17 @@ esp_err_t ts_temp_provider_update(ts_temp_source_type_t type, int16_t temp_01c); */ int16_t ts_temp_get_effective(ts_temp_data_t *data); +/** + * @brief 获取当前有效温度(非阻塞) + * + * 与 ts_temp_get_effective() 相同,但不会等待温度源锁或变量锁。 + * 如果当前锁繁忙,返回默认温度且 data.valid=false。 + * + * @param[out] data 温度数据(可为 NULL 仅获取温度值) + * @return 当前温度(0.1°C 单位) + */ +int16_t ts_temp_get_effective_nonblocking(ts_temp_data_t *data); + /** * @brief 获取指定源的温度 * diff --git a/components/ts_drivers/src/ts_fan.c b/components/ts_drivers/src/ts_fan.c index 9fad95e..e127e98 100644 --- a/components/ts_drivers/src/ts_fan.c +++ b/components/ts_drivers/src/ts_fan.c @@ -21,16 +21,36 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include -#include +#include #define TAG "ts_fan" #define FAN_NVS_NAMESPACE "fan_config" #define FAN_CONFIG_VERSION 2 +#define FAN_AUTO_GUARD_TEMP 950 /* 95.0°C */ +#define FAN_AUTO_GUARD_RELEASE_TEMP 900 /* 90.0°C */ +#define FAN_AUTO_GUARD_RELEASE_MS 30000 +#define FAN_AUTO_PREDICT_WINDOW_SEC 45.0f +#define FAN_AUTO_SLOPE_WINDOW_MS 30000 +#define FAN_AUTO_MIN_SAMPLE_MS 10000 +#define FAN_AUTO_RESPONSE_WINDOW_MS 30000 +#define FAN_AUTO_RESPONSE_DUTY_DELTA 5 +#define FAN_AUTO_GAIN_DEFAULT 0.75f +#define FAN_AUTO_GAIN_MIN 0.50f +#define FAN_AUTO_GAIN_MAX 2.00f +#define FAN_AUTO_RISE_RATE_PER_SEC 12.0f +#define FAN_AUTO_FALL_RATE_PER_SEC 0.30f +#define FAN_AUTO_HISTORY_SIZE 32 /*===========================================================================*/ /* Internal Types */ /*===========================================================================*/ +typedef struct { + int16_t temp; + int64_t timestamp_ms; + bool valid; +} fan_temp_sample_t; + typedef struct { bool initialized; bool enabled; @@ -47,6 +67,24 @@ typedef struct { volatile uint32_t tach_count; int64_t last_tach_time; bool fault; + int16_t control_temperature; + int16_t guard_temperature; + int16_t predicted_temperature; + float slope_c_per_min; + float controller_gain; + float cooling_response; + ts_fan_auto_state_t auto_state; + bool guard_active; + bool temp_stale; + int64_t guard_release_since_ms; + bool response_observing; + int64_t response_until_ms; + float response_slope_before; + fan_temp_sample_t temp_history[FAN_AUTO_HISTORY_SIZE]; + uint8_t temp_history_next; + uint8_t temp_history_count; + int64_t last_auto_update_ms; + float auto_fall_credit; } fan_instance_t; /*===========================================================================*/ @@ -67,6 +105,7 @@ static void tach_isr_callback(ts_gpio_handle_t handle, void *arg); static void fan_update_callback(void *arg); static uint8_t calc_duty_from_curve(fan_instance_t *fan, int16_t temp); static esp_err_t apply_curve_with_hysteresis(fan_instance_t *fan, uint8_t fan_id); +static esp_err_t apply_adaptive_auto(fan_instance_t *fan, const ts_temp_data_t *temp_data); static esp_err_t update_pwm(fan_instance_t *fan, uint8_t duty); static void temp_event_handler(const ts_event_t *event, void *user_data); @@ -89,11 +128,15 @@ static void temp_event_handler(const ts_event_t *event, void *user_data) const ts_temp_event_data_t *temp_evt = (const ts_temp_event_data_t *)event->data; if (temp_evt == NULL) return; + if (temp_evt->source == TS_TEMP_SOURCE_DEFAULT || + temp_evt->temp < TS_TEMP_MIN_VALID || + temp_evt->temp > TS_TEMP_MAX_VALID) { + return; + } - /* 更新所有处于自动/曲线模式的风扇温度 */ + /* AUTO 模式只由 fan_update_callback() 推进,事件仅服务旧的 CURVE 快照路径 */ for (int i = 0; i < TS_FAN_MAX; i++) { - if (s_fans[i].initialized && - (s_fans[i].mode == TS_FAN_MODE_AUTO || s_fans[i].mode == TS_FAN_MODE_CURVE)) { + if (s_fans[i].initialized && s_fans[i].mode == TS_FAN_MODE_CURVE) { s_fans[i].temperature = temp_evt->temp; TS_LOGD(TAG, "Fan %d temp updated: %d.%d°C (source=%d)", i, temp_evt->temp / 10, @@ -152,11 +195,347 @@ static uint8_t calc_duty_from_curve(fan_instance_t *fan, int16_t temp) return fan->config.max_duty; } +static float clamp_float(float value, float min_value, float max_value) +{ + if (value < min_value) return min_value; + if (value > max_value) return max_value; + return value; +} + +static uint8_t apply_duty_limits(fan_instance_t *fan, uint8_t target) +{ + if (target < fan->config.min_duty && target > 0) { + target = fan->config.min_duty; + } + if (target > fan->config.max_duty) { + target = fan->config.max_duty; + } + return target; +} + +static bool is_temp_data_basic_valid(const ts_temp_data_t *data) +{ + if (!data || + !data->valid || + data->source == TS_TEMP_SOURCE_DEFAULT || + data->value < TS_TEMP_MIN_VALID || + data->value > TS_TEMP_MAX_VALID) { + return false; + } + + return true; +} + +static bool is_auto_temp_data_usable(const ts_temp_data_t *data) +{ + if (!is_temp_data_basic_valid(data)) { + return false; + } + + if (data->bound_total_count > 0 && + (data->partial_stale || data->bound_valid_count < data->bound_total_count)) { + return false; + } + + return true; +} + +static void reset_adaptive_auto_state(fan_instance_t *fan) +{ + fan->target_duty = fan->current_duty; + fan->control_temperature = fan->temperature; + fan->guard_temperature = fan->temperature; + fan->predicted_temperature = fan->temperature; + fan->slope_c_per_min = 0.0f; + fan->controller_gain = FAN_AUTO_GAIN_DEFAULT; + fan->cooling_response = 0.0f; + fan->auto_state = TS_FAN_AUTO_STATE_IDLE; + fan->guard_active = false; + fan->temp_stale = false; + fan->guard_release_since_ms = 0; + fan->response_observing = false; + fan->response_until_ms = 0; + fan->response_slope_before = 0.0f; + memset(fan->temp_history, 0, sizeof(fan->temp_history)); + fan->temp_history_next = 0; + fan->temp_history_count = 0; + fan->last_auto_update_ms = 0; + fan->auto_fall_credit = 0.0f; +} + +static bool get_adaptive_temperatures(const ts_temp_data_t *temp_data, + int16_t *control_temp, + int16_t *guard_temp) +{ + if (!control_temp || !guard_temp || !is_auto_temp_data_usable(temp_data)) { + return false; + } + + *control_temp = temp_data->value; + if (temp_data->guard_valid && + temp_data->guard_value >= TS_TEMP_MIN_VALID && + temp_data->guard_value <= TS_TEMP_MAX_VALID) { + *guard_temp = temp_data->guard_value; + } else { + *guard_temp = temp_data->value; + } + + return true; +} + +static void record_auto_temp_sample(fan_instance_t *fan, int16_t temp, int64_t now_ms) +{ + fan->temp_history[fan->temp_history_next].temp = temp; + fan->temp_history[fan->temp_history_next].timestamp_ms = now_ms; + fan->temp_history[fan->temp_history_next].valid = true; + fan->temp_history_next = (fan->temp_history_next + 1) % FAN_AUTO_HISTORY_SIZE; + if (fan->temp_history_count < FAN_AUTO_HISTORY_SIZE) { + fan->temp_history_count++; + } +} + +static bool calculate_auto_slope(fan_instance_t *fan, int16_t current_temp, + int64_t now_ms, uint32_t min_span_ms, + float *slope_c_per_min) +{ + const fan_temp_sample_t *oldest = NULL; + int64_t oldest_age = 0; + + if (!fan || !slope_c_per_min || fan->temp_history_count == 0) { + return false; + } + + for (uint8_t i = 0; i < fan->temp_history_count; i++) { + const fan_temp_sample_t *sample = &fan->temp_history[i]; + if (!sample->valid) { + continue; + } + int64_t age = now_ms - sample->timestamp_ms; + if (age < 0 || age > FAN_AUTO_SLOPE_WINDOW_MS) { + continue; + } + if (!oldest || age > oldest_age) { + oldest = sample; + oldest_age = age; + } + } + + if (!oldest || oldest_age < min_span_ms) { + return false; + } + + float delta_c = (current_temp - oldest->temp) / 10.0f; + *slope_c_per_min = delta_c * 60000.0f / (float)oldest_age; + return true; +} + +static uint8_t apply_auto_rate_limit(fan_instance_t *fan, uint8_t target, + bool allow_down, int64_t now_ms) +{ + uint8_t current = fan->current_duty; + float dt_sec = 1.0f; + if (fan->last_auto_update_ms > 0) { + dt_sec = (now_ms - fan->last_auto_update_ms) / 1000.0f; + } + if (dt_sec <= 0.0f) { + dt_sec = 1.0f; + } + + if (target > current) { + fan->auto_fall_credit = 0.0f; + uint8_t max_step = (uint8_t)(FAN_AUTO_RISE_RATE_PER_SEC * dt_sec + 0.5f); + if (max_step < 1) max_step = 1; + if ((uint16_t)target > (uint16_t)current + max_step) { + target = current + max_step; + } + if (current == 0 && target > 0 && + target < fan->config.min_duty) { + target = fan->config.min_duty; + } + } else if (target < current) { + if (!allow_down) { + fan->auto_fall_credit = 0.0f; + target = current; + } else { + fan->auto_fall_credit += FAN_AUTO_FALL_RATE_PER_SEC * dt_sec; + uint8_t max_step = (uint8_t)fan->auto_fall_credit; + if (max_step == 0) { + target = current; + } else { + uint8_t desired_step = current - target; + uint8_t actual_step = desired_step < max_step ? desired_step : max_step; + target = current - actual_step; + fan->auto_fall_credit -= actual_step; + } + } + } else { + fan->auto_fall_credit = 0.0f; + } + + fan->last_auto_update_ms = now_ms; + return target; +} + +static void update_response_learning(fan_instance_t *fan, bool slope_valid, + float slope, int64_t now_ms) +{ + if (!fan->response_observing || now_ms < fan->response_until_ms) { + return; + } + + fan->response_observing = false; + if (!slope_valid) { + return; + } + + fan->cooling_response = fan->response_slope_before - slope; + if (fan->cooling_response < 0.2f) { + fan->controller_gain *= 1.15f; + } else if (fan->cooling_response > 1.5f || slope < -0.5f) { + fan->controller_gain *= 0.95f; + } + fan->controller_gain = clamp_float(fan->controller_gain, + FAN_AUTO_GAIN_MIN, + FAN_AUTO_GAIN_MAX); +} + +static esp_err_t apply_adaptive_auto(fan_instance_t *fan, const ts_temp_data_t *temp_data) +{ + int64_t now_ms = esp_timer_get_time() / 1000; + int16_t control_temp = TS_TEMP_DEFAULT_VALUE; + int16_t guard_temp = TS_TEMP_DEFAULT_VALUE; + uint8_t old_duty = fan->current_duty; + + if (!get_adaptive_temperatures(temp_data, &control_temp, &guard_temp)) { + fan->temp_stale = true; + fan->auto_state = TS_FAN_AUTO_STATE_STALE; + fan->response_observing = false; + fan->auto_fall_credit = 0.0f; + fan->target_duty = 100; + fan->predicted_temperature = fan->control_temperature; + fan->last_auto_update_ms = now_ms; + return update_pwm(fan, 100); + } + + fan->temp_stale = false; + fan->temperature = control_temp; + fan->control_temperature = control_temp; + fan->guard_temperature = guard_temp; + record_auto_temp_sample(fan, control_temp, now_ms); + + if (guard_temp >= FAN_AUTO_GUARD_TEMP) { + fan->guard_active = true; + fan->guard_release_since_ms = 0; + } else if (fan->guard_active) { + if (guard_temp <= FAN_AUTO_GUARD_RELEASE_TEMP) { + if (fan->guard_release_since_ms == 0) { + fan->guard_release_since_ms = now_ms; + } else if ((now_ms - fan->guard_release_since_ms) >= FAN_AUTO_GUARD_RELEASE_MS) { + fan->guard_active = false; + fan->guard_release_since_ms = 0; + } + } else { + fan->guard_release_since_ms = 0; + } + } + + bool slope_valid = calculate_auto_slope(fan, control_temp, now_ms, + FAN_AUTO_MIN_SAMPLE_MS, + &fan->slope_c_per_min); + if (!slope_valid) { + fan->slope_c_per_min = 0.0f; + } + update_response_learning(fan, slope_valid, fan->slope_c_per_min, now_ms); + + if (fan->guard_active) { + fan->auto_state = TS_FAN_AUTO_STATE_GUARD; + fan->response_observing = false; + fan->auto_fall_credit = 0.0f; + fan->target_duty = 100; + fan->predicted_temperature = guard_temp; + fan->last_auto_update_ms = now_ms; + return update_pwm(fan, 100); + } + + uint8_t baseline = apply_duty_limits(fan, calc_duty_from_curve(fan, control_temp)); + uint8_t target = baseline; + + if (slope_valid) { + float predicted = control_temp + (fan->slope_c_per_min * (FAN_AUTO_PREDICT_WINDOW_SEC / 60.0f) * 10.0f); + if (predicted < TS_TEMP_MIN_VALID) predicted = TS_TEMP_MIN_VALID; + if (predicted > TS_TEMP_MAX_VALID) predicted = TS_TEMP_MAX_VALID; + fan->predicted_temperature = (int16_t)predicted; + + if (fan->predicted_temperature >= FAN_AUTO_GUARD_TEMP) { + fan->auto_state = TS_FAN_AUTO_STATE_GUARD; + target = fan->config.max_duty; + } else { + float boost = 0.0f; + float margin_c = (FAN_AUTO_GUARD_TEMP - guard_temp) / 10.0f; + if (margin_c < 25.0f) { + boost += (25.0f - margin_c) * 1.2f; + } + if (fan->predicted_temperature > 850) { + boost += ((fan->predicted_temperature - 850) / 10.0f) * 1.5f; + } + if (fan->slope_c_per_min > 0.0f) { + boost += fan->slope_c_per_min * 2.0f; + } + + float adaptive_target = baseline + boost * fan->controller_gain; + if (adaptive_target > 100.0f) adaptive_target = 100.0f; + target = apply_duty_limits(fan, (uint8_t)(adaptive_target + 0.5f)); + fan->auto_state = TS_FAN_AUTO_STATE_ACTIVE; + } + } else { + fan->predicted_temperature = control_temp; + fan->auto_state = TS_FAN_AUTO_STATE_BASELINE; + } + + bool allow_down = (guard_temp <= FAN_AUTO_GUARD_RELEASE_TEMP) && + (!slope_valid || fan->slope_c_per_min <= 0.0f); + if (target < fan->current_duty && !allow_down) { + target = fan->current_duty; + } + + target = apply_auto_rate_limit(fan, target, allow_down, now_ms); + fan->target_duty = target; + esp_err_t ret = update_pwm(fan, target); + + if (ret == ESP_OK && !fan->response_observing && slope_valid && + target >= old_duty + FAN_AUTO_RESPONSE_DUTY_DELTA) { + fan->response_observing = true; + fan->response_until_ms = now_ms + FAN_AUTO_RESPONSE_WINDOW_MS; + fan->response_slope_before = fan->slope_c_per_min; + } + + return ret; +} + +static esp_err_t apply_auto_immediate(fan_instance_t *fan) +{ + ts_temp_data_t temp_data = {0}; + const ts_temp_data_t *auto_temp = NULL; + + if (s_auto_temp_enabled) { + ts_temp_get_effective(&temp_data); + if (is_temp_data_basic_valid(&temp_data)) { + fan->temperature = temp_data.value; + } + auto_temp = &temp_data; + } + + reset_adaptive_auto_state(fan); + return apply_adaptive_auto(fan, auto_temp); +} + /** * @brief 应用曲线(带迟滞控制) */ static esp_err_t apply_curve_with_hysteresis(fan_instance_t *fan, uint8_t fan_id) { + (void)fan_id; + if (fan->config.curve_points == 0) { return ESP_ERR_INVALID_STATE; } @@ -167,12 +546,7 @@ static esp_err_t apply_curve_with_hysteresis(fan_instance_t *fan, uint8_t fan_id uint8_t target = calc_duty_from_curve(fan, fan->temperature); // 应用最小/最大限制 - if (target < fan->config.min_duty && target > 0) { - target = fan->config.min_duty; - } - if (target > fan->config.max_duty) { - target = fan->config.max_duty; - } + target = apply_duty_limits(fan, target); fan->target_duty = target; @@ -234,11 +608,25 @@ static void fan_update_callback(void *arg) { int64_t now = esp_timer_get_time(); static uint32_t s_log_counter = 0; + ts_temp_data_t temp_data = {0}; + bool have_temp_data = false; + bool basic_temp_data_valid = false; + bool needs_temp_data = false; + + for (int i = 0; i < TS_FAN_MAX; i++) { + if (s_fans[i].initialized && + (s_fans[i].mode == TS_FAN_MODE_CURVE || + s_fans[i].mode == TS_FAN_MODE_AUTO)) { + needs_temp_data = true; + break; + } + } /* 主动获取最新温度(确保曲线模式能及时响应温度变化) */ - if (s_auto_temp_enabled) { - ts_temp_data_t temp_data; - int16_t current_temp = ts_temp_get_effective(&temp_data); + if (s_auto_temp_enabled && needs_temp_data) { + int16_t current_temp = ts_temp_get_effective_nonblocking(&temp_data); + have_temp_data = true; + basic_temp_data_valid = is_temp_data_basic_valid(&temp_data); /* 每 10 秒输出一次调试日志 */ if (++s_log_counter >= 10) { @@ -248,10 +636,11 @@ static void fan_update_callback(void *arg) temp_data.source, temp_data.valid); } - if (current_temp > TS_TEMP_MIN_VALID) { + if (basic_temp_data_valid) { for (int i = 0; i < TS_FAN_MAX; i++) { if (s_fans[i].initialized && - (s_fans[i].mode == TS_FAN_MODE_AUTO || s_fans[i].mode == TS_FAN_MODE_CURVE)) { + (s_fans[i].mode == TS_FAN_MODE_CURVE || + s_fans[i].mode == TS_FAN_MODE_AUTO)) { s_fans[i].temperature = current_temp; } } @@ -285,18 +674,9 @@ static void fan_update_callback(void *arg) break; case TS_FAN_MODE_AUTO: - // 简单自动模式:基于曲线但无迟滞 { - uint8_t target = calc_duty_from_curve(fan, fan->temperature); - if (target < fan->config.min_duty && target > 0) { - target = fan->config.min_duty; - } - if (target > fan->config.max_duty) { - target = fan->config.max_duty; - } - if (target != fan->current_duty) { - update_pwm(fan, target); - } + const ts_temp_data_t *auto_temp = have_temp_data ? &temp_data : NULL; + apply_adaptive_auto(fan, auto_temp); } break; @@ -339,6 +719,7 @@ esp_err_t ts_fan_init(void) s_fans[i].last_stable_temp = 250; // 25.0°C s_fans[i].temperature = 250; s_fans[i].enabled = true; + reset_adaptive_auto_state(&s_fans[i]); } // 启动更新定时器 @@ -498,6 +879,7 @@ esp_err_t ts_fan_configure(ts_fan_id_t fan, const ts_fan_config_t *config) f->mode = TS_FAN_MODE_MANUAL; f->current_duty = config->min_duty; f->fault = false; + reset_adaptive_auto_state(f); TS_LOGI(TAG, "Fan %d configured: PWM=GPIO%d, TACH=%d, curve=%d points", fan, config->gpio_pwm, config->gpio_tach, config->curve_points); @@ -516,9 +898,10 @@ esp_err_t ts_fan_set_mode(ts_fan_id_t fan, ts_fan_mode_t mode) ts_fan_mode_t old_mode = s_fans[fan].mode; s_fans[fan].mode = mode; + esp_err_t ret = ESP_OK; if (mode == TS_FAN_MODE_OFF) { - update_pwm(&s_fans[fan], 0); + ret = update_pwm(&s_fans[fan], 0); } /* 切换到曲线/自动模式时,重置迟滞状态以允许立即生效 */ @@ -527,11 +910,12 @@ esp_err_t ts_fan_set_mode(ts_fan_id_t fan, ts_fan_mode_t mode) s_fans[fan].last_stable_temp = -1000; /* -100.0°C,远低于任何实际温度 */ s_fans[fan].last_speed_change_time = 0; /* 允许立即调速 */ - /* 立即获取当前温度 */ - if (s_auto_temp_enabled) { + if (mode == TS_FAN_MODE_AUTO) { + ret = apply_auto_immediate(&s_fans[fan]); + } else if (s_auto_temp_enabled) { ts_temp_data_t temp_data; int16_t current_temp = ts_temp_get_effective(&temp_data); - if (current_temp > TS_TEMP_MIN_VALID) { + if (is_temp_data_basic_valid(&temp_data)) { s_fans[fan].temperature = current_temp; } } @@ -542,7 +926,7 @@ esp_err_t ts_fan_set_mode(ts_fan_id_t fan, ts_fan_mode_t mode) TS_LOGI(TAG, "Fan %d mode set to %d", fan, mode); } - return ESP_OK; + return ret; } esp_err_t ts_fan_get_mode(ts_fan_id_t fan, ts_fan_mode_t *mode) @@ -563,6 +947,7 @@ esp_err_t ts_fan_set_duty(ts_fan_id_t fan, uint8_t duty_percent) s_fans[fan].mode = TS_FAN_MODE_MANUAL; s_fans[fan].current_duty = duty_percent; + reset_adaptive_auto_state(&s_fans[fan]); return update_pwm(&s_fans[fan], duty_percent); } @@ -598,6 +983,10 @@ esp_err_t ts_fan_is_enabled(ts_fan_id_t fan, bool *enabled) esp_err_t ts_fan_set_temperature(ts_fan_id_t fan, int16_t temp_01c) { if (fan >= TS_FAN_MAX) return ESP_ERR_INVALID_ARG; + if (!s_fans[fan].initialized) return ESP_ERR_INVALID_STATE; + if (temp_01c < TS_TEMP_MIN_VALID || temp_01c > TS_TEMP_MAX_VALID) { + return ESP_ERR_INVALID_ARG; + } s_fans[fan].temperature = temp_01c; return ESP_OK; } @@ -677,26 +1066,11 @@ esp_err_t ts_fan_get_status(ts_fan_id_t fan, ts_fan_status_t *status) fan_instance_t *f = &s_fans[fan]; - /* 同步获取最新温度(确保从绑定变量读取最新值) */ - if (f->mode == TS_FAN_MODE_AUTO || f->mode == TS_FAN_MODE_CURVE) { - ts_temp_data_t temp_data; - int16_t temp = ts_temp_get_effective(&temp_data); - if (temp > TS_TEMP_MIN_VALID) { - f->temperature = temp; - - /* 计算基于当前温度的目标转速(用于显示) */ - uint8_t computed_target = calc_duty_from_curve(f, f->temperature); - if (computed_target < f->config.min_duty && computed_target > 0) { - computed_target = f->config.min_duty; - } - if (computed_target > f->config.max_duty) { - computed_target = f->config.max_duty; - } - f->target_duty = computed_target; - } else { - /* 温度无效时,目标转速等于当前转速 */ - f->target_duty = f->current_duty; - } + /* 控制状态只由 fan_update_callback() 推进,状态查询不触发温度重算或调速 */ + if (f->mode == TS_FAN_MODE_CURVE) { + /* CURVE 的 target_duty 由定时器回调维护 */ + } else if (f->mode == TS_FAN_MODE_AUTO) { + /* 自适应状态只能在 fan_update_callback() 推进,这里不触发学习或调速 */ } else { /* MANUAL/OFF 模式:目标转速等于当前转速 */ f->target_duty = f->current_duty; @@ -711,6 +1085,15 @@ esp_err_t ts_fan_get_status(ts_fan_id_t fan, ts_fan_status_t *status) status->is_running = f->current_duty > 0 && f->enabled; status->enabled = f->enabled; status->fault = f->fault; + status->control_temp = f->control_temperature; + status->guard_temp = f->guard_temperature; + status->predicted_temp = f->predicted_temperature; + status->slope_c_per_min = f->slope_c_per_min; + status->controller_gain = f->controller_gain; + status->cooling_response = f->cooling_response; + status->auto_state = f->auto_state; + status->guard_active = f->guard_active; + status->temp_stale = f->temp_stale; return ESP_OK; } @@ -893,8 +1276,9 @@ esp_err_t ts_fan_load_config(void) memcpy(s_fans[i].config.curve, cfg.curve, cfg.curve_points * sizeof(ts_fan_curve_point_t)); - // 应用占空比 - if (s_fans[i].mode == TS_FAN_MODE_MANUAL && s_fans[i].pwm) { + if (s_fans[i].mode == TS_FAN_MODE_AUTO && s_fans[i].pwm) { + apply_auto_immediate(&s_fans[i]); + } else if (s_fans[i].mode == TS_FAN_MODE_MANUAL && s_fans[i].pwm) { update_pwm(&s_fans[i], cfg.duty); } @@ -919,4 +1303,4 @@ esp_err_t ts_fan_set_auto_temp_enabled(bool enable) bool ts_fan_is_auto_temp_enabled(void) { return s_auto_temp_enabled; -} \ No newline at end of file +} diff --git a/components/ts_drivers/src/ts_temp_source.c b/components/ts_drivers/src/ts_temp_source.c index d714c19..7a436ff 100644 --- a/components/ts_drivers/src/ts_temp_source.c +++ b/components/ts_drivers/src/ts_temp_source.c @@ -40,7 +40,7 @@ typedef struct { ts_temp_source_type_t type; const char *name; int16_t value; - uint32_t last_update_ms; + int64_t last_update_ms; uint32_t update_count; bool registered; bool active; @@ -51,6 +51,15 @@ typedef struct { bool manual_mode; int16_t manual_temp; int16_t current_temp; + bool variable_valid; + int64_t variable_last_update_ms; + int16_t variable_guard_temp; + bool variable_guard_valid; + bool variable_partial_stale; + uint8_t variable_valid_count; + uint8_t variable_total_count; + float variable_valid_weight; + float variable_total_weight; ts_temp_source_type_t active_source; ts_temp_source_type_t preferred_source; /**< 用户首选源(0=自动)*/ char bound_variable[TS_TEMP_MAX_VARNAME_LEN]; /**< 向后兼容:第一个绑定变量名 */ @@ -60,6 +69,19 @@ typedef struct { SemaphoreHandle_t mutex; } temp_source_state_t; +typedef struct { + bool valid; + int16_t temp; + int64_t timestamp_ms; + int16_t guard_temp; + bool guard_valid; + bool partial_stale; + uint8_t valid_count; + uint8_t total_count; + float valid_weight; + float total_weight; +} variable_temp_snapshot_t; + /*===========================================================================*/ /* Static Variables */ /*===========================================================================*/ @@ -70,8 +92,9 @@ static temp_source_state_t s_state = {0}; /* Forward Declarations */ /*===========================================================================*/ -static uint32_t get_current_ms(void); +static int64_t get_current_ms(void); static void evaluate_active_source(void); +static void evaluate_active_source_with_lock_mode(bool try_variable_lock); static void publish_temp_event(int16_t new_temp, ts_temp_source_type_t new_source, int16_t prev_temp, ts_temp_source_type_t prev_source); static void sync_bound_variable_compat(void); @@ -81,14 +104,18 @@ static esp_err_t save_preferred_source_to_nvs(ts_temp_source_type_t type); static void export_temp_config_to_sdcard(void); static float sanitize_bound_weight(float weight); static bool has_positive_bound_weight(const ts_temp_bound_var_t *vars, uint8_t count); +static bool read_fresh_variable_float(const char *name, int64_t now, double *value, + int64_t *last_update_ms, bool try_lock); +static bool read_variable_temp_snapshot(variable_temp_snapshot_t *snapshot, int64_t now, + bool try_lock); /*===========================================================================*/ /* Utility Functions */ /*===========================================================================*/ -static uint32_t get_current_ms(void) +static int64_t get_current_ms(void) { - return (uint32_t)(esp_timer_get_time() / 1000); + return esp_timer_get_time() / 1000; } static float sanitize_bound_weight(float weight) @@ -113,6 +140,151 @@ static bool has_positive_bound_weight(const ts_temp_bound_var_t *vars, uint8_t c return false; } +static void store_variable_snapshot(const variable_temp_snapshot_t *snapshot) +{ + if (!snapshot) { + s_state.variable_valid = false; + s_state.variable_last_update_ms = 0; + s_state.variable_guard_temp = TS_TEMP_DEFAULT_VALUE; + s_state.variable_guard_valid = false; + s_state.variable_partial_stale = false; + s_state.variable_valid_count = 0; + s_state.variable_total_count = 0; + s_state.variable_valid_weight = 0.0f; + s_state.variable_total_weight = 0.0f; + return; + } + + s_state.variable_valid = snapshot->valid; + s_state.variable_last_update_ms = snapshot->timestamp_ms; + s_state.variable_guard_temp = snapshot->guard_temp; + s_state.variable_guard_valid = snapshot->guard_valid; + s_state.variable_partial_stale = snapshot->partial_stale; + s_state.variable_valid_count = snapshot->valid_count; + s_state.variable_total_count = snapshot->total_count; + s_state.variable_valid_weight = snapshot->valid_weight; + s_state.variable_total_weight = snapshot->total_weight; +} + +static bool read_fresh_variable_float(const char *name, int64_t now, double *value, + int64_t *last_update_ms, bool try_lock) +{ + ts_variable_info_t var = {0}; + double temp_c = 0.0; + esp_err_t ret; + + if (!name || !value) { + return false; + } + + ret = try_lock ? ts_variable_get_info_try(name, &var) + : ts_variable_get_info(name, &var); + if (ret != ESP_OK) { + return false; + } + + if (var.last_update_ms <= 0) { + return false; + } + + int64_t age = now - var.last_update_ms; + if (age < 0 || age > TS_TEMP_DATA_TIMEOUT_MS) { + return false; + } + if (last_update_ms) { + *last_update_ms = var.last_update_ms; + } + + switch (var.value.type) { + case TS_AUTO_VAL_FLOAT: + temp_c = var.value.float_val; + break; + case TS_AUTO_VAL_INT: + temp_c = (double)var.value.int_val; + break; + default: + return false; + } + + if (temp_c < (TS_TEMP_MIN_VALID / 10.0) || + temp_c > (TS_TEMP_MAX_VALID / 10.0)) { + return false; + } + + *value = temp_c; + return true; +} + +static bool read_variable_temp_snapshot(variable_temp_snapshot_t *snapshot, int64_t now, + bool try_lock) +{ + double weighted_sum = 0.0; + int16_t max_bound = TS_TEMP_MIN_VALID; + + if (!snapshot) { + return false; + } + + memset(snapshot, 0, sizeof(*snapshot)); + snapshot->guard_temp = TS_TEMP_DEFAULT_VALUE; + if (s_state.bound_var_count == 0) { + return false; + } + + for (uint8_t i = 0; i < s_state.bound_var_count; i++) { + float w = sanitize_bound_weight(s_state.bound_vars[i].weight); + if (w <= 0.001f) { + continue; + } + + snapshot->total_count++; + snapshot->total_weight += w; + + double value = 0.0; + int64_t last_update_ms = 0; + if (!read_fresh_variable_float(s_state.bound_vars[i].name, now, &value, + &last_update_ms, try_lock)) { + TS_LOGD(TAG, "Variable '%s' unavailable or stale", + s_state.bound_vars[i].name); + continue; + } + + weighted_sum += value * w; + snapshot->valid_weight += w; + snapshot->valid_count++; + int16_t temp_01c = (int16_t)(value * 10.0); + if (temp_01c > max_bound) { + max_bound = temp_01c; + } + if (snapshot->timestamp_ms == 0 || last_update_ms < snapshot->timestamp_ms) { + snapshot->timestamp_ms = last_update_ms; + } + } + + snapshot->partial_stale = (snapshot->valid_count < snapshot->total_count); + if (snapshot->valid_count == 0 || snapshot->valid_weight <= 0.001f) { + return false; + } + + snapshot->guard_temp = max_bound; + snapshot->guard_valid = true; + + if (snapshot->partial_stale) { + return false; + } + + double effective_temp = weighted_sum / snapshot->valid_weight; + int16_t temp_01c = (int16_t)(effective_temp * 10.0); + if (temp_01c < TS_TEMP_MIN_VALID || temp_01c > TS_TEMP_MAX_VALID) { + TS_LOGW(TAG, "Weighted temp out of range: %.1f°C", effective_temp); + return false; + } + + snapshot->temp = temp_01c; + snapshot->valid = true; + return true; +} + const char *ts_temp_source_type_to_str(ts_temp_source_type_t type) { switch (type) { @@ -132,87 +304,25 @@ const char *ts_temp_source_type_to_str(ts_temp_source_type_t type) /** * @brief 检查 provider 是否可用(已注册、激活且数据未过期) */ -static bool is_provider_valid(ts_temp_source_type_t type, uint32_t now) +static bool is_provider_valid(ts_temp_source_type_t type, int64_t now, bool try_variable_lock) { if (type >= TS_TEMP_SOURCE_MAX) return false; if (type == TS_TEMP_SOURCE_VARIABLE) { - if (s_state.bound_var_count == 0) return false; - for (uint8_t i = 0; i < s_state.bound_var_count; i++) { - float weight = sanitize_bound_weight(s_state.bound_vars[i].weight); - if (weight <= 0.001f) { - continue; - } - double value = 0; - if (ts_variable_get_float(s_state.bound_vars[i].name, &value) == ESP_OK) { - return true; - } - } - return false; + variable_temp_snapshot_t snapshot = {0}; + bool valid = read_variable_temp_snapshot(&snapshot, now, try_variable_lock); + store_variable_snapshot(&snapshot); + return valid; } provider_t *p = &s_state.providers[type]; if (!p->registered || !p->active) return false; - if (type == TS_TEMP_SOURCE_DEFAULT) return true; - - uint32_t age = now - p->last_update_ms; - return (age < TS_TEMP_DATA_TIMEOUT_MS); -} - -/** - * @brief 从绑定的变量读取加权温度值 - * - * 遍历 bound_vars 数组,对每个可读变量读值乘以权重求和。 - * 不可读的变量跳过,用剩余有效变量的权重归一化。 - */ -static int16_t read_temp_from_variable(void) -{ - if (s_state.bound_var_count == 0) { - return TS_TEMP_DEFAULT_VALUE; - } - - double weighted_sum = 0.0; - double total_weight = 0.0; - uint8_t valid_count = 0; - - for (uint8_t i = 0; i < s_state.bound_var_count; i++) { - double value = 0; - esp_err_t ret = ts_variable_get_float(s_state.bound_vars[i].name, &value); - if (ret != ESP_OK) { - TS_LOGD(TAG, "Failed to read variable '%s': %s", - s_state.bound_vars[i].name, esp_err_to_name(ret)); - continue; - } - float w = sanitize_bound_weight(s_state.bound_vars[i].weight); - if (w <= 0.001f) { - continue; - } - weighted_sum += value * w; - total_weight += w; - valid_count++; - } - - if (valid_count == 0) { - return TS_TEMP_DEFAULT_VALUE; - } - - /* 归一化:如果总权重不为 0 且不为 1.0,按实际权重比例缩放 */ - double effective_temp; - if (total_weight > 0.001) { - effective_temp = weighted_sum / total_weight; - } else { - effective_temp = weighted_sum; - } - - int16_t temp_01c = (int16_t)(effective_temp * 10.0); - - if (temp_01c < TS_TEMP_MIN_VALID || temp_01c > TS_TEMP_MAX_VALID) { - TS_LOGW(TAG, "Weighted temp out of range: %.1f°C", effective_temp); - return TS_TEMP_DEFAULT_VALUE; - } + if (type == TS_TEMP_SOURCE_DEFAULT) return false; + if (type == TS_TEMP_SOURCE_MANUAL) return p->registered && p->active; - return temp_01c; + int64_t age = now - p->last_update_ms; + return (age >= 0 && age < TS_TEMP_DATA_TIMEOUT_MS); } /** @@ -237,16 +347,20 @@ static void sync_bound_variable_compat(void) * 2. 如果设置了 preferred_source 且该源可用,使用它 * 3. 否则按默认优先级(VARIABLE > AGX > SENSOR > DEFAULT) */ -static void evaluate_active_source(void) +static void evaluate_active_source_with_lock_mode(bool try_variable_lock) { - uint32_t now = get_current_ms(); + int64_t now = get_current_ms(); ts_temp_source_type_t best_source = TS_TEMP_SOURCE_DEFAULT; int16_t best_temp = TS_TEMP_DEFAULT_VALUE; + variable_temp_snapshot_t variable_snapshot = {0}; + bool variable_snapshot_valid = read_variable_temp_snapshot(&variable_snapshot, now, + try_variable_lock); + store_variable_snapshot(&variable_snapshot); // 1. 手动模式最高优先级 if (s_state.manual_mode) { provider_t *p = &s_state.providers[TS_TEMP_SOURCE_MANUAL]; - if (p->registered) { + if (p->registered && p->active) { best_source = TS_TEMP_SOURCE_MANUAL; best_temp = p->value; goto done; @@ -259,12 +373,12 @@ static void evaluate_active_source(void) // 变量绑定特殊处理 if (s_state.preferred_source == TS_TEMP_SOURCE_VARIABLE) { - if (is_provider_valid(TS_TEMP_SOURCE_VARIABLE, now)) { + if (variable_snapshot_valid) { best_source = TS_TEMP_SOURCE_VARIABLE; - best_temp = read_temp_from_variable(); + best_temp = variable_snapshot.temp; goto done; } - } else if (is_provider_valid(s_state.preferred_source, now)) { + } else if (is_provider_valid(s_state.preferred_source, now, try_variable_lock)) { best_source = s_state.preferred_source; best_temp = s_state.providers[s_state.preferred_source].value; goto done; @@ -275,19 +389,19 @@ static void evaluate_active_source(void) } // 3. 默认优先级:VARIABLE > AGX > SENSOR > DEFAULT - if (is_provider_valid(TS_TEMP_SOURCE_VARIABLE, now)) { + if (variable_snapshot_valid) { best_source = TS_TEMP_SOURCE_VARIABLE; - best_temp = read_temp_from_variable(); + best_temp = variable_snapshot.temp; goto done; } - if (is_provider_valid(TS_TEMP_SOURCE_AGX_AUTO, now)) { + if (is_provider_valid(TS_TEMP_SOURCE_AGX_AUTO, now, try_variable_lock)) { best_source = TS_TEMP_SOURCE_AGX_AUTO; best_temp = s_state.providers[TS_TEMP_SOURCE_AGX_AUTO].value; goto done; } - if (is_provider_valid(TS_TEMP_SOURCE_SENSOR_LOCAL, now)) { + if (is_provider_valid(TS_TEMP_SOURCE_SENSOR_LOCAL, now, try_variable_lock)) { best_source = TS_TEMP_SOURCE_SENSOR_LOCAL; best_temp = s_state.providers[TS_TEMP_SOURCE_SENSOR_LOCAL].value; goto done; @@ -310,6 +424,44 @@ static void evaluate_active_source(void) } } +static void evaluate_active_source(void) +{ + evaluate_active_source_with_lock_mode(false); +} + +static bool is_cached_active_source_valid_locked(int64_t now) +{ + ts_temp_source_type_t source = s_state.active_source; + + if (source == TS_TEMP_SOURCE_VARIABLE) { + if (!s_state.variable_valid || + s_state.variable_last_update_ms <= 0 || + s_state.variable_partial_stale || + s_state.variable_total_count == 0 || + s_state.variable_valid_count < s_state.variable_total_count) { + return false; + } + + int64_t age = now - s_state.variable_last_update_ms; + return age >= 0 && age < TS_TEMP_DATA_TIMEOUT_MS; + } + if (source == TS_TEMP_SOURCE_DEFAULT || source >= TS_TEMP_SOURCE_MAX) { + return false; + } + if (source == TS_TEMP_SOURCE_MANUAL) { + return s_state.providers[source].registered && + s_state.providers[source].active; + } + + provider_t *p = &s_state.providers[source]; + if (!p->registered || !p->active || p->last_update_ms <= 0) { + return false; + } + + int64_t age = now - p->last_update_ms; + return age >= 0 && age < TS_TEMP_DATA_TIMEOUT_MS; +} + /** * @brief 发布温度更新事件 */ @@ -473,44 +625,102 @@ esp_err_t ts_temp_provider_update(ts_temp_source_type_t type, int16_t temp_01c) /* Public API - Consumer */ /*===========================================================================*/ -int16_t ts_temp_get_effective(ts_temp_data_t *data) +static void fill_invalid_temp_data(ts_temp_data_t *data) +{ + if (!data) { + return; + } + + memset(data, 0, sizeof(*data)); + data->value = TS_TEMP_DEFAULT_VALUE; + data->source = TS_TEMP_SOURCE_DEFAULT; + data->guard_value = TS_TEMP_DEFAULT_VALUE; +} + +static void fill_effective_data_locked(ts_temp_data_t *data) +{ + if (!data) { + return; + } + + int16_t temp = s_state.current_temp; + ts_temp_source_type_t source = s_state.active_source; + int64_t now = get_current_ms(); + + data->value = temp; + data->source = source; + data->timestamp_ms = (source == TS_TEMP_SOURCE_VARIABLE) + ? s_state.variable_last_update_ms + : s_state.providers[source].last_update_ms; + data->valid = is_cached_active_source_valid_locked(now); + data->guard_value = s_state.variable_guard_valid + ? s_state.variable_guard_temp : temp; + data->guard_valid = s_state.variable_guard_valid ? true : data->valid; + data->partial_stale = s_state.variable_partial_stale; + data->bound_valid_count = s_state.variable_valid_count; + data->bound_total_count = s_state.variable_total_count; + data->bound_valid_weight = s_state.variable_valid_weight; + data->bound_total_weight = s_state.variable_total_weight; +} + +static int16_t ts_temp_get_effective_impl(ts_temp_data_t *data, bool try_lock) { if (!s_state.initialized) { - if (data) { - data->value = TS_TEMP_DEFAULT_VALUE; - data->source = TS_TEMP_SOURCE_DEFAULT; - data->timestamp_ms = 0; - data->valid = false; - } + fill_invalid_temp_data(data); return TS_TEMP_DEFAULT_VALUE; } - xSemaphoreTake(s_state.mutex, portMAX_DELAY); + if (xSemaphoreTake(s_state.mutex, try_lock ? 0 : portMAX_DELAY) != pdTRUE) { + fill_invalid_temp_data(data); + return TS_TEMP_DEFAULT_VALUE; + } /* 重新评估活动源并更新温度(确保获取最新值) */ - evaluate_active_source(); + evaluate_active_source_with_lock_mode(try_lock); int16_t temp = s_state.current_temp; - ts_temp_source_type_t source = s_state.active_source; - - if (data) { - data->value = temp; - data->source = source; - data->timestamp_ms = s_state.providers[source].last_update_ms; - data->valid = true; - } + fill_effective_data_locked(data); xSemaphoreGive(s_state.mutex); return temp; } +int16_t ts_temp_get_effective(ts_temp_data_t *data) +{ + return ts_temp_get_effective_impl(data, false); +} + +int16_t ts_temp_get_effective_nonblocking(ts_temp_data_t *data) +{ + return ts_temp_get_effective_impl(data, true); +} + esp_err_t ts_temp_get_by_source(ts_temp_source_type_t type, ts_temp_data_t *data) { if (!s_state.initialized) return ESP_ERR_INVALID_STATE; if (type >= TS_TEMP_SOURCE_MAX || !data) return ESP_ERR_INVALID_ARG; xSemaphoreTake(s_state.mutex, portMAX_DELAY); + + if (type == TS_TEMP_SOURCE_VARIABLE) { + variable_temp_snapshot_t snapshot = {0}; + bool valid = read_variable_temp_snapshot(&snapshot, get_current_ms(), false); + store_variable_snapshot(&snapshot); + data->value = valid ? snapshot.temp : TS_TEMP_DEFAULT_VALUE; + data->source = TS_TEMP_SOURCE_VARIABLE; + data->timestamp_ms = snapshot.timestamp_ms; + data->valid = valid; + data->guard_value = snapshot.guard_temp; + data->guard_valid = snapshot.guard_valid; + data->partial_stale = snapshot.partial_stale; + data->bound_valid_count = snapshot.valid_count; + data->bound_total_count = snapshot.total_count; + data->bound_valid_weight = snapshot.valid_weight; + data->bound_total_weight = snapshot.total_weight; + xSemaphoreGive(s_state.mutex); + return (snapshot.total_count > 0) ? ESP_OK : ESP_ERR_NOT_FOUND; + } provider_t *p = &s_state.providers[type]; if (!p->registered) { @@ -521,7 +731,15 @@ esp_err_t ts_temp_get_by_source(ts_temp_source_type_t type, ts_temp_data_t *data data->value = p->value; data->source = p->type; data->timestamp_ms = p->last_update_ms; - data->valid = p->active; + data->valid = (type != TS_TEMP_SOURCE_DEFAULT) && + is_provider_valid(type, get_current_ms(), false); + data->guard_value = p->value; + data->guard_valid = data->valid; + data->partial_stale = false; + data->bound_valid_count = 0; + data->bound_total_count = 0; + data->bound_valid_weight = 0.0f; + data->bound_total_weight = 0.0f; xSemaphoreGive(s_state.mutex); @@ -535,6 +753,9 @@ esp_err_t ts_temp_get_by_source(ts_temp_source_type_t type, ts_temp_data_t *data esp_err_t ts_temp_set_manual(int16_t temp_01c) { if (!s_state.initialized) return ESP_ERR_INVALID_STATE; + if (temp_01c < TS_TEMP_MIN_VALID || temp_01c > TS_TEMP_MAX_VALID) { + return ESP_ERR_INVALID_ARG; + } // 注册手动 provider(如果尚未注册) if (!s_state.providers[TS_TEMP_SOURCE_MANUAL].registered) { @@ -566,6 +787,10 @@ esp_err_t ts_temp_set_manual_mode(bool enable) p->last_update_ms = get_current_ms(); p->registered = true; p->active = true; + } else if (enable) { + provider_t *p = &s_state.providers[TS_TEMP_SOURCE_MANUAL]; + p->active = true; + p->last_update_ms = get_current_ms(); } evaluate_active_source(); @@ -596,15 +821,24 @@ esp_err_t ts_temp_get_status(ts_temp_status_t *status) } xSemaphoreTake(s_state.mutex, portMAX_DELAY); - - /* 重新评估活动源并更新温度(确保获取最新值) */ - evaluate_active_source(); - + + int64_t now = get_current_ms(); status->initialized = s_state.initialized; status->active_source = s_state.active_source; status->preferred_source = s_state.preferred_source; status->current_temp = s_state.current_temp; + if (s_state.active_source == TS_TEMP_SOURCE_VARIABLE) { + status->current_timestamp_ms = s_state.variable_last_update_ms; + } else if (s_state.active_source < TS_TEMP_SOURCE_MAX) { + status->current_timestamp_ms = s_state.providers[s_state.active_source].last_update_ms; + } + status->current_valid = is_cached_active_source_valid_locked(now); status->manual_mode = s_state.manual_mode; + status->variable_partial_stale = s_state.variable_partial_stale; + status->variable_valid_count = s_state.variable_valid_count; + status->variable_total_count = s_state.variable_total_count; + status->variable_valid_weight = s_state.variable_valid_weight; + status->variable_total_weight = s_state.variable_total_weight; /* 复制绑定变量信息 */ if (s_state.bound_variable[0] != '\0') { @@ -954,23 +1188,33 @@ static esp_err_t save_bound_variable_to_nvs(const char *var_name) static esp_err_t save_bound_vars_to_nvs(void) { nvs_handle_t handle; - esp_err_t ret = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle); - - if (ret != ESP_OK) { - TS_LOGE(TAG, "Failed to open NVS for write: %s", esp_err_to_name(ret)); - return ret; - } - typedef struct { uint8_t count; ts_temp_bound_var_t vars[TS_TEMP_MAX_BOUND_VARS]; } bound_vars_blob_t; - - if (s_state.bound_var_count > 0) { - bound_vars_blob_t blob = {0}; + + bound_vars_blob_t blob = {0}; + char bound_variable[TS_TEMP_MAX_VARNAME_LEN] = {0}; + + if (s_state.mutex) { + xSemaphoreTake(s_state.mutex, portMAX_DELAY); blob.count = s_state.bound_var_count; - memcpy(blob.vars, s_state.bound_vars, - s_state.bound_var_count * sizeof(ts_temp_bound_var_t)); + if (blob.count > 0) { + memcpy(blob.vars, s_state.bound_vars, + blob.count * sizeof(ts_temp_bound_var_t)); + } + strncpy(bound_variable, s_state.bound_variable, sizeof(bound_variable) - 1); + xSemaphoreGive(s_state.mutex); + } + + esp_err_t ret = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle); + + if (ret != ESP_OK) { + TS_LOGE(TAG, "Failed to open NVS for write: %s", esp_err_to_name(ret)); + return ret; + } + + if (blob.count > 0) { ret = nvs_set_blob(handle, NVS_KEY_BOUND_VARS, &blob, sizeof(blob)); } else { ret = nvs_erase_key(handle, NVS_KEY_BOUND_VARS); @@ -978,10 +1222,17 @@ static esp_err_t save_bound_vars_to_nvs(void) } /* 同步旧 key 以保持向后兼容 */ - if (s_state.bound_variable[0] != '\0') { - nvs_set_str(handle, NVS_KEY_BOUND_VAR, s_state.bound_variable); - } else { - nvs_erase_key(handle, NVS_KEY_BOUND_VAR); + if (ret == ESP_OK) { + esp_err_t legacy_ret; + if (bound_variable[0] != '\0') { + legacy_ret = nvs_set_str(handle, NVS_KEY_BOUND_VAR, bound_variable); + } else { + legacy_ret = nvs_erase_key(handle, NVS_KEY_BOUND_VAR); + if (legacy_ret == ESP_ERR_NVS_NOT_FOUND) legacy_ret = ESP_OK; + } + if (legacy_ret != ESP_OK) { + ret = legacy_ret; + } } if (ret != ESP_OK) { @@ -993,7 +1244,11 @@ static esp_err_t save_bound_vars_to_nvs(void) ret = nvs_commit(handle); nvs_close(handle); - TS_LOGD(TAG, "Saved %d bound vars to NVS", s_state.bound_var_count); + if (ret == ESP_OK) { + TS_LOGD(TAG, "Saved %d bound vars to NVS", blob.count); + } else { + TS_LOGE(TAG, "Failed to commit bound vars: %s", esp_err_to_name(ret)); + } return ret; } @@ -1004,31 +1259,50 @@ static esp_err_t save_bound_vars_to_nvs(void) */ static void export_temp_config_to_sdcard(void) { + ts_temp_source_type_t preferred_source = TS_TEMP_SOURCE_DEFAULT; + char bound_variable[TS_TEMP_MAX_VARNAME_LEN] = {0}; + ts_temp_bound_var_t bound_vars[TS_TEMP_MAX_BOUND_VARS] = {0}; + uint8_t bound_var_count = 0; + + if (s_state.mutex) { + xSemaphoreTake(s_state.mutex, portMAX_DELAY); + } + preferred_source = s_state.preferred_source; + strncpy(bound_variable, s_state.bound_variable, sizeof(bound_variable) - 1); + bound_var_count = s_state.bound_var_count; + if (bound_var_count > 0) { + memcpy(bound_vars, s_state.bound_vars, + bound_var_count * sizeof(ts_temp_bound_var_t)); + } + if (s_state.mutex) { + xSemaphoreGive(s_state.mutex); + } + cJSON *root = cJSON_CreateObject(); if (!root) { TS_LOGW(TAG, "Failed to create JSON for SD card export"); return; } - const char *preferred_str = s_state.preferred_source == TS_TEMP_SOURCE_DEFAULT + const char *preferred_str = preferred_source == TS_TEMP_SOURCE_DEFAULT ? "auto" - : ts_temp_source_type_to_str(s_state.preferred_source); + : ts_temp_source_type_to_str(preferred_source); cJSON_AddStringToObject(root, "preferred_source", preferred_str); /* 向后兼容:导出第一个变量名 */ - if (s_state.bound_variable[0] != '\0') { - cJSON_AddStringToObject(root, "bound_variable", s_state.bound_variable); + if (bound_variable[0] != '\0') { + cJSON_AddStringToObject(root, "bound_variable", bound_variable); } /* 导出加权变量数组 */ - if (s_state.bound_var_count > 0) { + if (bound_var_count > 0) { cJSON *arr = cJSON_CreateArray(); if (arr) { - for (uint8_t i = 0; i < s_state.bound_var_count; i++) { + for (uint8_t i = 0; i < bound_var_count; i++) { cJSON *item = cJSON_CreateObject(); if (item) { - cJSON_AddStringToObject(item, "name", s_state.bound_vars[i].name); - cJSON_AddNumberToObject(item, "weight", s_state.bound_vars[i].weight); + cJSON_AddStringToObject(item, "name", bound_vars[i].name); + cJSON_AddNumberToObject(item, "weight", bound_vars[i].weight); cJSON_AddItemToArray(arr, item); } } diff --git a/components/ts_webui/src/ts_webui_ws.c b/components/ts_webui/src/ts_webui_ws.c index 2842261..d0a1abd 100644 --- a/components/ts_webui/src/ts_webui_ws.c +++ b/components/ts_webui/src/ts_webui_ws.c @@ -1612,7 +1612,7 @@ static void ssh_exec_output_callback(const char *data, size_t len, bool is_stder strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_STRING; strncpy(var.value.str_val, s_exec_params->extracted_value, sizeof(var.value.str_val) - 1); - esp_err_t ret = ts_variable_register(&var); + esp_err_t ret = ts_variable_upsert(&var); TS_LOGI(TAG, "Registered %s = %s (ret=%d)", full_var_name, s_exec_params->extracted_value, ret); } @@ -1621,21 +1621,21 @@ static void ssh_exec_output_callback(const char *data, size_t len, bool is_stder strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_STRING; strncpy(var.value.str_val, "running", sizeof(var.value.str_val) - 1); - ts_variable_register(&var); + ts_variable_upsert(&var); /* 更新 host */ snprintf(full_var_name, sizeof(full_var_name), "%s.host", s_exec_params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_STRING; strncpy(var.value.str_val, s_exec_params->config.host ? s_exec_params->config.host : "", sizeof(var.value.str_val) - 1); - ts_variable_register(&var); + ts_variable_upsert(&var); /* 更新 timestamp */ snprintf(full_var_name, sizeof(full_var_name), "%s.timestamp", s_exec_params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_INT; var.value.int_val = (int32_t)(esp_timer_get_time() / 1000000); - ts_variable_register(&var); + ts_variable_upsert(&var); TS_LOGD(TAG, "Continuous mode: realtime update %s", s_exec_params->var_name); } @@ -1900,49 +1900,49 @@ static void ssh_exec_task(void *arg) strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_STRING; strncpy(var.value.str_val, status_str, sizeof(var.value.str_val) - 1); - ts_variable_register(&var); + ts_variable_upsert(&var); /* exit_code (int) */ snprintf(full_var_name, sizeof(full_var_name), "%s.exit_code", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_INT; var.value.int_val = exit_code; - ts_variable_register(&var); + ts_variable_upsert(&var); /* extracted (string) */ snprintf(full_var_name, sizeof(full_var_name), "%s.extracted", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_STRING; strncpy(var.value.str_val, extracted_value ? extracted_value : "", sizeof(var.value.str_val) - 1); - ts_variable_register(&var); + ts_variable_upsert(&var); /* expect_matched (bool) */ snprintf(full_var_name, sizeof(full_var_name), "%s.expect_matched", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_BOOL; var.value.bool_val = expect_matched; - ts_variable_register(&var); + ts_variable_upsert(&var); /* fail_matched (bool) */ snprintf(full_var_name, sizeof(full_var_name), "%s.fail_matched", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_BOOL; var.value.bool_val = fail_matched; - ts_variable_register(&var); + ts_variable_upsert(&var); /* host (string) */ snprintf(full_var_name, sizeof(full_var_name), "%s.host", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_STRING; strncpy(var.value.str_val, params->config.host ? params->config.host : "", sizeof(var.value.str_val) - 1); - ts_variable_register(&var); + ts_variable_upsert(&var); /* timestamp (int) */ snprintf(full_var_name, sizeof(full_var_name), "%s.timestamp", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_INT; var.value.int_val = (int32_t)(esp_timer_get_time() / 1000000); /* 秒级时间戳 */ - ts_variable_register(&var); + ts_variable_upsert(&var); TS_LOGI(TAG, "Synced SSH result to automation variables: %s (7 vars)", params->var_name); } @@ -2258,49 +2258,49 @@ esp_err_t ts_webui_ssh_exec_start_ex(const char *host, uint16_t port, strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_STRING; strncpy(var.value.str_val, "running", sizeof(var.value.str_val) - 1); - ts_variable_register(&var); + ts_variable_upsert(&var); /* 初始化 exit_code 为 -1(未完成) */ snprintf(full_var_name, sizeof(full_var_name), "%s.exit_code", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_INT; var.value.int_val = -1; - ts_variable_register(&var); + ts_variable_upsert(&var); /* 初始化 extracted 为空 */ snprintf(full_var_name, sizeof(full_var_name), "%s.extracted", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_STRING; var.value.str_val[0] = '\0'; - ts_variable_register(&var); + ts_variable_upsert(&var); /* 初始化 expect_matched 为 false */ snprintf(full_var_name, sizeof(full_var_name), "%s.expect_matched", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_BOOL; var.value.bool_val = false; - ts_variable_register(&var); + ts_variable_upsert(&var); /* 初始化 fail_matched 为 false */ snprintf(full_var_name, sizeof(full_var_name), "%s.fail_matched", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_BOOL; var.value.bool_val = false; - ts_variable_register(&var); + ts_variable_upsert(&var); /* 初始化 host */ snprintf(full_var_name, sizeof(full_var_name), "%s.host", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_STRING; strncpy(var.value.str_val, params->config.host ? params->config.host : "", sizeof(var.value.str_val) - 1); - ts_variable_register(&var); + ts_variable_upsert(&var); /* 初始化 timestamp */ snprintf(full_var_name, sizeof(full_var_name), "%s.timestamp", params->var_name); strncpy(var.name, full_var_name, sizeof(var.name) - 1); var.value.type = TS_AUTO_VAL_INT; var.value.int_val = (int32_t)(esp_timer_get_time() / 1000000); /* 秒级时间戳 */ - ts_variable_register(&var); + ts_variable_upsert(&var); TS_LOGI(TAG, "Initialized variables for %s (status=running)", params->var_name); } diff --git a/components/ts_webui/web/css/style.css b/components/ts_webui/web/css/style.css index e04ce1d..1f2150f 100644 --- a/components/ts_webui/web/css/style.css +++ b/components/ts_webui/web/css/style.css @@ -3037,12 +3037,80 @@ button.btn-gray:hover, margin-left: 2px; } +.fan-auto-info-btn { + width: 24px; + height: 24px; + padding: 0; + margin-left: 6px; + border: 1px solid var(--border-color); + border-radius: 50%; + background: var(--bg-elevated); + color: var(--text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: 16px; + cursor: pointer; + transition: all var(--t-fast); +} + +.fan-auto-info-btn:hover { + color: var(--text-primary); + background: var(--bg-card); + border-color: var(--text-muted); +} + +.fan-auto-info-btn i { + font-size: 0.95rem; + line-height: 1; +} + .fan-rpm-small { font-size: 0.85rem; color: var(--text-light); margin-top: 6px; } +.fan-auto-help-content { + max-width: 460px; +} + +.fan-auto-help-body { + color: var(--text-secondary); + font-size: 0.92rem; + line-height: 1.65; +} + +.fan-auto-help-body p { + margin: 0 0 12px; +} + +.fan-auto-help-body p:last-child { + margin-bottom: 0; +} + +.fan-auto-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + margin: -4px 0 14px; + font-size: 0.75rem; + color: var(--text-light); +} + +.fan-auto-meta span { + padding: 3px 6px; + border-radius: 6px; + background: var(--bg-elevated); + white-space: nowrap; +} + +.fan-auto-meta.is-guard span, +.fan-auto-meta.is-stale span { + color: var(--error); +} + /* 模式选择Tabs */ .fan-mode-tabs { display: flex; diff --git a/components/ts_webui/web/js/app.js b/components/ts_webui/web/js/app.js index ee0651b..82e4560 100644 --- a/components/ts_webui/web/js/app.js +++ b/components/ts_webui/web/js/app.js @@ -1458,6 +1458,13 @@ function updateFanInfo(data) { const rpm = fan.rpm || 0; const isManual = mode === 'manual'; const isOff = mode === 'off'; + const hasAutoTelemetry = mode === 'auto' && ( + fan.auto_state || + typeof fan.guard_temperature === 'number' || + typeof fan.predicted_temperature === 'number' || + fan.temp_stale === true || + fan.guard_active === true + ); const _off = typeof t === 'function' ? t('fanPage.modeOff') : '关闭'; const _manual = typeof t === 'function' ? t('fanPage.modeManual') : '手动'; @@ -1473,6 +1480,28 @@ function updateFanInfo(data) { const _fanTitle = typeof t === 'function' ? t('fanPage.fanN', { id: fan.id }) : `风扇 ${fan.id}`; const _speedAdjust = typeof t === 'function' ? t('fanPage.speedAdjust') : '转速调节'; const _manualHint = typeof t === 'function' ? t('fanPage.manualModeHint') : '切换到手动模式后可调节'; + const _autoHelpTitle = typeof t === 'function' ? t('fanPage.autoHelpTitle') : '自动模式有什么不同?'; + const _autoHelpTitleSafe = typeof escapeHtml === 'function' ? escapeHtml(_autoHelpTitle) : _autoHelpTitle; + const autoStateLabels = { + idle: typeof t === 'function' ? t('fanPage.autoStateIdle') : '待机', + baseline: typeof t === 'function' ? t('fanPage.autoStateBaseline') : '基线', + active: typeof t === 'function' ? t('fanPage.autoStateActive') : '自适应', + guard: typeof t === 'function' ? t('fanPage.autoStateGuard') : '保护', + stale: typeof t === 'function' ? t('fanPage.autoStateStale') : '失效', + unknown: typeof t === 'function' ? t('fanPage.autoStateUnknown') : '未知' + }; + const autoState = fan.auto_state || 'unknown'; + const autoStateText = autoStateLabels[autoState] || autoState; + const guardLabel = typeof t === 'function' ? t('fanPage.guardTempShort') : '保护'; + const predictedLabel = typeof t === 'function' ? t('fanPage.predictedTempShort') : '预测'; + const autoMeta = hasAutoTelemetry ? ` +
+ ${autoStateText} + ${typeof fan.guard_temperature === 'number' ? `${guardLabel} ${fan.guard_temperature.toFixed(1)}°C` : ''} + ${typeof fan.predicted_temperature === 'number' ? `${predictedLabel} ${fan.predicted_temperature.toFixed(1)}°C` : ''} + ${typeof fan.slope_c_per_min === 'number' ? `${fan.slope_c_per_min.toFixed(2)}°C/min` : ''} +
+ ` : ''; return `
@@ -1485,8 +1514,10 @@ function updateFanInfo(data) {
${displayDuty} % + ${mode === 'auto' ? `` : ''} ${rpm > 0 ? `
${rpm} RPM
` : ''}
+ ${autoMeta}
@@ -1512,6 +1543,49 @@ function updateFanInfo(data) { } } +function showFanAutoHelpModal() { + const existing = document.getElementById('fan-auto-help-modal'); + if (existing) existing.remove(); + + const modal = document.createElement('div'); + modal.id = 'fan-auto-help-modal'; + modal.className = 'modal fan-auto-help-modal'; + modal.onclick = (event) => { + if (event.target === modal) closeFanAutoHelpModal(); + }; + + const title = typeof t === 'function' ? t('fanPage.autoHelpTitle') : '自动模式有什么不同?'; + const closeLabel = typeof t === 'function' ? t('common.close') : '关闭'; + const safeTitle = typeof escapeHtml === 'function' ? escapeHtml(title) : title; + const safeCloseLabel = typeof escapeHtml === 'function' ? escapeHtml(closeLabel) : closeLabel; + const paragraphs = [ + typeof t === 'function' ? t('fanPage.autoHelpIntro') : '自动模式会根据设备当前的散热状态实时调节风扇,而不是只按一条固定曲线运行。', + typeof t === 'function' ? t('fanPage.autoHelpCurveDiff') : '曲线模式更像一张固定规则表:温度到多少,风扇就转到多少。它稳定、可预期,适合你想手动定义散热策略的场景。', + typeof t === 'function' ? t('fanPage.autoHelpAdaptive') : '自动模式会在曲线的基础上继续判断温度变化趋势。如果设备正在快速升温,它会提前加速;如果温度稳定下降,它才会慢慢把转速降下来。', + typeof t === 'function' ? t('fanPage.autoHelpSafety') : '这样做的目的,是让风扇不只是看到温度后再反应,而是尽量提前压住温度波动。日常轻载时保持更安静,高负载或温度异常时优先保护设备安全。' + ]; + + modal.innerHTML = ` + + `; + document.body.appendChild(modal); +} + +function closeFanAutoHelpModal() { + document.getElementById('fan-auto-help-modal')?.remove(); +} + // 更新滑块 UI(实时反馈) function updateFanSliderUI(fanId, value) { const slider = document.getElementById(`fan-slider-${fanId}`); @@ -1844,30 +1918,41 @@ function renderTempVarBindings() { function buildVarSelectOptions(selectedName) { if (!availableTempVars || availableTempVars.length === 0) return ''; - const priorityVars = availableTempVars.filter(v => v.name.includes('temp')); - const otherVars = availableTempVars.filter(v => !v.name.includes('temp')); + const isPriorityTempVar = (v) => { + const name = (v.name || '').toLowerCase(); + return name.includes('temp') || name.includes('tj') || + name.includes('cpu') || name.includes('gpu'); + }; + const renderOption = (v) => { + const name = v.name || ''; + const safeName = typeof escapeHtml === 'function' ? escapeHtml(name) : name; + const valueText = typeof v.value === 'number' ? ` (${v.value.toFixed(1)}°C)` : ''; + const sel = name === selectedName ? 'selected' : ''; + return ``; + }; + const priorityVars = availableTempVars.filter(isPriorityTempVar); + const otherVars = availableTempVars.filter(v => !isPriorityTempVar(v)); let html = ''; if (priorityVars.length > 0) { html += ``; priorityVars.forEach(v => { - const sel = v.name === selectedName ? 'selected' : ''; - html += ``; + html += renderOption(v); }); html += ``; } if (otherVars.length > 0) { html += ``; otherVars.forEach(v => { - const sel = v.name === selectedName ? 'selected' : ''; - html += ``; + html += renderOption(v); }); html += ``; } /* 如果已选变量不在列表中,追加显示 */ if (selectedName && !availableTempVars.find(v => v.name === selectedName)) { - html += ``; + const safeName = typeof escapeHtml === 'function' ? escapeHtml(selectedName) : selectedName; + html += ``; } return html; @@ -1938,6 +2023,7 @@ function updateWeightedTempFormula() { let parts = []; let weightedSum = 0; let totalWeight = 0; + let allValuesKnown = true; validBindings.forEach(b => { const varInfo = availableTempVars.find(v => v.name === b.name); @@ -1946,14 +2032,17 @@ function updateWeightedTempFormula() { parts.push(`${val.toFixed(1)}°C × ${b.weight}`); weightedSum += val * b.weight; } else { - parts.push(`??°C × ${b.weight}`); + allValuesKnown = false; + parts.push(`--°C × ${b.weight}`); } totalWeight += b.weight; }); let formula = parts.join(' + '); - if (totalWeight > 0.001) { + if (allValuesKnown && totalWeight > 0.001) { formula += ` = ${(weightedSum / totalWeight).toFixed(1)}°C`; + } else { + formula += ` = ${t('fanPage.variableNoData')}`; } formulaEl.innerHTML = `${t('fanPage.weightedTemp')}: ${formula}`; @@ -2221,14 +2310,22 @@ async function loadTempSourceStatus() { async function loadVariableBindStatus() { // 两个 API 调用独立 try-catch,互不阻塞 try { - const varsResult = await api.call('automation.variables.list'); + const varsResult = await api.call('automation.variables.list', { + include_value: true, + include_meta: false + }); if (varsResult.code === 0 && varsResult.data?.variables) { - const tempVars = varsResult.data.variables.filter(v => - v.type === 'float' || v.type === 'double' || v.type === 'number' || - v.name.includes('temp') || v.name.includes('cpu') || v.name.includes('gpu') - ); + const tempVars = varsResult.data.variables.filter(v => { + const name = (v.name || '').toLowerCase(); + return v.type === 'float' || v.type === 'double' || + v.type === 'number' || v.type === 'int' || + name.includes('temp') || name.includes('cpu') || + name.includes('gpu') || name.includes('tj'); + }); availableTempVars = tempVars.length > 0 ? tempVars : varsResult.data.variables.filter(v => - v.type === 'float' || v.type === 'double' || typeof v.value === 'number' + v.type === 'float' || v.type === 'double' || + v.type === 'number' || v.type === 'int' || + typeof v.value === 'number' ); } } catch (e) { @@ -2248,16 +2345,57 @@ async function loadVariableBindStatus() { name: bv.name, weight: bv.weight ?? 1.0 })); + boundVars.forEach(bv => { + if (!bv.name) return; + let existing = availableTempVars.find(v => v.name === bv.name); + if (!existing) { + existing = { name: bv.name, type: typeof bv.value === 'number' ? 'float' : 'unknown' }; + availableTempVars.push(existing); + } + if (typeof bv.value === 'number') { + existing.value = bv.value; + } + existing.valid = bv.valid; + existing.stale = bv.stale; + existing.last_update_ms = bv.last_update_ms; + existing.age_ms = bv.age_ms; + }); } else if (data.bound_variable) { tempVarBindings = [{ name: data.bound_variable, weight: 1.0 }]; } else { tempVarBindings = []; } + const positiveBoundVars = boundVars.filter(bv => (bv.weight ?? 1.0) > 0.001); + const positiveCount = typeof data.bound_total_count === 'number' + ? data.bound_total_count : positiveBoundVars.length; + const validCount = typeof data.bound_valid_count === 'number' + ? data.bound_valid_count + : positiveBoundVars.filter(bv => bv.valid === true).length; + const staleCount = positiveBoundVars.filter(bv => bv.stale === true).length; + const freshCount = Math.max(0, positiveCount - staleCount); + const partialStale = data.partial_stale === true || + (positiveCount > 0 && validCount < positiveCount); + const hasWeightedTemp = typeof data.weighted_temp_c === 'number'; + if (statusEl) { if (tempVarBindings.length > 0) { - statusEl.textContent = t('fanPage.boundVarCount', { count: tempVarBindings.length }); - statusEl.className = 'badge badge-success'; + if (positiveCount > 0 && validCount === 0 && staleCount === positiveCount) { + statusEl.textContent = t('fanPage.boundVarsStale', { count: tempVarBindings.length }); + statusEl.className = 'badge badge-warning'; + } else if (positiveCount > 0 && partialStale && validCount > 0) { + statusEl.textContent = t('fanPage.boundVarsPartialStale', { fresh: validCount, count: positiveCount }); + statusEl.className = 'badge badge-warning'; + } else if (positiveCount > 0 && !hasWeightedTemp) { + statusEl.textContent = t('fanPage.boundVarsInvalid', { count: tempVarBindings.length }); + statusEl.className = 'badge badge-warning'; + } else if (positiveCount === 0) { + statusEl.textContent = t('fanPage.boundVarsInvalid', { count: tempVarBindings.length }); + statusEl.className = 'badge badge-warning'; + } else { + statusEl.textContent = t('fanPage.boundVarCount', { count: tempVarBindings.length }); + statusEl.className = 'badge badge-success'; + } } else { statusEl.textContent = t('fanPage.unbound'); statusEl.className = 'badge badge-secondary'; @@ -2265,11 +2403,21 @@ async function loadVariableBindStatus() { } const tempEl = document.getElementById('fan-curve-temp-current'); - if (tempEl && typeof data.weighted_temp_c === 'number') { + if (tempEl && hasWeightedTemp) { tempEl.textContent = `${data.weighted_temp_c.toFixed(1)}°C`; tempEl.style.color = 'var(--primary)'; + } else if (tempEl && positiveCount > 0) { + if (validCount === 0 && staleCount === positiveCount) { + tempEl.textContent = t('fanPage.boundVarsStaleShort'); + } else if (partialStale && validCount > 0) { + tempEl.textContent = t('fanPage.boundVarsPartialStale', { fresh: validCount, count: positiveCount }); + } else { + tempEl.textContent = freshCount === 0 ? t('fanPage.boundVarsStaleShort') : t('fanPage.boundVarsInvalidShort'); + } + tempEl.style.color = 'var(--warning)'; } else if (tempEl && typeof data.temperature_c === 'number') { tempEl.textContent = `${data.temperature_c.toFixed(1)}°C`; + tempEl.style.color = ''; } } } catch (e) { @@ -3375,7 +3523,7 @@ async function refreshDataWidgets() { const variables = {}; if (varNames.size > 0) { try { - const resp = await api.call('automation.variables.list'); + const resp = await api.call('automation.variables.list', { include_meta: false }); if (resp.code === 0 && resp.data?.variables) { for (const v of resp.data.variables) { if (varNames.has(v.name) && v.value !== undefined) { @@ -7812,8 +7960,7 @@ function getSignalIcon(rssi) { } function escapeHtml(str) { - if (!str) return ''; - return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); + return String(str ?? '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } function connectWifi(ssid) { @@ -9850,7 +9997,10 @@ async function showCommandVariables(varName) { modal.classList.remove('hidden'); try { - const result = await api.call('automation.variables.list'); + const result = await api.call('automation.variables.list', { + prefix: `${varName}.`, + include_meta: true + }); if (result.code === 0 && result.data && result.data.variables) { // 过滤出属于该指令的变量 // SSH 指令变量的 source_id 就是 varName(不带 cmd. 前缀) @@ -9879,7 +10029,7 @@ async function showCommandVariables(varName) { ${v.name} ${v.type || '-'} ${formatVariableValue(v.value, v.type)} - ${v.updated_at ? formatTimeAgo(v.updated_at) : '-'} + ${formatVariableUpdateTime(v)} `).join('')} @@ -14857,7 +15007,7 @@ function getLevelName(level) { function escapeHtml(text) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; - return text.replace(/[&<>"']/g, m => map[m]); + return String(text ?? '').replace(/[&<>"']/g, m => map[m]); } function escapeRegex(text) { @@ -17541,7 +17691,7 @@ async function refreshVariables() { container.innerHTML = '
' + t('common.loading') + '
'; try { - const result = await api.call('automation.variables.list'); + const result = await api.call('automation.variables.list', { include_meta: true }); if (result.code === 0 && result.data && result.data.variables) { allVariables = result.data.variables; if (countBadge) countBadge.textContent = allVariables.length; @@ -17614,7 +17764,7 @@ function renderVariables(variables) { ${v.name} ${v.type || '-'} ${formatVariableValue(v.value, v.type)} - ${v.updated_at ? formatTimeAgo(v.updated_at) : '-'} + ${formatVariableUpdateTime(v)} `).join('')} @@ -17664,20 +17814,39 @@ function formatTimeAgo(timestamp) { const now = Date.now(); const ts = typeof timestamp === 'number' ? timestamp * 1000 : new Date(timestamp).getTime(); const diff = now - ts; - - if (diff < 1000) return '刚刚'; - if (diff < 60000) return `${Math.floor(diff / 1000)}秒前`; - if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`; - if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`; + + if (diff >= 0 && diff < 86400000) { + return formatAgeMs(diff); + } return new Date(ts).toLocaleString(); } +function formatAgeMs(ageMs) { + const age = Math.max(0, ageMs); + if (age < 1000) return t('common.justNow'); + if (age < 60000) return `${Math.floor(age / 1000)}${t('common.secondsAgo')}`; + if (age < 3600000) return `${Math.floor(age / 60000)}${t('common.minutesAgo')}`; + if (age < 86400000) return `${Math.floor(age / 3600000)}${t('common.hoursAgo')}`; + return `${Math.floor(age / 86400000)}${t('common.daysAgo')}`; +} + +function formatVariableUpdateTime(variable) { + if (!variable) return '-'; + if (typeof variable.age_ms === 'number') { + return formatAgeMs(variable.age_ms); + } + if (variable.updated_at) { + return formatTimeAgo(variable.updated_at); + } + return '-'; +} + /** * HTML 转义 */ function escapeHtml(text) { const div = document.createElement('div'); - div.textContent = text; + div.textContent = String(text ?? ''); return div.innerHTML; } @@ -18925,7 +19094,10 @@ async function showVariableSelectModal(targetInputId, mode = 'insert') { // 加载变量列表 try { - const result = await api.call('automation.variables.list'); + const result = await api.call('automation.variables.list', { + include_value: false, + include_meta: false + }); const variables = result.data?.variables || []; document.getElementById('variable-select-loading').style.display = 'none'; @@ -18949,29 +19121,38 @@ async function showVariableSelectModal(targetInputId, mode = 'insert') { let html = ''; for (const [sourceId, vars] of Object.entries(grouped)) { const groupId = `var-group-${sourceId.replace(/[^a-zA-Z0-9]/g, '_')}`; - html += `
+ const safeSourceId = escapeHtml(sourceId); + const sourceLabel = sourceId === '_system' + ? (typeof t === 'function' ? t('automation.systemVariables') : 'System Variables') + : sourceId; + html += `
- ${sourceId === '_system' ? (typeof t === 'function' ? t('automation.systemVariables') : 'System Variables') : sourceId} (${vars.length}) + ${escapeHtml(sourceLabel)} (${vars.length})