From f8d98ac4948b0b14702cd5a79fe2967dce452e6e Mon Sep 17 00:00:00 2001 From: Nikolay Vasilchuk Date: Sat, 9 Nov 2019 20:37:52 +0300 Subject: [PATCH] http_request component (#719) * It works * Template doesn't work * Template fix * CA Certificate untested * ESP32 done * URL validation * Lint fix * Lint fix (2) * Lint fix (<3) * Support unsecure requests with framework >=2.5.0 * Removed fingerprint, payload renamed to body * Removed add_extra * Review * Review * New HTTP methods * Check recommended version * Removed dead code * Small improvement * Small improvement * CONF_METHOD from const * JSON support * New JSON syntax * Templatable headers * verify_ssl param * verify_ssl param (fix) * Lint * nolint * JSON string_strict * Two json syntax * Lambda url fix validation * CI fix * CI fix --- esphome/automation.py | 8 +- esphome/components/http_request/__init__.py | 148 ++++++++++++++++++ .../components/http_request/http_request.cpp | 63 ++++++++ .../components/http_request/http_request.h | 121 ++++++++++++++ esphome/const.py | 2 +- esphome/core_config.py | 4 +- 6 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 esphome/components/http_request/__init__.py create mode 100644 esphome/components/http_request/http_request.cpp create mode 100644 esphome/components/http_request/http_request.h diff --git a/esphome/automation.py b/esphome/automation.py index 9f9ad35520..f758191268 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -7,13 +7,17 @@ from esphome.util import Registry def maybe_simple_id(*validators): + return maybe_conf(CONF_ID, *validators) + + +def maybe_conf(conf, *validators): validator = cv.All(*validators) def validate(value): if isinstance(value, dict): return validator(value) - with cv.remove_prepend_path([CONF_ID]): - return validator({CONF_ID: value}) + with cv.remove_prepend_path([conf]): + return validator({conf: value}) return validate diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py new file mode 100644 index 0000000000..23fc38ba40 --- /dev/null +++ b/esphome/components/http_request/__init__.py @@ -0,0 +1,148 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation +from esphome.const import CONF_ID, CONF_TIMEOUT, CONF_ESPHOME, CONF_METHOD, \ + CONF_ARDUINO_VERSION, ARDUINO_VERSION_ESP8266_2_5_0 +from esphome.core import CORE, Lambda +from esphome.core_config import PLATFORMIO_ESP8266_LUT +from esphome.py_compat import IS_PY3 + +if IS_PY3: + import urllib.parse as urlparse # pylint: disable=no-name-in-module,import-error +else: + import urlparse # pylint: disable=import-error + +DEPENDENCIES = ['network'] +AUTO_LOAD = ['json'] + +http_request_ns = cg.esphome_ns.namespace('http_request') +HttpRequestComponent = http_request_ns.class_('HttpRequestComponent', cg.Component) +HttpRequestSendAction = http_request_ns.class_('HttpRequestSendAction', automation.Action) + +CONF_URL = 'url' +CONF_HEADERS = 'headers' +CONF_USERAGENT = 'useragent' +CONF_BODY = 'body' +CONF_JSON = 'json' +CONF_VERIFY_SSL = 'verify_ssl' + + +def validate_framework(config): + if CORE.is_esp32: + return config + + version = 'RECOMMENDED' + if CONF_ARDUINO_VERSION in CORE.raw_config[CONF_ESPHOME]: + version = CORE.raw_config[CONF_ESPHOME][CONF_ARDUINO_VERSION] + + if version in ['LATEST', 'DEV']: + return config + + framework = PLATFORMIO_ESP8266_LUT[version] if version in PLATFORMIO_ESP8266_LUT else version + if framework < ARDUINO_VERSION_ESP8266_2_5_0: + raise cv.Invalid('This component is not supported on arduino framework version below 2.5.0') + return config + + +def validate_url(value): + value = cv.string(value) + try: + parsed = list(urlparse.urlparse(value)) + except Exception: + raise cv.Invalid('Invalid URL') + + if not parsed[0] or not parsed[1]: + raise cv.Invalid('URL must have a URL scheme and host') + + if parsed[0] not in ['http', 'https']: + raise cv.Invalid('Scheme must be http or https') + + if not parsed[2]: + parsed[2] = '/' + + return urlparse.urlunparse(parsed) + + +def validate_secure_url(config): + url_ = config[CONF_URL] + if config.get(CONF_VERIFY_SSL) and not isinstance(url_, Lambda) \ + and url_.lower().startswith('https:'): + raise cv.Invalid('Currently ESPHome doesn\'t support SSL verification. ' + 'Set \'verify_ssl: false\' to make insecure HTTPS requests.') + return config + + +CONFIG_SCHEMA = cv.Schema({ + cv.GenerateID(): cv.declare_id(HttpRequestComponent), + cv.Optional(CONF_USERAGENT, 'ESPHome'): cv.string, + cv.Optional(CONF_TIMEOUT, default='5s'): cv.positive_time_period_milliseconds, +}).add_extra(validate_framework).extend(cv.COMPONENT_SCHEMA) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + cg.add(var.set_timeout(config[CONF_TIMEOUT])) + cg.add(var.set_useragent(config[CONF_USERAGENT])) + yield cg.register_component(var, config) + + +HTTP_REQUEST_ACTION_SCHEMA = cv.Schema({ + cv.GenerateID(): cv.use_id(HttpRequestComponent), + cv.Required(CONF_URL): cv.templatable(validate_url), + cv.Optional(CONF_HEADERS): cv.All(cv.Schema({cv.string: cv.templatable(cv.string)})), + cv.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, +}).add_extra(validate_secure_url) +HTTP_REQUEST_GET_ACTION_SCHEMA = automation.maybe_conf( + CONF_URL, HTTP_REQUEST_ACTION_SCHEMA.extend({ + cv.Optional(CONF_METHOD, default='GET'): cv.one_of('GET', upper=True), + }) +) +HTTP_REQUEST_POST_ACTION_SCHEMA = automation.maybe_conf( + CONF_URL, HTTP_REQUEST_ACTION_SCHEMA.extend({ + cv.Optional(CONF_METHOD, default='POST'): cv.one_of('POST', upper=True), + cv.Exclusive(CONF_BODY, 'body'): cv.templatable(cv.string), + cv.Exclusive(CONF_JSON, 'body'): cv.Any( + cv.lambda_, cv.All(cv.Schema({cv.string: cv.templatable(cv.string_strict)})), + ), + }) +) +HTTP_REQUEST_SEND_ACTION_SCHEMA = HTTP_REQUEST_ACTION_SCHEMA.extend({ + cv.Required(CONF_METHOD): cv.one_of('GET', 'POST', 'PUT', 'DELETE', 'PATCH', upper=True), + cv.Exclusive(CONF_BODY, 'body'): cv.templatable(cv.string), + cv.Exclusive(CONF_JSON, 'body'): cv.Any( + cv.lambda_, cv.All(cv.Schema({cv.string: cv.templatable(cv.string_strict)})), + ), +}) + + +@automation.register_action('http_request.get', HttpRequestSendAction, + HTTP_REQUEST_GET_ACTION_SCHEMA) +@automation.register_action('http_request.post', HttpRequestSendAction, + HTTP_REQUEST_POST_ACTION_SCHEMA) +@automation.register_action('http_request.send', HttpRequestSendAction, + HTTP_REQUEST_SEND_ACTION_SCHEMA) +def http_request_action_to_code(config, action_id, template_arg, args): + paren = yield cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + + template_ = yield cg.templatable(config[CONF_URL], args, cg.const_char_ptr) + cg.add(var.set_url(template_)) + cg.add(var.set_method(config[CONF_METHOD])) + if CONF_BODY in config: + template_ = yield cg.templatable(config[CONF_BODY], args, cg.std_string) + cg.add(var.set_body(template_)) + if CONF_JSON in config: + json_ = config[CONF_JSON] + if isinstance(json_, Lambda): + args_ = args + [(cg.JsonObjectRef, 'root')] + lambda_ = yield cg.process_lambda(json_, args_, return_type=cg.void) + cg.add(var.set_json(lambda_)) + else: + for key in json_: + template_ = yield cg.templatable(json_[key], args, cg.std_string) + cg.add(var.add_json(key, template_)) + for key in config.get(CONF_HEADERS, []): + template_ = yield cg.templatable(config[CONF_HEADERS][key], args, cg.const_char_ptr) + cg.add(var.add_header(key, template_)) + + yield var diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp new file mode 100644 index 0000000000..9df7cf7913 --- /dev/null +++ b/esphome/components/http_request/http_request.cpp @@ -0,0 +1,63 @@ +#include "http_request.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace http_request { + +static const char *TAG = "http_request"; + +void HttpRequestComponent::dump_config() { + ESP_LOGCONFIG(TAG, "HTTP Request:"); + ESP_LOGCONFIG(TAG, " Timeout: %ums", this->timeout_); + ESP_LOGCONFIG(TAG, " User-Agent: %s", this->useragent_); +} + +void HttpRequestComponent::send() { + bool begin_status = false; +#ifdef ARDUINO_ARCH_ESP32 + begin_status = this->client_.begin(this->url_); +#endif +#ifdef ARDUINO_ARCH_ESP8266 +#ifndef CLANG_TIDY + begin_status = this->client_.begin(*this->wifi_client_, this->url_); + this->client_.setFollowRedirects(true); + this->client_.setRedirectLimit(3); +#endif +#endif + + if (!begin_status) { + this->client_.end(); + this->status_set_warning(); + ESP_LOGW(TAG, "HTTP Request failed at the begin phase. Please check the configuration"); + return; + } + + this->client_.setTimeout(this->timeout_); + if (this->useragent_ != nullptr) { + this->client_.setUserAgent(this->useragent_); + } + for (const auto &header : this->headers_) { + this->client_.addHeader(header.name, header.value, false, true); + } + + int http_code = this->client_.sendRequest(this->method_, this->body_.c_str()); + this->client_.end(); + + if (http_code < 0) { + ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", this->url_, HTTPClient::errorToString(http_code).c_str()); + this->status_set_warning(); + return; + } + + if (http_code < 200 || http_code >= 300) { + ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Code: %d", this->url_, http_code); + this->status_set_warning(); + return; + } + + this->status_clear_warning(); + ESP_LOGD(TAG, "HTTP Request completed; URL: %s; Code: %d", this->url_, http_code); +} + +} // namespace http_request +} // namespace esphome diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h new file mode 100644 index 0000000000..4f772d6826 --- /dev/null +++ b/esphome/components/http_request/http_request.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/json/json_util.h" + +#ifdef ARDUINO_ARCH_ESP32 +#include +#endif +#ifdef ARDUINO_ARCH_ESP8266 +#include +#include +#endif + +namespace esphome { +namespace http_request { + +struct Header { + const char *name; + const char *value; +}; + +class HttpRequestComponent : public Component { + public: + void setup() override { +#ifdef ARDUINO_ARCH_ESP8266 + this->wifi_client_ = new BearSSL::WiFiClientSecure(); + this->wifi_client_->setInsecure(); +#endif + } + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + void set_url(const char *url) { this->url_ = url; } + void set_method(const char *method) { this->method_ = method; } + void set_useragent(const char *useragent) { this->useragent_ = useragent; } + void set_timeout(uint16_t timeout) { this->timeout_ = timeout; } + void set_body(std::string body) { this->body_ = body; } + void set_headers(std::list
headers) { this->headers_ = headers; } + void send(); + + protected: + HTTPClient client_{}; + const char *url_; + const char *method_; + const char *useragent_{nullptr}; + uint16_t timeout_{5000}; + std::string body_; + std::list
headers_; +#ifdef ARDUINO_ARCH_ESP8266 + BearSSL::WiFiClientSecure *wifi_client_; +#endif +}; + +template class HttpRequestSendAction : public Action { + public: + HttpRequestSendAction(HttpRequestComponent *parent) : parent_(parent) {} + TEMPLATABLE_VALUE(const char *, url) + TEMPLATABLE_VALUE(const char *, method) + TEMPLATABLE_VALUE(std::string, body) + TEMPLATABLE_VALUE(const char *, useragent) + TEMPLATABLE_VALUE(uint16_t, timeout) + + void add_header(const char *key, TemplatableValue value) { this->headers_.insert({key, value}); } + + void add_json(const char *key, TemplatableValue value) { this->json_.insert({key, value}); } + + void set_json(std::function json_func) { this->json_func_ = json_func; } + + void play(Ts... x) override { + this->parent_->set_url(this->url_.value(x...)); + this->parent_->set_method(this->method_.value(x...)); + if (this->body_.has_value()) { + this->parent_->set_body(this->body_.value(x...)); + } + if (!this->json_.empty()) { + auto f = std::bind(&HttpRequestSendAction::encode_json_, this, x..., std::placeholders::_1); + this->parent_->set_body(json::build_json(f)); + } + if (this->json_func_ != nullptr) { + auto f = std::bind(&HttpRequestSendAction::encode_json_func_, this, x..., std::placeholders::_1); + this->parent_->set_body(json::build_json(f)); + } + if (this->useragent_.has_value()) { + this->parent_->set_useragent(this->useragent_.value(x...)); + } + if (this->timeout_.has_value()) { + this->parent_->set_timeout(this->timeout_.value(x...)); + } + if (!this->headers_.empty()) { + std::list
headers; + for (const auto &item : this->headers_) { + auto val = item.second; + Header header; + header.name = item.first; + header.value = val.value(x...); + headers.push_back(header); + } + this->parent_->set_headers(headers); + } + this->parent_->send(); + } + + protected: + void encode_json_(Ts... x, JsonObject &root) { + for (const auto &item : this->json_) { + auto val = item.second; + root[item.first] = val.value(x...); + } + } + void encode_json_func_(Ts... x, JsonObject &root) { this->json_func_(x..., root); } + HttpRequestComponent *parent_; + std::map> headers_{}; + std::map> json_{}; + std::function json_func_{nullptr}; +}; + +} // namespace http_request +} // namespace esphome diff --git a/esphome/const.py b/esphome/const.py index 2af143984c..5867666576 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -20,7 +20,7 @@ ARDUINO_VERSION_ESP32_1_0_3 = 'espressif32@1.10.0' ARDUINO_VERSION_ESP32_1_0_4 = 'espressif32@1.11.0' ARDUINO_VERSION_ESP8266_DEV = 'https://github.com/platformio/platform-espressif8266.git#feature' \ '/stage' -ARDUINO_VERSION_ESP8266_2_5_0 = 'espressif8266@2.0.0' +ARDUINO_VERSION_ESP8266_2_5_0 = 'espressif8266@2.0.1' ARDUINO_VERSION_ESP8266_2_5_1 = 'espressif8266@2.1.0' ARDUINO_VERSION_ESP8266_2_5_2 = 'espressif8266@2.2.3' ARDUINO_VERSION_ESP8266_2_3_0 = 'espressif8266@1.5.0' diff --git a/esphome/core_config.py b/esphome/core_config.py index f63d2e17e3..f7149db797 100644 --- a/esphome/core_config.py +++ b/esphome/core_config.py @@ -76,7 +76,7 @@ def validate_arduino_version(value): if VERSION_REGEX.match(value) is not None and value_ not in PLATFORMIO_ESP8266_LUT: raise cv.Invalid("Unfortunately the arduino framework version '{}' is unsupported " "at this time. You can override this by manually using " - "espressif8266@") + "espressif8266@".format(value)) if value_ in PLATFORMIO_ESP8266_LUT: return PLATFORMIO_ESP8266_LUT[value_] return value @@ -84,7 +84,7 @@ def validate_arduino_version(value): if VERSION_REGEX.match(value) is not None and value_ not in PLATFORMIO_ESP32_LUT: raise cv.Invalid("Unfortunately the arduino framework version '{}' is unsupported " "at this time. You can override this by manually using " - "espressif32@") + "espressif32@".format(value)) if value_ in PLATFORMIO_ESP32_LUT: return PLATFORMIO_ESP32_LUT[value_] return value