From 8e80a7786c2b34f35486a28c32756e4fffcccb76 Mon Sep 17 00:00:00 2001 From: Max Prokhorov Date: Sat, 5 Dec 2020 14:14:38 +0300 Subject: [PATCH] api: rework plain and JSON implementations (#2405) - match paths through a custom AsyncWebHandler instead of using generic not-found fallback handler - allow MQTT-like patterns when registering paths (`simple/path`, `path/+/something`, `path/#`) Replaces `relay/0`, `relay/1` etc. with `relay/+`. Magnitudes are plain paths, but using `/+` in case there's more than 1 magnitude of the same type. - restore `std::function` as callback container (no more single-byte arg nonsense). Still, limit to 1 type per handler type - adds JSON handlers which will receive JsonObject root as both input and output. Same logic as plain - GET returns resource data, PUT updates it. - breaking change to `apiAuthenticate(request)`, it no longer will do `request->send(403)` and expect this to be handled externally. - allow `Api-Key` header containing the key, works for both GET & PUT plain requests. The only way to set apikey for JSON. - add `ApiRequest::param` to retrieve both GET and PUT params (aka args), remove ApiBuffer - remove `API_BUFFER_SIZE`. Allow custom form-data key=value pairs for requests, allow to send basic `String`. - add `API_JSON_BUFFER_SIZE` for the JSON buffer (both input and output) - `/apis` replaced with `/api/list`, no longer uses custom handler and is an `apiRegister` callback - `/api/rpc` custom handler replaced with an `apiRegister` callback WIP further down: - no more `webLog` for API requests, unless `webAccessLog` / `WEB_ACCESS_LOG` is set to `1`. This also needs to happen to the other handlers. - migrate to ArduinoJson v6, since it become apparent it is actually a good upgrade :) - actually make use of JSON endpoints more, right now it's just existing GET for sensors and relays - fork ESPAsyncWebServer to cleanup path parsing and temporary objects attached to the request (also, fix things a lot of things based on PRs there...) --- code/espurna/alexa.cpp | 1 + code/espurna/api.cpp | 773 +++++++++++++++++++++++++++------- code/espurna/api.h | 80 +--- code/espurna/api_common.cpp | 37 +- code/espurna/api_impl.h | 182 ++++++++ code/espurna/config/general.h | 14 +- code/espurna/encoder.cpp | 5 + code/espurna/encoder.h | 5 - code/espurna/light.cpp | 210 +++++---- code/espurna/light.h | 8 +- code/espurna/ota_web.cpp | 2 +- code/espurna/prometheus.cpp | 38 +- code/espurna/relay.cpp | 272 +++++++----- code/espurna/rfbridge.cpp | 41 +- code/espurna/sensor.cpp | 100 +++-- code/espurna/ssdp.cpp | 2 +- code/espurna/terminal.cpp | 68 ++- code/espurna/web.cpp | 25 +- code/espurna/web.h | 2 +- code/espurna/ws.cpp | 4 +- 20 files changed, 1364 insertions(+), 505 deletions(-) create mode 100644 code/espurna/api_impl.h diff --git a/code/espurna/alexa.cpp b/code/espurna/alexa.cpp index e06d06b4..6d07353d 100644 --- a/code/espurna/alexa.cpp +++ b/code/espurna/alexa.cpp @@ -12,6 +12,7 @@ Copyright (C) 2016-2019 by Xose Pérez #include +#include "api.h" #include "broker.h" #include "light.h" #include "relay.h" diff --git a/code/espurna/api.cpp b/code/espurna/api.cpp index 9aa66cbe..a07ea560 100644 --- a/code/espurna/api.cpp +++ b/code/espurna/api.cpp @@ -12,8 +12,6 @@ Copyright (C) 2016-2019 by Xose Pérez #if API_SUPPORT -#include - #include "system.h" #include "web.h" #include "rpc.h" @@ -21,252 +19,725 @@ Copyright (C) 2016-2019 by Xose Pérez #include #include -constexpr size_t ApiPathSizeMax { 64ul }; -std::vector _apis; +#include +#include +#include +#include -// ----------------------------------------------------------------------------- -// API // ----------------------------------------------------------------------------- -bool _asJson(AsyncWebServerRequest *request) { - bool asJson = false; - if (request->hasHeader("Accept")) { - AsyncWebHeader* h = request->getHeader("Accept"); - asJson = h->value().equals("application/json"); +PathParts::PathParts(const String& path) : + _path(path) +{ + if (!_path.length()) { + _ok = false; + return; + } + + PathPart::Type type { PathPart::Type::Unknown }; + size_t length { 0ul }; + size_t offset { 0ul }; + + const char* p { _path.c_str() }; + if (*p == '\0') { + goto error; + } + + _parts.reserve(std::count(_path.begin(), _path.end(), '/') + 1); + +start: + type = PathPart::Type::Unknown; + length = 0; + offset = p - _path.c_str(); + + switch (*p) { + case '+': + goto parse_single_wildcard; + case '#': + goto parse_multi_wildcard; + case '/': + default: + goto parse_value; } - return asJson; -} -void _onAPIsText(AsyncWebServerRequest *request) { - AsyncResponseStream *response = request->beginResponseStream("text/plain"); - char buffer[ApiPathSizeMax] = {0}; - for (auto& api : _apis) { - sprintf_P(buffer, PSTR("/api/%s\n"), api.path.c_str()); - response->write(buffer); +parse_value: + type = PathPart::Type::Value; + + switch (*p) { + case '+': + case '#': + goto error; + case '/': + case '\0': + goto push_result; + } + + ++p; + ++length; + + goto parse_value; + +parse_single_wildcard: + type = PathPart::Type::SingleWildcard; + + ++p; + switch (*p) { + case '/': + ++p; + case '\0': + goto push_result; + } + + goto error; + +parse_multi_wildcard: + type = PathPart::Type::MultiWildcard; + + ++p; + if (*p == '\0') { + goto push_result; + } + goto error; + +push_result: + emplace_back(type, offset, length); + if (*p == '/') { + ++p; + goto start; + } else if (*p != '\0') { + goto start; } - request->send(response); + goto success; + +error: + _ok = false; + _parts.clear(); + return; + +success: + _ok = true; } -constexpr size_t ApiJsonBufferSize = 1024; +// match when, for example, given the path 'topic/one/two/three' and pattern 'topic/+/two/+' + +bool PathParts::match(const PathParts& path) const { + if (!_ok || !path) { + return false; + } -void _onAPIsJson(AsyncWebServerRequest *request) { + auto lhs = begin(); + auto rhs = path.begin(); - DynamicJsonBuffer jsonBuffer(ApiJsonBufferSize); - JsonArray& root = jsonBuffer.createArray(); + auto lhs_end = end(); + auto rhs_end = path.end(); - char buffer[ApiPathSizeMax] = {0}; - for (auto& api : _apis) { - sprintf(buffer, "/api/%s", api.path.c_str()); - root.add(buffer); +loop: + if (lhs == lhs_end) { + goto check_end; } - AsyncResponseStream *response = request->beginResponseStream("application/json"); - root.printTo(*response); - request->send(response); + switch ((*lhs).type) { + case PathPart::Type::Value: + if ( + (rhs != rhs_end) + && ((*rhs).type == PathPart::Type::Value) + && ((*rhs).offset == (*lhs).offset) + && ((*rhs).length == (*lhs).length) + ) { + if (0 == std::memcmp( + _path.c_str() + (*lhs).offset, + path.path().c_str() + (*rhs).offset, + (*rhs).length)) + { + std::advance(lhs, 1); + std::advance(rhs, 1); + goto loop; + } + } + goto error; + + case PathPart::Type::SingleWildcard: + if ( + (rhs != rhs_end) + && ((*rhs).type == PathPart::Type::Value) + ) { + std::advance(lhs, 1); + std::advance(rhs, 1); + goto loop; + } + goto error; + + case PathPart::Type::MultiWildcard: + if (std::next(lhs) == lhs_end) { + while (rhs != rhs_end) { + if ((*rhs).type != PathPart::Type::Value) { + goto error; + } + std::advance(rhs, 1); + } + lhs = lhs_end; + break; + } + goto error; + case PathPart::Type::Unknown: + goto error; + }; + +check_end: + if ((lhs == lhs_end) && (rhs == rhs_end)) { + return true; + } + +error: + return false; } -void _onAPIs(AsyncWebServerRequest *request) { +String ApiRequest::wildcard(int index) const { + if (index < 0) { + index = std::abs(index + 1); + } - webLog(request); - if (!apiAuthenticate(request)) return; + if (std::abs(index) >= _pattern.parts().size()) { + return _empty_string(); + } - bool asJson = _asJson(request); + int counter { 0 }; + auto& pattern = _pattern.parts(); - String output; - if (asJson) { - _onAPIsJson(request); - } else { - _onAPIsText(request); + for (unsigned int part = 0; part < pattern.size(); ++part) { + auto& lhs = pattern[part]; + if (PathPart::Type::SingleWildcard == lhs.type) { + if (counter == index) { + auto& rhs = _parts.parts()[part]; + return _parts.path().substring(rhs.offset, rhs.offset + rhs.length); + } + ++counter; + } } + return _empty_string(); } -void _onRPC(AsyncWebServerRequest *request) { +size_t ApiRequest::wildcards() const { + size_t result { 0ul }; + for (auto& part : _pattern) { + if (PathPart::Type::SingleWildcard == part.type) { + ++result; + } + } - webLog(request); - if (!apiAuthenticate(request)) return; + return result; +} - //bool asJson = _asJson(request); - int response = 404; +// ----------------------------------------------------------------------------- - if (request->hasParam("action")) { +bool _apiAccepts(AsyncWebServerRequest* request, const __FlashStringHelper* str) { + auto* header = request->getHeader(F("Accept")); + if (header) { + return + (header->value().indexOf(F("*/*")) >= 0) + || (header->value().indexOf(str) >= 0); + } - AsyncWebParameter* p = request->getParam("action"); + return false; +} - const auto action = p->value(); - DEBUG_MSG_P(PSTR("[RPC] Action: %s\n"), action.c_str()); +bool _apiAcceptsText(AsyncWebServerRequest* request) { + return _apiAccepts(request, F("text/plain")); +} - if (rpcHandleAction(action)) { - response = 204; - } +bool _apiAcceptsJson(AsyncWebServerRequest* request) { + return _apiAccepts(request, F("application/json")); +} +bool _apiMatchHeader(AsyncWebServerRequest* request, const __FlashStringHelper* key, const __FlashStringHelper* value) { + auto* header = request->getHeader(key); + if (header) { + return header->value().equals(value); } - request->send(response); + return false; +} +bool _apiIsJsonContent(AsyncWebServerRequest* request) { + return _apiMatchHeader(request, F("Content-Type"), F("application/json")); } -struct ApiMatch { - Api* api { nullptr }; - Api::Type type { Api::Type::Basic }; -}; +bool _apiIsFormDataContent(AsyncWebServerRequest* request) { + return _apiMatchHeader(request, F("Content-Type"), F("application/x-www-form-urlencoded")); +} -ApiMatch _apiMatch(const String& url, AsyncWebServerRequest* request) { +struct ApiRequestHelper { + ApiRequestHelper(const ApiRequestHelper&) = delete; + ApiRequestHelper(ApiRequestHelper&&) noexcept = default; - ApiMatch result; - char buffer[ApiPathSizeMax] = {0}; + // &path is expected to be request->url(), which is valid throughout the request's lifetime + explicit ApiRequestHelper(AsyncWebServerRequest& request, const PathParts& pattern) : + _request(request), + _pattern(pattern), + _path(request.url()), + _match(_pattern.match(_path)) + {} - for (auto& api : _apis) { - sprintf_P(buffer, PSTR("/api/%s"), api.path.c_str()); - if (url != buffer) { - continue; - } + ApiRequest request() const { + return ApiRequest(_request, _pattern, _path); + } - auto type = _asJson(request) - ? Api::Type::Json - : Api::Type::Basic; + const PathParts& parts() const { + return _path; + } - result.api = &api; - result.type = type; - break; + bool match() const { + return _match; } - return result; +private: + AsyncWebServerRequest& _request; + const PathParts& _pattern; + PathParts _path; + bool _match; +}; + +// Because the webserver request is split between multiple separate function invocations, we need to preserve some state. +// TODO: in case we are dealing with multicore, perhaps enforcing static-size data structs instead of the vector would we better, +// to avoid calling generic malloc when paths are parsed? +// +// Some quirks to deal with: +// - handleBody is called before handleRequest, and there's no way to signal completion / success of both callbacks to the server +// - Server never checks for request closing in filter or canHandle, so if we don't want to handle large content-length, it +// will still flow through the lwip backend. +// - `request->_tempObject` is used to keep API request state, but it's just a plain void pointer +// - espasyncwebserver will `free(_tempObject)` when request is disconnected, but only after this callbackhandler is done. +// make sure it's set to nullptr via `AsyncWebServerRequest::onDisconnect` +// - ALL headers are parsed (and we could access those during filter and canHandle callbacks), but we need to explicitly +// request them to stay in memory so that the actual handler can work with them + +void _apiAttachHelper(AsyncWebServerRequest& request, ApiRequestHelper&& helper) { + request._tempObject = new ApiRequestHelper(std::move(helper)); + request.onDisconnect([&]() { + auto* ptr = reinterpret_cast(request._tempObject); + delete ptr; + request._tempObject = nullptr; + }); + request.addInterestingHeader(F("Api-Key")); } -bool _apiDispatchRequest(const String& url, AsyncWebServerRequest* request) { +class ApiBaseWebHandler : public AsyncWebHandler { +public: + ApiBaseWebHandler() = delete; + ApiBaseWebHandler(const ApiBaseWebHandler&) = delete; + ApiBaseWebHandler(ApiBaseWebHandler&&) = delete; - auto match = _apiMatch(url, request); - if (!match.api) { - return false; + // In case this needs to be copied or moved, ensure PathParts copy references the new object's string + + template + explicit ApiBaseWebHandler(Pattern&& pattern) : + _pattern(std::forward(pattern)), + _parts(_pattern) + {} + + const String& pattern() const { + return _pattern; } - if (match.type != match.api->type) { - DEBUG_MSG_P(PSTR("[API] Cannot handle the request type\n")); - request->send(404); - return true; + const PathParts& parts() const { + return _parts; } - const bool is_put = ( - (!apiRestFul() || (request->method() == HTTP_PUT)) - && request->hasParam("value", request->method() == HTTP_PUT) - ); +private: + String _pattern; + PathParts _parts; +}; - ApiBuffer buffer; +// 'Modernized' API configuration: +// - `Api-Key` header for both GET and PUT +// - Parse request body as JSON object. Limited to LWIP internal buffer size, and will also break when client +// does weird stuff and PUTs data in multiple packets b/c only the initial packet is parsed. +// - Same as the text/plain, when ApiRequest::handle was not called it will then call GET +// +// TODO: bump to arduinojson v6 to handle partial / broken data payloads +// TODO: somehow detect partial data and buffer (optionally) +// TODO: POST instead of PUT? + +class ApiJsonWebHandler final : public ApiBaseWebHandler { +public: + static constexpr size_t BufferSize { API_JSON_BUFFER_SIZE }; + + struct ReadOnlyStream : public Stream { + ReadOnlyStream() = delete; + explicit ReadOnlyStream(const uint8_t* buffer, size_t size) : + _buffer(buffer), + _size(size) + {} + + int available() override { + return _size - _index; + } - switch (match.api->type) { + int peek() override { + if (_index < _size) { + return static_cast(_buffer[_index]); + } - case Api::Type::Basic: { - if (!match.api->get.basic) { - break; + return -1; } - if (is_put) { - if (!match.api->put.basic) { - break; + int read() override { + auto peeked = peek(); + if (peeked >= 0) { + ++_index; } - auto value = request->getParam("value", request->method() == HTTP_PUT)->value(); - if (buffer.size < (value.length() + 1ul)) { - break; + + return peeked; + } + + // since we are fixed in size, no need for any timeouts and the only available option is to return full chunk of data + size_t readBytes(uint8_t* ptr, size_t size) override { + if ((_index < _size) && ((_size - _index) >= size)) { + std::copy(_buffer + _index, _buffer + _index + size, ptr); + _index += size; + return size; } - std::copy(value.c_str(), value.c_str() + value.length() + 1, buffer.data); - match.api->put.basic(*match.api, buffer); - buffer.erase(); + + return 0; } - match.api->get.basic(*match.api, buffer); - request->send(200, "text/plain", buffer.data); + size_t readBytes(char* ptr, size_t size) override { + return readBytes(reinterpret_cast(ptr), size); + } + void flush() override { + } + + size_t write(const uint8_t*, size_t) override { + return 0; + } + + size_t write(uint8_t) override { + return 0; + } + + const uint8_t* _buffer; + const size_t _size; + size_t _index { 0 }; + }; + + ApiJsonWebHandler() = delete; + ApiJsonWebHandler(const ApiJsonWebHandler&) = delete; + ApiJsonWebHandler(ApiJsonWebHandler&&) = delete; + + template + ApiJsonWebHandler(Path&& path, Callback&& get, Callback&& put) : + ApiBaseWebHandler(std::forward(path)), + _get(std::forward(get)), + _put(std::forward(put)) + {} + + bool isRequestHandlerTrivial() override { return true; } - // TODO: pass the body instead of `value` param - // TODO: handle HTTP_PUT - case Api::Type::Json: { - if (!match.api->get.json || is_put) { - break; + bool canHandle(AsyncWebServerRequest* request) override { + if (!apiEnabled()) { + return false; } - DynamicJsonBuffer jsonBuffer(API_BUFFER_SIZE); + if (!_apiAcceptsJson(request)) { + return false; + } + + auto helper = ApiRequestHelper(*request, parts()); + if (helper.match() && apiAuthenticate(request)) { + switch (request->method()) { + case HTTP_HEAD: + return true; + case HTTP_PUT: + if (!_apiIsJsonContent(request)) { + return false; + } + if (!_put) { + return false; + } + case HTTP_GET: + if (!_get) { + return false; + } + break; + default: + return false; + } + _apiAttachHelper(*request, std::move(helper)); + return true; + } + + return false; + } + + void _handleGet(AsyncWebServerRequest* request, ApiRequest& apireq) { + DynamicJsonBuffer jsonBuffer(API_JSON_BUFFER_SIZE); JsonObject& root = jsonBuffer.createObject(); + if (!_get(apireq, root)) { + request->send(500); + return; + } - match.api->get.json(*match.api, root); + if (!apireq.done()) { + AsyncResponseStream *response = request->beginResponseStream("application/json", root.measureLength() + 1); + root.printTo(*response); + request->send(response); + return; + } - AsyncResponseStream *response = request->beginResponseStream("application/json", root.measureLength() + 1); - root.printTo(*response); - request->send(response); + request->send(500); + } - return true; + void _handlePut(AsyncWebServerRequest* request, uint8_t* data, size_t size) { + // XXX: arduinojson v5 de-serializer will happily read garbage from raw ptr, since there's no length limit + // this is fixed in v6 though. for now, use a wrapper, but be aware that this actually uses more mem for the jsonbuffer + DynamicJsonBuffer jsonBuffer(API_JSON_BUFFER_SIZE); + ReadOnlyStream stream(data, size); + + JsonObject& root = jsonBuffer.parseObject(stream); + if (!root.success()) { + request->send(500); + return; + } + + auto& helper = *reinterpret_cast(request->_tempObject); + + auto apireq = helper.request(); + if (!_put(apireq, root)) { + request->send(500); + return; + } + + if (!apireq.done()) { + _handleGet(request, apireq); + } + + return; } + void handleBody(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t, size_t total) override { + if (total && (len == total)) { + _handlePut(request, data, total); + } } - DEBUG_MSG_P(PSTR("[API] Method not supported\n")); - request->send(405); + void handleRequest(AsyncWebServerRequest* request) override { + auto& helper = *reinterpret_cast(request->_tempObject); - return true; + switch (request->method()) { + case HTTP_HEAD: + request->send(204); + return; -} + case HTTP_GET: { + auto apireq = helper.request(); + _handleGet(request, apireq); + return; + } -bool _apiRequestCallback(AsyncWebServerRequest* request) { + // see handleBody() + case HTTP_PUT: + break; - String url = request->url(); + default: + request->send(405); + break; + } + } - if (url.equals("/rpc")) { - _onRPC(request); - return true; + const String& pattern() const { + return ApiBaseWebHandler::pattern(); } - if (url.equals("/api") || url.equals("/apis")) { - _onAPIs(request); - return true; + const PathParts& parts() const { + return ApiBaseWebHandler::parts(); } - if (!url.startsWith("/api/")) return false; +private: + ApiJsonHandler _get; + ApiJsonHandler _put; +}; -// [alexa] don't call the http api -> response for alexa is done by fauxmoesp lib -#if ALEXA_SUPPORT - if (url.indexOf("/lights") > 14 ) return false; -#endif +// ESPurna legacy API configuration +// - ?apikey=... to authorize in GET or PUT +// - ?anything=... for input data (common key is "value") +// MUST correctly override isRequestHandlerTrivial() to allow auth with PUT +// (i.e. so that ESPAsyncWebServer parses the body and adds form-data to request params list) + +class ApiBasicWebHandler final : public ApiBaseWebHandler { +public: + template + ApiBasicWebHandler(Path&& path, Callback&& get, Callback&& put) : + ApiBaseWebHandler(std::forward(path)), + _get(std::forward(get)), + _put(std::forward(put)) + {} + + bool isRequestHandlerTrivial() override { + return false; + } - if (!apiAuthenticate(request)) return false; + bool canHandle(AsyncWebServerRequest* request) override { + if (!apiEnabled()) { + return false; + } - return _apiDispatchRequest(url, request); + if (!_apiAcceptsText(request)) { + return false; + } -} + switch (request->method()) { + case HTTP_HEAD: + case HTTP_GET: + break; + case HTTP_PUT: + if (!_apiIsFormDataContent(request)) { + return false; + } + break; + default: + return false; + } + + auto helper = ApiRequestHelper(*request, parts()); + if (helper.match()) { + _apiAttachHelper(*request, std::move(helper)); + return true; + } + + return false; + } + + void handleRequest(AsyncWebServerRequest* request) override { + if (!apiAuthenticate(request)) { + request->send(403); + return; + } + + auto method = request->method(); + const bool is_put = ( + (!apiRestFul()|| (HTTP_PUT == method)) + && request->hasParam("value", HTTP_PUT == method) + ); + + switch (method) { + case HTTP_HEAD: + request->send(204); + return; + case HTTP_GET: + case HTTP_PUT: { + auto& helper = *reinterpret_cast(request->_tempObject); + + auto apireq = helper.request(); + if (is_put) { + if (!_put(apireq)) { + request->send(500); + return; + } + + if (apireq.done()) { + return; + } + } + + if (!_get(apireq)) { + request->send(500); + return; + } + + if (!apireq.done()) { + request->send(204); + return; + } + } + default: + request->send(405); + return; + } + } + + const ApiBasicHandler& get() const { + return _get; + } + + const ApiBasicHandler& put() const { + return _put; + } + + const String& pattern() const { + return ApiBaseWebHandler::pattern(); + } + + const PathParts& parts() const { + return ApiBaseWebHandler::parts(); + } + +private: + ApiBasicHandler _get; + ApiBasicHandler _put; +}; // ----------------------------------------------------------------------------- -void apiReserve(size_t size) { - _apis.reserve(_apis.size() + size); +namespace { + +std::forward_list _apis; + +template +void _apiRegister(const String& path, Callback&& get, Callback&& put) { + // `String` is a given, since we *do* need to construct this dynamically in sensors + auto* ptr = new Handler(String(F(API_BASE_PATH)) + path, std::forward(get), std::forward(put)); + webServer().addHandler(reinterpret_cast(ptr)); + _apis.emplace_front(ptr); } -void apiRegister(const Api& api) { - if (api.path.length() >= (ApiPathSizeMax - strlen("/api/") - 1ul)) { - return; - } - _apis.push_back(api); +} // namespace + +void apiRegister(const String& path, ApiBasicHandler&& get, ApiBasicHandler&& put) { + _apiRegister(path, std::move(get), std::move(put)); +} + +void apiRegister(const String& path, ApiJsonHandler&& get, ApiJsonHandler&& put) { + _apiRegister(path, std::move(get), std::move(put)); } void apiSetup() { - webRequestRegister(_apiRequestCallback); + apiRegister(F("list"), + [](ApiRequest& request) { + String paths; + for (auto& api : _apis) { + paths += api->pattern() + "\r\n"; + } + request.send(paths); + return true; + }, + nullptr + ); + + apiRegister(F("rpc"), + nullptr, + [](ApiRequest& request) { + if (rpcHandleAction(request.param(F("action")))) { + return apiOk(request); + } + return apiError(request); + } + ); + } -void apiOk(const Api&, ApiBuffer& buffer) { - buffer.data[0] = 'O'; - buffer.data[1] = 'K'; - buffer.data[2] = '\0'; +bool apiOk(ApiRequest& request) { + request.send(F("OK")); + return true; } -void apiError(const Api&, ApiBuffer& buffer) { - buffer.data[0] = '-'; - buffer.data[1] = 'E'; - buffer.data[2] = 'R'; - buffer.data[3] = 'R'; - buffer.data[4] = 'O'; - buffer.data[5] = 'R'; - buffer.data[6] = '\0'; +bool apiError(ApiRequest& request) { + request.send(F("ERROR")); + return true; } #endif // API_SUPPORT diff --git a/code/espurna/api.h b/code/espurna/api.h index bcacf2cd..91a19ba5 100644 --- a/code/espurna/api.h +++ b/code/espurna/api.h @@ -13,7 +13,10 @@ Copyright (C) 2016-2019 by Xose Pérez #if WEB_SUPPORT +bool apiAuthenticateHeader(AsyncWebServerRequest*, const String& key); +bool apiAuthenticateParam(AsyncWebServerRequest*, const String& key); bool apiAuthenticate(AsyncWebServerRequest*); +void apiCommonSetup(); bool apiEnabled(); bool apiRestFul(); String apiKey(); @@ -22,74 +25,19 @@ String apiKey(); #if WEB_SUPPORT && API_SUPPORT -#include - -constexpr unsigned char ApiUnusedArg = 0u; - -struct ApiBuffer { - constexpr static size_t size = API_BUFFER_SIZE; - char data[size]; - - void erase() { - std::fill(data, data + size, '\0'); - } -}; - -struct Api { - using BasicHandler = void(*)(const Api& api, ApiBuffer& buffer); - using JsonHandler = void(*)(const Api& api, JsonObject& root); - - enum class Type { - Basic, - Json - }; - - Api() = delete; - - // TODO: - // - bind to multiple paths, dispatch specific path in the callback - // - allow index to be passed through path argument (/{arg1}/{arg2} syntax, for example) - Api(const String& path_, Type type_, unsigned char arg_, BasicHandler get_, BasicHandler put_ = nullptr) : - path(path_), - type(type_), - arg(arg_) - { - get.basic = get_; - put.basic = put_; - } - - Api(const String& path_, Type type_, unsigned char arg_, JsonHandler get_, JsonHandler put_ = nullptr) : - path(path_), - type(type_), - arg(arg_) - { - get.json = get_; - put.json = put_; - } - - String path; - Type type; - unsigned char arg; - - union { - BasicHandler basic; - JsonHandler json; - } get; - - union { - BasicHandler basic; - JsonHandler json; - } put; -}; - -void apiRegister(const Api& api); +#include "api_impl.h" -void apiCommonSetup(); -void apiSetup(); +#include -void apiReserve(size_t); +using ApiBasicHandler = std::function; +using ApiJsonHandler = std::function; + +void apiRegister(const String& path, ApiBasicHandler&& get, ApiBasicHandler&& put); +void apiRegister(const String& path, ApiJsonHandler&& get, ApiJsonHandler&& put); + +void apiSetup(); -void apiError(const Api&, ApiBuffer& buffer); -void apiOk(const Api&, ApiBuffer& buffer); +bool apiError(ApiRequest&); +bool apiOk(ApiRequest&); #endif // API_SUPPORT == 1 diff --git a/code/espurna/api_common.cpp b/code/espurna/api_common.cpp index 9d406843..0d87f9e7 100644 --- a/code/espurna/api_common.cpp +++ b/code/espurna/api_common.cpp @@ -49,24 +49,41 @@ String apiKey() { return getSetting("apiKey", API_KEY); } -bool apiAuthenticate(AsyncWebServerRequest *request) { +bool apiAuthenticateHeader(AsyncWebServerRequest* request, const String& key) { + if (apiEnabled() && key.length()) { + auto* header = request->getHeader(F("Api-Key")); + if (header && (key == header->value())) { + return true; + } + } + + return false; +} + +bool apiAuthenticateParam(AsyncWebServerRequest* request, const String& key) { + auto* param = request->getParam("apikey", (request->method() == HTTP_PUT)); + if (param && (key == param->value())) { + return true; + } + return false; +} + +bool apiAuthenticate(AsyncWebServerRequest* request) { const auto key = apiKey(); - if (!apiEnabled() || !key.length()) { - DEBUG_MSG_P(PSTR("[WEBSERVER] HTTP API is not enabled\n")); - request->send(403); + if (!key.length()) { return false; } - AsyncWebParameter* keyParam = request->getParam("apikey", (request->method() == HTTP_PUT)); - if (!keyParam || !keyParam->value().equals(key)) { - DEBUG_MSG_P(PSTR("[WEBSERVER] Wrong / missing apikey parameter\n")); - request->send(403); - return false; + if (apiAuthenticateHeader(request, key)) { + return true; } - return true; + if (apiAuthenticateParam(request, key)) { + return true; + } + return false; } void apiCommonSetup() { diff --git a/code/espurna/api_impl.h b/code/espurna/api_impl.h new file mode 100644 index 00000000..d0a2de08 --- /dev/null +++ b/code/espurna/api_impl.h @@ -0,0 +1,182 @@ +/* + +Part of the API MODULE + +Copyright (C) 2020 by Maxim Prokhorov + +*/ + +#pragma once + +#include + +#include + +#include +#include +#include + +// ----------------------------------------------------------------------------- + +struct PathPart { + enum class Type { + Unknown, + Value, + SingleWildcard, + MultiWildcard + }; + + Type type; + size_t offset; + size_t length; +}; + +struct PathParts { + using Parts = std::vector; + + PathParts() = delete; + + PathParts(const PathParts&) = default; + PathParts(PathParts&&) noexcept = default; + + explicit PathParts(const String& path); + + explicit operator bool() const { + return _ok; + } + + void clear() { + _parts.clear(); + } + + void reserve(size_t size) { + _parts.reserve(size); + } + + String operator[](size_t index) const { + auto& part = _parts[index]; + return _path.substring(part.offset, part.offset + part.length); + } + + const String& path() const { + return _path; + } + + const Parts& parts() const { + return _parts; + } + + size_t size() const { + return _parts.size(); + } + + Parts::const_iterator begin() const { + return _parts.begin(); + } + + Parts::const_iterator end() const { + return _parts.end(); + } + + bool match(const PathParts& path) const; + bool match(const String& path) const { + return match(PathParts(path)); + } + +private: + PathPart& emplace_back(PathPart::Type type, size_t offset, size_t length) { + PathPart part { type, offset, length }; + _parts.push_back(std::move(part)); + return _parts.back(); + } + + const String& _path; + Parts _parts; + bool _ok { false }; +}; + +// this is a purely temporary object, which we can only create while doing the API dispatch + +struct ApiRequest { + ApiRequest() = delete; + + ApiRequest(const ApiRequest&) = default; + ApiRequest(ApiRequest&&) noexcept = default; + + explicit ApiRequest(AsyncWebServerRequest& request, const PathParts& pattern, const PathParts& parts) : + _request(request), + _pattern(pattern), + _parts(parts) + {} + + template + void handle(T&& handler) { + _done = true; + handler(&_request); + } + + template + void param_foreach(T&& handler) { + const size_t params { _request.params() }; + for (size_t current = 0; current < params; ++current) { + auto* param = _request.getParam(current); + handler(param->name(), param->value()); + } + } + + template + void param_foreach(const String& name, T&& handler) { + param_foreach([&](const String& param_name, const String& param_value) { + if (param_name == name) { + handler(param_value); + } + }); + } + + const String& param(const String& name) { + auto* result = _request.getParam(name, HTTP_PUT == _request.method()); + if (result) { + return result->value(); + } + + return _empty_string(); + } + + void send(const String& payload) { + if (payload.length()) { + _request.send(200, "text/plain", payload); + } else { + _request.send(204); + } + _done = true; + } + + bool done() const { + return _done; + } + + const PathParts& parts() const { + return _parts; + } + + String part(size_t index) const { + return _parts[index]; + } + + // Only works when pattern cointains '+', retrieving the part at the same index from the real path + // e.g. for the pair of `some/+/path` and `some/data/path`, calling `wildcard(0)` will return `data` + String wildcard(int index) const; + size_t wildcards() const; + +private: + const String& _empty_string() const { + static const String string; + return string; + } + + bool _done { false }; + + AsyncWebServerRequest& _request; + const PathParts& _pattern; + const PathParts& _parts; +}; diff --git a/code/espurna/config/general.h b/code/espurna/config/general.h index 2f0d9efa..deaf8ed2 100644 --- a/code/espurna/config/general.h +++ b/code/espurna/config/general.h @@ -190,7 +190,7 @@ #endif #ifndef TERMINAL_WEB_API_PATH -#define TERMINAL_WEB_API_PATH "/api/cmd" +#define TERMINAL_WEB_API_PATH "cmd" #endif //------------------------------------------------------------------------------ @@ -734,6 +734,10 @@ #define WEB_EMBEDDED 1 // Build the firmware with the web interface embedded in #endif +#ifndef WEB_ACCESS_LOG +#define WEB_ACCESS_LOG 0 // Log every request that was received by the server (but, not necessarily processed) +#endif + // Requires ESPAsyncTCP to be built with ASYNC_TCP_SSL_ENABLED=1 and Arduino Core version >= 2.4.0 // XXX: This is not working at the moment!! Pending https://github.com/me-no-dev/ESPAsyncTCP/issues/95 #ifndef WEB_SSL_ENABLED @@ -806,8 +810,12 @@ // Setting this to 0 will allow using GET to change relays, for instance #endif -#ifndef API_BUFFER_SIZE -#define API_BUFFER_SIZE 64 // Size of the buffer for HTTP GET API responses +#ifndef API_JSON_BUFFER_SIZE +#define API_JSON_BUFFER_SIZE 256 // Size of the (de)serializer buffer. +#endif + +#ifndef API_BASE_PATH +#define API_BASE_PATH "/api/" #endif #ifndef API_REAL_TIME_VALUES diff --git a/code/espurna/encoder.cpp b/code/espurna/encoder.cpp index 623b5888..321551d4 100644 --- a/code/espurna/encoder.cpp +++ b/code/espurna/encoder.cpp @@ -10,6 +10,11 @@ Copyright (C) 2018-2019 by Xose Pérez #if ENCODER_SUPPORT && (LIGHT_PROVIDER != LIGHT_PROVIDER_NONE) +#include "light.h" +#include "libs/Encoder.h" + +#include + struct encoder_t { Encoder * encoder; unsigned char button_pin; diff --git a/code/espurna/encoder.h b/code/espurna/encoder.h index 0b37ba8e..0f62dc68 100644 --- a/code/espurna/encoder.h +++ b/code/espurna/encoder.h @@ -8,9 +8,4 @@ Copyright (C) 2018-2019 by Xose Pérez #include "espurna.h" -#if ENCODER_SUPPORT && (LIGHT_PROVIDER != LIGHT_PROVIDER_NONE) -#include "libs/Encoder.h" -#include -#endif - void encoderSetup(); diff --git a/code/espurna/light.cpp b/code/espurna/light.cpp index e7acddb9..ce3eb7c1 100644 --- a/code/espurna/light.cpp +++ b/code/espurna/light.cpp @@ -444,6 +444,12 @@ void _toRGB(char * rgb, size_t len, bool target = false) { snprintf_P(rgb, len, PSTR("#%06X"), value); } +String _toRGB(bool target) { + char buffer[64] { 0 }; + _toRGB(buffer, sizeof(buffer), target); + return buffer; +} + void _toHSV(char * hsv, size_t len) { double h {0.}, s {0.}, v {0.}; double r {0.}, g {0.}, b {0.}; @@ -486,6 +492,12 @@ void _toHSV(char * hsv, size_t len) { ); } +String _toHSV() { + char buffer[64] { 0 }; + _toHSV(buffer, sizeof(buffer)); + return buffer; +} + void _toLong(char * color, size_t len, bool target) { if (!_light_has_color) return; @@ -502,6 +514,12 @@ void _toLong(char * color, size_t len) { _toLong(color, len, false); } +String _toLong(bool target = false) { + char buffer[64] { 0 }; + _toLong(buffer, sizeof(buffer), target); + return buffer; +} + String _toCSV(bool target) { const auto channels = lightChannels(); @@ -539,22 +557,38 @@ int _lightAdjustValue(const int& value, const String& operation) { return updated; } -void _lightAdjustBrightness(const char *payload) { +void _lightAdjustBrightness(const char* payload) { lightBrightness(_lightAdjustValue(lightBrightness(), payload)); } -void _lightAdjustChannel(unsigned char id, const char *payload) { +void _lightAdjustBrightness(const String& payload) { + _lightAdjustBrightness(payload.c_str()); +} + +void _lightAdjustChannel(unsigned char id, const char* payload) { lightChannel(id, _lightAdjustValue(lightChannel(id), payload)); } -void _lightAdjustKelvin(const char *payload) { +void _lightAdjustChannel(unsigned char id, const String& payload) { + _lightAdjustChannel(id, payload.c_str()); +} + +void _lightAdjustKelvin(const char* payload) { _fromKelvin(_lightAdjustValue(_toKelvin(_light_mireds), payload)); } -void _lightAdjustMireds(const char *payload) { +void _lightAdjustKelvin(const String& payload) { + _lightAdjustKelvin(payload.c_str()); +} + +void _lightAdjustMireds(const char* payload) { _fromMireds(_lightAdjustValue(_light_mireds, payload)); } +void _lightAdjustMireds(const String& payload) { + _lightAdjustMireds(payload.c_str()); +} + // ----------------------------------------------------------------------------- // PROVIDER // ----------------------------------------------------------------------------- @@ -853,101 +887,123 @@ void lightBroker() { #if API_SUPPORT -void _lightApiSetup() { +bool _lightTryParseChannel(const char* p, unsigned char& id) { + char* endp { nullptr }; + const unsigned long result { strtoul(p, &endp, 10) }; + if ((endp == p) || (*endp != '\0') || (result >= lightChannels())) { + DEBUG_MSG_P(PSTR("[LIGHT] Invalid channelID (%s)\n"), p); + return false; + } - // Note that we expect a fixed number of entries. - // Otherwise, underlying vector will reserve more than we need (likely, *2 of the current size) - apiReserve( - (_light_has_color ? 4u : 0u) + 2u + _light_channels.size() - ); + id = result; + return true; +} + +template +bool _lightApiTryHandle(ApiRequest& request, T&& callback) { + auto id_param = request.wildcard(0); + unsigned char id; + if (!_lightTryParseChannel(id_param.c_str(), id)) { + return false; + } + + return callback(id); +} + +void _lightApiSetup() { if (_light_has_color) { - apiRegister({ - MQTT_TOPIC_COLOR_RGB, Api::Type::Basic, ApiUnusedArg, - [](const Api&, ApiBuffer& buffer) { - if (getSetting("useCSS", 1 == LIGHT_USE_CSS)) { - _toRGB(buffer.data, buffer.size, true); - } else { - _toLong(buffer.data, buffer.size, true); - } + apiRegister(F(MQTT_TOPIC_COLOR_RGB), + [](ApiRequest& request) { + auto result = getSetting("useCSS", 1 == LIGHT_USE_CSS) + ? _toRGB(true) : _toLong(true); + request.send(result); + return true; }, - [](const Api&, ApiBuffer& buffer) { - lightColor(buffer.data, true); + [](ApiRequest& request) { + lightColor(request.param(F("value")), true); lightUpdate(true, true); + return true; } - }); + ); - apiRegister({ - MQTT_TOPIC_COLOR_HSV, Api::Type::Basic, ApiUnusedArg, - [](const Api&, ApiBuffer& buffer) { - _toHSV(buffer.data, buffer.size); + apiRegister(F(MQTT_TOPIC_COLOR_HSV), + [](ApiRequest& request) { + request.send(_toHSV()); + return true; }, - [](const Api&, ApiBuffer& buffer) { - lightColor(buffer.data, false); + [](ApiRequest& request) { + lightColor(request.param(F("value")), false); lightUpdate(true, true); + return true; } - }); + ); - apiRegister({ - MQTT_TOPIC_MIRED, Api::Type::Basic, ApiUnusedArg, - [](const Api&, ApiBuffer& buffer) { - sprintf(buffer.data, PSTR("%d"), _light_mireds); + apiRegister(F(MQTT_TOPIC_MIRED), + [](ApiRequest& request) { + request.send(String(_light_mireds)); + return true; }, - [](const Api&, ApiBuffer& buffer) { - _lightAdjustMireds(buffer.data); + [](ApiRequest& request) { + _lightAdjustMireds(request.param(F("value"))); lightUpdate(true, true); + return true; } - }); + ); - apiRegister({ - MQTT_TOPIC_KELVIN, Api::Type::Basic, ApiUnusedArg, - [](const Api&, ApiBuffer& buffer) { - sprintf(buffer.data, PSTR("%d"), _toKelvin(_light_mireds)); + apiRegister(F(MQTT_TOPIC_KELVIN), + [](ApiRequest& request) { + request.send(String(_toKelvin(_light_mireds))); + return true; }, - [](const Api&, ApiBuffer& buffer) { - _lightAdjustKelvin(buffer.data); + [](ApiRequest& request) { + _lightAdjustKelvin(request.param(F("value"))); lightUpdate(true, true); + return true; } - }); + ); } - apiRegister({ - MQTT_TOPIC_TRANSITION, Api::Type::Basic, ApiUnusedArg, - [](const Api&, ApiBuffer& buffer) { - snprintf_P(buffer.data, buffer.size, PSTR("%u"), lightTransitionTime()); + apiRegister(F(MQTT_TOPIC_TRANSITION), + [](ApiRequest& request) { + request.send(String(lightTransitionTime())); + return true; }, - [](const Api&, ApiBuffer& buffer) { - lightTransitionTime(atol(buffer.data)); + [](ApiRequest& request) { + lightTransitionTime(request.param(F("value")).toInt()); + return true; } - }); + ); - apiRegister({ - MQTT_TOPIC_BRIGHTNESS, Api::Type::Basic, ApiUnusedArg, - [](const Api&, ApiBuffer& buffer) { - snprintf_P(buffer.data, buffer.size, PSTR("%u"), _light_brightness); + apiRegister(F(MQTT_TOPIC_BRIGHTNESS), + [](ApiRequest& request) { + request.send(String(static_cast(_light_brightness))); + return true; }, - [](const Api&, ApiBuffer& buffer) { - _lightAdjustBrightness(buffer.data); + [](ApiRequest& request) { + _lightAdjustBrightness(request.param(F("value"))); lightUpdate(true, true); + return true; } - }); + ); - char path[32] = {0}; - for (unsigned char id = 0; id < _light_channels.size(); ++id) { - snprintf_P(path, sizeof(path), PSTR(MQTT_TOPIC_CHANNEL "/%u"), id); - apiRegister({ - path, Api::Type::Basic, id, - [](const Api& api, ApiBuffer& buffer) { - snprintf_P(buffer.data, buffer.size, PSTR("%u"), _light_channels[api.arg].target); - }, - [](const Api& api, ApiBuffer& buffer) { - _lightAdjustChannel(api.arg, buffer.data); + apiRegister(F(MQTT_TOPIC_CHANNEL "/+"), + [](ApiRequest& request) { + return _lightApiTryHandle(request, [&](unsigned char id) { + request.send(String(static_cast(_light_channels[id].target))); + return true; + }); + }, + [](ApiRequest& request) { + return _lightApiTryHandle(request, [&](unsigned char id) { + _lightAdjustChannel(id, request.param(F("value"))); lightUpdate(true, true); - } - }); - } + return true; + }); + } + ); } @@ -1006,11 +1062,11 @@ void _lightWebSocketOnAction(uint32_t client_id, const char * action, JsonObject if (_light_has_color) { if (strcmp(action, "color") == 0) { if (data.containsKey("rgb")) { - lightColor(data["rgb"], true); + lightColor(data["rgb"].as(), true); lightUpdate(true, true); } if (data.containsKey("hsv")) { - lightColor(data["hsv"], false); + lightColor(data["hsv"].as(), false); lightUpdate(true, true); } } @@ -1104,7 +1160,7 @@ void _lightInitCommands() { terminalRegisterCommand(F("MIRED"), [](const terminal::CommandContext& ctx) { if (ctx.argc > 1) { - _lightAdjustMireds(ctx.argv[1].c_str()); + _lightAdjustMireds(ctx.argv[1]); lightUpdate(true, true); } DEBUG_MSG_P(PSTR("Color: %s\n"), lightColor().c_str()); @@ -1225,10 +1281,18 @@ void lightColor(const char * color, bool rgb) { } } -void lightColor(const char * color) { +void lightColor(const String& color, bool rgb) { + lightColor(color.c_str(), rgb); +} + +void lightColor(const char* color) { lightColor(color, true); } +void lightColor(const String& color) { + lightColor(color.c_str()); +} + void lightColor(unsigned long color) { _fromLong(color, false); } diff --git a/code/espurna/light.h b/code/espurna/light.h index 41f925cc..585a8efc 100644 --- a/code/espurna/light.h +++ b/code/espurna/light.h @@ -31,8 +31,12 @@ size_t lightChannels(); unsigned int lightTransitionTime(); void lightTransitionTime(unsigned long ms); -void lightColor(const char * color, bool rgb); -void lightColor(const char * color); +void lightColor(const char* color, bool rgb); +void lightColor(const String& color, bool rgb); + +void lightColor(const String& color); +void lightColor(const char* color); + void lightColor(unsigned long color); String lightColor(bool rgb); String lightColor(); diff --git a/code/espurna/ota_web.cpp b/code/espurna/ota_web.cpp index 20acf3b3..2b2a7737 100644 --- a/code/espurna/ota_web.cpp +++ b/code/espurna/ota_web.cpp @@ -136,7 +136,7 @@ void _onUpgradeFile(AsyncWebServerRequest *request, String filename, size_t inde } void otaWebSetup() { - webServer()->on("/upgrade", HTTP_POST, _onUpgrade, _onUpgradeFile); + webServer().on("/upgrade", HTTP_POST, _onUpgrade, _onUpgradeFile); wsRegister(). onVisible([](JsonObject& root) { root["otaVisible"] = 1; diff --git a/code/espurna/prometheus.cpp b/code/espurna/prometheus.cpp index 2047ea52..a33788de 100644 --- a/code/espurna/prometheus.cpp +++ b/code/espurna/prometheus.cpp @@ -20,7 +20,7 @@ Copyright (C) 2020 by Maxim Prokhorov #include void _prometheusRequestHandler(AsyncWebServerRequest* request) { - static_assert(RELAY_SUPPORT || SENSOR_SUPPORT, ""); + static_assert((RELAY_SUPPORT) || (SENSOR_SUPPORT), ""); // TODO: Add more stuff? // Note: Response 'stream' backing buffer is customizable. Default is 1460 bytes (see ESPAsyncWebServer.h) @@ -55,20 +55,36 @@ void _prometheusRequestHandler(AsyncWebServerRequest* request) { request->send(response); } -bool _prometheusRequestCallback(AsyncWebServerRequest* request) { - if (request->url().equals(F("/api/metrics"))) { - webLog(request); - if (apiAuthenticate(request)) { - _prometheusRequestHandler(request); - } - return true; - } - return false; +#if API_SUPPORT + +void prometheusSetup() { + apiRegister(F("metrics"), + [](ApiRequest& request) { + request.handle(_prometheusRequestHandler); + return true; + }, + nullptr + ); } +#else + void prometheusSetup() { - webRequestRegister(_prometheusRequestCallback); + webRequestRegister([](AsyncWebServerRequest* request) { + if (request->url().equals(F(API_BASE_PATH "metrics"))) { + if (apiAuthenticate(request)) { + _prometheusRequestHandler(request); + return true; + } + request->send(403); + return true; + } + + return false; + }); } +#endif // API_SUPPORT + #endif // PROMETHEUS_SUPPORT diff --git a/code/espurna/relay.cpp b/code/espurna/relay.cpp index 28a54a75..3176c756 100644 --- a/code/espurna/relay.cpp +++ b/code/espurna/relay.cpp @@ -134,21 +134,85 @@ String _relay_rpc_payload_toggle; // UTILITY // ----------------------------------------------------------------------------- -bool _relayHandlePayload(unsigned char relayID, const char* payload) { - auto value = relayParsePayload(payload); - if (value == PayloadStatus::Unknown) return false; +bool _relayTryParseId(const char* p, unsigned char& relayID) { + char* endp { nullptr }; + const unsigned long result { strtoul(p, &endp, 10) }; + if ((endp == p) || (*endp != '\0') || (result >= relayCount())) { + DEBUG_MSG_P(PSTR("[RELAY] Invalid relayID (%s)\n"), p); + return false; + } + + relayID = result; + return true; +} - if (value == PayloadStatus::Off) { +bool _relayTryParseIdFromPath(const String& endpoint, unsigned char& relayID) { + int next_slash { endpoint.lastIndexOf('/') }; + if (next_slash < 0) { + return false; + } + + const char* p { endpoint.c_str() + next_slash + 1 }; + if (*p == '\0') { + DEBUG_MSG_P(PSTR("[RELAY] relayID was not specified\n")); + return false; + } + + return _relayTryParseId(p, relayID); +} + +void _relayHandleStatus(unsigned char relayID, PayloadStatus status) { + switch (status) { + case PayloadStatus::Off: relayStatus(relayID, false); - } else if (value == PayloadStatus::On) { + break; + case PayloadStatus::On: relayStatus(relayID, true); - } else if (value == PayloadStatus::Toggle) { + break; + case PayloadStatus::Toggle: relayToggle(relayID); + break; + case PayloadStatus::Unknown: + break; + } +} + +bool _relayHandlePayload(unsigned char relayID, const char* payload) { + auto status = relayParsePayload(payload); + if (status != PayloadStatus::Unknown) { + _relayHandleStatus(relayID, status); + return true; + } + + DEBUG_MSG_P(PSTR("[RELAY] Invalid API payload (%s)\n"), payload); + return false; +} + +bool _relayHandlePayload(unsigned char relayID, const String& payload) { + return _relayHandlePayload(relayID, payload.c_str()); +} + +bool _relayHandlePulsePayload(unsigned char id, const char* payload) { + unsigned long pulse = 1000 * atof(payload); + if (!pulse) { + return false; } + if (RELAY_PULSE_NONE != _relays[id].pulse) { + DEBUG_MSG_P(PSTR("[RELAY] Overriding relayID %u pulse settings\n"), id); + } + + _relays[id].pulse_ms = pulse; + _relays[id].pulse = relayStatus(id) ? RELAY_PULSE_ON : RELAY_PULSE_OFF; + relayToggle(id, true, false); + return true; } +bool _relayHandlePulsePayload(unsigned char id, const String& payload) { + return _relayHandlePulsePayload(id, payload.c_str()); +} + PayloadStatus _relayStatusInvert(PayloadStatus status) { return (status == PayloadStatus::On) ? PayloadStatus::Off : status; } @@ -600,8 +664,8 @@ bool relayStatus(unsigned char id, bool status, bool report, bool group_report) } _relays[id].target_status = status; - if (report) _relays[id].report = true; - if (group_report) _relays[id].group_report = true; + _relays[id].report = report; + _relays[id].group_report = group_report; relaySync(id); @@ -1009,7 +1073,7 @@ void _relayWebSocketOnAction(uint32_t client_id, const char * action, JsonObject relayID = data["id"]; } - _relayHandlePayload(relayID, data["status"]); + _relayHandlePayload(relayID, data["status"].as()); } @@ -1032,75 +1096,71 @@ void relaySetupWS() { #if API_SUPPORT -void relaySetupAPI() { +template +bool _relayApiTryHandle(ApiRequest& request, T&& callback) { + auto id_param = request.wildcard(0); + unsigned char id; + if (!_relayTryParseId(id_param.c_str(), id)) { + return false; + } - // Note that we expect a fixed number of entries. - // Otherwise, underlying vector will reserve more than we need (likely, *2 of the current size) - apiReserve(2u + (relayCount() * 2u)); + return callback(id); +} + +void relaySetupAPI() { - apiRegister({ - MQTT_TOPIC_RELAY, Api::Type::Json, ApiUnusedArg, - [](const Api&, JsonObject& root) { + apiRegister(F(MQTT_TOPIC_RELAY), + [](ApiRequest&, JsonObject& root) { JsonArray& relays = root.createNestedArray("relayStatus"); for (unsigned char id = 0; id < relayCount(); ++id) { relays.add(_relays[id].target_status ? 1 : 0); } + return true; + }, + nullptr + ); + + apiRegister(F(MQTT_TOPIC_RELAY "/+"), + [](ApiRequest& request) { + return _relayApiTryHandle(request, [&](unsigned char id) { + request.send(String(_relays[id].target_status ? 1 : 0)); + return true; + }); + }, + [](ApiRequest& request) { + return _relayApiTryHandle(request, [&](unsigned char id) { + return _relayHandlePayload(id, request.param(F("value"))); + }); } - }); + ); + + apiRegister(F(MQTT_TOPIC_PULSE "/+"), + [](ApiRequest& request) { + return _relayApiTryHandle(request, [&](unsigned char id) { + request.send(String(static_cast(_relays[id].pulse_ms) / 1000)); + return true; + }); + }, + [](ApiRequest& request) { + return _relayApiTryHandle(request, [&](unsigned char id) { + return _relayHandlePulsePayload(id, request.param(F("value"))); + }); + } + ); #if defined(ITEAD_SONOFF_IFAN02) - apiRegister({ - MQTT_TOPIC_SPEED, Api::Type::Basic, ApiUnusedArg, - [](const Api&, ApiBuffer& buffer) { - snprintf(buffer.data, buffer.size, "%u", getSpeed()); - }, - [](const Api&, ApiBuffer& buffer) { - setSpeed(atoi(buffer.data)); - snprintf(buffer.data, buffer.size, "%u", getSpeed()); - } - }); - #endif - - char path[64] = {0}; - for (unsigned char id = 0; id < relayCount(); ++id) { - sprintf_P(path, PSTR(MQTT_TOPIC_RELAY "/%u"), id); - apiRegister({ - path, Api::Type::Basic, id, - [](const Api& api, ApiBuffer& buffer) { - snprintf_P(buffer.data, buffer.size, PSTR("%d"), _relays[api.arg].target_status ? 1 : 0); + apiRegister(F(MQTT_TOPIC_SPEED), { + [](ApiRequest& request) { + request.send(String(static_cast(getSpeed()))); + return true; }, - [](const Api& api, ApiBuffer& buffer) { - if (!_relayHandlePayload(api.arg, buffer.data)) { - DEBUG_MSG_P(PSTR("[RELAY] Invalid API payload (%s)\n"), buffer.data); - return; - } - } - }); - - sprintf_P(path, PSTR(MQTT_TOPIC_PULSE "/%u"), id); - apiRegister({ - path, Api::Type::Basic, id, - [](const Api& api, ApiBuffer& buffer) { - dtostrf((double) _relays[api.arg].pulse_ms / 1000, 1, 3, buffer.data); + [](ApiRequest& request) { + setSpeed(atoi(request.param(F("value")))); + return true; }, - [](const Api& api, ApiBuffer& buffer) { - unsigned long pulse = 1000 * atof(buffer.data); - if (0 == pulse) { - return; - } - - if (RELAY_PULSE_NONE != _relays[api.arg].pulse) { - DEBUG_MSG_P(PSTR("[RELAY] Overriding relay #%d pulse settings\n"), api.arg); - } - - _relays[api.arg].pulse_ms = pulse; - _relays[api.arg].pulse = relayStatus(api.arg) - ? RELAY_PULSE_ON - : RELAY_PULSE_OFF; - relayToggle(api.arg, true, false); - } + nullptr }); - } + #endif } @@ -1243,58 +1303,31 @@ void relayMQTTCallback(unsigned int type, const char * topic, const char * paylo if (type == MQTT_MESSAGE_EVENT) { String t = mqttMagnitude((char *) topic); + unsigned char id; + if (!_relayTryParseIdFromPath(t.c_str(), id)) { + return; + } - // magnitude is relay/#/pulse if (t.startsWith(MQTT_TOPIC_PULSE)) { - - unsigned int id = t.substring(strlen(MQTT_TOPIC_PULSE)+1).toInt(); - - if (id >= relayCount()) { - DEBUG_MSG_P(PSTR("[RELAY] Wrong relayID (%d)\n"), id); - return; - } - - unsigned long pulse = 1000 * atof(payload); - if (0 == pulse) return; - - if (RELAY_PULSE_NONE != _relays[id].pulse) { - DEBUG_MSG_P(PSTR("[RELAY] Overriding relay #%d pulse settings\n"), id); - } - - _relays[id].pulse_ms = pulse; - _relays[id].pulse = relayStatus(id) ? RELAY_PULSE_ON : RELAY_PULSE_OFF; - relayToggle(id, true, false); - + _relayHandlePulsePayload(id, payload); + _relays[id].report = mqttForward(); return; - } - // magnitude is relay/# if (t.startsWith(MQTT_TOPIC_RELAY)) { - - // Get relay ID - unsigned int id = t.substring(strlen(MQTT_TOPIC_RELAY)+1).toInt(); - if (id >= relayCount()) { - DEBUG_MSG_P(PSTR("[RELAY] Wrong relayID (%d)\n"), id); - return; - } - - // Get value - auto value = relayParsePayload(payload); - if (value == PayloadStatus::Unknown) return; - - relayStatusWrap(id, value, false); - + _relayHandlePayload(id, payload); + _relays[id].report = mqttForward(); return; } - - // Check group topics + // TODO: cache group topics instead of reading settings each time? + // TODO: this is another kvs::foreach case, since we slow down MQTT when settings grow for (unsigned char i=0; i < _relays.size(); i++) { const String t = getSetting({"mqttGroup", i}); + if (!t.length()) break; - if ((t.length() > 0) && t.equals(topic)) { + if (t == topic) { auto value = relayParsePayload(payload); if (value == PayloadStatus::Unknown) return; @@ -1306,7 +1339,8 @@ void relayMQTTCallback(unsigned int type, const char * topic, const char * paylo } DEBUG_MSG_P(PSTR("[RELAY] Matched group topic for relayID %d\n"), i); - relayStatusWrap(i, value, true); + _relayHandleStatus(i, value); + _relays[i].group_report = false; } } @@ -1320,18 +1354,28 @@ void relayMQTTCallback(unsigned int type, const char * topic, const char * paylo } + // TODO: safeguard against network issues. this one has good intentions, but we may end up + // switching relays back and forth when connection is unstable but reconnects very fast after the failure + if (type == MQTT_DISCONNECT_EVENT) { - for (unsigned char i=0; i < _relays.size(); i++){ + for (unsigned char i=0; i < _relays.size(); i++) { const auto reaction = getSetting({"relayOnDisc", i}, 0); - if (1 == reaction) { // switch relay OFF - DEBUG_MSG_P(PSTR("[RELAY] Reset relay (%d) due to MQTT disconnection\n"), i); - relayStatusWrap(i, PayloadStatus::Off, false); - } else if(2 == reaction) { // switch relay ON - DEBUG_MSG_P(PSTR("[RELAY] Set relay (%d) due to MQTT disconnection\n"), i); - relayStatusWrap(i, PayloadStatus::On, false); + + bool status; + switch (reaction) { + case 1: + status = false; + break; + case 2: + status = true; + break; + default: + return; } - } + DEBUG_MSG_P(PSTR("[RELAY] Turn %s relay #%u due to MQTT disconnection\n"), status ? "ON" : "OFF", i); + relayStatus(i, status); + } } } diff --git a/code/espurna/rfbridge.cpp b/code/espurna/rfbridge.cpp index 2fdb9041..d592622c 100644 --- a/code/espurna/rfbridge.cpp +++ b/code/espurna/rfbridge.cpp @@ -1020,42 +1020,43 @@ void _rfbMqttCallback(unsigned int type, const char * topic, char * payload) { void _rfbApiSetup() { - apiReserve(3u); - - apiRegister({ - MQTT_TOPIC_RFOUT, Api::Type::Basic, ApiUnusedArg, + apiRegister(F(MQTT_TOPIC_RFOUT), apiOk, // just a stub, nothing to return - [](const Api&, ApiBuffer& buffer) { - _rfbSendFromPayload(buffer.data); + [](ApiRequest& request) { + _rfbSendFromPayload(request.param(F("value")).c_str()); + return true; } - }); + ); #if RELAY_SUPPORT - apiRegister({ - MQTT_TOPIC_RFLEARN, Api::Type::Basic, ApiUnusedArg, - [](const Api&, ApiBuffer& buffer) { + apiRegister(F(MQTT_TOPIC_RFLEARN), + [](ApiRequest& request) { + char buffer[64] { 0 }; if (_rfb_learn) { - snprintf_P(buffer.data, buffer.size, PSTR("learning id:%u,status:%c"), + snprintf_P(buffer, sizeof(buffer), PSTR("learning id:%u,status:%c"), _rfb_learn->id, _rfb_learn->status ? 't' : 'f' ); } else { - snprintf_P(buffer.data, buffer.size, PSTR("waiting")); + snprintf_P(buffer, sizeof(buffer), PSTR("waiting")); } + request.send(buffer); + return true; }, - [](const Api&, ApiBuffer& buffer) { - _rfbLearnStartFromPayload(buffer.data); + [](ApiRequest& request) { + _rfbLearnStartFromPayload(request.param(F("value")).c_str()); + return true; } - }); + ); #endif #if RFB_PROVIDER == RFB_PROVIDER_EFM8BB1 - apiRegister({ - MQTT_TOPIC_RFRAW, Api::Type::Basic, ApiUnusedArg, + apiRegister(F(MQTT_TOPIC_RFRAW), apiOk, // just a stub, nothing to return - [](const Api&, ApiBuffer& buffer) { - _rfbSendRawFromPayload(buffer.data); + [](ApiRequest& request) { + _rfbSendRawFromPayload(request.param(F("value")).c_str()); + return true; } - }); + ); #endif } diff --git a/code/espurna/sensor.cpp b/code/espurna/sensor.cpp index 2268c295..13033b73 100644 --- a/code/espurna/sensor.cpp +++ b/code/espurna/sensor.cpp @@ -449,6 +449,10 @@ void _sensorApiResetEnergy(const sensor_magnitude_t& magnitude, const char* payl sensor->resetEnergy(magnitude.index_local, energy); } +void _sensorApiResetEnergy(const sensor_magnitude_t& magnitude, const String& payload) { + _sensorApiResetEnergy(magnitude, payload.c_str()); +} + sensor::Energy _sensorEnergyTotal(unsigned char index) { sensor::Energy result; @@ -1436,44 +1440,86 @@ String _sensorApiMagnitudeName(sensor_magnitude_t& magnitude) { return name; } -void _sensorApiJsonCallback(const Api&, JsonObject& root) { - JsonArray& magnitudes = root.createNestedArray("magnitudes"); - for (auto& magnitude : _magnitudes) { - JsonArray& data = magnitudes.createNestedArray(); - data.add(_sensorApiMagnitudeName(magnitude)); - data.add(magnitude.last); - data.add(magnitude.reported); +bool _sensorApiTryParseMagnitudeIndex(const char* p, unsigned char type, unsigned char& magnitude_index) { + char* endp { nullptr }; + const unsigned long result { strtoul(p, &endp, 10) }; + if ((endp == p) || (*endp != '\0') || (result >= sensor_magnitude_t::counts(type))) { + DEBUG_MSG_P(PSTR("[RELAY] Invalid magnitude ID (%s)\n"), p); + return false; } -} -void _sensorApiGetValue(const Api& api, ApiBuffer& buffer) { - auto& magnitude = _magnitudes[api.arg]; - double value = _sensor_realtime ? magnitude.last : magnitude.reported; - dtostrf(value, 1, magnitude.decimals, buffer.data); + magnitude_index = result; + return true; } -void _sensorApiResetEnergyPutCallback(const Api& api, ApiBuffer& buffer) { - _sensorApiResetEnergy(_magnitudes[api.arg], buffer.data); +template +bool _sensorApiTryHandle(ApiRequest& request, unsigned char type, T&& callback) { + unsigned char index { 0u }; + if (request.wildcards()) { + auto index_param = request.wildcard(0); + if (!_sensorApiTryParseMagnitudeIndex(index_param.c_str(), type, index)) { + return false; + } + } + + for (auto& magnitude : _magnitudes) { + if ((type == magnitude.type) && (index == magnitude.index_global)) { + callback(magnitude); + return true; + } + } + + return false; } void _sensorApiSetup() { - apiReserve( - _magnitudes.size() + sensor_magnitude_t::counts(MAGNITUDE_ENERGY) + 1u + apiRegister(F("magnitudes"), + [](ApiRequest&, JsonObject& root) { + JsonArray& magnitudes = root.createNestedArray("magnitudes"); + for (auto& magnitude : _magnitudes) { + JsonArray& data = magnitudes.createNestedArray(); + data.add(_sensorApiMagnitudeName(magnitude)); + data.add(magnitude.last); + data.add(magnitude.reported); + } + return true; + }, + nullptr ); - apiRegister({"magnitudes", Api::Type::Json, ApiUnusedArg, _sensorApiJsonCallback}); + _magnitudeForEachCounted([](unsigned char type) { + String pattern = magnitudeTopic(type); + if (SENSOR_USE_INDEX || (sensor_magnitude_t::counts(type) > 1)) { + pattern += "/+"; + } - for (unsigned char id = 0; id < _magnitudes.size(); ++id) { - apiRegister({ - _sensorApiMagnitudeName(_magnitudes[id]).c_str(), - Api::Type::Basic, id, - _sensorApiGetValue, - (_magnitudes[id].type == MAGNITUDE_ENERGY) - ? _sensorApiResetEnergyPutCallback - : nullptr - }); - } + ApiBasicHandler get { + [type](ApiRequest& request) { + return _sensorApiTryHandle(request, type, [&](const sensor_magnitude_t& magnitude) { + char buffer[64] { 0 }; + dtostrf( + _sensor_realtime ? magnitude.last : magnitude.reported, + 1, magnitude.decimals, + buffer + ); + request.send(String(buffer)); + return true; + }); + } + }; + + ApiBasicHandler put { nullptr }; + if (type == MAGNITUDE_ENERGY) { + put = [](ApiRequest& request) { + return _sensorApiTryHandle(request, MAGNITUDE_ENERGY, [&](const sensor_magnitude_t& magnitude) { + _sensorApiResetEnergy(magnitude, request.param(F("value"))); + }); + }; + } + + apiRegister(pattern, std::move(get), std::move(put)); + }); } diff --git a/code/espurna/ssdp.cpp b/code/espurna/ssdp.cpp index 41cd39c9..4c94fadf 100644 --- a/code/espurna/ssdp.cpp +++ b/code/espurna/ssdp.cpp @@ -42,7 +42,7 @@ const char _ssdp_template[] PROGMEM = void ssdpSetup() { - webServer()->on("/description.xml", HTTP_GET, [](AsyncWebServerRequest *request) { + webServer().on("/description.xml", HTTP_GET, [](AsyncWebServerRequest *request) { DEBUG_MSG_P(PSTR("[SSDP] Schema request\n")); diff --git a/code/espurna/terminal.cpp b/code/espurna/terminal.cpp index 6d4e6773..c96fc735 100644 --- a/code/espurna/terminal.cpp +++ b/code/espurna/terminal.cpp @@ -471,24 +471,64 @@ void _terminalLoop() { } -#if WEB_SUPPORT && TERMINAL_WEB_API_SUPPORT - -bool _terminalWebApiMatchPath(AsyncWebServerRequest* request) { - const String api_path = getSetting("termWebApiPath", TERMINAL_WEB_API_PATH); - return request->url().equals(api_path); -} +#if TERMINAL_WEB_API_SUPPORT void _terminalWebApiSetup() { +#if API_SUPPORT + + apiRegister(getSetting("termWebApiPath", TERMINAL_WEB_API_PATH), + [](ApiRequest& api) { + api.handle([](AsyncWebServerRequest* request) { + AsyncResponseStream *response = request->beginResponseStream("text/plain"); + for (auto& command : _terminal.commandNames()) { + response->print(command); + response->print("\r\n"); + } + + request->send(response); + }); + return true; + }, + [](ApiRequest& api) { + // TODO: since HTTP spec allows query string to contain repeating keys, allow iteration + // over every 'value' available to provide a way to call multiple commands at once + auto cmd = api.param(F("value")); + if (!cmd.length()) { + return false; + } + + if (!cmd.endsWith("\r\n") && !cmd.endsWith("\n")) { + cmd += '\n'; + } + + api.handle([&](AsyncWebServerRequest* request) { + AsyncWebPrint::scheduleFromRequest(request, [cmd](Print& print) { + StreamAdapter stream(print, cmd.c_str(), cmd.c_str() + cmd.length() + 1); + terminal::Terminal handler(stream); + handler.processLine(); + }); + }); + + return true; + } + ); + +#else + webRequestRegister([](AsyncWebServerRequest* request) { - // continue to the next handler if path does not match - if (!_terminalWebApiMatchPath(request)) return false; + String path(F(API_BASE_PATH)); + path += getSetting("termWebApiPath", TERMINAL_WEB_API_PATH); + if (path != request->url()) { + return false; + } - // return 'true' after this point, since we did handle the request - webLog(request); - if (!apiAuthenticate(request)) return true; + if (!apiAuthenticate(request)) { + request->send(403); + return true; + } - auto* cmd_param = request->getParam("line", (request->method() == HTTP_PUT)); + auto* cmd_param = request->getParam("value", (request->method() == HTTP_PUT)); if (!cmd_param) { request->send(500); return true; @@ -514,9 +554,11 @@ void _terminalWebApiSetup() { return true; }); +#endif // API_SUPPORT + } -#endif // WEB_SUPPORT && TERMINAL_WEB_API_SUPPORT +#endif // TERMINAL_WEB_API_SUPPORT #if MQTT_SUPPORT && TERMINAL_MQTT_SUPPORT diff --git a/code/espurna/web.cpp b/code/espurna/web.cpp index b2fd3cf5..6fc4dee0 100644 --- a/code/espurna/web.cpp +++ b/code/espurna/web.cpp @@ -514,8 +514,8 @@ bool webAuthenticate(AsyncWebServerRequest *request) { // ----------------------------------------------------------------------------- -AsyncWebServer * webServer() { - return _server; +AsyncWebServer& webServer() { + return *_server; } void webBodyRegister(web_body_callback_f callback) { @@ -536,22 +536,37 @@ uint16_t webPort() { } void webLog(AsyncWebServerRequest *request) { - DEBUG_MSG_P(PSTR("[WEBSERVER] Request: %s %s\n"), request->methodToString(), request->url().c_str()); + DEBUG_MSG_P(PSTR("[WEBSERVER] %s %s\n"), request->methodToString(), request->url().c_str()); } +class WebAccessLogHandler : public AsyncWebHandler { + bool canHandle(AsyncWebServerRequest* request) override { + webLog(request); + return false; + } +}; + void webSetup() { // Cache the Last-Modifier header value snprintf_P(_last_modified, sizeof(_last_modified), PSTR("%s %s GMT"), __DATE__, __TIME__); - // Create server + // Create server and install global URL debug handler + // (since we don't want to forcibly add it to each instance) unsigned int port = webPort(); _server = new AsyncWebServer(port); +#if DEBUG_SUPPORT + if (getSetting("webAccessLog", (1 == WEB_ACCESS_LOG))) { + static WebAccessLogHandler log; + _server->addHandler(&log); + } +#endif + // Rewrites _server->rewrite("/", "/index.html"); - // Serve home (basic authentication protection) + // Serve home (basic authentication protection is done manually b/c the handler is installed through callback functions) #if WEB_EMBEDDED _server->on("/index.html", HTTP_GET, _onHome); #endif diff --git a/code/espurna/web.h b/code/espurna/web.h index 96a31b1b..671a3d99 100644 --- a/code/espurna/web.h +++ b/code/espurna/web.h @@ -80,7 +80,7 @@ struct AsyncWebPrint : public Print { using web_body_callback_f = std::function; using web_request_callback_f = std::function; -AsyncWebServer* webServer(); +AsyncWebServer& webServer(); bool webAuthenticate(AsyncWebServerRequest *request); void webLog(AsyncWebServerRequest *request); diff --git a/code/espurna/ws.cpp b/code/espurna/ws.cpp index 3adecb24..7c4c4ade 100644 --- a/code/espurna/ws.cpp +++ b/code/espurna/ws.cpp @@ -714,7 +714,7 @@ void wsSend_P(uint32_t client_id, PGM_P payload) { void wsSetup() { _ws.onEvent(_wsEvent); - webServer()->addHandler(&_ws); + webServer().addHandler(&_ws); // CORS const String webDomain = getSetting("webDomain", WEB_REMOTE_DOMAIN); @@ -723,7 +723,7 @@ void wsSetup() { DefaultHeaders::Instance().addHeader("Access-Control-Allow-Credentials", "true"); } - webServer()->on("/auth", HTTP_GET, _onAuth); + webServer().on("/auth", HTTP_GET, _onAuth); wsRegister() .onConnected(_wsOnConnected)