From b7dcb1b4e6d4f93ba43287e64732f7327820d67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Sun, 26 May 2024 14:27:03 -0400 Subject: [PATCH] [protocols] Add CoAP support --- cmake/OssiaDeps.cmake | 7 + cmake/deps/coap.cmake | 22 + .../protocols/coap/coap_client_protocol.cpp | 486 ++++++++++++++++++ .../protocols/coap/coap_client_protocol.hpp | 57 ++ .../protocols/coap/coap_server_protocol.cpp | 0 .../protocols/coap/coap_server_protocol.hpp | 0 .../protocols/coap/link_format_parser.cpp | 89 ++++ .../protocols/coap/link_format_parser.hpp | 34 ++ src/ossia_features.cmake | 6 + src/ossia_sources.cmake | 13 + 10 files changed, 714 insertions(+) create mode 100644 cmake/deps/coap.cmake create mode 100644 src/ossia/protocols/coap/coap_client_protocol.cpp create mode 100644 src/ossia/protocols/coap/coap_client_protocol.hpp create mode 100644 src/ossia/protocols/coap/coap_server_protocol.cpp create mode 100644 src/ossia/protocols/coap/coap_server_protocol.hpp create mode 100644 src/ossia/protocols/coap/link_format_parser.cpp create mode 100644 src/ossia/protocols/coap/link_format_parser.hpp diff --git a/cmake/OssiaDeps.cmake b/cmake/OssiaDeps.cmake index 87900fcb404..7c30fefe37a 100644 --- a/cmake/OssiaDeps.cmake +++ b/cmake/OssiaDeps.cmake @@ -103,6 +103,13 @@ include(deps/unordered_dense) include(deps/verdigris) include(deps/websocketpp) +if(OSSIA_PROTOCOL_COAP) + include(deps/coap) + if(NOT TARGET libcoap::coap-3) + set(OSSIA_PROTOCOL_COAP FALSE CACHE INTERNAL "" FORCE) + endif() +endif() + if(OSSIA_PROTOCOL_MQTT5) include(deps/mqtt) if(NOT TARGET Async::MQTT5) diff --git a/cmake/deps/coap.cmake b/cmake/deps/coap.cmake new file mode 100644 index 00000000000..a23158b1a1d --- /dev/null +++ b/cmake/deps/coap.cmake @@ -0,0 +1,22 @@ +if(OSSIA_USE_SYSTEM_LIBRARIES) + find_path(LIBCOAP_INCLUDEDIR coap3/libcoap.h) + find_library(LIBCOAP_LIBRARIES coap-3) + add_library(coap-3 IMPORTED SHARED GLOBAL) + add_library(libcoap::coap-3 ALIAS coap-3) + target_include_directories(coap-3 INTERFACE "${LIBCOAP_INCLUDEDIR}") + set_target_properties(coap-3 PROPERTIES IMPORTED_LOCATION "${LIBCOAP_LIBRARIES}") +endif() + +if(NOT TARGET libcoap::coap-3) + include(FetchContent) + FetchContent_Declare( + libcoap + GIT_REPOSITORY "https://github.com/obgm/libcoap" + GIT_TAG develop + GIT_PROGRESS true + ) + + set(ENABLE_DTLS OFF) + FetchContent_MakeAvailable(libcoap) +endif() + diff --git a/src/ossia/protocols/coap/coap_client_protocol.cpp b/src/ossia/protocols/coap/coap_client_protocol.cpp new file mode 100644 index 00000000000..d6bae585ba1 --- /dev/null +++ b/src/ossia/protocols/coap/coap_client_protocol.cpp @@ -0,0 +1,486 @@ +#include "coap_client_protocol.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +namespace ossia::net +{ +struct coap_destination +{ + coap_uri_t uri{}; + coap_address_t dst{}; + + static std::optional parse(const char* coap_uri) + { + coap_uri_t uri{}; + coap_address_t dst{}; + int len = coap_split_uri((const unsigned char*)coap_uri, strlen(coap_uri), &uri); + if(len != 0) + { + coap_log_warn("Failed to parse uri %s\n", coap_uri); + return {}; + } + + len = resolve_address(&uri.host, uri.port, &dst, 1 << uri.scheme); + if(len <= 0) + { + coap_log_warn( + "Failed to resolve address %*.*s\n", (int)uri.host.length, + (int)uri.host.length, (const char*)uri.host.s); + return {}; + } + + return coap_destination{uri, dst}; + } + + static int resolve_address( + coap_str_const_t* host, uint16_t port, coap_address_t* dst, int scheme_hint_bits) + { + int ret = 0; + coap_addr_info_t* addr_info; + + addr_info = coap_resolve_address_info( + host, port, port, port, port, AF_UNSPEC, scheme_hint_bits, + COAP_RESOLVE_TYPE_REMOTE); + if(addr_info) + { + ret = 1; + *dst = addr_info->addr; + } + + coap_free_address_info(addr_info); + return ret; + } +}; + +struct coap_context +{ + coap_context() { coap_startup(); } + ~coap_context() { coap_cleanup(); } +}; + +enum coap_reply_action +{ + None, + DeleteSession +}; +using coap_reply_callback + = std::function; +struct coap_session; +struct coap_client +{ + static const inline coap_context lib_ctx; + + coap_context_t* ctx = nullptr; + + coap_client(); + ~coap_client() + { + in_flight.clear(); + coap_free_context(ctx); + } + + std::shared_ptr get(std::string_view req, coap_reply_callback); + std::shared_ptr subscribe(std::string_view req, coap_reply_callback); + void unsubscribe(const coap_session& dest); + std::shared_ptr + post(std::string_view req, std::string_view fmt, std::string_view bytes); + + void poll() { coap_io_process(ctx, COAP_IO_NO_WAIT); } + +private: + boost::unordered::concurrent_flat_map< + coap_session_t*, std::shared_ptr, std::hash> + in_flight; +}; + +struct coap_session +{ + coap_client& client; + + coap_session_t* session = nullptr; + coap_optlist_t* optlist = nullptr; + coap_pdu_t* pdu = nullptr; + + coap_reply_callback on_reply + = [](const coap_pdu_t* received, const coap_mid_t mid) -> coap_reply_action { + return DeleteSession; + }; + + int mid = 0; + + int is_mcast{}; + unsigned char scratch[255]; + static inline int have_response = 0; + + explicit coap_session( + coap_client& clt, const coap_destination& dest, coap_reply_callback rep = {}) + : client{clt} + { + if(rep) + on_reply = std::move(rep); + + if(dest.uri.scheme == COAP_URI_SCHEME_COAP) + { + qDebug("UDP?"); + session = coap_new_client_session(client.ctx, NULL, &dest.dst, COAP_PROTO_UDP); + } + else if(dest.uri.scheme == COAP_URI_SCHEME_COAP_TCP) + { + qDebug("TCP?"); + session = coap_new_client_session(client.ctx, NULL, &dest.dst, COAP_PROTO_TCP); + } + else if(dest.uri.scheme == COAP_URI_SCHEME_COAP_WS) + { + qDebug("WS?"); + session = coap_new_client_session(client.ctx, NULL, &dest.dst, COAP_PROTO_WS); + coap_ws_set_host_request(session, coap_make_str_const("localhost")); + } + + if(!session) + throw std::runtime_error("Cannot create CoAP session"); + + coap_session_set_app_data(session, this); + + is_mcast = coap_is_mcast(&dest.dst); + } + + bool init_message( + const coap_destination& dest, coap_pdu_type_t confirmable, coap_pdu_code_t code) + { + mid = coap_new_message_id(session); + pdu = coap_pdu_init(confirmable, code, mid, coap_session_max_pdu_size(session)); + if(!pdu) + { + return false; + } + + int len = coap_uri_into_options( + &dest.uri, &dest.dst, &optlist, 1, scratch, sizeof(scratch)); + if(len) + { + return false; + } + return true; + } + + void get(const coap_destination& dest) + { + if(!init_message( + dest, is_mcast ? COAP_MESSAGE_NON : COAP_MESSAGE_CON, COAP_REQUEST_CODE_GET)) + return; + + send(); + } + + void subscribe(const coap_destination& dest) + { + if(!init_message( + dest, is_mcast ? COAP_MESSAGE_NON : COAP_MESSAGE_CON, COAP_REQUEST_CODE_GET)) + return; + + if(!add_option(COAP_OPTION_OBSERVE, COAP_OBSERVE_ESTABLISH, nullptr)) + return; + send(); + } + + bool add_option(uint16_t number, size_t length, const uint8_t* data) + { + if(!coap_insert_optlist(&optlist, coap_new_optlist(number, length, data))) + { + return false; + } + return true; + } + + void post(const coap_destination& dest, std::string_view fmt, std::string_view bytes) + { + if(!init_message( + dest, is_mcast ? COAP_MESSAGE_NON : COAP_MESSAGE_CON, COAP_REQUEST_CODE_POST)) + return; + + add_option(COAP_OPTION_CONTENT_TYPE, fmt.length(), (const uint8_t*)fmt.data()); + coap_add_data(pdu, bytes.size(), (const uint8_t*)bytes.data()); + send(); + } + + void send() + { + if(optlist) + { + int res = coap_add_optlist_pdu(pdu, &optlist); + if(res != 1) + { + coap_log_warn("Failed to add options to PDU\n"); + return; + } + } + + coap_send(session, pdu); + } + + ~coap_session() + { + coap_delete_optlist(optlist); + coap_session_release(session); + } +}; + +coap_client::coap_client() +{ + coap_set_log_level(COAP_LOG_EMERG); + + if(!(ctx = coap_new_context(nullptr))) + throw std::runtime_error("Cannot create libcoap context"); + + coap_context_set_block_mode(ctx, COAP_BLOCK_USE_LIBCOAP | COAP_BLOCK_SINGLE_BODY); + + coap_register_response_handler( + ctx, [](coap_session_t* session, const coap_pdu_t* sent, + const coap_pdu_t* received, const coap_mid_t mid) { + try + { + auto sesh = (coap_session*)coap_session_get_app_data(session); + const auto action = sesh->on_reply(received, mid); + if(action == DeleteSession) + { + sesh->client.in_flight.erase(sesh->session); + } + return COAP_RESPONSE_OK; + } + catch(...) + { + return COAP_RESPONSE_FAIL; + } + }); +} + +std::shared_ptr +coap_client::get(std::string_view req, coap_reply_callback rep) +{ + qDebug() << "GET:" << req; + auto dest = coap_destination::parse(req.data()); + if(!dest) + return {}; + auto s = std::make_shared(*this, *dest, rep); + s->get(*dest); + this->in_flight.emplace(s->session, s); + return s; +} + +std::shared_ptr +coap_client::subscribe(std::string_view req, coap_reply_callback rep) +{ + qDebug() << "S:" << req; + auto dest = coap_destination::parse(req.data()); + if(!dest) + return {}; + auto s = std::make_shared(*this, *dest, rep); + s->subscribe(*dest); + this->in_flight.emplace(s->session, s); + return s; +} + +void coap_client::unsubscribe(const coap_session& dest) +{ + in_flight.erase(dest.session); +} + +std::shared_ptr +coap_client::post(std::string_view req, std::string_view fmt, std::string_view bytes) +{ + qDebug() << "P:" << req; + auto dest = coap_destination::parse(req.data()); + if(!dest) + return {}; + + auto s = std::make_shared(*this, *dest); + s->post(*dest, fmt, bytes); + this->in_flight.emplace(s->session, s); + + return {}; +} + +coap_client_protocol::coap_client_protocol( + network_context_ptr ctx, const coap_client_configuration& conf) + : m_context{ctx} + , m_conf{conf} + , m_strand{boost::asio::make_strand(m_context->context)} + , m_timer{m_strand} +{ + m_client = std::make_unique(); + struct + { + coap_client_protocol& self; + + void operator()(const ossia::net::udp_configuration& conf) + { + self.m_host += "coap://"; + self.m_host += conf.remote->host; + if(conf.remote->port != 0) + { + self.m_host += ':'; + self.m_host += std::to_string(conf.remote->port); + } + qDebug() << self.m_host; + } + + void operator()(const ossia::net::tcp_configuration& conf) + { + self.m_host += "coap+tcp://"; + self.m_host += conf.host; + if(conf.port != 0) + { + self.m_host += ':'; + self.m_host += std::to_string(conf.port); + } + } + + void operator()(const ossia::net::ws_client_configuration& conf) + { + if(conf.url.starts_with("ws://")) + self.m_host = "coap+" + conf.url; + else + self.m_host = "coap+ws://" + conf.url; + } + } setup{*this}; + ossia::visit(setup, conf.transport); +} + +coap_client_protocol::~coap_client_protocol() { } + +bool coap_client_protocol::pull(ossia::net::parameter_base& param) +{ + auto req = m_client->get( + param.get_node().osc_address(), + [¶m](const coap_pdu_t* received, coap_mid_t mid) -> coap_reply_action { + size_t len; + const uint8_t* databuf; + size_t offset; + size_t total; + + if(coap_get_data_large(received, &len, &databuf, &offset, &total)) + { + param.set_value(std::string((const char*)databuf, len)); + } + + return coap_reply_action::DeleteSession; + }); + + return false; +} + +bool coap_client_protocol::push( + const ossia::net::parameter_base& p, const ossia::value& v) +{ + auto req = m_client->post( + m_host + p.get_node().osc_address(), "text/plain", ossia::convert(v)); + return false; +} + +bool coap_client_protocol::push_raw(const ossia::net::full_parameter_data&) +{ + return false; +} + +bool coap_client_protocol::observe(ossia::net::parameter_base& param, bool obs) +{ + const auto& addr = param.get_node().osc_address(); + if(obs) + { + if(m_topics.contains(addr)) + return false; + + auto req = m_client->subscribe( + m_host + param.get_node().osc_address(), + [¶m](const coap_pdu_t* received, coap_mid_t mid) -> coap_reply_action { + size_t len; + const uint8_t* databuf; + size_t offset; + size_t total; + + if(coap_get_data_large(received, &len, &databuf, &offset, &total)) + { + param.set_value(std::string((const char*)databuf, len)); + } + + return coap_reply_action::None; + }); + + m_topics.emplace(param.get_node().osc_address(), req); + } + else + { + if(!m_topics.contains(addr)) + return false; + m_topics.visit(addr, [this](const auto& sesh) { + if(sesh.second) + m_client->unsubscribe(*sesh.second); + }); + } + return true; +} + +void coap_client_protocol::parse_namespace( + ossia::net::node_base& dev, std::string_view data) +{ + if(auto res = ossia::coap::parse_link_format(data)) + { + for(const auto& [name, opts] : res->resources) + { + ossia::create_parameter(dev, name, "string"); + } + } +} +bool coap_client_protocol::update(ossia::net::node_base& node_base) +{ + auto req = m_client->get( + m_host + "/.well-known/core", + [this, + &node_base](const coap_pdu_t* received, coap_mid_t mid) -> coap_reply_action { + size_t len; + const uint8_t* databuf; + size_t offset; + size_t total; + + if(coap_get_data_large(received, &len, &databuf, &offset, &total)) + { + parse_namespace(node_base, std::string_view((const char*)databuf, len)); + } + + return coap_reply_action::DeleteSession; + }); + return true; +} + +void coap_client_protocol::set_device(device_base& dev) +{ + using namespace std::chrono; + m_device = &dev; + + m_timer.set_delay(5ms); + m_timer.start([this](this auto&& self) -> void { m_client->poll(); }); +} + +void coap_client_protocol::stop() +{ + m_timer.stop(); + std::future wait = boost::asio::dispatch( + boost::asio::bind_executor(m_strand, std::packaged_task([this] { + m_topics.clear(); + m_client.reset(); + }))); + wait.get(); +} +} diff --git a/src/ossia/protocols/coap/coap_client_protocol.hpp b/src/ossia/protocols/coap/coap_client_protocol.hpp new file mode 100644 index 00000000000..11db4d76956 --- /dev/null +++ b/src/ossia/protocols/coap/coap_client_protocol.hpp @@ -0,0 +1,57 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace ossia::net +{ +struct coap_session; +using strand_type + = decltype(boost::asio::make_strand(std::declval())); +struct coap_client_configuration +{ + ossia::variant + transport; +}; +struct coap_client; +class OSSIA_EXPORT coap_client_protocol + : public ossia::net::protocol_base + , public Nano::Observer +{ +public: + explicit coap_client_protocol( + ossia::net::network_context_ptr, const coap_client_configuration& conf); + ~coap_client_protocol(); + + bool pull(parameter_base&) override; + bool push(const parameter_base&, const value& v) override; + bool push_raw(const full_parameter_data&) override; + bool observe(parameter_base&, bool) override; + bool update(node_base& node_base) override; + void set_device(device_base& dev) override; + void stop() override; + +private: + void parse_namespace(ossia::net::node_base& dev, std::string_view data); + + ossia::net::network_context_ptr m_context; + ossia::net::device_base* m_device{}; + coap_client_configuration m_conf{}; + + std::unique_ptr m_client; + boost::unordered::concurrent_flat_map> + m_topics; + + strand_type m_strand; + std::string m_host; + + ossia::timer m_timer; +}; +} diff --git a/src/ossia/protocols/coap/coap_server_protocol.cpp b/src/ossia/protocols/coap/coap_server_protocol.cpp new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ossia/protocols/coap/coap_server_protocol.hpp b/src/ossia/protocols/coap/coap_server_protocol.hpp new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ossia/protocols/coap/link_format_parser.cpp b/src/ossia/protocols/coap/link_format_parser.cpp new file mode 100644 index 00000000000..a4d59e51d89 --- /dev/null +++ b/src/ossia/protocols/coap/link_format_parser.cpp @@ -0,0 +1,89 @@ +#include "link_format_parser.hpp" + +#include + +namespace ossia::coap +{ + +namespace +{ +struct actions +{ + link_format res; + link_format::resource cur_resource; + + void begin_resource(std::string x) + { + cur_resource = {.path = std::move(x), .options = {}}; + } + + void end_resource(const auto&...) + { + res.resources.push_back(std::move(cur_resource)); + cur_resource = {}; + } + + void pair_option(const auto& res) + { + auto pair_name = at_c<0>(res); + auto pair_value_opt = at_c<1>(res); + if(pair_value_opt) + { + auto& pair_value = *pair_value_opt; + switch(pair_value.which()) + { + case 0: + cur_resource.options[pair_name] = boost::get(pair_value); + break; + case 1: + cur_resource.options[pair_name] = boost::get(pair_value); + break; + } + } + else + { + cur_resource.options[pair_name] = ossia::monostate{}; + } + } +}; + +// clang-format off +using namespace boost::spirit; + +#define EVENT(e) ([](auto& ctx) { x3::get(ctx).e(x3::_attr(ctx)); }) +template struct as_type { + auto operator()(auto p) const { return x3::rule{} = p; } +}; +static constexpr as_type as_string{}; + +static const auto resource_identifier_char = x3::alnum | x3::char_('_') | x3::char_('/'); +static const auto resource_identifier = as_string(+resource_identifier_char); +static const auto resource_name = '<' >> resource_identifier[EVENT(begin_resource)] >> '>'; + +static const auto option_identifier_char = x3::alnum | x3::char_('_'); +static const auto option_identifier = as_string(+option_identifier_char); +static const auto str_option = x3::lexeme['"' >> as_string(+(x3::char_ - '"')) >> '"']; +static const auto num_option = x3::int64; +static const auto option_value = (str_option | num_option); +static const auto option = (option_identifier >> -('=' >> option_value))[EVENT(pair_option)]; + +static const auto resource = (resource_name >> *(x3::lit(';') >> (option % x3::lit(';'))))[EVENT(end_resource)]; +static const auto resources = (resource % x3::lit(',')); +#undef EVENT +// clang-format on +} + +std::optional parse_link_format(std::string_view str) +{ + ossia::coap::actions r; + auto first = str.begin(); + auto last = str.end(); + bool res = phrase_parse( + first, last, boost::spirit::x3::with(r)[resources], + boost::spirit::x3::ascii::space, boost::spirit::x3::skip_flag::post_skip); + + if(!res || first != last) + return std::nullopt; + return std::move(r.res); +} +} diff --git a/src/ossia/protocols/coap/link_format_parser.hpp b/src/ossia/protocols/coap/link_format_parser.hpp new file mode 100644 index 00000000000..3a514716486 --- /dev/null +++ b/src/ossia/protocols/coap/link_format_parser.hpp @@ -0,0 +1,34 @@ +#pragma once +#include + +#include + +#include +#include +#include +#include +#include + +namespace ossia::coap +{ + +struct link_format +{ + struct resource + { + std::string path; + boost::container::flat_map< + std::string, ossia::variant> + options; + }; + + std::vector resources; +}; + +} + +namespace ossia::coap +{ +//! Parser for RFC 6690 CoRE Link Format +std::optional parse_link_format(std::string_view str); +} diff --git a/src/ossia_features.cmake b/src/ossia_features.cmake index 699b78dbf73..96e76e24068 100644 --- a/src/ossia_features.cmake +++ b/src/ossia_features.cmake @@ -24,6 +24,7 @@ if(IOS OR CMAKE_SYSTEM_NAME MATCHES Emscripten) set(OSSIA_PROTOCOL_SERIAL FALSE CACHE INTERNAL "") set(OSSIA_PROTOCOL_ARTNET FALSE CACHE INTERNAL "") set(OSSIA_PROTOCOL_MQTT5 FALSE CACHE INTERNAL "") + set(OSSIA_PROTOCOL_COAP FALSE CACHE INTERNAL "") endif() if(NOT OSSIA_QML) @@ -165,6 +166,11 @@ if (OSSIA_PROTOCOL_MQTT5) set(OSSIA_PROTOCOLS ${OSSIA_PROTOCOLS} mqtt5) endif() +if (OSSIA_PROTOCOL_COAP) + target_sources(ossia PRIVATE ${OSSIA_COAP_SRCS} ${OSSIA_COAP_HEADERS}) + target_link_libraries(ossia PRIVATE libcoap::coap-3) + set(OSSIA_PROTOCOLS ${OSSIA_PROTOCOLS} coap) +endif() # Additional features if(OSSIA_C) diff --git a/src/ossia_sources.cmake b/src/ossia_sources.cmake index b45bd1c09a2..2b6b74d242b 100644 --- a/src/ossia_sources.cmake +++ b/src/ossia_sources.cmake @@ -536,6 +536,19 @@ set(OSSIA_MQTT5_HEADERS set(OSSIA_MQTT5_SRCS "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/mqtt/mqtt_protocol.cpp") +set(OSSIA_COAP_HEADERS + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/coap/coap_client_protocol.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/coap/coap_server_protocol.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/coap/link_format_parser.hpp" +) + +set(OSSIA_COAP_SRCS + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/coap/coap_client_protocol.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/coap/coap_server_protocol.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/protocols/coap/link_format_parser.cpp" +) + + set(OSSIA_WS_CLIENT_HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/ossia-qt/websocket-generic-client/ws_generic_client_protocol.hpp")