mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	Merge branch 'client_info_flash' into integration
This commit is contained in:
		| @@ -205,7 +205,8 @@ void APIConnection::loop() { | |||||||
|     // Disconnect if not responded within 2.5*keepalive |     // Disconnect if not responded within 2.5*keepalive | ||||||
|     if (now - this->last_traffic_ > KEEPALIVE_DISCONNECT_TIMEOUT) { |     if (now - this->last_traffic_ > KEEPALIVE_DISCONNECT_TIMEOUT) { | ||||||
|       on_fatal_error(); |       on_fatal_error(); | ||||||
|       ESP_LOGW(TAG, "%s (%s) is unresponsive; disconnecting", this->get_name(), this->get_peername()); |       ESP_LOGW(TAG, "%s (%s) is unresponsive; disconnecting", this->client_info_.name.c_str(), | ||||||
|  |                this->client_info_.peername.c_str()); | ||||||
|     } |     } | ||||||
|   } else if (now - this->last_traffic_ > KEEPALIVE_TIMEOUT_MS && !this->flags_.remove) { |   } else if (now - this->last_traffic_ > KEEPALIVE_TIMEOUT_MS && !this->flags_.remove) { | ||||||
|     // Only send ping if we're not disconnecting |     // Only send ping if we're not disconnecting | ||||||
| @@ -255,7 +256,7 @@ bool APIConnection::send_disconnect_response(const DisconnectRequest &msg) { | |||||||
|   // remote initiated disconnect_client |   // remote initiated disconnect_client | ||||||
|   // don't close yet, we still need to send the disconnect response |   // don't close yet, we still need to send the disconnect response | ||||||
|   // close will happen on next loop |   // close will happen on next loop | ||||||
|   ESP_LOGD(TAG, "%s (%s) disconnected", this->get_name(), this->get_peername()); |   ESP_LOGD(TAG, "%s (%s) disconnected", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); | ||||||
|   this->flags_.next_close = true; |   this->flags_.next_close = true; | ||||||
|   DisconnectResponse resp; |   DisconnectResponse resp; | ||||||
|   return this->send_message(resp, DisconnectResponse::MESSAGE_TYPE); |   return this->send_message(resp, DisconnectResponse::MESSAGE_TYPE); | ||||||
| @@ -1385,7 +1386,7 @@ void APIConnection::complete_authentication_() { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   this->flags_.connection_state = static_cast<uint8_t>(ConnectionState::AUTHENTICATED); |   this->flags_.connection_state = static_cast<uint8_t>(ConnectionState::AUTHENTICATED); | ||||||
|   ESP_LOGD(TAG, "%s (%s) connected", this->get_name(), this->get_peername()); |   ESP_LOGD(TAG, "%s (%s) connected", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); | ||||||
| #ifdef USE_API_CLIENT_CONNECTED_TRIGGER | #ifdef USE_API_CLIENT_CONNECTED_TRIGGER | ||||||
|   this->parent_->get_client_connected_trigger()->trigger(this->client_info_.name, this->client_info_.peername); |   this->parent_->get_client_connected_trigger()->trigger(this->client_info_.name, this->client_info_.peername); | ||||||
| #endif | #endif | ||||||
| @@ -1609,12 +1610,12 @@ bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { | |||||||
| #ifdef USE_API_PASSWORD | #ifdef USE_API_PASSWORD | ||||||
| void APIConnection::on_unauthenticated_access() { | void APIConnection::on_unauthenticated_access() { | ||||||
|   this->on_fatal_error(); |   this->on_fatal_error(); | ||||||
|   ESP_LOGD(TAG, "%s (%s) no authentication", this->get_name(), this->get_peername()); |   ESP_LOGD(TAG, "%s (%s) no authentication", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); | ||||||
| } | } | ||||||
| #endif | #endif | ||||||
| void APIConnection::on_no_setup_connection() { | void APIConnection::on_no_setup_connection() { | ||||||
|   this->on_fatal_error(); |   this->on_fatal_error(); | ||||||
|   ESP_LOGD(TAG, "%s (%s) no connection setup", this->get_name(), this->get_peername()); |   ESP_LOGD(TAG, "%s (%s) no connection setup", this->client_info_.name.c_str(), this->client_info_.peername.c_str()); | ||||||
| } | } | ||||||
| void APIConnection::on_fatal_error() { | void APIConnection::on_fatal_error() { | ||||||
|   this->helper_->close(); |   this->helper_->close(); | ||||||
| @@ -1866,8 +1867,8 @@ void APIConnection::process_state_subscriptions_() { | |||||||
| #endif  // USE_API_HOMEASSISTANT_STATES | #endif  // USE_API_HOMEASSISTANT_STATES | ||||||
|  |  | ||||||
| void APIConnection::log_warning_(const LogString *message, APIError err) { | void APIConnection::log_warning_(const LogString *message, APIError err) { | ||||||
|   ESP_LOGW(TAG, "%s (%s): %s %s errno=%d", this->get_name(), this->get_peername(), LOG_STR_ARG(message), |   ESP_LOGW(TAG, "%s (%s): %s %s errno=%d", this->client_info_.name.c_str(), this->client_info_.peername.c_str(), | ||||||
|            LOG_STR_ARG(api_error_to_logstr(err)), errno); |            LOG_STR_ARG(message), LOG_STR_ARG(api_error_to_logstr(err)), errno); | ||||||
| } | } | ||||||
|  |  | ||||||
| void APIConnection::log_socket_operation_failed_(APIError err) { | void APIConnection::log_socket_operation_failed_(APIError err) { | ||||||
|   | |||||||
| @@ -270,8 +270,8 @@ class APIConnection final : public APIServerConnection { | |||||||
|   bool try_to_clear_buffer(bool log_out_of_space); |   bool try_to_clear_buffer(bool log_out_of_space); | ||||||
|   bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; |   bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override; | ||||||
|  |  | ||||||
|   const char *get_name() const { return this->client_info_.name.c_str(); } |   const std::string &get_name() const { return this->client_info_.name; } | ||||||
|   const char *get_peername() const { return this->client_info_.peername.c_str(); } |   const std::string &get_peername() const { return this->client_info_.peername; } | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   // Helper function to handle authentication completion |   // Helper function to handle authentication completion | ||||||
|   | |||||||
| @@ -177,7 +177,8 @@ void APIServer::loop() { | |||||||
|     // Network is down - disconnect all clients |     // Network is down - disconnect all clients | ||||||
|     for (auto &client : this->clients_) { |     for (auto &client : this->clients_) { | ||||||
|       client->on_fatal_error(); |       client->on_fatal_error(); | ||||||
|       ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->get_name(), client->get_peername()); |       ESP_LOGW(TAG, "%s (%s): Network down; disconnect", client->client_info_.name.c_str(), | ||||||
|  |                client->client_info_.peername.c_str()); | ||||||
|     } |     } | ||||||
|     // Continue to process and clean up the clients below |     // Continue to process and clean up the clients below | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -7,24 +7,20 @@ namespace hdc1080 { | |||||||
|  |  | ||||||
| static const char *const TAG = "hdc1080"; | static const char *const TAG = "hdc1080"; | ||||||
|  |  | ||||||
| static const uint8_t HDC1080_ADDRESS = 0x40;  // 0b1000000 from datasheet |  | ||||||
| static const uint8_t HDC1080_CMD_CONFIGURATION = 0x02; | static const uint8_t HDC1080_CMD_CONFIGURATION = 0x02; | ||||||
| static const uint8_t HDC1080_CMD_TEMPERATURE = 0x00; | static const uint8_t HDC1080_CMD_TEMPERATURE = 0x00; | ||||||
| static const uint8_t HDC1080_CMD_HUMIDITY = 0x01; | static const uint8_t HDC1080_CMD_HUMIDITY = 0x01; | ||||||
|  |  | ||||||
| void HDC1080Component::setup() { | void HDC1080Component::setup() { | ||||||
|   const uint8_t data[2] = { |   const uint8_t config[2] = {0x00, 0x00};  // resolution 14bit for both humidity and temperature | ||||||
|       0b00000000,  // resolution 14bit for both humidity and temperature |  | ||||||
|       0b00000000   // reserved |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   if (!this->write_bytes(HDC1080_CMD_CONFIGURATION, data, 2)) { |   // if configuration fails - there is a problem | ||||||
|     // as instruction is same as powerup defaults (for now), interpret as warning if this fails |   if (this->write_register(HDC1080_CMD_CONFIGURATION, config, 2) != i2c::ERROR_OK) { | ||||||
|     ESP_LOGW(TAG, "HDC1080 initial config instruction error"); |     this->mark_failed(); | ||||||
|     this->status_set_warning(); |  | ||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| void HDC1080Component::dump_config() { | void HDC1080Component::dump_config() { | ||||||
|   ESP_LOGCONFIG(TAG, "HDC1080:"); |   ESP_LOGCONFIG(TAG, "HDC1080:"); | ||||||
|   LOG_I2C_DEVICE(this); |   LOG_I2C_DEVICE(this); | ||||||
| @@ -35,39 +31,51 @@ void HDC1080Component::dump_config() { | |||||||
|   LOG_SENSOR("  ", "Temperature", this->temperature_); |   LOG_SENSOR("  ", "Temperature", this->temperature_); | ||||||
|   LOG_SENSOR("  ", "Humidity", this->humidity_); |   LOG_SENSOR("  ", "Humidity", this->humidity_); | ||||||
| } | } | ||||||
|  |  | ||||||
| void HDC1080Component::update() { | void HDC1080Component::update() { | ||||||
|   uint16_t raw_temp; |   // regardless of what sensor/s are defined in yaml configuration | ||||||
|  |   // the hdc1080 setup configuration used, requires both temperature and humidity to be read | ||||||
|  |  | ||||||
|  |   this->status_clear_warning(); | ||||||
|  |  | ||||||
|   if (this->write(&HDC1080_CMD_TEMPERATURE, 1) != i2c::ERROR_OK) { |   if (this->write(&HDC1080_CMD_TEMPERATURE, 1) != i2c::ERROR_OK) { | ||||||
|     this->status_set_warning(); |     this->status_set_warning(); | ||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|   delay(20); |  | ||||||
|   if (this->read(reinterpret_cast<uint8_t *>(&raw_temp), 2) != i2c::ERROR_OK) { |   this->set_timeout(20, [this]() { | ||||||
|  |     uint16_t raw_temperature; | ||||||
|  |     if (this->read(reinterpret_cast<uint8_t *>(&raw_temperature), 2) != i2c::ERROR_OK) { | ||||||
|       this->status_set_warning(); |       this->status_set_warning(); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|   raw_temp = i2c::i2ctohs(raw_temp); |  | ||||||
|   float temp = raw_temp * 0.0025177f - 40.0f;  // raw * 2^-16 * 165 - 40 |  | ||||||
|   this->temperature_->publish_state(temp); |  | ||||||
|  |  | ||||||
|   uint16_t raw_humidity; |     if (this->temperature_ != nullptr) { | ||||||
|  |       raw_temperature = i2c::i2ctohs(raw_temperature); | ||||||
|  |       float temperature = raw_temperature * 0.0025177f - 40.0f;  // raw * 2^-16 * 165 - 40 | ||||||
|  |       this->temperature_->publish_state(temperature); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     if (this->write(&HDC1080_CMD_HUMIDITY, 1) != i2c::ERROR_OK) { |     if (this->write(&HDC1080_CMD_HUMIDITY, 1) != i2c::ERROR_OK) { | ||||||
|       this->status_set_warning(); |       this->status_set_warning(); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|   delay(20); |  | ||||||
|  |     this->set_timeout(20, [this]() { | ||||||
|  |       uint16_t raw_humidity; | ||||||
|       if (this->read(reinterpret_cast<uint8_t *>(&raw_humidity), 2) != i2c::ERROR_OK) { |       if (this->read(reinterpret_cast<uint8_t *>(&raw_humidity), 2) != i2c::ERROR_OK) { | ||||||
|         this->status_set_warning(); |         this->status_set_warning(); | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|  |       if (this->humidity_ != nullptr) { | ||||||
|         raw_humidity = i2c::i2ctohs(raw_humidity); |         raw_humidity = i2c::i2ctohs(raw_humidity); | ||||||
|         float humidity = raw_humidity * 0.001525879f;  // raw * 2^-16 * 100 |         float humidity = raw_humidity * 0.001525879f;  // raw * 2^-16 * 100 | ||||||
|         this->humidity_->publish_state(humidity); |         this->humidity_->publish_state(humidity); | ||||||
|  |  | ||||||
|   ESP_LOGD(TAG, "Got temperature=%.1f°C humidity=%.1f%%", temp, humidity); |  | ||||||
|   this->status_clear_warning(); |  | ||||||
|       } |       } | ||||||
| float HDC1080Component::get_setup_priority() const { return setup_priority::DATA; } |     }); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  |  | ||||||
| }  // namespace hdc1080 | }  // namespace hdc1080 | ||||||
| }  // namespace esphome | }  // namespace esphome | ||||||
|   | |||||||
| @@ -12,13 +12,11 @@ class HDC1080Component : public PollingComponent, public i2c::I2CDevice { | |||||||
|   void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } |   void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } | ||||||
|   void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } |   void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } | ||||||
|  |  | ||||||
|   /// Setup the sensor and check for connection. |  | ||||||
|   void setup() override; |   void setup() override; | ||||||
|   void dump_config() override; |   void dump_config() override; | ||||||
|   /// Retrieve the latest sensor values. This operation takes approximately 16ms. |  | ||||||
|   void update() override; |   void update() override; | ||||||
|  |  | ||||||
|   float get_setup_priority() const override; |   float get_setup_priority() const override { return setup_priority::DATA; } | ||||||
|  |  | ||||||
|  protected: |  protected: | ||||||
|   sensor::Sensor *temperature_{nullptr}; |   sensor::Sensor *temperature_{nullptr}; | ||||||
|   | |||||||
| @@ -62,6 +62,11 @@ SPIRAM_SPEEDS = { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def supported() -> bool: | ||||||
|  |     variant = get_esp32_variant() | ||||||
|  |     return variant in SPIRAM_MODES | ||||||
|  |  | ||||||
|  |  | ||||||
| def validate_psram_mode(config): | def validate_psram_mode(config): | ||||||
|     esp32_config = fv.full_config.get()[PLATFORM_ESP32] |     esp32_config = fv.full_config.get()[PLATFORM_ESP32] | ||||||
|     if config[CONF_SPEED] == "120MHZ": |     if config[CONF_SPEED] == "120MHZ": | ||||||
| @@ -95,7 +100,7 @@ def get_config_schema(config): | |||||||
|     variant = get_esp32_variant() |     variant = get_esp32_variant() | ||||||
|     speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] |     speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])] | ||||||
|     if not speeds: |     if not speeds: | ||||||
|         return cv.Invalid("PSRAM is not supported on this chip") |         raise cv.Invalid("PSRAM is not supported on this chip") | ||||||
|     modes = SPIRAM_MODES[variant] |     modes = SPIRAM_MODES[variant] | ||||||
|     return cv.Schema( |     return cv.Schema( | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -40,7 +40,13 @@ void RemoteTransmitterComponent::await_target_time_() { | |||||||
|   if (this->target_time_ == 0) { |   if (this->target_time_ == 0) { | ||||||
|     this->target_time_ = current_time; |     this->target_time_ = current_time; | ||||||
|   } else if ((int32_t) (this->target_time_ - current_time) > 0) { |   } else if ((int32_t) (this->target_time_ - current_time) > 0) { | ||||||
|  | #if defined(USE_LIBRETINY) | ||||||
|  |     // busy loop for libretiny is required (see the comment inside micros() in wiring.c) | ||||||
|  |     while ((int32_t) (this->target_time_ - micros()) > 0) | ||||||
|  |       ; | ||||||
|  | #else | ||||||
|     delayMicroseconds(this->target_time_ - current_time); |     delayMicroseconds(this->target_time_ - current_time); | ||||||
|  | #endif | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,9 +1,10 @@ | |||||||
|  | from ast import literal_eval | ||||||
| import logging | import logging | ||||||
| import math | import math | ||||||
| import re | import re | ||||||
|  |  | ||||||
| import jinja2 as jinja | import jinja2 as jinja | ||||||
| from jinja2.nativetypes import NativeEnvironment | from jinja2.sandbox import SandboxedEnvironment | ||||||
|  |  | ||||||
| TemplateError = jinja.TemplateError | TemplateError = jinja.TemplateError | ||||||
| TemplateSyntaxError = jinja.TemplateSyntaxError | TemplateSyntaxError = jinja.TemplateSyntaxError | ||||||
| @@ -70,7 +71,7 @@ class Jinja: | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, context_vars): |     def __init__(self, context_vars): | ||||||
|         self.env = NativeEnvironment( |         self.env = SandboxedEnvironment( | ||||||
|             trim_blocks=True, |             trim_blocks=True, | ||||||
|             lstrip_blocks=True, |             lstrip_blocks=True, | ||||||
|             block_start_string="<%", |             block_start_string="<%", | ||||||
| @@ -90,6 +91,15 @@ class Jinja: | |||||||
|             **SAFE_GLOBAL_FUNCTIONS, |             **SAFE_GLOBAL_FUNCTIONS, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |     def safe_eval(self, expr): | ||||||
|  |         try: | ||||||
|  |             result = literal_eval(expr) | ||||||
|  |             if not isinstance(result, str): | ||||||
|  |                 return result | ||||||
|  |         except (ValueError, SyntaxError, MemoryError, TypeError): | ||||||
|  |             pass | ||||||
|  |         return expr | ||||||
|  |  | ||||||
|     def expand(self, content_str): |     def expand(self, content_str): | ||||||
|         """ |         """ | ||||||
|         Renders a string that may contain Jinja expressions or statements |         Renders a string that may contain Jinja expressions or statements | ||||||
| @@ -106,7 +116,7 @@ class Jinja: | |||||||
|             override_vars = content_str.upvalues |             override_vars = content_str.upvalues | ||||||
|         try: |         try: | ||||||
|             template = self.env.from_string(content_str) |             template = self.env.from_string(content_str) | ||||||
|             result = template.render(override_vars) |             result = self.safe_eval(template.render(override_vars)) | ||||||
|             if isinstance(result, Undefined): |             if isinstance(result, Undefined): | ||||||
|                 # This happens when the expression is simply an undefined variable. Jinja does not |                 # This happens when the expression is simply an undefined variable. Jinja does not | ||||||
|                 # raise an exception, instead we get "Undefined". |                 # raise an exception, instead we get "Undefined". | ||||||
|   | |||||||
| @@ -429,8 +429,9 @@ void VoiceAssistant::client_subscription(api::APIConnection *client, bool subscr | |||||||
|  |  | ||||||
|   if (this->api_client_ != nullptr) { |   if (this->api_client_ != nullptr) { | ||||||
|     ESP_LOGE(TAG, "Multiple API Clients attempting to connect to Voice Assistant"); |     ESP_LOGE(TAG, "Multiple API Clients attempting to connect to Voice Assistant"); | ||||||
|     ESP_LOGE(TAG, "Current client: %s (%s)", this->api_client_->get_name(), this->api_client_->get_peername()); |     ESP_LOGE(TAG, "Current client: %s (%s)", this->api_client_->get_name().c_str(), | ||||||
|     ESP_LOGE(TAG, "New client: %s (%s)", client->get_name(), client->get_peername()); |              this->api_client_->get_peername().c_str()); | ||||||
|  |     ESP_LOGE(TAG, "New client: %s (%s)", client->get_name().c_str(), client->get_peername().c_str()); | ||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -829,15 +829,28 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     auto call = obj->make_call(); |     auto call = obj->make_call(); | ||||||
|     if (match.method_equals("open")) { |  | ||||||
|       call.set_command_open(); |     // Lookup table for cover methods | ||||||
|     } else if (match.method_equals("close")) { |     static const struct { | ||||||
|       call.set_command_close(); |       const char *name; | ||||||
|     } else if (match.method_equals("stop")) { |       cover::CoverCall &(cover::CoverCall::*action)(); | ||||||
|       call.set_command_stop(); |     } METHODS[] = { | ||||||
|     } else if (match.method_equals("toggle")) { |         {"open", &cover::CoverCall::set_command_open}, | ||||||
|       call.set_command_toggle(); |         {"close", &cover::CoverCall::set_command_close}, | ||||||
|     } else if (!match.method_equals("set")) { |         {"stop", &cover::CoverCall::set_command_stop}, | ||||||
|  |         {"toggle", &cover::CoverCall::set_command_toggle}, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     bool found = false; | ||||||
|  |     for (const auto &method : METHODS) { | ||||||
|  |       if (match.method_equals(method.name)) { | ||||||
|  |         (call.*method.action)(); | ||||||
|  |         found = true; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!found && !match.method_equals("set")) { | ||||||
|       request->send(404); |       request->send(404); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| @@ -1483,15 +1496,28 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     auto call = obj->make_call(); |     auto call = obj->make_call(); | ||||||
|     if (match.method_equals("open")) { |  | ||||||
|       call.set_command_open(); |     // Lookup table for valve methods | ||||||
|     } else if (match.method_equals("close")) { |     static const struct { | ||||||
|       call.set_command_close(); |       const char *name; | ||||||
|     } else if (match.method_equals("stop")) { |       valve::ValveCall &(valve::ValveCall::*action)(); | ||||||
|       call.set_command_stop(); |     } METHODS[] = { | ||||||
|     } else if (match.method_equals("toggle")) { |         {"open", &valve::ValveCall::set_command_open}, | ||||||
|       call.set_command_toggle(); |         {"close", &valve::ValveCall::set_command_close}, | ||||||
|     } else if (!match.method_equals("set")) { |         {"stop", &valve::ValveCall::set_command_stop}, | ||||||
|  |         {"toggle", &valve::ValveCall::set_command_toggle}, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     bool found = false; | ||||||
|  |     for (const auto &method : METHODS) { | ||||||
|  |       if (match.method_equals(method.name)) { | ||||||
|  |         (call.*method.action)(); | ||||||
|  |         found = true; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!found && !match.method_equals("set")) { | ||||||
|       request->send(404); |       request->send(404); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| @@ -1555,17 +1581,28 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques | |||||||
|     auto call = obj->make_call(); |     auto call = obj->make_call(); | ||||||
|     parse_string_param_(request, "code", call, &decltype(call)::set_code); |     parse_string_param_(request, "code", call, &decltype(call)::set_code); | ||||||
|  |  | ||||||
|     if (match.method_equals("disarm")) { |     // Lookup table for alarm control panel methods | ||||||
|       call.disarm(); |     static const struct { | ||||||
|     } else if (match.method_equals("arm_away")) { |       const char *name; | ||||||
|       call.arm_away(); |       alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*action)(); | ||||||
|     } else if (match.method_equals("arm_home")) { |     } METHODS[] = { | ||||||
|       call.arm_home(); |         {"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm}, | ||||||
|     } else if (match.method_equals("arm_night")) { |         {"arm_away", &alarm_control_panel::AlarmControlPanelCall::arm_away}, | ||||||
|       call.arm_night(); |         {"arm_home", &alarm_control_panel::AlarmControlPanelCall::arm_home}, | ||||||
|     } else if (match.method_equals("arm_vacation")) { |         {"arm_night", &alarm_control_panel::AlarmControlPanelCall::arm_night}, | ||||||
|       call.arm_vacation(); |         {"arm_vacation", &alarm_control_panel::AlarmControlPanelCall::arm_vacation}, | ||||||
|     } else { |     }; | ||||||
|  |  | ||||||
|  |     bool found = false; | ||||||
|  |     for (const auto &method : METHODS) { | ||||||
|  |       if (match.method_equals(method.name)) { | ||||||
|  |         (call.*method.action)(); | ||||||
|  |         found = true; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (!found) { | ||||||
|       request->send(404); |       request->send(404); | ||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
| @@ -1731,24 +1768,24 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { | |||||||
|   const auto &url = request->url(); |   const auto &url = request->url(); | ||||||
|   const auto method = request->method(); |   const auto method = request->method(); | ||||||
|  |  | ||||||
|   // Simple URL checks |   // Static URL checks | ||||||
|   if (url == "/") |   static const char *const STATIC_URLS[] = { | ||||||
|     return true; |       "/", | ||||||
|  |  | ||||||
| #ifdef USE_ARDUINO | #ifdef USE_ARDUINO | ||||||
|   if (url == "/events") |       "/events", | ||||||
|     return true; |  | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
| #ifdef USE_WEBSERVER_CSS_INCLUDE | #ifdef USE_WEBSERVER_CSS_INCLUDE | ||||||
|   if (url == "/0.css") |       "/0.css", | ||||||
|     return true; |  | ||||||
| #endif | #endif | ||||||
|  |  | ||||||
| #ifdef USE_WEBSERVER_JS_INCLUDE | #ifdef USE_WEBSERVER_JS_INCLUDE | ||||||
|   if (url == "/0.js") |       "/0.js", | ||||||
|     return true; |  | ||||||
| #endif | #endif | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   for (const auto &static_url : STATIC_URLS) { | ||||||
|  |     if (url == static_url) | ||||||
|  |       return true; | ||||||
|  |   } | ||||||
|  |  | ||||||
| #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS | #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS | ||||||
|   if (method == HTTP_OPTIONS && request->hasHeader(HEADER_CORS_REQ_PNA)) |   if (method == HTTP_OPTIONS && request->hasHeader(HEADER_CORS_REQ_PNA)) | ||||||
| @@ -1768,92 +1805,87 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { | |||||||
|   if (!is_get_or_post) |   if (!is_get_or_post) | ||||||
|     return false; |     return false; | ||||||
|  |  | ||||||
|   // GET-only components |   // Use lookup tables for domain checks | ||||||
|   if (is_get) { |   static const char *const GET_ONLY_DOMAINS[] = { | ||||||
| #ifdef USE_SENSOR | #ifdef USE_SENSOR | ||||||
|     if (match.domain_equals("sensor")) |       "sensor", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_BINARY_SENSOR | #ifdef USE_BINARY_SENSOR | ||||||
|     if (match.domain_equals("binary_sensor")) |       "binary_sensor", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_TEXT_SENSOR | #ifdef USE_TEXT_SENSOR | ||||||
|     if (match.domain_equals("text_sensor")) |       "text_sensor", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_EVENT | #ifdef USE_EVENT | ||||||
|     if (match.domain_equals("event")) |       "event", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
|   } |   }; | ||||||
|  |  | ||||||
|   // GET+POST components |   static const char *const GET_POST_DOMAINS[] = { | ||||||
|   if (is_get_or_post) { |  | ||||||
| #ifdef USE_SWITCH | #ifdef USE_SWITCH | ||||||
|     if (match.domain_equals("switch")) |       "switch", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_BUTTON | #ifdef USE_BUTTON | ||||||
|     if (match.domain_equals("button")) |       "button", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_FAN | #ifdef USE_FAN | ||||||
|     if (match.domain_equals("fan")) |       "fan", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_LIGHT | #ifdef USE_LIGHT | ||||||
|     if (match.domain_equals("light")) |       "light", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_COVER | #ifdef USE_COVER | ||||||
|     if (match.domain_equals("cover")) |       "cover", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_NUMBER | #ifdef USE_NUMBER | ||||||
|     if (match.domain_equals("number")) |       "number", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_DATETIME_DATE | #ifdef USE_DATETIME_DATE | ||||||
|     if (match.domain_equals("date")) |       "date", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_DATETIME_TIME | #ifdef USE_DATETIME_TIME | ||||||
|     if (match.domain_equals("time")) |       "time", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_DATETIME_DATETIME | #ifdef USE_DATETIME_DATETIME | ||||||
|     if (match.domain_equals("datetime")) |       "datetime", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_TEXT | #ifdef USE_TEXT | ||||||
|     if (match.domain_equals("text")) |       "text", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_SELECT | #ifdef USE_SELECT | ||||||
|     if (match.domain_equals("select")) |       "select", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_CLIMATE | #ifdef USE_CLIMATE | ||||||
|     if (match.domain_equals("climate")) |       "climate", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_LOCK | #ifdef USE_LOCK | ||||||
|     if (match.domain_equals("lock")) |       "lock", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_VALVE | #ifdef USE_VALVE | ||||||
|     if (match.domain_equals("valve")) |       "valve", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_ALARM_CONTROL_PANEL | #ifdef USE_ALARM_CONTROL_PANEL | ||||||
|     if (match.domain_equals("alarm_control_panel")) |       "alarm_control_panel", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
| #ifdef USE_UPDATE | #ifdef USE_UPDATE | ||||||
|     if (match.domain_equals("update")) |       "update", | ||||||
|       return true; |  | ||||||
| #endif | #endif | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Check GET-only domains | ||||||
|  |   if (is_get) { | ||||||
|  |     for (const auto &domain : GET_ONLY_DOMAINS) { | ||||||
|  |       if (match.domain_equals(domain)) | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Check GET+POST domains | ||||||
|  |   if (is_get_or_post) { | ||||||
|  |     for (const auto &domain : GET_POST_DOMAINS) { | ||||||
|  |       if (match.domain_equals(domain)) | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return false; |   return false; | ||||||
|   | |||||||
| @@ -192,7 +192,7 @@ def install_custom_components_meta_finder(): | |||||||
|     install_meta_finder(custom_components_dir) |     install_meta_finder(custom_components_dir) | ||||||
|  |  | ||||||
|  |  | ||||||
| def _lookup_module(domain, exception): | def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None: | ||||||
|     if domain in _COMPONENT_CACHE: |     if domain in _COMPONENT_CACHE: | ||||||
|         return _COMPONENT_CACHE[domain] |         return _COMPONENT_CACHE[domain] | ||||||
|  |  | ||||||
| @@ -219,16 +219,16 @@ def _lookup_module(domain, exception): | |||||||
|     return manif |     return manif | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_component(domain, exception=False): | def get_component(domain: str, exception: bool = False) -> ComponentManifest | None: | ||||||
|     assert "." not in domain |     assert "." not in domain | ||||||
|     return _lookup_module(domain, exception) |     return _lookup_module(domain, exception) | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_platform(domain, platform): | def get_platform(domain: str, platform: str) -> ComponentManifest | None: | ||||||
|     full = f"{platform}.{domain}" |     full = f"{platform}.{domain}" | ||||||
|     return _lookup_module(full, False) |     return _lookup_module(full, False) | ||||||
|  |  | ||||||
|  |  | ||||||
| _COMPONENT_CACHE = {} | _COMPONENT_CACHE: dict[str, ComponentManifest] = {} | ||||||
| CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() | CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve() | ||||||
| _COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) | _COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config) | ||||||
|   | |||||||
							
								
								
									
										194
									
								
								tests/component_tests/psram/test_psram.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								tests/component_tests/psram/test_psram.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,194 @@ | |||||||
|  | """Tests for PSRAM component.""" | ||||||
|  |  | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from esphome.components.esp32.const import ( | ||||||
|  |     KEY_VARIANT, | ||||||
|  |     VARIANT_ESP32, | ||||||
|  |     VARIANT_ESP32C2, | ||||||
|  |     VARIANT_ESP32C3, | ||||||
|  |     VARIANT_ESP32C5, | ||||||
|  |     VARIANT_ESP32C6, | ||||||
|  |     VARIANT_ESP32H2, | ||||||
|  |     VARIANT_ESP32P4, | ||||||
|  |     VARIANT_ESP32S2, | ||||||
|  |     VARIANT_ESP32S3, | ||||||
|  | ) | ||||||
|  | import esphome.config_validation as cv | ||||||
|  | from esphome.const import CONF_ESPHOME, PlatformFramework | ||||||
|  | from tests.component_tests.types import SetCoreConfigCallable | ||||||
|  |  | ||||||
|  | UNSUPPORTED_PSRAM_VARIANTS = [ | ||||||
|  |     VARIANT_ESP32C2, | ||||||
|  |     VARIANT_ESP32C3, | ||||||
|  |     VARIANT_ESP32C5, | ||||||
|  |     VARIANT_ESP32C6, | ||||||
|  |     VARIANT_ESP32H2, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | SUPPORTED_PSRAM_VARIANTS = [ | ||||||
|  |     VARIANT_ESP32, | ||||||
|  |     VARIANT_ESP32S2, | ||||||
|  |     VARIANT_ESP32S3, | ||||||
|  |     VARIANT_ESP32P4, | ||||||
|  | ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     ("config", "error_match"), | ||||||
|  |     [ | ||||||
|  |         pytest.param( | ||||||
|  |             {}, | ||||||
|  |             r"PSRAM is not supported on this chip", | ||||||
|  |             id="psram_not_supported", | ||||||
|  |         ), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | @pytest.mark.parametrize("variant", UNSUPPORTED_PSRAM_VARIANTS) | ||||||
|  | def test_psram_configuration_errors_unsupported_variants( | ||||||
|  |     config: Any, | ||||||
|  |     error_match: str, | ||||||
|  |     variant: str, | ||||||
|  |     set_core_config: SetCoreConfigCallable, | ||||||
|  | ) -> None: | ||||||
|  |     set_core_config( | ||||||
|  |         PlatformFramework.ESP32_IDF, | ||||||
|  |         platform_data={KEY_VARIANT: variant}, | ||||||
|  |         full_config={CONF_ESPHOME: {}}, | ||||||
|  |     ) | ||||||
|  |     """Test detection of invalid PSRAM configuration on unsupported variants.""" | ||||||
|  |     from esphome.components.psram import CONFIG_SCHEMA | ||||||
|  |  | ||||||
|  |     with pytest.raises(cv.Invalid, match=error_match): | ||||||
|  |         CONFIG_SCHEMA(config) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize("variant", SUPPORTED_PSRAM_VARIANTS) | ||||||
|  | def test_psram_configuration_valid_supported_variants( | ||||||
|  |     variant: str, | ||||||
|  |     set_core_config: SetCoreConfigCallable, | ||||||
|  | ) -> None: | ||||||
|  |     set_core_config( | ||||||
|  |         PlatformFramework.ESP32_IDF, | ||||||
|  |         platform_data={KEY_VARIANT: variant}, | ||||||
|  |         full_config={ | ||||||
|  |             CONF_ESPHOME: {}, | ||||||
|  |             "esp32": { | ||||||
|  |                 "variant": variant, | ||||||
|  |                 "cpu_frequency": "160MHz", | ||||||
|  |                 "framework": {"type": "esp-idf"}, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     """Test that PSRAM configuration is valid on supported variants.""" | ||||||
|  |     from esphome.components.psram import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA | ||||||
|  |  | ||||||
|  |     # This should not raise an exception | ||||||
|  |     config = CONFIG_SCHEMA({}) | ||||||
|  |     FINAL_VALIDATE_SCHEMA(config) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _setup_psram_final_validation_test( | ||||||
|  |     esp32_config: dict, | ||||||
|  |     set_core_config: SetCoreConfigCallable, | ||||||
|  |     set_component_config: Any, | ||||||
|  | ) -> str: | ||||||
|  |     """Helper function to set up ESP32 configuration for PSRAM final validation tests.""" | ||||||
|  |     # Use ESP32S3 for schema validation to allow all options, then override for final validation | ||||||
|  |     schema_variant = "ESP32S3" | ||||||
|  |     final_variant = esp32_config.get("variant", "ESP32S3") | ||||||
|  |     full_esp32_config = { | ||||||
|  |         "variant": final_variant, | ||||||
|  |         "cpu_frequency": esp32_config.get("cpu_frequency", "240MHz"), | ||||||
|  |         "framework": {"type": "esp-idf"}, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     set_core_config( | ||||||
|  |         PlatformFramework.ESP32_IDF, | ||||||
|  |         platform_data={KEY_VARIANT: schema_variant}, | ||||||
|  |         full_config={ | ||||||
|  |             CONF_ESPHOME: {}, | ||||||
|  |             "esp32": full_esp32_config, | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |     set_component_config("esp32", full_esp32_config) | ||||||
|  |  | ||||||
|  |     return final_variant | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     ("config", "esp32_config", "expect_error", "error_match"), | ||||||
|  |     [ | ||||||
|  |         pytest.param( | ||||||
|  |             {"speed": "120MHz"}, | ||||||
|  |             {"cpu_frequency": "160MHz"}, | ||||||
|  |             True, | ||||||
|  |             r"PSRAM 120MHz requires 240MHz CPU frequency", | ||||||
|  |             id="120mhz_requires_240mhz_cpu", | ||||||
|  |         ), | ||||||
|  |         pytest.param( | ||||||
|  |             {"mode": "octal"}, | ||||||
|  |             {"variant": "ESP32"}, | ||||||
|  |             True, | ||||||
|  |             r"Octal PSRAM is only supported on ESP32-S3", | ||||||
|  |             id="octal_mode_only_esp32s3", | ||||||
|  |         ), | ||||||
|  |         pytest.param( | ||||||
|  |             {"mode": "quad", "enable_ecc": True}, | ||||||
|  |             {}, | ||||||
|  |             True, | ||||||
|  |             r"ECC is only available in octal mode", | ||||||
|  |             id="ecc_only_in_octal_mode", | ||||||
|  |         ), | ||||||
|  |         pytest.param( | ||||||
|  |             {"speed": "120MHZ"}, | ||||||
|  |             {"cpu_frequency": "240MHZ"}, | ||||||
|  |             False, | ||||||
|  |             None, | ||||||
|  |             id="120mhz_with_240mhz_cpu", | ||||||
|  |         ), | ||||||
|  |         pytest.param( | ||||||
|  |             {"mode": "octal"}, | ||||||
|  |             {"variant": "ESP32S3"}, | ||||||
|  |             False, | ||||||
|  |             None, | ||||||
|  |             id="octal_mode_on_esp32s3", | ||||||
|  |         ), | ||||||
|  |         pytest.param( | ||||||
|  |             {"mode": "octal", "enable_ecc": True}, | ||||||
|  |             {"variant": "ESP32S3"}, | ||||||
|  |             False, | ||||||
|  |             None, | ||||||
|  |             id="ecc_in_octal_mode", | ||||||
|  |         ), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | def test_psram_final_validation( | ||||||
|  |     config: Any, | ||||||
|  |     esp32_config: dict, | ||||||
|  |     expect_error: bool, | ||||||
|  |     error_match: str | None, | ||||||
|  |     set_core_config: SetCoreConfigCallable, | ||||||
|  |     set_component_config: Any, | ||||||
|  | ) -> None: | ||||||
|  |     """Test PSRAM final validation for both error and valid cases.""" | ||||||
|  |     from esphome.components.psram import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA | ||||||
|  |     from esphome.core import CORE | ||||||
|  |  | ||||||
|  |     final_variant = _setup_psram_final_validation_test( | ||||||
|  |         esp32_config, set_core_config, set_component_config | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     validated_config = CONFIG_SCHEMA(config) | ||||||
|  |  | ||||||
|  |     # Update CORE variant for final validation | ||||||
|  |     CORE.data["esp32"][KEY_VARIANT] = final_variant | ||||||
|  |  | ||||||
|  |     if expect_error: | ||||||
|  |         with pytest.raises(cv.Invalid, match=error_match): | ||||||
|  |             FINAL_VALIDATE_SCHEMA(validated_config) | ||||||
|  |     else: | ||||||
|  |         # This should not raise an exception | ||||||
|  |         FINAL_VALIDATE_SCHEMA(validated_config) | ||||||
| @@ -5,6 +5,9 @@ substitutions: | |||||||
|   var21: '79' |   var21: '79' | ||||||
|   value: 33 |   value: 33 | ||||||
|   values: 44 |   values: 44 | ||||||
|  |   position: | ||||||
|  |     x: 79 | ||||||
|  |     y: 82 | ||||||
|  |  | ||||||
| esphome: | esphome: | ||||||
|   name: test |   name: test | ||||||
| @@ -26,3 +29,7 @@ test_list: | |||||||
|   - Literal $values ${are not substituted} |   - Literal $values ${are not substituted} | ||||||
|   - ["list $value", "${is not}", "${substituted}"] |   - ["list $value", "${is not}", "${substituted}"] | ||||||
|   - {"$dictionary": "$value", "${is not}": "${substituted}"} |   - {"$dictionary": "$value", "${is not}": "${substituted}"} | ||||||
|  |   - |- | ||||||
|  |     {{{ "x", "79"}, { "y", "82"}}} | ||||||
|  |   - '{{{"AA"}}}' | ||||||
|  |   - '"HELLO"' | ||||||
|   | |||||||
| @@ -8,6 +8,9 @@ substitutions: | |||||||
|   var21: "79" |   var21: "79" | ||||||
|   value: 33 |   value: 33 | ||||||
|   values: 44 |   values: 44 | ||||||
|  |   position: | ||||||
|  |     x: 79 | ||||||
|  |     y: 82 | ||||||
|  |  | ||||||
| test_list: | test_list: | ||||||
|   - "$var1" |   - "$var1" | ||||||
| @@ -27,3 +30,7 @@ test_list: | |||||||
|   - !literal Literal $values ${are not substituted} |   - !literal Literal $values ${are not substituted} | ||||||
|   - !literal ["list $value", "${is not}", "${substituted}"] |   - !literal ["list $value", "${is not}", "${substituted}"] | ||||||
|   - !literal {"$dictionary": "$value", "${is not}": "${substituted}"} |   - !literal {"$dictionary": "$value", "${is not}": "${substituted}"} | ||||||
|  |   - |- # Test parsing things that look like a python set of sets when rendered: | ||||||
|  |     {{{ "x", "${ position.x }"}, { "y", "${ position.y }"}}} | ||||||
|  |   - ${ '{{{"AA"}}}' } | ||||||
|  |   - ${ '"HELLO"' } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user