diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..587643a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +lib/ +tools/vcpkg/ +tools/webthing-tester/ +.vscode/ +todo.md diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..2e60df9 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.1) +cmake_policy(SET CMP0091 NEW) +project(Webthing-CPP VERSION 1.0 DESCRIPTION "Webthing implementation for C++" LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) + +IF(WIN32) + ADD_DEFINITIONS(-DNOMINMAX) +ENDIF(WIN32) + +IF(WIN32) + ADD_DEFINITIONS(-DNOMINMAX) +ENDIF(WIN32) + +option(WT_UNORDERED_JSON_OBJECT_LAYOUT "Enable unordered json object layout." OFF) +IF(WT_UNORDERED_JSON_OBJECT_LAYOUT) + ADD_DEFINITIONS(-DWT_UNORDERED_JSON_OBJECT_LAYOUT) +ENDIF(WT_UNORDERED_JSON_OBJECT_LAYOUT) +message("WT_UNORDERED_JSON_OBJECT_LAYOUT: ${WT_UNORDERED_JSON_OBJECT_LAYOUT}") + +option(WT_USE_JSON_SCHEMA_VALIDATION "Enable json schema validation of properties and actions." ON) +IF(WT_USE_JSON_SCHEMA_VALIDATION) + ADD_DEFINITIONS(-DWT_USE_JSON_SCHEMA_VALIDATION) +ENDIF(WT_USE_JSON_SCHEMA_VALIDATION) +message("WT_USE_JSON_SCHEMA_VALIDATION: ${WT_USE_JSON_SCHEMA_VALIDATION}") + +option(WT_WITH_SSL "Enable SSL support." ON) +IF(WT_WITH_SSL) + ADD_DEFINITIONS(-DWT_WITH_SSL) +ENDIF(WT_WITH_SSL) +message("WT_WITH_SSL: ${WT_WITH_SSL}") + +set(VCPKG_BUILD_TYPE ${CMAKE_BUILD_TYPE}) +message("CMAKE_BUILD_TYPE: ${CMAKE_BUILD_TYPE}") +message("VCPKG_BUILD_TYPE: ${VCPKG_BUILD_TYPE}") + +find_path(UWEBSOCKETS_INCLUDE_DIRS "uwebsockets/App.h") +message("µWebsockets include dir: ${UWEBSOCKETS_INCLUDE_DIRS}") +IF (WIN32) + find_library(LIBUSOCKETS_STATIC uSockets.lib) +ELSE(WIN32) + find_library(LIBUSOCKETS_STATIC libuSockets.a) +ENDIF(WIN32) +message(${LIBUSOCKETS_STATIC}) + +find_path(MDNS_INCLUDE_DIRS "mdns.h") +message("mdns include dir: ${MDNS_INCLUDE_DIRS}") + +find_package(mdns REQUIRED) +find_package(nlohmann_json 3.11.2 REQUIRED) +find_package(nlohmann_json_schema_validator REQUIRED) +find_package(libuv REQUIRED NO_MODULE) +find_package(ZLIB REQUIRED) + +IF(WT_WITH_SSL) + find_package(OpenSSL REQUIRED) +ENDIF(WT_WITH_SSL) + +add_subdirectory(examples) +add_subdirectory(test) diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..4f93b4e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +Webthing-CPP is licensed under the MIT License + +Copyright (c) 2023 Benno Waldhauer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..b62ffe9 --- /dev/null +++ b/build.bat @@ -0,0 +1,49 @@ +@echo off + +set "script_path=%~f0" +set "base_dir=%~dp0" +set "build_dir=%base_dir%build" +set "toolchain_file=%base_dir%tools\vcpkg\scripts\buildsystems\vcpkg.cmake" + +echo %* | find /i "clean" > nul +if %errorlevel% equ 0 ( + echo Cleaning project - removing %build_dir% + rmdir /s /q %build_dir% +) else ( + echo Using cache for project build +) + +echo %* | find /i "release" > nul +if %errorlevel% equ 0 ( + set "build_type=Release" +) else ( + set "build_type=Debug" +) +echo Project build type: %build_type% + +echo %* | find /i "Win32" > nul +if %errorlevel% equ 0 ( + set "build_arch=Win32" + set "vcpkg_triplet=x86-windows-static" +) else ( + set "build_arch=x64" + set "vcpkg_triplet=x64-windows-static" +) +echo Project architecture: %build_arch% + + +echo %* | find /i "with_ssl" > nul +if %errorlevel% equ 0 ( + set "ssl_support=ON" + set "vcpkg_file=vcpkg-with-ssl.json" +) else ( + set "ssl_support=OFF" + set "vcpkg_file=vcpkg-no-ssl.json" +) +echo Project SSL support: %ssl_support% +copy %vcpkg_file% vcpkg.json + +cmake -B "%build_dir%" -S . -DWT_WITH_SSL=%ssl_support% -DCMAKE_BUILD_TYPE=%build_type% -DCMAKE_TOOLCHAIN_FILE="%toolchain_file%" -DVCPKG_TARGET_TRIPLET="%vcpkg_triplet%" -G "Visual Studio 15 2017" -A "%build_arch%" +cmake --build "%build_dir%" --config "%build_type%" + +ctest --test-dir "%build_dir%\test\" \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..78bd5c1 --- /dev/null +++ b/build.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +script_path=$(realpath "$0") +base_dir="$( dirname "$script_path" )" +build_dir="$base_dir/build" +toolchain_file="$base_dir/tools/vcpkg/scripts/buildsystems/vcpkg.cmake" + +if [[ "${@#clean}" = "$@" ]] +then + echo "use cache for project build" +else + echo "clean project - remove $build_dir" + rm -rf $build_dir +fi + +if [[ "${@#release}" = "$@" ]] +then + build_type="Debug" +else + build_type="Release" + rm -rf $build_dir +fi +echo "project build type: $build_type" + +if [[ "${@#with_ssl}" = "$@" ]] +then + ssl_support="OFF" + vcpkg_file=vcpkg-no-ssl.json +else + ssl_support="ON" + vcpkg_file=vcpkg-with-ssl.json +fi +echo "project SSL support: $ssl_support" +cp $vcpkg_file vcpkg.json + + +cmake -B build -S . -D"WT_WITH_SSL=$ssl_support" -D"CMAKE_BUILD_TYPE=$build_type" -D"CMAKE_TOOLCHAIN_FILE=$toolchain_file" -D"CMAKE_MAKE_PROGRAM:PATH=make" -D"CMAKE_CXX_COMPILER=g++" +cmake --build build + +ctest --test-dir build/test/ \ No newline at end of file diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..e545b48 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,33 @@ +set(LIBS_FOR_EXAMPLES + ${LIBUSOCKETS_STATIC} + nlohmann_json::nlohmann_json + nlohmann_json_schema_validator + ZLIB::ZLIB +) + +set(INCLUDES_FOR_EXAMPLES ../include ${UWEBSOCKETS_INCLUDE_DIRS} ${MDNS_INCLUDE_DIRS}) + + +function(create_example_binary cpp_file) + cmake_path(GET cpp_file STEM target) + message("configure example '${target}'") + add_executable("${target}" ${cpp_file}) + + set_property(TARGET "${target}" PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + + target_include_directories("${target}" PRIVATE ${INCLUDES_FOR_EXAMPLES}) + target_link_libraries("${target}" PRIVATE ${LIBS_FOR_EXAMPLES}) + + IF(WIN32) + target_link_libraries("${target}" PRIVATE uv_a) + ENDIF(WIN32) + + IF(WT_WITH_SSL) + target_link_libraries("${target}" PRIVATE OpenSSL::SSL OpenSSL::Crypto) + ENDIF(WT_WITH_SSL) + +endfunction() + +create_example_binary(single-thing.cpp) +create_example_binary(multiple-things.cpp) +create_example_binary(gui-thing.cpp) diff --git a/examples/gui-html.hpp b/examples/gui-html.hpp new file mode 100644 index 0000000..54e2dd9 --- /dev/null +++ b/examples/gui-html.hpp @@ -0,0 +1,401 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +constexpr char gui_html[] = R"""( + + + + Slot Machine GUI + + + + + + +
+
+

Slot Machine

+
+ + + +
+
+
+

Flush of ... 16€

+

Flush of ... 12€

+

Flush of ♠ .... 8€

+

Flush of ♣ .... 4€

+
+
+ Drop a coin here or hit
'space' to start a game +
+
+
+
+
+ + +)"""; \ No newline at end of file diff --git a/examples/gui-thing.cpp b/examples/gui-thing.cpp new file mode 100644 index 0000000..b07c852 --- /dev/null +++ b/examples/gui-thing.cpp @@ -0,0 +1,224 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include "bw/webthing/webthing.hpp" +#include "gui-html.hpp" + +using namespace bw::webthing; + +struct SlotMachine +{ + typedef std::tuple card_set; + const card_set unknown_cards = {"?", "?", "?"}; + card_set current_cards = unknown_cards; + + int coins_inserted = 0; + + std::function credit_changed_callback; + std::function cards_changed_callback; + std::function win_callback; + + void on_credit_changed(std::function callback) + { + credit_changed_callback = callback; + } + + void on_cards_changed(std::function callback) + { + cards_changed_callback = callback; + } + + void on_win(std::function callback) + { + win_callback = callback; + } + + void set_cards(const card_set& cards) + { + current_cards = cards; + if(cards_changed_callback) + cards_changed_callback(current_cards); + } + + void insert_coin() + { + coins_inserted++; + if(credit_changed_callback) + credit_changed_callback(coins_inserted); + } + + void consume_coin() + { + if(coins_inserted < 1) + throw std::runtime_error("The credit is exhausted."); + + coins_inserted--; + if(credit_changed_callback) + credit_changed_callback(coins_inserted); + } + + void next_round() + { + consume_coin(); + set_cards(unknown_cards); + + // some addtional delay + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + auto n_to_c = [](int n) + { + switch(n) + { + case 1: + return "♦"; + case 2: + return "♥"; + case 3: + return "♠"; + default: + return "♣"; + } + }; + + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> dist(1, 4); + + auto c1 = n_to_c(dist(gen)); + auto c2 = n_to_c(dist(gen)); + auto c3 = n_to_c(dist(gen)); + card_set cards{c1, c2, c3}; + + set_cards(cards); + + int win = calculate_win(cards); + if(win > 0 && win_callback) + win_callback(win); + } + + int calculate_win(card_set cards) const + { + auto[c1, c2, c3] = cards; + if(c1 == c2 && c2 == c3) + { + if(c1 == "♣") + return 4; + if(c1 == "♠") + return 8; + if(c1 == "♦") + return 12; + if(c1 == "♥") + return 16; + } + return 0; + } +}; + +static SlotMachine slot_machine; + +struct InsertCoinAction : Action +{ + InsertCoinAction(Thing* thing) + : Action(generate_uuid(), thing, this, "insert_coin", 1) + {} + + void perform_action() + { + logger::info("insert_coin"); + slot_machine.insert_coin(); + } +}; + +struct PlayGameAction : Action +{ + PlayGameAction(Thing* thing) + : Action(generate_uuid(), thing, this, "play_game") + { + if(slot_machine.coins_inserted < 1) + throw std::runtime_error("At least 1 coin credit is required to play a game."); + } + + void perform_action() + { + logger::info("play_game"); + slot_machine.next_round(); + } +}; + +json card_set_to_json(const SlotMachine::card_set& cards) +{ + auto[c1, c2, c3] = cards; + return json{{"c1",c1},{"c2",c2},{"c3",c3}}; +} + +auto make_gui_thing() +{ + auto thing = make_thing("urn:gui-thing-123", "The WebThing Slot Machine", "SLOT_MACHINE_THING", "A slot machine thing with GUI"); + thing->set_ui_href("/gui"); + + link_property(thing, "coins", slot_machine.coins_inserted, { + {"title", "coins"}, + {"type", "integer"}, + {"minimum", 0}, + {"unit", "Euro"}, + {"description", "Credit available."}}); + + link_property(thing, "cards", card_set_to_json(slot_machine.current_cards), { + {"title", "cards"}, + {"type", "object"}, + {"description", "The current card set consisting of 3 chars from [♠,♥,♦,♣,?]."}}); + + link_action(thing, "play_game", json::object({ + {"title", "Play a game"}, + {"description", "Play a game to win some coins. Make sure to insert one before :)"} + })); + + link_action(thing, "insert_coin", json::object({ + {"title", "Insert coin"}, + {"description", "Please insert a coin to to increase the credit."}, + {"input", { + {"type", "integer"}, + {"enum", {1}}, + {"unit", "Euro"}}} + })); + + link_event(thing, "win", { + {"description", "You win!"}, + {"type", "integer"}, + {"unit", "Euro"}}); + + slot_machine.on_credit_changed([thing](int credit) + { + thing->set_property("coins", credit); + }); + + slot_machine.on_cards_changed([thing](auto cards) + { + thing->set_property("cards", card_set_to_json(cards)); + }); + + slot_machine.on_win([thing](int amount) + { + emit_event(thing, "win", amount); + }); + + return thing; +} + +int main() +{ + auto thing = make_gui_thing(); + auto server = WebThingServer::host(SingleThing(thing.get())) + .port(8888) + .build(); + + auto web = server.get_web_server(); + web->get("/gui", [&](auto res, auto req){ + res->writeHeader("Content-Type", "text/html; charset=utf-8"); + res->end(gui_html); + }); + + server.start(); + return 0; +} \ No newline at end of file diff --git a/examples/multiple-things.cpp b/examples/multiple-things.cpp new file mode 100644 index 0000000..d00fd0a --- /dev/null +++ b/examples/multiple-things.cpp @@ -0,0 +1,217 @@ +#include +#include +#include + +using namespace bw::webthing; + +struct ExampleDimmableLight : public Thing +{ + struct OverheatedEvent : public Event + { + OverheatedEvent(Thing* thing, double temperature) + : Event(thing, "overheated", temperature) + { + logger::warn("Overheated " + std::to_string(temperature)); + } + }; + + struct FadeAction : public Action + { + FadeAction(Thing* thing, json fade_input) + : Action(generate_uuid(), thing, this, "fade", fade_input) + , cancel(false) + { + logger::info("Fade to " + fade_input["brightness"].dump() + " in " + + fade_input["duration"].dump() + "ms"); + } + + bool cancel; + + void perform_action() + { + int duration = (*get_input())["duration"]; + int destination = (*get_input())["brightness"]; + + bool interpolate = (*get_input()).value("interpolate", false); + if(!interpolate) + { + std::this_thread::sleep_for(std::chrono::milliseconds(duration)); + get_thing()->set_property("brightness", destination); + } + else + { + int current = *get_thing()->get_property("brightness"); + int steps = 1 + std::ceil(std::abs(destination - current) / 10); + int inc = 1 + (destination - current) / steps; + int sleep = std::ceil(duration / steps); + + while(!cancel && current != destination) + { + std::this_thread::sleep_for(std::chrono::milliseconds(sleep)); + if(cancel) + return; + + int incremented = current + inc; + int next_step = inc > 0 ? (incremented < destination ? incremented : destination) + : (incremented > destination ? incremented : destination); + get_thing()->set_property("brightness", next_step); + current = *get_thing()->get_property("brightness"); + } + } + + emit_event(get_thing(), OverheatedEvent(get_thing(), 102)); + } + + void cancel_action() + { + logger::info("Cancel fade instance " + get_id()); + cancel = true; + } + }; + + ExampleDimmableLight() : Thing( + "urn:dev:ops:my-lamp-1234", "My Lamp", std::vector{"OnOffSwitch", "Light"}, + "A web connected lamp") + { + auto on_value = make_value(true, [](auto v){ + logger::info("On-State is now " + std::string( v ? "on" : "off")); + }); + link_property(this, "on", on_value, { + {"@type", "OnOffProperty"}, + {"title", "On/Off"}, + {"type", "boolean"}, + {"description", "Whether the lamp is turned on"}}); + + auto brightness_value = make_value(50, [](auto v){ + logger::info("Brightness is now " + std::to_string(v)); + }); + link_property(this, "brightness", brightness_value, { + {"@type", "BrightnessProperty"}, + {"title", "Brightness"}, + {"type", "integer"}, + {"description", "The level of light from 0-100"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}}); + + link_action(this, "fade", { + {"title", "Fade"}, + {"description", "Fade the lamp to a given level"}, + {"input", { + {"type", "object"}, + {"required", {"brightness", "duration"}}, + {"properties", { + {"brightness", { + {"type", "integer"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}}}, + {"duration", { + {"type", "integer"}, + {"minimum", 1}, + {"unit", "milliseconds"}}}, + {"interpolate", { + {"type", "boolean"}, + {"default", false}}}}}}}}); + + link_event(this, "overheated", { + {"description", "The lamp has exceeded its safe operating temperature"}, + {"type", "number"}, + {"unit", "degree celsius"}}); + } + +}; + +struct FakeGpioHumiditySensor : public Thing +{ + FakeGpioHumiditySensor() : Thing( + "urn:dev:ops:my-humidity-sensor-1234", "My Humidity Sensor", + "MultiLevelSensor", "A web connected humidity sensor") + { + level = make_value(0.0); + link_property(this, "level", level, { + {"@type", "LevelProperty"}, + {"title", "Humidity"}, + {"type", "number"}, + {"description", "The current humidity in %"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}, + {"readOnly", true}}); + + // Start a thread that polls the sensor reading every 3 seconds + runner = std::thread([this]{ + while(read_from_sensor) + { + std::this_thread::sleep_for(std::chrono::seconds(3)); + if(!read_from_sensor) + return; + + // Update the underlying value, which in turn notifies + // all listeners + double new_level = read_from_gpio(); + logger::info("setting new humidity level: " + std::to_string(new_level)); + level->notify_of_external_update(new_level); + } + }); + } + + void cancel() + { + logger::info("canceling the sensor"); + read_from_sensor = false; + runner.join(); + } + + // Mimic an actual sensor updating its reading every couple seconds. + double read_from_gpio() + { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_real_distribution dist(0.0, 100.0); + return dist(gen); + } + + std::shared_ptr> level; + std::thread runner; + bool read_from_sensor = true; +}; + +void run_server() +{ + // Create a thing that represents a dimmable light + static ExampleDimmableLight light; + + // Create a thing that represents a humidity sensor + static FakeGpioHumiditySensor sensor; + + MultipleThings multiple_things({&light, &sensor}, "LightAndTempDevice"); + + try + { + // If adding more than one thing, use MultipleThings() with a name. + // In the single thing case, the thing's name will be broadcast. + static auto server = WebThingServer::host(multiple_things).port(8888).build(); + + std::signal(SIGINT, [](int signal) { + if (signal == SIGINT) + { + sensor.cancel(); + server.stop(); + } + }); + server.start(); + + } + catch(std::exception& ex) + { + logger::error(std::string("exception when running server: ") + ex.what()); + } +} + +int main() +{ + logger::set_level(log_level::info); + run_server(); + return 0; +} \ No newline at end of file diff --git a/examples/single-thing.cpp b/examples/single-thing.cpp new file mode 100644 index 0000000..2f6d3ef --- /dev/null +++ b/examples/single-thing.cpp @@ -0,0 +1,64 @@ +#include + +using namespace bw::webthing; + +struct FadeAction : public Action +{ + FadeAction(Thing* thing, std::optional input) + : Action(generate_uuid(), thing, this, "fade", input) + {} + + void perform_action() + { + int duration = (*get_input())["duration"]; + std::this_thread::sleep_for(std::chrono::milliseconds(duration)); + get_thing()->set_property("brightness", (*get_input())["brightness"].get()); + emit_event(get_thing(), "overheated", 102); + } +}; + +auto make_lamp() +{ + auto thing = make_thing("urn:dev:ops:my-lamp-1234", "My Lamp", std::vector({"OnOffSwitch", "Light"}), "A web connected lamp"); + link_property(thing, "on", true, { + {"@type", "OnOffProperty"}, + {"title", "On/Off"}, + {"type", "boolean"}, + {"description", "Whether the lamp is turned on"}}); + + link_property(thing, "brightness", 50, { + {"@type", "BrightnessProperty"}, + {"title", "Brightness"}, + {"type", "integer"}, + {"description", "The level of light from 0-100"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}}); + + link_action(thing, "fade", { + {"title", "Fade"}, + {"description", "Fade the lamp to a given level"}, + {"input", {{"type", "object"}, + {"required", {"brightness", "duration"}}, + {"properties", {{"brightness", {{"type", "integer"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}}}, + {"duration", {{"type", "integer"}, + {"minimum", 1}, + {"unit", "milliseconds"}}}}}}}}); + + link_event(thing, "overheated", { + {"description", "The lamp has exceeded its safe operating temperature"}, + {"type", "number"}, + {"unit", "degree celsius"}}); + + return thing; +} + +int main() +{ + auto lamp = make_lamp(); + WebThingServer::host(SingleThing(lamp.get())).port(8888).start(); + return 0; +} diff --git a/include/bw/webthing/action.hpp b/include/bw/webthing/action.hpp new file mode 100644 index 0000000..a3a4708 --- /dev/null +++ b/include/bw/webthing/action.hpp @@ -0,0 +1,232 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +namespace bw::webthing { + +class Action; + +json action_status_message(const Action& action); +json action_status_message(std::shared_ptr action); + +struct ActionBehavior +{ + std::function notify_thing; + std::function perform_action; + std::function cancel_action; + std::function get_thing; +}; + + +template ActionBehavior make_action_behavior(T* thing, + std::function perform_action = nullptr, + std::function cancel_action = nullptr) +{ + return { + [thing](auto action_status){ thing->action_notify(action_status); }, + std::move(perform_action), + std::move(cancel_action), + [thing]{ return thing; } + }; +} + +template +struct has_perform_action : std::false_type {}; + +template +struct has_perform_action> : std::true_type {}; + +template +struct has_cancel_action : std::false_type {}; + +template +struct has_cancel_action> : std::true_type {}; + +template +typename std::enable_if::value>::type +execute_cancel_action(T& action_impl) +{ + (action_impl.*&T::cancel_action)(); +} + +template +typename std::enable_if::value>::type +execute_cancel_action(T& action_impl) +{ + // action_impl has no cancel_action that could be executed +} + +template ActionBehavior make_action_behavior(T* thing, A* action_impl) +{ + return { + [thing](auto action_status){ thing->action_notify(action_status); }, + [action_impl]{ action_impl->perform_action(); }, + [action_impl]{ execute_cancel_action(*action_impl); }, + [thing]{ return thing; } + }; +} + +//An Action represents an individual action on a thing. +class Action : public std::enable_shared_from_this +{ +public: + Action(std::string id, ActionBehavior action_behavior, std::string name, std::optional input = std::nullopt) + : id(id) + , action_behavior(action_behavior) + , name(name) + , input(input) + , href("/actions/" + name + "/" + id) + , status("created") + , time_requested(bw::webthing::timestamp()) + { + } + + template Action(std::string id, T* thing, A* action_impl, std::string name, std::optional input = std::nullopt) + : Action(id, make_action_behavior(thing, action_impl), name, input) + { + } + + // Get the action description of the action as a json object. + json as_action_description() const + { + json description; + description[name]["href"] = href_prefix + href; + description[name]["timeRequested"] = time_requested; + description[name]["status"] = status; + + if(input) + description[name]["input"] = *input; + + if(time_completed) + description[name]["timeCompleted"] = *time_completed; + + return description; + } + + // Set the prefix of any hrefs associated with this action. + void set_href_prefix(const std::string& prefix) + { + href_prefix = prefix; + } + + std::string get_id() const + { + return id; + } + + std::string get_name() const + { + return name; + } + + std::string get_href() const + { + return href_prefix + href; + } + + std::string get_status() const + { + return status; + } + + std::string get_time_requested() const + { + return time_requested; + } + + std::optional get_time_completed() const + { + return time_completed; + } + + std::optional get_input() const + { + return input; + } + + template T* get_thing() + { + if(action_behavior.get_thing) + return static_cast(action_behavior.get_thing()); + return nullptr; + } + + // Start performing the action. + void start() + { + status = "pending"; + notify_thing(); + perform_action(); + finish(); + } + + // Finish performing the action. + void finish() + { + status = "completed"; + time_completed = timestamp(); + notify_thing(); + } + + void perform_action() + { + if(action_behavior.perform_action) + action_behavior.perform_action(); + } + + void cancel() + { + if(action_behavior.cancel_action) + action_behavior.cancel_action(); + } + +private: + void notify_thing() + { + if(action_behavior.notify_thing) + { + try + { + // Pass this as shared_ptr to ensure Action remains alive during callback execution + // this might be necessary, as an Action is interacted with from different threads. + action_behavior.notify_thing(action_status_message(shared_from_this())); + } + catch(std::bad_weak_ptr&) + { + action_behavior.notify_thing(action_status_message(*this)); + } + } + } + + std::string id; + ActionBehavior action_behavior; + std::string name; + std::optional input; + std::string href_prefix; + std::string href; + std::string status; + std::string time_requested; + std::optional time_completed; +}; + +inline json action_status_message(const Action& action) +{ + json description = action.as_action_description(); + return json({ + {"messageType", "actionStatus"}, + {"data", description} + }); +} + +inline json action_status_message(std::shared_ptr action) +{ + return action_status_message(*action); +} + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/errors.hpp b/include/bw/webthing/errors.hpp new file mode 100644 index 0000000..f2b1a7c --- /dev/null +++ b/include/bw/webthing/errors.hpp @@ -0,0 +1,59 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +namespace bw::webthing { + +class InvalidJson : public std::runtime_error +{ +public: + InvalidJson() + : std::runtime_error("General json validation error") + {} + + InvalidJson(std::string message) + : std::runtime_error(message) + {} +}; + +class PropertyError : public std::runtime_error +{ +public: + PropertyError() + : std::runtime_error("General property error") + {} + + PropertyError(std::string message) + : std::runtime_error(message) + {} +}; + +class ActionError : public std::runtime_error +{ +public: + ActionError() + : std::runtime_error("General action error") + {} + + ActionError(std::string message) + : std::runtime_error(message) + {} +}; + +class EventError : public std::runtime_error +{ +public: + EventError() + : std::runtime_error("General event error") + {} + + EventError(std::string message) + : std::runtime_error(message) + {} +}; + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/event.hpp b/include/bw/webthing/event.hpp new file mode 100644 index 0000000..ada8aaf --- /dev/null +++ b/include/bw/webthing/event.hpp @@ -0,0 +1,77 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include + +namespace bw::webthing { + +//#include +class Thing; + +// An Event represents an individual event from a thing. +class Event +{ +public: + Event(Thing* thing, std::string name, std::optional data = std::nullopt) + : thing(thing) + , name(name) + , data(data) + , time(bw::webthing::timestamp()) + {} + + // Get the event description of the event as a json object. + json as_event_description() const + { + json description; + description[name]["timestamp"] = time; + + if(data) + description[name]["data"] = *data; + + return description; + } + + Thing* get_thing() const + { + return thing; + } + + std::string get_name() const + { + return name; + } + + std::optional get_data() + { + return data; + } + + std::string get_time() + { + return time; + } + +private: + Thing* thing; + std::string name; + std::optional data; + std::string time; +}; + +inline json event_message(const Event& event) +{ + json description = event.as_event_description(); + return json({ + {"messageType", "event"}, + {"data", description} + }); +} + + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/json.hpp b/include/bw/webthing/json.hpp new file mode 100644 index 0000000..a1690c2 --- /dev/null +++ b/include/bw/webthing/json.hpp @@ -0,0 +1,17 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +namespace bw::webthing { + +#ifdef WT_UNORDERED_JSON_OBJECT_LAYOUT + typedef nlohmann::json json; +#else + typedef nlohmann::ordered_json json; +#endif + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/json_validator.hpp b/include/bw/webthing/json_validator.hpp new file mode 100644 index 0000000..9c18e4d --- /dev/null +++ b/include/bw/webthing/json_validator.hpp @@ -0,0 +1,39 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +#ifdef WT_USE_JSON_SCHEMA_VALIDATION + + #include + +#endif + +namespace bw::webthing { + +template +void validate_value_by_scheme(const T& value, json schema) +{ +#ifdef WT_USE_JSON_SCHEMA_VALIDATION + + nlohmann::json_schema::json_validator validator; + validator.set_root_schema(schema); + try + { + validator.validate(value); + } + catch(std::exception& ex) + { + throw InvalidJson(ex.what()); + } + +#else + // always succeed +#endif +} + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/mdns.hpp b/include/bw/webthing/mdns.hpp new file mode 100644 index 0000000..e078027 --- /dev/null +++ b/include/bw/webthing/mdns.hpp @@ -0,0 +1,944 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +// MdnsService follows mdns.c example of Mattia Janssons mdns lib. +// also cf. https://github.com/mjansson/mdns + +#pragma once + +// Link with Iphlpapi.lib +#pragma comment(lib, "IPHLPAPI.lib") + +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#endif + +namespace bw::webthing +{ + +static void mdns_log(std::string msg) +{ + logger::trace("MdnsService - " + msg); +} + +const static int MAX_HOST_SIZE = 1025; + +static char addrbuffer[64]; +static char entrybuffer[256]; +static char namebuffer[256]; +static char sendbuffer[1024]; +static mdns_record_txt_t txtbuffer[128]; + +static struct sockaddr_in service_address_ipv4; +static struct sockaddr_in6 service_address_ipv6; + +static int has_ipv4; +static int has_ipv6; + +static int is_tls_server; + + +static mdns_string_t ipv4_address_to_string(char* buffer, size_t capacity, const struct sockaddr_in* addr, + size_t addrlen) { + char host[MAX_HOST_SIZE] = {0}; + char service[NI_MAXSERV] = {0}; + int ret = getnameinfo((const struct sockaddr*)addr, (socklen_t)addrlen, host, MAX_HOST_SIZE, + service, NI_MAXSERV, NI_NUMERICSERV | NI_NUMERICHOST); + int len = 0; + if (ret == 0) { + if (addr->sin_port != 0) + len = snprintf(buffer, capacity, "%s:%s", host, service); + else + len = snprintf(buffer, capacity, "%s", host); + } + if (len >= (int)capacity) + len = (int)capacity - 1; + mdns_string_t str; + str.str = buffer; + str.length = len; + return str; +} + +static mdns_string_t ipv6_address_to_string(char* buffer, size_t capacity, const struct sockaddr_in6* addr, + size_t addrlen) { + char host[NI_MAXHOST] = {0}; + char service[NI_MAXSERV] = {0}; + int ret = getnameinfo((const struct sockaddr*)addr, (socklen_t)addrlen, host, NI_MAXHOST, + service, NI_MAXSERV, NI_NUMERICSERV | NI_NUMERICHOST); + int len = 0; + if (ret == 0) { + if (addr->sin6_port != 0) + len = snprintf(buffer, capacity, "[%s]:%s", host, service); + else + len = snprintf(buffer, capacity, "%s", host); + } + if (len >= (int)capacity) + len = (int)capacity - 1; + mdns_string_t str; + str.str = buffer; + str.length = len; + return str; +} + +static mdns_string_t ip_address_to_string(char* buffer, size_t capacity, const struct sockaddr* addr, size_t addrlen) { + if (addr->sa_family == AF_INET6) + return ipv6_address_to_string(buffer, capacity, (const struct sockaddr_in6*)addr, addrlen); + return ipv4_address_to_string(buffer, capacity, (const struct sockaddr_in*)addr, addrlen); +} + +static std::vector get_addresses() +{ + std::vector ips; + + #ifdef _WIN32 + + PIP_ADAPTER_ADDRESSES ifaddresses = NULL; + ULONG size = 0; + DWORD ret = GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_ANYCAST, NULL, ifaddresses, &size); + if (ret == ERROR_BUFFER_OVERFLOW) + { + ifaddresses = (IP_ADAPTER_ADDRESSES *)malloc(size); + ret = GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_ANYCAST, NULL, ifaddresses, &size); + } + + for (PIP_ADAPTER_ADDRESSES adapter = ifaddresses; adapter != NULL; adapter = adapter->Next) + { + for (PIP_ADAPTER_UNICAST_ADDRESS unicast = adapter->FirstUnicastAddress; unicast != NULL; unicast = unicast->Next) + { + sockaddr *sa = unicast->Address.lpSockaddr; + char ip[INET6_ADDRSTRLEN]; + + if (sa->sa_family == AF_INET) + { + struct sockaddr_in *saddr = (struct sockaddr_in *)sa; + if (saddr->sin_addr.s_addr == htonl(INADDR_LOOPBACK)) + continue; + + inet_ntop(AF_INET, &(saddr)->sin_addr, ip, INET_ADDRSTRLEN); + ips.push_back(ip); + } + else if (sa->sa_family == AF_INET6) + { + struct sockaddr_in6 *saddr = (struct sockaddr_in6 *)sa; + // Ignore link-local addresses + if (saddr->sin6_scope_id) + continue; + + static const unsigned char localhost[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; + static const unsigned char localhost_mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0x7f, 0, 0, 1}; + + if (!memcmp(saddr->sin6_addr.s6_addr, localhost, 16) || + !memcmp(saddr->sin6_addr.s6_addr, localhost_mapped, 16)) + { + continue; + } + + inet_ntop(AF_INET6, &((sockaddr_in6 *)sa)->sin6_addr, ip, INET6_ADDRSTRLEN); + ips.push_back(ip); + } + } + } + free(ifaddresses); + + #else + struct ifaddrs* ifaddr = 0; + struct ifaddrs* ifa = 0; + + if (getifaddrs(&ifaddr) >= 0) + { + char addr_buffer[64]; + for (ifa = ifaddr; ifa; ifa = ifa->ifa_next) + { + if(!ifa->ifa_addr) + continue; + + if(ifa->ifa_addr->sa_family == AF_INET) + { + struct sockaddr_in* saddr = (struct sockaddr_in*)ifa->ifa_addr; + if (saddr->sin_addr.s_addr == htonl(INADDR_LOOPBACK)) + continue; + mdns_string_t ip = ip_address_to_string(addr_buffer, sizeof(addr_buffer), ifa->ifa_addr, sizeof(sockaddr_in)); + ips.push_back(ip.str); + + } + else if(ifa->ifa_addr->sa_family == AF_INET6) + { + struct sockaddr_in6* saddr = (struct sockaddr_in6*)ifa->ifa_addr; + // Ignore link-local addresses + if (saddr->sin6_scope_id) + continue; + + static const unsigned char localhost[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; + static const unsigned char localhost_mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0x7f, 0, 0, 1}; + + if(!memcmp(saddr->sin6_addr.s6_addr, localhost, 16) || + !memcmp(saddr->sin6_addr.s6_addr, localhost_mapped, 16)) + { + continue; + } + + mdns_string_t ip = ip_address_to_string(addr_buffer, sizeof(addr_buffer), ifa->ifa_addr, sizeof(sockaddr_in6)); + ips.push_back(ip.str); + } + } + } + freeifaddrs(ifaddr); + #endif + + return ips; +} + + + +struct MdnsService +{ + volatile bool run_requested = false; + volatile bool running = false; + + // Data for our service including the mDNS records + typedef struct { + mdns_string_t service; + mdns_string_t hostname; + mdns_string_t service_instance; + mdns_string_t hostname_qualified; + struct sockaddr_in address_ipv4; + struct sockaddr_in6 address_ipv6; + int port; + mdns_record_t record_ptr; + mdns_record_t record_srv; + mdns_record_t record_a; + mdns_record_t record_aaaa; + mdns_record_t txt_record[2]; + } service_t; + + + // Callback handling questions incoming on service sockets + static int service_callback(int sock, const struct sockaddr* from, size_t addrlen, mdns_entry_type_t entry, + uint16_t query_id, uint16_t ui16_rtype, uint16_t rclass, uint32_t ttl, const void* data, + size_t size, size_t name_offset, size_t name_length, size_t record_offset, + size_t record_length, void* user_data) { + (void)sizeof(ttl); + if (entry != MDNS_ENTRYTYPE_QUESTION) + return 0; + + mdns_record_type rtype = static_cast(ui16_rtype); + + const char dns_sd[] = "_services._dns-sd._udp.local."; + const service_t* service = (const service_t*)user_data; + + mdns_string_t fromaddrstr = ip_address_to_string(addrbuffer, sizeof(addrbuffer), from, addrlen); + + size_t offset = name_offset; + mdns_string_t name = mdns_string_extract(data, size, &offset, namebuffer, sizeof(namebuffer)); + + const char* record_name = 0; + if (rtype == MDNS_RECORDTYPE_PTR) + record_name = "PTR"; + else if (rtype == MDNS_RECORDTYPE_SRV) + record_name = "SRV"; + else if (rtype == MDNS_RECORDTYPE_A) + record_name = "A"; + else if (rtype == MDNS_RECORDTYPE_AAAA) + record_name = "AAAA"; + else if (rtype == MDNS_RECORDTYPE_TXT) + record_name = "TXT"; + else if (rtype == MDNS_RECORDTYPE_ANY) + record_name = "ANY"; + else + return 0; + mdns_log("Query " + std::string(record_name) + " " + std::string(name.str)); + + if ((name.length == (sizeof(dns_sd) - 1)) && + (strncmp(name.str, dns_sd, sizeof(dns_sd) - 1) == 0)) { + if ((rtype == MDNS_RECORDTYPE_PTR) || (rtype == MDNS_RECORDTYPE_ANY)) { + // The PTR query was for the DNS-SD domain, send answer with a PTR record for the + // service name we advertise, typically on the "<_service-name>._tcp.local." format + + // Answer PTR record reverse mapping "<_service-name>._tcp.local." to + // ".<_service-name>._tcp.local." + mdns_record_t answer = { + name, MDNS_RECORDTYPE_PTR, {service->service}}; + + // Send the answer, unicast or multicast depending on flag in query + uint16_t unicast = (rclass & MDNS_UNICAST_RESPONSE); + mdns_log(" --> answer " + std::string(answer.data.ptr.name.str) + " " + + std::string(unicast ? "unicast" : "multicast")); + + if (unicast) { + mdns_query_answer_unicast(sock, from, addrlen, sendbuffer, sizeof(sendbuffer), + query_id, rtype, name.str, name.length, answer, 0, 0, 0, + 0); + } else { + mdns_query_answer_multicast(sock, sendbuffer, sizeof(sendbuffer), answer, 0, 0, 0, + 0); + } + } + } else if ((name.length == service->service.length) && + (strncmp(name.str, service->service.str, name.length) == 0)) { + if ((rtype == MDNS_RECORDTYPE_PTR) || (rtype == MDNS_RECORDTYPE_ANY)) { + // The PTR query was for our service (usually "<_service-name._tcp.local"), answer a PTR + // record reverse mapping the queried service name to our service instance name + // (typically on the ".<_service-name>._tcp.local." format), and add + // additional records containing the SRV record mapping the service instance name to our + // qualified hostname (typically ".local.") and port, as well as any IPv4/IPv6 + // address for the hostname as A/AAAA records, and two test TXT records + + // Answer PTR record reverse mapping "<_service-name>._tcp.local." to + // ".<_service-name>._tcp.local." + mdns_record_t answer = service->record_ptr; + + mdns_record_t additional[5] = {0}; + size_t additional_count = 0; + + // SRV record mapping ".<_service-name>._tcp.local." to + // ".local." with port. Set weight & priority to 0. + additional[additional_count++] = service->record_srv; + + // A/AAAA records mapping ".local." to IPv4/IPv6 addresses + if (service->address_ipv4.sin_family == AF_INET) + additional[additional_count++] = service->record_a; + if (service->address_ipv6.sin6_family == AF_INET6) + additional[additional_count++] = service->record_aaaa; + + // Add two test TXT records for our service instance name, will be coalesced into + // one record with both key-value pair strings by the library + additional[additional_count++] = service->txt_record[0]; + if(is_tls_server) + additional[additional_count++] = service->txt_record[1]; + + // Send the answer, unicast or multicast depending on flag in query + uint16_t unicast = (rclass & MDNS_UNICAST_RESPONSE); + mdns_log(" --> answer " + std::string(service->record_ptr.data.ptr.name.str) + + " (" + std::string(unicast ? "unicast" : "multicast") + ")"); + + if (unicast) { + mdns_query_answer_unicast(sock, from, addrlen, sendbuffer, sizeof(sendbuffer), + query_id, rtype, name.str, name.length, answer, 0, 0, + additional, additional_count); + } else { + mdns_query_answer_multicast(sock, sendbuffer, sizeof(sendbuffer), answer, 0, 0, + additional, additional_count); + } + } + } else if ((name.length == service->service_instance.length) && + (strncmp(name.str, service->service_instance.str, name.length) == 0)) { + if ((rtype == MDNS_RECORDTYPE_SRV) || (rtype == MDNS_RECORDTYPE_ANY)) { + // The SRV query was for our service instance (usually + // ".<_service-name._tcp.local"), answer a SRV record mapping the service + // instance name to our qualified hostname (typically ".local.") and port, as + // well as any IPv4/IPv6 address for the hostname as A/AAAA records, and two test TXT + // records + + // Answer PTR record reverse mapping "<_service-name>._tcp.local." to + // ".<_service-name>._tcp.local." + mdns_record_t answer = service->record_srv; + + mdns_record_t additional[5] = {0}; + size_t additional_count = 0; + + // A/AAAA records mapping ".local." to IPv4/IPv6 addresses + if (service->address_ipv4.sin_family == AF_INET) + additional[additional_count++] = service->record_a; + if (service->address_ipv6.sin6_family == AF_INET6) + additional[additional_count++] = service->record_aaaa; + + // Add two test TXT records for our service instance name, will be coalesced into + // one record with both key-value pair strings by the library + additional[additional_count++] = service->txt_record[0]; + if(is_tls_server) + additional[additional_count++] = service->txt_record[1]; + + // Send the answer, unicast or multicast depending on flag in query + uint16_t unicast = (rclass & MDNS_UNICAST_RESPONSE); + mdns_log(" --> answer " + std::string(service->record_srv.data.srv.name.str) + + " port " + std::to_string(service->port) + " (" + (unicast ? "unicast" : "multicast") + ")"); + + if (unicast) { + mdns_query_answer_unicast(sock, from, addrlen, sendbuffer, sizeof(sendbuffer), + query_id, rtype, name.str, name.length, answer, 0, 0, + additional, additional_count); + } else { + mdns_query_answer_multicast(sock, sendbuffer, sizeof(sendbuffer), answer, 0, 0, + additional, additional_count); + } + } + } else if ((name.length == service->hostname_qualified.length) && + (strncmp(name.str, service->hostname_qualified.str, name.length) == 0)) { + if (((rtype == MDNS_RECORDTYPE_A) || (rtype == MDNS_RECORDTYPE_ANY)) && + (service->address_ipv4.sin_family == AF_INET)) { + // The A query was for our qualified hostname (typically ".local.") and we + // have an IPv4 address, answer with an A record mappiing the hostname to an IPv4 + // address, as well as any IPv6 address for the hostname, and two test TXT records + + // Answer A records mapping ".local." to IPv4 address + mdns_record_t answer = service->record_a; + + mdns_record_t additional[5] = {0}; + size_t additional_count = 0; + + // AAAA record mapping ".local." to IPv6 addresses + if (service->address_ipv6.sin6_family == AF_INET6) + additional[additional_count++] = service->record_aaaa; + + // Add two test TXT records for our service instance name, will be coalesced into + // one record with both key-value pair strings by the library + additional[additional_count++] = service->txt_record[0]; + if(is_tls_server) + additional[additional_count++] = service->txt_record[1]; + + // Send the answer, unicast or multicast depending on flag in query + uint16_t unicast = (rclass & MDNS_UNICAST_RESPONSE); + mdns_string_t addrstr = ip_address_to_string( + addrbuffer, sizeof(addrbuffer), (struct sockaddr*)&service->record_a.data.a.addr, + sizeof(service->record_a.data.a.addr)); + mdns_log(" --> answer " + std::string(service->record_a.name.str) + + " IPv4 " + std::string(addrstr.str) + " (" + std::string(unicast ? "unicast" : "multicast") + ")"); + + if (unicast) { + mdns_query_answer_unicast(sock, from, addrlen, sendbuffer, sizeof(sendbuffer), + query_id, rtype, name.str, name.length, answer, 0, 0, + additional, additional_count); + } else { + mdns_query_answer_multicast(sock, sendbuffer, sizeof(sendbuffer), answer, 0, 0, + additional, additional_count); + } + } else if (((rtype == MDNS_RECORDTYPE_AAAA) || (rtype == MDNS_RECORDTYPE_ANY)) && + (service->address_ipv6.sin6_family == AF_INET6)) { + // The AAAA query was for our qualified hostname (typically ".local.") and we + // have an IPv6 address, answer with an AAAA record mappiing the hostname to an IPv6 + // address, as well as any IPv4 address for the hostname, and two test TXT records + + // Answer AAAA records mapping ".local." to IPv6 address + mdns_record_t answer = service->record_aaaa; + + mdns_record_t additional[5] = {0}; + size_t additional_count = 0; + + // A record mapping ".local." to IPv4 addresses + if (service->address_ipv4.sin_family == AF_INET) + additional[additional_count++] = service->record_a; + + // Add two test TXT records for our service instance name, will be coalesced into + // one record with both key-value pair strings by the library + additional[additional_count++] = service->txt_record[0]; + if(is_tls_server) + additional[additional_count++] = service->txt_record[1]; + + // Send the answer, unicast or multicast depending on flag in query + uint16_t unicast = (rclass & MDNS_UNICAST_RESPONSE); + mdns_string_t addrstr = + ip_address_to_string(addrbuffer, sizeof(addrbuffer), + (struct sockaddr*)&service->record_aaaa.data.aaaa.addr, + sizeof(service->record_aaaa.data.aaaa.addr)); + mdns_log(" --> answer " + std::string(service->record_aaaa.name.str) + + " IPv6 " + std::string(addrstr.str) + " (" + std::string(unicast ? "unicast" : "multicast") + ")"); + + if (unicast) { + mdns_query_answer_unicast(sock, from, addrlen, sendbuffer, sizeof(sendbuffer), + query_id, rtype, name.str, name.length, answer, 0, 0, + additional, additional_count); + } else { + mdns_query_answer_multicast(sock, sendbuffer, sizeof(sendbuffer), answer, 0, 0, + additional, additional_count); + } + } + } + return 0; + } + + // Open sockets for sending one-shot multicast queries from an ephemeral port + static int open_client_sockets(int* sockets, int max_sockets, int port) { + // When sending, each socket can only send to one network interface + // Thus we need to open one socket for each interface and address family + int num_sockets = 0; + + #ifdef _WIN32 + + IP_ADAPTER_ADDRESSES* adapter_address = 0; + ULONG address_size = 8000; + unsigned int ret; + unsigned int num_retries = 4; + do { + adapter_address = (IP_ADAPTER_ADDRESSES*)malloc(address_size); + ret = GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_ANYCAST, 0, + adapter_address, &address_size); + if (ret == ERROR_BUFFER_OVERFLOW) { + free(adapter_address); + adapter_address = 0; + address_size *= 2; + } else { + break; + } + } while (num_retries-- > 0); + + if (!adapter_address || (ret != NO_ERROR)) { + free(adapter_address); + mdns_log("Failed to get network adapter addresses"); + return num_sockets; + } + + int first_ipv4 = 1; + int first_ipv6 = 1; + for (PIP_ADAPTER_ADDRESSES adapter = adapter_address; adapter; adapter = adapter->Next) { + if (adapter->TunnelType == TUNNEL_TYPE_TEREDO) + continue; + if (adapter->OperStatus != IfOperStatusUp) + continue; + + for (IP_ADAPTER_UNICAST_ADDRESS* unicast = adapter->FirstUnicastAddress; unicast; + unicast = unicast->Next) { + if (unicast->Address.lpSockaddr->sa_family == AF_INET) { + struct sockaddr_in* saddr = (struct sockaddr_in*)unicast->Address.lpSockaddr; + if ((saddr->sin_addr.S_un.S_un_b.s_b1 != 127) || + (saddr->sin_addr.S_un.S_un_b.s_b2 != 0) || + (saddr->sin_addr.S_un.S_un_b.s_b3 != 0) || + (saddr->sin_addr.S_un.S_un_b.s_b4 != 1)) { + int log_addr = 0; + if (first_ipv4) { + service_address_ipv4 = *saddr; + first_ipv4 = 0; + log_addr = 1; + } + has_ipv4 = 1; + if (num_sockets < max_sockets) { + saddr->sin_port = htons((unsigned short)port); + int sock = mdns_socket_open_ipv4(saddr); + if (sock >= 0) { + sockets[num_sockets++] = sock; + log_addr = 1; + } else { + log_addr = 0; + } + } + if (log_addr) { + char buffer[128]; + mdns_string_t addr = ipv4_address_to_string(buffer, sizeof(buffer), saddr, + sizeof(struct sockaddr_in)); + mdns_log("Local IPv4 address: " + std::string(addr.str)); + } + } + } else if (unicast->Address.lpSockaddr->sa_family == AF_INET6) { + struct sockaddr_in6* saddr = (struct sockaddr_in6*)unicast->Address.lpSockaddr; + // Ignore link-local addresses + if (saddr->sin6_scope_id) + continue; + static const unsigned char localhost[] = {0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1}; + static const unsigned char localhost_mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0xff, 0xff, 0x7f, 0, 0, 1}; + if ((unicast->DadState == NldsPreferred) && + memcmp(saddr->sin6_addr.s6_addr, localhost, 16) && + memcmp(saddr->sin6_addr.s6_addr, localhost_mapped, 16)) { + int log_addr = 0; + if (first_ipv6) { + service_address_ipv6 = *saddr; + first_ipv6 = 0; + log_addr = 1; + } + has_ipv6 = 1; + if (num_sockets < max_sockets) { + saddr->sin6_port = htons((unsigned short)port); + int sock = mdns_socket_open_ipv6(saddr); + if (sock >= 0) { + sockets[num_sockets++] = sock; + log_addr = 1; + } else { + log_addr = 0; + } + } + if (log_addr) { + char buffer[128]; + mdns_string_t addr = ipv6_address_to_string(buffer, sizeof(buffer), saddr, + sizeof(struct sockaddr_in6)); + mdns_log("Local IPv6 address: " + std::string(addr.str)); + } + } + } + } + } + + free(adapter_address); + + #else + + struct ifaddrs* ifaddr = 0; + struct ifaddrs* ifa = 0; + + if (getifaddrs(&ifaddr) < 0) + mdns_log("Unable to get interface addresses"); + + int first_ipv4 = 1; + int first_ipv6 = 1; + for (ifa = ifaddr; ifa; ifa = ifa->ifa_next) { + if (!ifa->ifa_addr) + continue; + if (!(ifa->ifa_flags & IFF_UP) || !(ifa->ifa_flags & IFF_MULTICAST)) + continue; + if ((ifa->ifa_flags & IFF_LOOPBACK) || (ifa->ifa_flags & IFF_POINTOPOINT)) + continue; + + if (ifa->ifa_addr->sa_family == AF_INET) { + struct sockaddr_in* saddr = (struct sockaddr_in*)ifa->ifa_addr; + if (saddr->sin_addr.s_addr != htonl(INADDR_LOOPBACK)) { + int log_addr = 0; + if (first_ipv4) { + service_address_ipv4 = *saddr; + first_ipv4 = 0; + log_addr = 1; + } + has_ipv4 = 1; + if (num_sockets < max_sockets) { + saddr->sin_port = htons(port); + int sock = mdns_socket_open_ipv4(saddr); + if (sock >= 0) { + sockets[num_sockets++] = sock; + log_addr = 1; + } else { + log_addr = 0; + } + } + if (log_addr) { + char buffer[128]; + mdns_string_t addr = ipv4_address_to_string(buffer, sizeof(buffer), saddr, + sizeof(struct sockaddr_in)); + mdns_log("Local IPv4 address: " + std::string(addr.str)); + } + } + } else if (ifa->ifa_addr->sa_family == AF_INET6) { + struct sockaddr_in6* saddr = (struct sockaddr_in6*)ifa->ifa_addr; + // Ignore link-local addresses + if (saddr->sin6_scope_id) + continue; + static const unsigned char localhost[] = {0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1}; + static const unsigned char localhost_mapped[] = {0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0xff, 0xff, 0x7f, 0, 0, 1}; + if (memcmp(saddr->sin6_addr.s6_addr, localhost, 16) && + memcmp(saddr->sin6_addr.s6_addr, localhost_mapped, 16)) { + int log_addr = 0; + if (first_ipv6) { + service_address_ipv6 = *saddr; + first_ipv6 = 0; + log_addr = 1; + } + has_ipv6 = 1; + if (num_sockets < max_sockets) { + saddr->sin6_port = htons(port); + int sock = mdns_socket_open_ipv6(saddr); + if (sock >= 0) { + sockets[num_sockets++] = sock; + log_addr = 1; + } else { + log_addr = 0; + } + } + if (log_addr) { + char buffer[128]; + mdns_string_t addr = ipv6_address_to_string(buffer, sizeof(buffer), saddr, + sizeof(struct sockaddr_in6)); + mdns_log("Local IPv6 address: " + std::string(addr.str)); + } + } + } + } + + freeifaddrs(ifaddr); + + #endif + + return num_sockets; + } + + // Open sockets to listen to incoming mDNS queries on port 5353 + static int open_service_sockets(int* sockets, int max_sockets) { + // When recieving, each socket can recieve data from all network interfaces + // Thus we only need to open one socket for each address family + int num_sockets = 0; + + // Call the client socket function to enumerate and get local addresses, + // but not open the actual sockets + open_client_sockets(0, 0, 0); + + if (num_sockets < max_sockets) { + struct sockaddr_in sock_addr; + memset(&sock_addr, 0, sizeof(struct sockaddr_in)); + sock_addr.sin_family = AF_INET; + #ifdef _WIN32 + sock_addr.sin_addr = in4addr_any; + #else + sock_addr.sin_addr.s_addr = INADDR_ANY; + #endif + sock_addr.sin_port = htons(MDNS_PORT); + #ifdef __APPLE__ + sock_addr.sin_len = sizeof(struct sockaddr_in); + #endif + int sock = mdns_socket_open_ipv4(&sock_addr); + if (sock >= 0) + sockets[num_sockets++] = sock; + } + + if (num_sockets < max_sockets) { + struct sockaddr_in6 sock_addr; + memset(&sock_addr, 0, sizeof(struct sockaddr_in6)); + sock_addr.sin6_family = AF_INET6; + sock_addr.sin6_addr = in6addr_any; + sock_addr.sin6_port = htons(MDNS_PORT); + #ifdef __APPLE__ + sock_addr.sin6_len = sizeof(struct sockaddr_in6); + #endif + int sock = mdns_socket_open_ipv6(&sock_addr); + if (sock >= 0) + sockets[num_sockets++] = sock; + } + + return num_sockets; + } + + + + // Provide a mDNS service, answering incoming DNS-SD and mDNS queries + int service_mdns(const char* hostname, const char* service_name, int service_port, const char* path, bool tls) + { + running = true; + + int sockets[32]; + int num_sockets = open_service_sockets(sockets, sizeof(sockets) / sizeof(sockets[0])); + if (num_sockets <= 0) { + mdns_log("Failed to open any client sockets"); + return -1; + } + mdns_log("Opened " + std::to_string(num_sockets) + " socket" + std::string(num_sockets ? "s" : "") + " for mDNS service"); + + size_t service_name_length = strlen(service_name); + if (!service_name_length) { + mdns_log("Invalid service name"); + return -1; + } + + char* service_name_buffer = (char*)malloc(service_name_length + 2); + memcpy(service_name_buffer, service_name, service_name_length); + if (service_name_buffer[service_name_length - 1] != '.') + service_name_buffer[service_name_length++] = '.'; + service_name_buffer[service_name_length] = 0; + service_name = service_name_buffer; + + mdns_log("Service mDNS: " + std::string(service_name) + ":" + std::to_string(service_port)); + mdns_log("Hostname: " + std::string(hostname)); + + size_t capacity = 2048; + void* buffer = malloc(capacity); + + mdns_string_t service_string = mdns_string_t{service_name, strlen(service_name)}; + mdns_string_t hostname_string = mdns_string_t{hostname, strlen(hostname)}; + + // Build the service instance ".<_service-name>._tcp.local." string + char service_instance_buffer[256] = {0}; + snprintf(service_instance_buffer, sizeof(service_instance_buffer) - 1, "%.*s.%.*s", + MDNS_STRING_FORMAT(hostname_string), MDNS_STRING_FORMAT(service_string)); + mdns_string_t service_instance_string = + mdns_string_t{service_instance_buffer, strlen(service_instance_buffer)}; + + // Build the ".local." string + char qualified_hostname_buffer[256] = {0}; + snprintf(qualified_hostname_buffer, sizeof(qualified_hostname_buffer) - 1, "%.*s.local.", + MDNS_STRING_FORMAT(hostname_string)); + mdns_string_t hostname_qualified_string = + mdns_string_t{qualified_hostname_buffer, strlen(qualified_hostname_buffer)}; + + service_t service = {0}; + service.service = service_string; + service.hostname = hostname_string; + service.service_instance = service_instance_string; + service.hostname_qualified = hostname_qualified_string; + service.address_ipv4 = service_address_ipv4; + service.address_ipv6 = service_address_ipv6; + service.port = service_port; + + // Setup our mDNS records + + // PTR record reverse mapping "<_service-name>._tcp.local." to + // ".<_service-name>._tcp.local." + mdns_record_t::mdns_record_data rd_ptr; + rd_ptr.ptr = { service.service_instance }; + + mdns_record_t ptr_rec; + ptr_rec.name = service.service; + ptr_rec.type = MDNS_RECORDTYPE_PTR; + ptr_rec.data = rd_ptr; + ptr_rec.rclass = 0; + ptr_rec.ttl = 0; + + service.record_ptr = ptr_rec; + + // SRV record mapping ".<_service-name>._tcp.local." to + // ".local." with port. Set weight & priority to 0. + uint16_t p = service.port; + mdns_record_t::mdns_record_data rd_srv; + rd_srv.srv = { 0, 0, p, service.hostname_qualified}; + + mdns_record_t srv_rec; + srv_rec.name = service.service_instance; + srv_rec.type = MDNS_RECORDTYPE_SRV; + srv_rec.data = rd_srv; + srv_rec.rclass = 0; + srv_rec.ttl = 0; + + service.record_srv = srv_rec; + + // A/AAAA records mapping ".local." to IPv4/IPv6 addresses + mdns_record_t::mdns_record_data rd_a; + rd_a.a = {service.address_ipv4}; + + mdns_record_t a_rec; + a_rec.name = service.hostname_qualified; + a_rec.type = MDNS_RECORDTYPE_A; + a_rec.data = rd_a; + a_rec.rclass = 0; + a_rec.ttl = 0; + + service.record_a = a_rec; + + + mdns_record_t::mdns_record_data rd_aaaa; + rd_aaaa.aaaa = {service.address_ipv6}; + + mdns_record_t aaaa_rec; + aaaa_rec.name = service.hostname_qualified; + aaaa_rec.type = MDNS_RECORDTYPE_AAAA; + aaaa_rec.data = rd_aaaa; + aaaa_rec.rclass = 0; + aaaa_rec.ttl = 0; + + service.record_aaaa = aaaa_rec; + + // Add two test TXT records for our service instance name, will be coalesced into + // one record with both key-value pair strings by the library + mdns_record_t::mdns_record_data rd_path; + rd_path.txt = {{MDNS_STRING_CONST("path")},{path, std::strlen(path)}}; + + mdns_record_t path_rec; + path_rec.name = service.service_instance; + path_rec.type = MDNS_RECORDTYPE_TXT; + path_rec.data = rd_path; + path_rec.rclass = 0; + path_rec.ttl = 0; + + service.txt_record[0] = path_rec; + + mdns_record_t::mdns_record_data rd_tls; + rd_tls.txt = {{MDNS_STRING_CONST("tls")},{MDNS_STRING_CONST(tls ? "1" : "0")}}; + + mdns_record_t tls_rec; + tls_rec.name = service.service_instance; + tls_rec.type = MDNS_RECORDTYPE_TXT; + tls_rec.data = rd_tls; + tls_rec.rclass = 0; + tls_rec.ttl = 0; + + service.txt_record[1] = tls_rec; + + // Send an announcement on startup of service + { + mdns_log("Sending announce"); + mdns_record_t additional[5] = {0}; + size_t additional_count = 0; + additional[additional_count++] = service.record_srv; + if (service.address_ipv4.sin_family == AF_INET) + additional[additional_count++] = service.record_a; + if (service.address_ipv6.sin6_family == AF_INET6) + additional[additional_count++] = service.record_aaaa; + additional[additional_count++] = service.txt_record[0]; + if(is_tls_server) + additional[additional_count++] = service.txt_record[1]; + + for (int isock = 0; isock < num_sockets; ++isock) + mdns_announce_multicast(sockets[isock], buffer, capacity, service.record_ptr, 0, 0, + additional, additional_count); + } + + // This is a crude implementation that checks for incoming queries + while (run_requested) { + int nfds = 0; + fd_set readfs; + FD_ZERO(&readfs); + for (int isock = 0; isock < num_sockets; ++isock) { + if (sockets[isock] >= nfds) + nfds = sockets[isock] + 1; + FD_SET(sockets[isock], &readfs); + } + + struct timeval timeout; + timeout.tv_sec = 0; + timeout.tv_usec = 100000; + + if (select(nfds, &readfs, 0, 0, &timeout) >= 0) { + for (int isock = 0; isock < num_sockets; ++isock) { + if (FD_ISSET(sockets[isock], &readfs)) { + mdns_socket_listen(sockets[isock], buffer, capacity, service_callback, + &service); + } + FD_SET(sockets[isock], &readfs); + } + } else { + break; + } + } + + // Send a goodbye on end of service + { + mdns_log("Sending goodbye"); + mdns_record_t additional[5] = {0}; + size_t additional_count = 0; + additional[additional_count++] = service.record_srv; + if (service.address_ipv4.sin_family == AF_INET) + additional[additional_count++] = service.record_a; + if (service.address_ipv6.sin6_family == AF_INET6) + additional[additional_count++] = service.record_aaaa; + additional[additional_count++] = service.txt_record[0]; + if(is_tls_server) + additional[additional_count++] = service.txt_record[1]; + + for (int isock = 0; isock < num_sockets; ++isock) + mdns_goodbye_multicast(sockets[isock], buffer, capacity, service.record_ptr, 0, 0, + additional, additional_count); + } + + free(buffer); + free(service_name_buffer); + + for (int isock = 0; isock < num_sockets; ++isock) + mdns_socket_close(sockets[isock]); + mdns_log("Closed socket" + std::string(num_sockets ? "s" : "")); + + return 0; + } + + + void start_service(std::string hostname, std::string service, int port, std::string path, bool tls) + { + is_tls_server = tls; + run_requested = true; + service_mdns(hostname.c_str(), service.c_str(), port, path.c_str(), tls); + running = false; + } + + void stop_service() + { + run_requested = false; + } + + bool is_running() + { + return running; + } +}; + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/property.hpp b/include/bw/webthing/property.hpp new file mode 100644 index 0000000..0c45aa0 --- /dev/null +++ b/include/bw/webthing/property.hpp @@ -0,0 +1,171 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include + +namespace bw::webthing { + +template class Property; + +template +json property_status_message(const Property& property) +{ + return json({ + {"messageType", "propertyStatus"}, + {"data", property_value_object(property)} + }); +} + + +template +json property_value_object(const Property& property) +{ + + auto value_as_json = property.get_value() ? json(*property.get_value()) : json(); + return json({{property.get_name(), value_as_json}}); +} + + +class PropertyBase +{ +public: + + PropertyBase(std::string name, json metadata) + : name(name) + , metadata(metadata) + { + if(this->metadata.type() != json::value_t::object) + throw PropertyError("Only json::object is allowed as meta data."); + + href = "/properties/" + this->name; + } + + virtual ~PropertyBase() = default; + virtual json get_property_value_object() const = 0; + + json as_property_description() const + { + json description = metadata; + + if(!description["links"].is_array()) + description["links"] = json::array(); + + description["links"] += {{"rel", "property"}, {"href", href_prefix + href}}; + + return description; + } + + void set_href_prefix(std::string prefix) + { + href_prefix = prefix; + } + + std::string get_href() const + { + return href_prefix + href; + } + + std::string get_name() const + { + return name; + } + + json get_metadata() const + { + return metadata; + } + + template std::optional get_value() const + { + return dynamic_cast&>(*this).get_value(); + } + + template void set_value(T value) + { + try{ + auto property = dynamic_cast&>(*this); + property.set_value(value); + } + catch(std::bad_cast&) + { + throw PropertyError("Property value type not matching"); + } + } + +protected: + std::string name; + std::string href_prefix; + std::string href; + json metadata; +}; + +typedef std::function PropertyChangedCallback; + +template +class Property : public PropertyBase +{ +public: + + Property(PropertyChangedCallback changed_callback, std::string name, std::shared_ptr> value, json metadata = json::object()) + : PropertyBase(name, metadata) + , property_change_callback(changed_callback) + , value(value) + { + // Add value change observer to notify the Thing about a property change. + if(property_change_callback) + this->value->add_observer([&](auto v){property_change_callback(property_status_message(*this));}); + } + + // Validate new proptery value before setting it. + void validate_value(const T& value) const + { + if(metadata.contains("readOnly")) + { + auto json_ro = metadata["readOnly"]; + bool read_only = json_ro.is_boolean() && json_ro.get(); + if(read_only) + throw PropertyError("Read-only property"); + } + + try + { + validate_value_by_scheme(value, metadata); + } + catch(std::exception& ex) + { + throw PropertyError("Invalid property value - " + std::string(ex.what())); + } + } + + json get_property_value_object() const + { + return property_value_object(*this); + } + + // Get the current property value. + std::optional get_value() const + { + return value->get(); + } + + // Set the current value of the property. + // throws PropertyError If value could not be set. + void set_value(T value) + { + this->validate_value(value); + this->value->set(value); + } + +private: + std::shared_ptr> value; + PropertyChangedCallback property_change_callback; +}; + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/server.hpp b/include/bw/webthing/server.hpp new file mode 100644 index 0000000..60a1ba4 --- /dev/null +++ b/include/bw/webthing/server.hpp @@ -0,0 +1,931 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace bw::webthing { + +typedef uWS::SocketContextOptions SSLOptions; + +#ifdef WT_WITH_SSL + typedef uWS::SSLApp uWebsocketsApp; +#else + typedef uWS::App uWebsocketsApp; +#endif + +enum class ThingType { SingleThing, MultipleThings }; + +struct ThingContainer +{ + ThingContainer(std::vector things, std::string name, ThingType type) + : things(things) + , name(name) + , type(type) + {} + + std::string get_name() const + { + return name; + } + + ThingType get_type () const + { + return type; + } + + std::optional get_thing(int index) + { + if(index >= things.size()) + return std::nullopt; + + if(type == ThingType::SingleThing) + return things[0]; + + return things[index]; + } + + std::vector get_things() + { + return things; + } + +protected: + std::vector things; + std::string name; + ThingType type; +}; + +struct SingleThing : public ThingContainer +{ + SingleThing(Thing* thing) + : ThingContainer({thing}, thing->get_title(), ThingType::SingleThing) + {} +}; + +struct MultipleThings : public ThingContainer{ + MultipleThings(std::vector things, std::string name) + : ThingContainer(things, name, ThingType::MultipleThings) + {} +}; + +class WebThingServer +{ + struct Builder + { + Builder(ThingContainer things) + : things_(things) + {} + + Builder& port(int port) + { + port_ = port; + return *this; + } + + Builder& hostname(std::string hostname) + { + hostname_ = hostname; + return *this; + } + + Builder& base_path(std::string base_path) + { + base_path_ = base_path; + return *this; + } + + Builder& disable_host_validation(bool disable_host_validation) + { + disable_host_validation_ = disable_host_validation; + return *this; + } + + Builder& ssl_options(SSLOptions options) + { + ssl_options_ = options; + return *this; + } + + Builder& disable_mdns() + { + mdns_enabled_ = false; + return *this; + } + + WebThingServer build() + { + return WebThingServer(things_, port_, hostname_, base_path_, + disable_host_validation_, ssl_options_, mdns_enabled_); + } + + void start() + { + build().start(); + } + private: + ThingContainer things_; + int port_ = 80; + std::optional hostname_; + /*std::vector additional_routes;*/ + SSLOptions ssl_options_; + std::string base_path_ = "/"; + bool disable_host_validation_ = false; + bool mdns_enabled_ = true; + + }; + +public: + static Builder host(ThingContainer things) + { + return Builder(things); + } + + // disable copy, only allow move + WebThingServer(const WebThingServer& other) = delete; + + WebThingServer(ThingContainer things, int port, std::optional hostname, + std::string base_path, bool disable_host_validation, SSLOptions ssl_options = {}, bool enable_mdns = true) + : things(things) + , name(things.get_name()) + , port(port) + , hostname(hostname) + , base_path(base_path) + , disable_host_validation(disable_host_validation) + , ssl_options(ssl_options) + , enable_mdns(enable_mdns) + { + if(this->base_path.back() == '/') + this->base_path.pop_back(); + + hosts.push_back("localhost"); + hosts.push_back("localhost:" + std::to_string(port)); + + auto if_ips = get_addresses(); + for(auto& ip : if_ips) + { + hosts.push_back(ip); + hosts.push_back(ip + ":" + std::to_string(port)); + } + + if(this->hostname) + { + auto hn = this->hostname.value(); + std::transform(hn.begin(), hn.end(), hn.begin(), ::tolower); + this->hostname = hn; + hosts.push_back(*this->hostname); + hosts.push_back(*this->hostname + ":" + std::to_string(port)); + } + + initialize_webthing_routes(); + } + + void initialize_webthing_routes() + { + web_server = std::make_unique(ssl_options); + auto& server = *web_server.get(); + + bool is_single = things.get_type() == ThingType::SingleThing; + + int thing_index = -1; + for(auto& thing : things.get_things()) + { + thing_index++; + thing->set_href_prefix(base_path + (is_single ? "" : "/" + std::to_string(thing_index))); + thing->add_message_observer([this](auto topic, auto msg) + { + handle_thing_message(topic, msg); + }); + } + + if(is_single) + { + server.get(base_path + "/", [&](auto* res, auto* req){handle_thing(res, req);}); + server.get(base_path + "/properties", [&](auto* res, auto* req){handle_properties(res, req);}); + server.get(base_path + "/properties/:property_name", [&](auto* res, auto* req){handle_property_get(res, req);}); + server.put(base_path + "/properties/:property_name", [&](auto* res, auto* req){handle_property_put(res, req);}); + server.get(base_path + "/actions", [&](auto* res, auto* req){handle_actions_get(res, req);}); + server.post(base_path + "/actions", [&](auto* res, auto* req){handle_actions_post(res, req);}); + server.get(base_path + "/actions/:action_name", [&](auto* res, auto* req){handle_actions_get(res, req);}); + server.post(base_path + "/actions/:action_name", [&](auto* res, auto* req){handle_actions_post(res, req);}); + server.get(base_path + "/actions/:action_name/:action_id", [&](auto* res, auto* req){handle_action_id_get(res, req);}); + server.put(base_path + "/actions/:action_name/:action_id", [&](auto* res, auto* req){handle_action_id_put(res, req);}); + server.del(base_path + "/actions/:action_name/:action_id", [&](auto* res, auto* req){handle_action_id_delete(res, req);}); + server.get(base_path + "/events", [&](auto* res, auto* req){handle_events(res, req);}); + server.get(base_path + "/events/:event_name", [&](auto* res, auto* req){handle_events(res, req);}); + } + else + { + server.get(base_path + "/", [&](auto* res, auto* req){handle_things(res, req);}); + server.get(base_path + "/:thing_id", [&](auto* res, auto* req){handle_thing(res, req);}); + server.get(base_path + "/:thing_id/properties", [&](auto* res, auto* req){handle_properties(res, req);}); + server.get(base_path + "/:thing_id/properties/:property_name", [&](auto* res, auto* req){handle_property_get(res, req);}); + server.put(base_path + "/:thing_id/properties/:property_name", [&](auto* res, auto* req){handle_property_put(res, req);}); + server.get(base_path + "/:thing_id/actions", [&](auto* res, auto* req){handle_actions_get(res, req);}); + server.post(base_path + "/:thing_id/actions", [&](auto* res, auto* req){handle_actions_post(res, req);}); + server.get(base_path + "/:thing_id/actions/:action_name", [&](auto* res, auto* req){handle_actions_get(res, req);}); + server.post(base_path + "/:thing_id/actions/:action_name", [&](auto* res, auto* req){handle_actions_post(res, req);}); + server.get(base_path + "/:thing_id/actions/:action_name/:action_id", [&](auto* res, auto* req){handle_action_id_get(res, req);}); + server.put(base_path + "/:thing_id/actions/:action_name/:action_id", [&](auto* res, auto* req){handle_action_id_put(res, req);}); + server.del(base_path + "/:thing_id/actions/:action_name/:action_id", [&](auto* res, auto* req){handle_action_id_delete(res, req);}); + server.get(base_path + "/:thing_id/events", [&](auto* res, auto* req){handle_events(res, req);}); + server.get(base_path + "/:thing_id/events/:event_name", [&](auto* res, auto* req){handle_events(res, req);}); + } + server.any("/*", [&](auto* res, auto* req){handle_invalid_requests(res, req);}); + server.options("/*", [&](auto* res, auto* req){handle_options_requests(res, req);}); + + for(auto& thing : things.get_things()) + { + auto thing_id = thing->get_id(); + uWebsocketsApp::WebSocketBehavior ws_behavior; + ws_behavior.compression = uWS::SHARED_COMPRESSOR; + ws_behavior.open = [thing_id](auto *ws) + { + std::string* ws_id = (std::string *) ws->getUserData(); + ws_id->append(generate_uuid()); + + logger::debug("websocket open " + *ws_id); + ws->subscribe(thing_id + "/properties"); + ws->subscribe(thing_id + "/actions"); + }; + ws_behavior.message = [thing_id, thing](auto *ws, std::string_view message, uWS::OpCode op_code) + { + logger::debug("websocket msg " + *((std::string*)ws->getUserData()) + ": " + std::string(message)); + json j; + try + { + j = json::parse(message); + } + catch (json::parse_error&) + { + json error_message = {{"messageType", "error"}, {"data", { + {"status", "400 Bad Request"}, + {"message", "Parsing request failed"} + }}}; + ws->send(error_message.dump(), op_code); + return; + } + + if(!j.contains("messageType") || !j.contains("data")) + { + json error_message = {{"messageType", "error"}, {"data", { + {"status", "400 Bad Request"}, + {"message", "Invalid message"} + }}}; + ws->send(error_message.dump(), op_code); + return; + } + + // e.g. {"messageType":"addEventSubscription", "data":{"eventName":{}}} + std::string message_type = j["messageType"]; + if(message_type == "addEventSubscription") + { + for(auto& evt : j["data"].items()) + ws->subscribe(thing_id + "/events/" + evt.key()); + } + else if(message_type == "setProperty") + { + for(auto& property_entry : j["data"].items()) + { + try + { + auto prop_setter = [&](auto val){ + thing->set_property(property_entry.key(), val); + }; + + json v = property_entry.value(); + if(v.is_boolean()) + prop_setter(v.get()); + else if(v.is_string()) + prop_setter(v.get()); + else if(v.is_number_integer()) + prop_setter(v.get()); + else if(v.is_number_unsigned()) + prop_setter(v.get()); + else if(v.is_number_float()) + prop_setter(v.get()); + else + prop_setter(v); + } + catch(std::exception& ex) + { + json error_message = {{"messageType", "error"}, {"data", { + {"status", "400 Bad Request"}, + {"message", ex.what()} + }}}; + ws->send(error_message.dump(), op_code); + } + } + } + else if(message_type == "requestAction") + { + for(auto& action_entry : j["data"].items()) + { + std::optional input; + if(j["data"][action_entry.key()].contains("input")) + input = j["data"][action_entry.key()]["input"]; + + auto action = thing->perform_action(action_entry.key(), std::move(input)); + if(action) + { + std::thread action_runner([action]{ + action->start(); + }); + action_runner.detach(); + } + } + } + else + { + json error_message = {{"messageType", "error"}, {"data", { + {"status", "400 Bad Request"}, + {"message", "Unknown messageType: " + message_type}, + {"request", message} + }}}; + ws->send(error_message.dump(), op_code); + } + }; + ws_behavior.close = [thing_id](auto *ws, int /*code*/, std::string_view /*message*/) + { + logger::debug("websocket close " + *((std::string*)ws->getUserData())); + ws->unsubscribe(thing_id + "/properties"); + ws->unsubscribe(thing_id + "/actions"); + ws->unsubscribe(thing_id + "/events/#"); + }; + + server.ws(thing->get_href(), std::move(ws_behavior)); + } + + server.listen(port, [&](auto *listen_socket) { + if (listen_socket) { + logger::info("Listening on port " + std::to_string(port)); + } + }); + } + + void start() + { + logger::info("Start WebThingServer hosting '" + things.get_name() + + "' containing " + std::to_string(things.get_things().size()) + " thing" + + std::string(things.get_things().size() == 1 ? "" : "s")); + + if(enable_mdns) + start_mdns_service(); + + webserver_loop = uWS::Loop::get(); + web_server->run(); + + logger::info("Stopped WebThingServer hosting '" + things.get_name() + "'"); + } + + void stop() + { + logger::info("Stop WebThingServer hosting '" + things.get_name() + "'"); + + if(enable_mdns) + stop_mdns_service(); + + web_server->close(); + } + + std::string get_name() const + { + return name; + } + + uWebsocketsApp* get_web_server() const + { + return web_server.get(); + } + +private: + + void start_mdns_service() + { + std::thread([this]{ + + logger::info("Start mDNS service for WebThingServer hosting '" + things.get_name() + "'"); + + #ifdef WT_WITH_SSL + bool is_tls = true; + #else + bool is_tls = false; + #endif + + mdns_service = std::make_unique(); + mdns_service->start_service(things.get_name(), "_webthing._tcp.local.", port, base_path + "/", is_tls); + + logger::info("Stopped mDNS service for WebThingServer hosting '" + things.get_name() + "'"); + + }).detach(); + } + + void stop_mdns_service() + { + using namespace std::chrono; + + if(mdns_service) + { + logger::info("Stop mDNS service for WebThingServer hosting '" + things.get_name() + "'"); + mdns_service->stop_service(); + + auto timeout = milliseconds(5000); + auto start = steady_clock::now(); + bool timeout_reached = false; + while(mdns_service->is_running() || timeout_reached) + { + std::this_thread::sleep_for(milliseconds(1)); + auto current = steady_clock::now(); + timeout_reached = duration_cast(current - start) >= timeout; + } + + } + } + + std::optional find_thing_from_url(uWS::HttpRequest* req) + { + if(things.get_type() == ThingType::SingleThing) + return things.get_thing(0); + + auto thing_id_str = req->getParameter(0); + try{ + int thing_id = std::stoi(thing_id_str.data()); + return things.get_thing(thing_id); + } + catch(std::exception&) + { + return std::nullopt; + } + } + + std::optional find_param_after_thing_id_from_url(uWS::HttpRequest* req, int index_after_thing_id = 0) + { + int parameter_index = index_after_thing_id; + if(things.get_type() == ThingType::MultipleThings) + parameter_index += 1; + + auto param = req->getParameter(parameter_index); + if(param.empty()) + return std::nullopt; + + return std::string(param); + } + + std::optional find_property_name_from_url(uWS::HttpRequest* req) + { + return find_param_after_thing_id_from_url(req); + } + + std::optional find_event_name_from_url(uWS::HttpRequest* req) + { + return find_param_after_thing_id_from_url(req); + } + + std::optional find_action_name_from_url(uWS::HttpRequest* req) + { + return find_param_after_thing_id_from_url(req, 0); + } + + std::optional find_action_id_from_url(uWS::HttpRequest* req) + { + return find_param_after_thing_id_from_url(req, 1); + } + + bool validate_host(uWS::HttpRequest* req) + { + if(disable_host_validation) + return true; + + std::string host(req->getHeader("host")); + return std::find(hosts.begin(), hosts.end(), host) != hosts.end(); + } + + template + uWS::HttpResponse* write_cors_response(uWS::HttpResponse* response) + { + response->writeHeader("Access-Control-Allow-Origin", "*"); + response->writeHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization"); + response->writeHeader("Access-Control-Allow-Methods", "GET, HEAD, PUT, POST, DELETE"); + return response; + } + + template + uWS::HttpResponse* write_json_response(uWS::HttpResponse* response) + { + response->writeHeader("Content-Type", "application/json"); + return response; + } + + template + void handle_invalid_requests(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto host = req->getHeader("host"); + auto path = req->getUrl(); + + if(path.back() == '/' && path != (base_path + "/")) + { + // redirect to non-trailing slash url + res->writeStatus("301 Moved Permanently"); + res->writeHeader("Location", (SSL ? "https://" : "http://") + std::string(host) + std::string(path.data(), path.size()-1)); + write_cors_response(res); + res->end(); + } + else + { + res->writeStatus("405 Method Not Allowed"); + write_cors_response(res); + res->end(); + } + } + + template + void handle_options_requests(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + res->writeStatus("204 No Content"); + write_cors_response(res); + res->end(); + } + + template + void base_handle_requests(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + if(!validate_host(req)) + { + res->writeStatus("405 Method Not Allowed"); + } + + write_cors_response(res); + write_json_response(res); + } + + template + json prepare_thing_description(Thing* thing, uWS::HttpRequest* req) + { + std::string http_protocol = SSL ? "https" : "http"; + std::string ws_protocol = http_protocol == "https" ? "wss" : "ws"; + std::string host = std::string(req->getHeader("host")); + std::string ws_href = ws_protocol + "://" + host; + + json desc = thing->as_thing_description(); + desc["href"] = thing->get_href(); + desc["links"].push_back({{"rel", "alternate"}, {"href", ws_href + thing->get_href()}}); + desc["base"] = http_protocol + "://" + host + thing->get_href(); + desc["securityDefinitions"] = {{"nosec_sc", {{"scheme", "nosec"}}}}; + desc["security"] = "nosec_sc"; + + return desc; + } + + template + void handle_things(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + json descriptions = json::array(); + + for(auto thing : things.get_things()) + { + json desc = prepare_thing_description(thing, req); + descriptions.push_back(desc); + } + + base_handle_requests(res, req); + res->end(descriptions.dump()); + } + + template + void handle_thing(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + if(!thing) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + json description = prepare_thing_description(*thing, req); + + base_handle_requests(res, req); + res->end(description.dump()); + } + + + template + void handle_properties(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + if(!thing) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + base_handle_requests(res, req); + res->end((*thing)->get_properties().dump()); + } + + template + void handle_property_get(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + auto property_name = find_property_name_from_url(req); + + if(!thing || !property_name) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + auto property = (*thing)->find_property(*property_name); + + if(!property) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + base_handle_requests(res, req); + res->end(property->get_property_value_object().dump()); + } + + template + void handle_property_put(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + auto property_name_in_url = find_property_name_from_url(req); + + if(!thing || !property_name_in_url) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + auto property = (*thing)->find_property(*property_name_in_url); + + if(!property) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + res->onData([res, thing, property_name_in_url, property](std::string_view body_chunk, bool is_last) + { + if(is_last) + { + try + { + if(body_chunk.empty()) + throw PropertyError("Empty property request body"); + + std::string prop_name = *property_name_in_url; + json body = json::parse(body_chunk); + + if(!body.contains(prop_name)) + throw PropertyError("Property request body does not contain " + prop_name); + + auto v = body[prop_name]; + auto prop_setter = [&](auto val){ + (*thing)->set_property(prop_name, val); + }; + + if(v.is_boolean()) + prop_setter(v.get()); + else if(v.is_string()) + prop_setter(v.get()); + else if(v.is_number_integer()) + prop_setter(v.get()); + else if(v.is_number_unsigned()) + prop_setter(v.get()); + else if(v.is_number_float()) + prop_setter(v.get()); + else + prop_setter(v); + + res->end(property->get_property_value_object().dump()); + } + catch(std::exception& ex) + { + json body = {{"message", ex.what()}}; + res->writeStatus("400 Bad Request")->end(body.dump()); + } + } + }); + + res->onAborted([]{ + logger::debug("transfer request body aborted"); + }); + } + + // Handles GET requests to: + // * /actions + // * /actions/ + template + void handle_actions_get(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + if(!thing) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + // can be std::nullopt which results in a collection of all actions + auto action_name = find_action_name_from_url(req); + + base_handle_requests(res, req); + res->end((*thing)->get_action_descriptions(action_name).dump()); + } + + + // Handles POST requests to: + // * /actions + // * /actions/ + template + void handle_actions_post(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + if(!thing) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + auto action_name_in_url = find_action_name_from_url(req); + + res->onData([res, thing, action_name_in_url](std::string_view body_chunk, bool is_last) + { + if(is_last) + { + try + { + if(body_chunk.empty()) + throw ActionError("Empty action request body"); + + json body = json::parse(body_chunk); + if(!body.is_object() || body.size() != 1 || + (action_name_in_url && !body.contains(action_name_in_url))) + throw ActionError("Invalid action request body"); + + std::string action_name = action_name_in_url.value_or(body.begin().key()); + json action_params = body[action_name]; + + std::optional input; + if(action_params.contains("input")) + input = action_params["input"]; + + auto action = (*thing)->perform_action(action_name, std::move(input)); + if(!action) + throw ActionError("Could not perform action"); + + json response = action->as_action_description(); + std::thread action_runner([action]{ + action->start(); + }); + action_runner.detach(); + + res->writeStatus("201 Created")->end(response.dump()); + + } + catch(std::exception& ex) + { + json body = {{"message", ex.what()}}; + res->writeStatus("400 Bad Request")->end(body.dump()); + } + } + }); + + res->onAborted([]{ + logger::debug("transfer request body aborted"); + }); + + } + + template + void handle_action_id_get(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + auto action_name = find_action_name_from_url(req); + auto action_id = find_action_id_from_url(req); + + if(!thing || !action_name || !action_id) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + auto action = (*thing)->get_action(*action_name, *action_id); + if(!action) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + base_handle_requests(res, req); + res->end(action->as_action_description().dump()); + } + + // TODO: this is not yet defined in the spec + // also cf. https://webthings.io/api/#actionrequest-resource + template + void handle_action_id_put(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + if(!thing) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + base_handle_requests(res, req); + res->end(); + } + + template + void handle_action_id_delete(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + auto action_name = find_action_name_from_url(req); + auto action_id = find_action_id_from_url(req); + + if(!thing || !action_name || !action_id) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + auto action = (*thing)->get_action(*action_name, *action_id); + if(!action) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + if(!(*thing)->remove_action(*action_name, *action_id)) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + res->writeStatus("204 No Content"); + base_handle_requests(res, req); + res->end(); + } + + // Handles requests to: + // * /events + // * /events/ + template + void handle_events(uWS::HttpResponse* res, uWS::HttpRequest* req) + { + auto thing = find_thing_from_url(req); + if(!thing) + { + res->writeStatus("404 Not Found")->end(); + return; + } + + // can be std::nullopt which results in a collection of all events + auto event_name = find_event_name_from_url(req); + + base_handle_requests(res, req); + res->end((*thing)->get_event_descriptions(event_name).dump()); + } + + // forward thing messages to servers websocket clients + void handle_thing_message(const std::string& topic, const json& message) + { + if(!webserver_loop) + return; + + std::string t = topic; + std::string m = message.dump(); + + webserver_loop->defer([this, t, m]{ + logger::debug("server broadcast : " + t + " : " + m); + web_server->publish(t, m, uWS::OpCode::TEXT); + }); + } + + ThingContainer things; + int port; + std::string name; + std::optional hostname; + /*std::vector additional_routes;*/ + SSLOptions ssl_options; + std::string base_path = "/"; + bool disable_host_validation = false; + bool enable_mdns = true; + + std::vector hosts; + + uWS::Loop* webserver_loop; // Must be initialized from thread that calls start() + std::unique_ptr web_server; + std::unique_ptr mdns_service; +}; + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/thing.hpp b/include/bw/webthing/thing.hpp new file mode 100644 index 0000000..ad1fad0 --- /dev/null +++ b/include/bw/webthing/thing.hpp @@ -0,0 +1,371 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include + +namespace bw::webthing { + +class Thing +{ +public: + typedef std::function (std::optional input)> ActionSupplier; + struct AvailableAction + { + json metadata; + ActionSupplier class_supplier; + }; + + typedef std::function MessageCallback; + + Thing(std::string id, std::string title, std::vector type, std::string description = "") + : id(id), title(title), type(type), description(description) + { + } + + Thing(std::string id, std::string title, std::string type = "", std::string description = "") + : Thing(id, title, std::vector{type}, description) + { + } + + json as_thing_description() const + { + json thing({ + {"id", id}, + {"title", title}, + {"@context", context}, + {"@type", type}, + {"properties", get_property_descriptions() }, + {"actions", json::object()}, + {"events", json::object()}, + {"description", description}, + {"links", { + {{"rel", "properties"}, {"href", href_prefix + "/properties"}}, + {{"rel", "actions"}, {"href", href_prefix + "/actions"}}, + {{"rel", "events"}, {"href", href_prefix + "/events"}} + }} + }); + + if(ui_href) + { + thing["links"].push_back({ + {"rel", "alternate"}, {"mediaType", "text/html"}, {"href", *ui_href} + }); + } + + for(auto& aa : available_actions) + { + std::string name = aa.first; + thing["actions"][name] = aa.second.metadata; + thing["actions"][name]["links"] = {{{"rel", "action"}, {"href", href_prefix + "/actions/" + name}}}; + } + + for(auto& ae : available_events) + { + std::string name = ae.first; + thing["events"][name] = ae.second; + thing["events"][name]["links"] = {{{"rel", "event"}, {"href", href_prefix + "/events/" + name}}}; + } + return thing; + } + + std::string get_href() const + { + if(href_prefix.size() > 0) + return href_prefix; + return "/"; + } + + std::optional get_ui_href() const + { + return ui_href; + } + + void set_ui_href(std::string href) + { + ui_href = href; + } + + std::string get_id() const + { + return id; + } + + std::string get_title() const + { + return title; + } + + std::string get_description() const + { + return description; + } + + std::vector get_type() const + { + return type; + } + + std::string get_context() const + { + return context; + } + + json get_property_descriptions() const + { + auto pds = json::object(); + for(const auto& p : properties) + pds[p.first] = p.second->as_property_description(); + return pds; + } + + // Get the thing's actions a json array + // action_name -- Optional action name to get description for + json get_action_descriptions(std::optional action_name = std::nullopt) const + { + json descriptions = json::array(); + + for(const auto& action_entry : actions) + for(const auto& action : action_entry.second) + if(!action_name || action_name == action_entry.first) + descriptions.push_back(action->as_action_description()); + + return descriptions; + } + + // Get the thing's events as a json array. + // event_name -- Optional event name to get description for + json get_event_descriptions(const std::optional& event_name = std::nullopt) const + { + json descriptions = json::array(); + + for(auto& evt : events) + if(!event_name || event_name == evt->get_name()) + descriptions.push_back(evt->as_event_description()); + + return descriptions; + } + + void add_property(std::shared_ptr property) + { + property->set_href_prefix(href_prefix); + properties[property->get_name()] = property; + } + + void remove_property(const PropertyBase& property) + { + properties.erase(property.get_name()); + } + + // Find a property by name + std::shared_ptr find_property(std::string property_name) const + { + if(properties.count(property_name) > 0) + return properties.at(property_name); + return nullptr; + } + + template + void set_property(std::string property_name, T value) + { + auto prop = find_property(property_name); + if(prop) + prop->set_value(value); + } + + template + std::optional get_property(std::string property_name) const + { + auto property = find_property(property_name); + if(property) + return property->get_value(); + return std::nullopt; + } + + // Get a mapping of all properties and their values. + json get_properties() const + { + auto json = json::object(); + + for(const auto& pe : properties) + { + json[pe.first] = pe.second->get_property_value_object()[pe.first]; + } + return json; + } + + //Determine whether or not this thing has a given property. + // property_name -- the property to look for + bool has_property(std::string property_name) const + { + return properties.find(property_name) != properties.end(); + } + + void property_notify(json property_status_message) + { + logger::debug("thing::property_notify : " + property_status_message.dump()); + for(auto& observer : observers) + observer( id + "/properties", property_status_message); + } + + // Perform an action on the thing. + // name -- name of the action + // input -- any action inputs + std::shared_ptr perform_action(std::string name, std::optional input = std::nullopt) + { + if(available_actions.count(name) == 0) + return nullptr; + + auto& action_type = available_actions[name]; + + if(action_type.metadata.contains("input")) + { + try + { + validate_value_by_scheme(input.value_or(json()), action_type.metadata["input"]); + } + catch(std::exception& ex) + { + logger::debug("action: '" + name + "' invalid input: " + + input.value_or(json()).dump() +" error: " + ex.what()); + return nullptr; + } + } + + try + { + auto action = action_type.class_supplier( std::move(input) ); + action->set_href_prefix(href_prefix); + action_notify(action_status_message(action)); + actions[name].push_back(action); + return action; + } + catch(std::exception& ex) + { + logger::debug("Construction of action '" + name + "' failed with error: " + ex.what()); + return nullptr; + } + } + + // Add an available action. + // name -- name of the action + // metadata -- action metadata, i.e. type, description, etc. as a json object + // class_supplier -- function to instantiate this action + void add_available_action(std::string name, json metadata, ActionSupplier class_supplier) + { + if(!metadata.is_object()) + throw ActionError("Action metadata must be encoded as json object."); + + available_actions[name] = { metadata, class_supplier }; + actions[name] = {}; + } + + void action_notify(json action_status_message) + { + logger::debug("thing::action_notify : " + action_status_message.dump()); + for(auto& observer : observers) + observer( id + "/actions", action_status_message); + } + + // Get an action by its name and id + // return the action when found, std::nullopt otherwise + std::shared_ptr get_action(std::string action_name, std::string action_id) const + { + if(actions.count(action_name) == 0) + return nullptr; + + const auto& actions_for_name = actions.at(action_name); + for(const auto& action : actions_for_name) + if(action->get_id() == action_id) + return action; + + return nullptr; + } + + // Remove an existing action identified by its name and id + // Returns bool indicating the presence of the action + bool remove_action(std::string action_name, std::string action_id) + { + auto action = get_action(action_name, action_id); + if(!action) + return false; + + action->cancel(); + auto& as = actions[action_name]; + as.erase(std::remove_if(as.begin(), as.end(), [&action_id](auto a){ + return a->get_id() == action_id;}), as.end()); + + return true; + } + + // Add a new event and notify subscribers + void add_event(std::shared_ptr event) + { + events.push_back(event); + event_notify(*event); + } + + // Add an available event. + // name -- name of the event + // metadata -- event metadata, i.e. type, description, etc., as a json object + void add_available_event(std::string name, json metadata = json::object()) + { + if(!metadata.is_object()) + throw EventError("Event metadata must be encoded as json object."); + + available_events[name] = metadata; + } + + void event_notify(const Event& event) + { + if(available_events.count(event.get_name()) == 0) + return; + + json message = event_message(event); + logger::debug("thing::event_notify : " + message.dump()); + + for(auto& observer : observers) + observer( id + "/events/" + event.get_name(), message); + } + + // Set the prefix of any hrefs associated with this thing. + void set_href_prefix(std::string prefix) + { + href_prefix = prefix; + + for(const auto& property : properties ) + property.second->set_href_prefix(prefix); + + for(auto& action_entry : actions) + for(auto& action : action_entry.second) + action->set_href_prefix(prefix); + } + + void add_message_observer(MessageCallback observer) + { + observers.push_back(observer); + } + +protected: + std::string id; + std::string context = "https://webthings.io/schemas"; + std::string title; + std::vector type; + std::string description; + std::map> properties; + std::map available_actions; + std::map available_events; + std::map>> actions; + std::vector> events; + std::string href_prefix; + std::optional ui_href; + std::vector observers; +}; + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/utils.hpp b/include/bw/webthing/utils.hpp new file mode 100644 index 0000000..e0c8b81 --- /dev/null +++ b/include/bw/webthing/utils.hpp @@ -0,0 +1,265 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace bw::webthing +{ + +namespace details +{ + struct global + { + static inline std::optional fixed_time; + static inline std::optional fixed_uuid; + }; + + // Generate a ISO8601-formatted local time timestamp + // and return as std::string + inline std::string current_ISO8601_time_local(const std::optional& fixed_time = std::nullopt) + { + if(fixed_time) + return *fixed_time; + + auto now = std::chrono::system_clock::now(); + std::time_t now_time = std::chrono::system_clock::to_time_t(now); + + // Get the milliseconds part of the current time + auto milliseconds = std::chrono::duration_cast(now.time_since_epoch()).count() % 1000; + + // Get the local time + std::tm local_time; + + // Get the timezone offset + #ifdef _WIN32 + localtime_s(&local_time, &now_time); + long timezone_offset = 0; + int err = _get_timezone(&timezone_offset); + if(err != 0) + timezone_offset = 0; + timezone_offset = -timezone_offset; + + if(local_time.tm_isdst) + timezone_offset += 3600; + #else + localtime_r(&now_time, &local_time); + long timezone_offset = local_time.tm_gmtoff; + #endif + + // Calculate the timezone offset manually + int offset_hours = timezone_offset / 3600; + int offset_minutes = (std::abs(timezone_offset) % 3600) / 60; + + // Format the time in ISO 8601 format, including milliseconds + std::ostringstream oss; + oss << std::put_time(&local_time, "%Y-%m-%dT%H:%M:%S") + << "." << std::setw(3) << std::setfill('0') << milliseconds; + if (timezone_offset >= 0) + oss << "+"; + else + oss << "-"; + oss << std::setw(2) << std::setfill('0') << std::abs(offset_hours) + << ":" << std::setw(2) << std::setfill('0') << offset_minutes; + + return oss.str(); + } +} // bw::webthing::details + +inline std::string timestamp() +{ + return details::current_ISO8601_time_local(details::global::fixed_time); +} + +enum log_level +{ + error = 5000, + warn = 4000, + info = 3000, + debug = 2000, + trace = 1000 +}; + +struct logger +{ + typedef std::function log_impl; + + static void error(const std::string& msg) + { + log(log_level::error, msg); + } + + static void warn(const std::string& msg) + { + log(log_level::warn, msg); + } + + static void info(const std::string& msg) + { + log(log_level::info, msg); + } + + static void debug(const std::string& msg) + { + log(log_level::debug, msg); + } + + static void trace(const std::string& msg) + { + log(log_level::trace, msg); + } + + static void log(log_level level, const std::string& msg) + { + if(level < custom_log_level) + return; + + if(custom_log_impl) + return custom_log_impl(level, msg); + + default_log_impl(level, msg); + } + + static void register_implementation(log_impl log_impl) + { + custom_log_impl = log_impl; + } + + static void set_level(log_level level) + { + custom_log_level = level; + } + +private: + static void default_log_impl(log_level level, const std::string& msg) + { + auto timestamp = details::current_ISO8601_time_local(std::nullopt); + auto level_str = level == log_level::error ? "E" : + level == log_level::warn ? "W" : + level == log_level::info ? "I" : + level == log_level::debug ? "D" : + level == log_level::trace ? "T" : + "L:" + std::to_string(level); + + std::stringstream ss; + ss << timestamp << " [" << std::this_thread::get_id() << "] " << level_str << " - " << msg; + + std::lock_guard lg(logger::log_mutex); + switch (level) + { + case log_level::error: + case log_level::warn: + std::cerr << ss.str() << std::endl; + break; + default: + std::cout << ss.str() << std::endl; + } + } + + static inline std::mutex log_mutex; + static inline log_impl custom_log_impl; + static inline log_level custom_log_level = log_level::debug; +}; + +// set a fixed time for timestamp generation +// this is useful for tests +// timestamp must follow ISO8601 format +// e.g. "2023-02-08T01:23:45" +inline void fix_time(std::string timestamp) +{ + details::global::fixed_time = timestamp; + logger::warn("time fixed to " + bw::webthing::timestamp()); +} + +// unfix the time for timestamp generation +inline void unfix_time() +{ + details::global::fixed_time = std::nullopt; + logger::warn("time unfixed"); +} + +// fix the time for the current scope. Not thread safe!!! +struct fix_time_scoped +{ + fix_time_scoped(std::string timestamp) + { + fix_time(timestamp); + } + + ~fix_time_scoped() + { + unfix_time(); + } +}; + +#define FIXED_TIME_SCOPED(timestamp) auto fixed_time_scope_guard = fix_time_scoped(timestamp); + +inline std::string generate_uuid() +{ + if(details::global::fixed_uuid) + return *details::global::fixed_uuid; + + // https://stackoverflow.com/a/58467162 + // TODO: include real uuid generator lib? + + static std::random_device dev; + static std::mt19937 rng(dev()); + + std::uniform_int_distribution dist(0, 15); + + const char *v = "0123456789abcdef"; + const bool dash[] = { 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0 }; + + std::string uuid; + for (int i = 0; i < 16; i++) { + if (dash[i]) uuid += "-"; + uuid += v[dist(rng)]; + uuid += v[dist(rng)]; + } + + return uuid; +} + +// set a fixed uuid for uuid generation +// this is useful for tests +inline void fix_uuid(std::string uuid) +{ + details::global::fixed_uuid = uuid; + logger::warn("uuid generation fixed to " + bw::webthing::generate_uuid()); +} + +// unfix the time for timestamp generation +inline void unfix_uuid() +{ + details::global::fixed_uuid = std::nullopt; + logger::warn("uuid generation unfixed"); +} + +// fix the uuid generation for the current scope. Not thread safe!!! +struct fix_uuid_scoped +{ + fix_uuid_scoped(std::string uuid) + { + fix_uuid(uuid); + } + + ~fix_uuid_scoped() + { + unfix_uuid(); + } +}; + +#define FIXED_UUID_SCOPED(uuid) auto fixed_uuid_scope_guard = fix_uuid_scoped(uuid); + + +} // bw::webthing \ No newline at end of file diff --git a/include/bw/webthing/value.hpp b/include/bw/webthing/value.hpp new file mode 100644 index 0000000..93a3cc6 --- /dev/null +++ b/include/bw/webthing/value.hpp @@ -0,0 +1,66 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +namespace bw::webthing { + +template +class Value +{ +public: + typedef std::function ValueForwarder; + typedef std::function ValueChangedCallback; + + Value(std::optional initial_value = std::nullopt, ValueForwarder value_forwarder = nullptr) + : last_value(initial_value) + , value_forwarder(value_forwarder) + { + } + + void set(T value) + { + if (value_forwarder) + value_forwarder(value); + notify_of_external_update(value); + } + + std::optional get() const + { + return last_value; + } + + void notify_of_external_update(T value) + { + if (value != *last_value) + { + last_value = value; + notify_observers(value); + } + } + + void add_observer(ValueChangedCallback observer) + { + observers.push_back(observer); + } + +private: + + void notify_observers(T value) + { + for (auto& observer : observers) + { + observer(value); + } + } + + std::optional last_value; + ValueForwarder value_forwarder; + std::vector observers; +}; + +} // bw::webthing diff --git a/include/bw/webthing/webthing.hpp b/include/bw/webthing/webthing.hpp new file mode 100644 index 0000000..f2f854b --- /dev/null +++ b/include/bw/webthing/webthing.hpp @@ -0,0 +1,178 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace bw::webthing +{ + +std::shared_ptr make_thing(std::string id, std::string title, std::vector type, std::string description) +{ + if(id == "") + id = "uuid:" + generate_uuid(); + if(title == "") + title = id; + return std::make_shared(id, title, type, description); +} + +std::shared_ptr make_thing(std::string id = "", std::string title = "", std::string type = "", std::string description = "") +{ + std::vector types; + if(type != "") + types.push_back(type); + return make_thing(id, title, types, description); +} + +template std::shared_ptr> make_value(T initial_value, typename Value::ValueForwarder value_forwarder = nullptr) +{ + return std::make_shared>(initial_value, std::move(value_forwarder)); +} + +template std::shared_ptr> make_unknown_value(typename Value::ValueForwarder value_forwarder = nullptr) +{ + return std::make_shared>(std::nullopt, std::move(value_forwarder)); +} + +template std::shared_ptr> link_property(Thing* thing, std::string name, std::shared_ptr> value, json metadata = json::object()) +{ + PropertyChangedCallback property_changed_callback = [thing](json property_status){ + thing->property_notify(property_status); + }; + auto property = std::make_shared>(std::move(property_changed_callback), name, value, metadata); + thing->add_property(property); + return property; +} + +template std::shared_ptr> link_property(Thing* thing, std::string name, T intial_value, json metadata = json::object()) +{ + return link_property(thing, name, make_value(intial_value), metadata); +} + +template std::shared_ptr> link_property(std::shared_ptr thing, std::string name, std::shared_ptr> value, json metadata = json::object()) +{ + return link_property(thing.get(), name, value, metadata); +} + +template std::shared_ptr> link_property(std::shared_ptr thing, std::string name, T intial_value, json metadata = json::object()) +{ + return link_property(thing, name, make_value(intial_value), metadata); +} + +void link_event(Thing* thing, std::string name, json metadata = json::object()) +{ + thing->add_available_event(name, metadata); +} + +void link_event(std::shared_ptr thing, std::string name, json metadata = json::object()) +{ + link_event(thing.get(), name, metadata); +} + +std::shared_ptr emit_event(Thing* thing, std::string name, std::optional data = std::nullopt) +{ + auto event = std::make_shared(thing, name, data); + thing->add_event(event); + return event; +} + +std::shared_ptr emit_event(std::shared_ptr thing, std::string name, std::optional data = std::nullopt) +{ + return emit_event(thing.get(), name, data); +} + +std::shared_ptr emit_event(Thing* thing, Event&& event) +{ + auto event_ptr = std::make_shared(event); + thing->add_event(event_ptr); + return event_ptr; +} + +std::shared_ptr emit_event(std::shared_ptr thing, Event&& event) +{ + auto event_ptr = std::make_shared(event); + thing->add_event(event_ptr); + return event_ptr; +} + +void link_action(Thing* thing, std::string action_name, json metadata, + std::function perform_action = nullptr, std::function cancel_action = nullptr) +{ + Thing::ActionSupplier action_supplier = [thing, action_name, perform_action, cancel_action](auto input){ + return std::make_shared(generate_uuid(), + make_action_behavior(thing, perform_action, cancel_action), + action_name, input); + }; + + thing->add_available_action(action_name, metadata, std::move(action_supplier)); +} + +void link_action(std::shared_ptr thing, std::string action_name, json metadata, + std::function perform_action = nullptr, std::function cancel_action = nullptr) +{ + link_action(thing.get(), action_name, metadata, perform_action, cancel_action); +} + +void link_action(Thing* thing, std::string action_name, + std::function perform_action = nullptr, std::function cancel_action = nullptr) +{ + link_action(thing, action_name, json::object(), perform_action, cancel_action); +} + +void link_action(std::shared_ptr thing, std::string action_name, + std::function perform_action = nullptr, std::function cancel_action = nullptr) +{ + link_action(thing.get(), action_name, perform_action, cancel_action); +} + +template void link_action(Thing* thing, std::string action_name, json metadata = json::object()) +{ + static_assert(has_perform_action::value, "ActionImpl does not have a perform_action method"); + + Thing::ActionSupplier action_supplier; + if constexpr(std::is_constructible_v>) + { + action_supplier = [thing](auto input){ + return std::make_shared(thing, input); + }; + } + else if constexpr(std::is_constructible_v) + { + action_supplier = [thing](auto input){ + if(!input) + throw ActionError("Input must not be empty for this Action type"); + return std::make_shared(thing, input.value_or(json())); + }; + } + else if constexpr(std::is_constructible_v) + { + action_supplier = [thing](auto input){ + return std::make_shared(thing); + }; + } + else + { + throw std::runtime_error("ActionImpl does not provide a suitable constructor"); + } + + thing->add_available_action(action_name, metadata, std::move(action_supplier)); +} + +template void link_action(std::shared_ptr thing, std::string action_name, json metadata = json::object()) +{ + link_action(thing.get(), action_name, metadata); +} + +} // bw::webthing diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..440b888 --- /dev/null +++ b/readme.md @@ -0,0 +1,234 @@ +[project_name]: Webthing-CPP + +# Webthing-CPP + +Webthing-CPP is a modern CPP/C++17 implementation of the [WebThings API](https://webthings.io/api). Goal of the project is to offer an easy way to set up digital twins with web interface for arbitrary things by just specifying their properties, actions and events. This projects focus lies on an easy to use API heavily inspired by similar projects from the [Java](https://github.com/WebThingsIO/webthing-java) and [Python](https://github.com/WebThingsIO/webthing-python) world. + +Webthing-CPP comes with MIT license without any warranty. DISCLAIMER: At the moment this project is in an early stage. Please make sure to perform sufficient number of tests if it suits your needs regarding stability before using it in production. + +## Project structure + +This project follows a header only approach to make integration into own projects easier. Nevertheless it relies on some dependencies to implement its features: + +- [µWebSockets](https://github.com/uNetworking/uWebSockets) is used as backing http/websocket server. +- [nlohmann::json](https://github.com/nlohmann/json) and [json-schema-validator](https://github.com/pboettch/json-schema-validator) are used to make working with json more comfortable. +- [mdns](https://github.com/mjansson/mdns) is used for easy service discovery. +- [OpenSSL](https://github.com/openssl/openssl) is used when SSL support is required. + +The projects sources can be found in the _include_ folder. Beside the library sources the project is shipped with some examples for demonstration purposes located in the _examples_ folder. In addition some unit tests backed by [Catch2](https://github.com/catchorg/Catch2) framework can be found in _test_ folder. In _tools_ folder there are some helpers available related to _vcpkg_, tests and certificates. + +## Defines + +__WT_USE_JSON_SCHEMA_VALIDATION__ + +When ```WT_USE_JSON_SCHEMA_VALIDATION``` is defined Webthing-CPP will validate JSON input for thing properties and thing actions against JSON scheme defined by the thing description. Otherwise validation will be omitted. + +__WT_UNORDERED_JSON_OBJECT_LAYOUT__ + +Webthing-CPP uses ```nlohmann::ordered_json``` as default json implementation. This specialization maintains the insertion order of object keys. By defining ```WT_UNORDERED_JSON_OBJECT_LAYOUT``` Webthing-CPP will use ```nlohmann::json``` as json implementation. + +__WT_WITH_SSL__ + +By defining ```WT_WITH_SSL``` Webthing-CPP will use the ```uWS::SSLApp``` as backing webserver. When definition is missing it will use ```uWS::App```. + +## Build system + +Webthing-CPP uses _cmake_ in conjunction with _vcpkg_ as default build system. By default, the build system is configured to statically link all dependencies to build simple self-contained executables. + +The __build.sh__ script is a little helper to ensure cmake is called with correct parameters and vcpkg installs all required dependencies. For Windows users there is an alternative __build.bat__ script available. Following arguments are supported: + +__clean__ + +Deletes the build folder that cmake creates to ensure a fresh rebuild. + +__release__ + +Use _Release_ as cmake build type. _Debug_ will be used as default. + +__with_ssl__ + +Configures the project to support SSL for WebThingServer and installs additional required dependencies. + +__win32__ + +Windows only: Use _Win32_ as target architecture. _x64_ will be used as default. + +## SSL support + +Build project with SSL support. + +```sh +./build.sh clean release with_ssl +``` + +A self signed certificate for test purposes can be created by using the __create-pems.sh__ script from the __tools__ folder. This will create a _key.pem_ as well as a _cert.pem_. Make sure to configure the ```WebThingServer``` with correct ```SSLOptions``` e.g.: + +```C++ +SSLOptions ssl_options; +ssl_options.key_file_name = "key.pem"; +ssl_options.cert_file_name = "cert.pem"; +ssl_options.passphrase = "1234"; + +auto server = WebThingServer::host(things) + .port(8888) + .ssl_options(ssl_options) + .build(); +``` + +## Examples + +At the moment three example applications are available. + +- [single-thing.cpp](examples/single-thing.cpp) Shows how to set up a simple WebThing with two properties and an action. +- [multiple-thing.cpp](examples/multiple-things.cpp) Shows how to host more then one WebThing in a single application. Things are a fake light and a fake humidity sensor. +- [gui-thing.cpp](examples/gui-thing.cpp) Demonstrates how to embed a HTML GUI of a fake slot machine and how interact with it. + +### Example implementation + +In this code-walkthrough we will set up a dimmable light and a humidity sensor (both using fake data, of course). All working examples can be found in the [examples](/examples/) directory. + +#### Dimmable light + +Imagine you have a dimmable light that you want to expose via the WebThings API. The light can be turned on/off and the brightness can be set from 0% to 100%. Besides the name, description, and type, a _Light_ is required to expose two properties: + +* ```on```: the state of the light, whether it is turned on or off + - Setting this property via a ```PUT {"on": true/false}``` call to the REST API toggles the light. + +* ```brightness```: the brightness level of the light from 0-100% + - Setting this property via a PUT call to the REST API sets the brightness level of this light. + +First we create a new Thing: + +```C++ +auto thing = make_thing("urn:dev:ops:my-lamp-1234", "My Lamp", + std::vector({"OnOffSwitch", "Light"}), + "A web connected lamp"); +``` +Now we can add the required properties. + +The ```on``` property reports and sets the on/off state of the light. For this, we need to have a ```Value``` object which holds the actual state and also a method to turn the light on/off. For our purposes, we just want to log the new state if the light is switched on/off. + +```C++ +auto on_value = make_value(true, [](auto v){ + logger::info("On-State is now " + std::string( v ? "on" : "off")); +}); +link_property(thing, "on", on_value, { + {"@type", "OnOffProperty"}, + {"title", "On/Off"}, + {"type", "boolean"}, + {"description", "Whether the lamp is turned on"}}); +``` + +The ``brightness`` property reports the brightness level of the light and sets the level. Like before, instead of actually setting the level of a light, we just log the level. + +```C++ +auto brightness_value = make_value(50, [](auto v){ + logger::info("Brightness is now " + std::to_string(v)); +}); +link_property(thing, "brightness", brightness_value, { + {"@type", "BrightnessProperty"}, + {"title", "Brightness"}, + {"type", "integer"}, + {"description", "The level of light from 0-100"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}}); +``` + +Now we can add our newly created thing to the server and start it: + +```C++ +// If adding more than one thing, use MultipleThings() with a name. +// In the single thing case, the thing's name will be broadcast. +WebThingServer::host(SingleThing(thing.get())).port(8888).start(); +``` + +This will start the server, making the light available via the WebThings REST API and announcing it as a discoverable resource on your local network via mDNS. + +#### Sensor + +Let's now also connect a humidity sensor to the server we set up for our light. + +A _MultiLevelSensor_ (a sensor that returns a level instead of just on/off) has one required property (besides the name, type, and optional description): ```level```. We want to monitor this property and get notified if the value changes. + +First we create a new Thing with a ```level``` property (Here we specify the property within the constructor of our custom Thing object): + +* ``level``: tells us what the sensor is actually reading + + - Contrary to the light, the value cannot be set via an API call, as it wouldn't make much sense, to SET what a sensor is reading. Therefore, we are creating a **readOnly** property. + +```C++ +struct FakeGpioHumiditySensor : public Thing +{ + FakeGpioHumiditySensor() : Thing( + "urn:dev:ops:my-humidity-sensor-1234", "My Humidity Sensor", + "MultiLevelSensor", "A web connected humidity sensor") + { + level = make_value(0.0); + link_property(this, "level", level, { + {"@type", "LevelProperty"}, + {"title", "Humidity"}, + {"type", "number"}, + {"description", "The current humidity in %"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}, + {"readOnly", true}}); + } + + std::shared_ptr> level; +}; +``` + +Now we have a sensor that constantly reports 0%. To make it usable, we need a thread or some kind of input when the sensor has a new reading available. For this purpose we start a thread that queries the physical sensor every few seconds. For our purposes, it just calls a fake method. + +```C++ +struct FakeGpioHumiditySensor : public Thing +{ + FakeGpioHumiditySensor() : Thing( + "urn:dev:ops:my-humidity-sensor-1234", "My Humidity Sensor", + "MultiLevelSensor", "A web connected humidity sensor") + { + level = make_value(0.0); + link_property(this, "level", level, { + {"@type", "LevelProperty"}, + {"title", "Humidity"}, + {"type", "number"}, + {"description", "The current humidity in %"}, + {"minimum", 0}, + {"maximum", 100}, + {"unit", "percent"}, + {"readOnly", true}}); + + // Start a thread that polls the sensor reading every 3 seconds + std::thread([this]{ + while(true) + { + std::this_thread::sleep_for(std::chrono::seconds(3)); + + // Update the underlying value, which in turn notifies + // all listeners + level->notify_of_external_update(read_from_gpio()); + } + }).detach(); + } + + + // Mimic an actual sensor updating its reading every couple seconds. + double read_from_gpio() + { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_real_distribution dist(0.0, 100.0); + return dist(gen); + } + + std::shared_ptr> level; +}; +``` + +This will update our ```Value``` object with the sensor readings via the ```level->notify_of_external_update(read_from_gpio())``` call. The ```Value``` object now notifies the property and the thing that the value has changed, which in turn notifies all websocket listeners. + +## Adding to Gateway + +To add your web thing to the WebThings Gateway, install the "Web Thing" add-on and follow the instructions [here](https://github.com/WebThingsIO/thing-url-adapter#readme). \ No newline at end of file diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..52498ed --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,36 @@ +find_package(Catch2 3 REQUIRED) + +add_executable(tests + "unit-test/action_tests.cpp" + "unit-test/event_tests.cpp" + "unit-test/json_validator_tests.cpp" + "unit-test/property_tests.cpp" + "unit-test/server_tests.cpp" + "unit-test/thing_tests.cpp" + "unit-test/utils_tests.cpp" + "unit-test/webthing_tests.cpp" +) + +set_property(TARGET tests PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + +set(INCLUDES_FOR_TESTS ../include ${UWEBSOCKETS_INCLUDE_DIRS} ${MDNS_INCLUDE_DIRS}) + +target_include_directories(tests PRIVATE ${INCLUDES_FOR_TESTS}) +target_link_libraries(tests PRIVATE Catch2::Catch2WithMain) +target_link_libraries(tests PRIVATE nlohmann_json::nlohmann_json) +target_link_libraries(tests PRIVATE nlohmann_json_schema_validator) +target_link_libraries(tests PRIVATE ${LIBUSOCKETS_STATIC}) +target_link_libraries(tests PRIVATE ZLIB::ZLIB) + +IF(WIN32) + target_link_libraries(tests PRIVATE uv_a) +ENDIF(WIN32) + +IF(WT_WITH_SSL) + target_link_libraries(tests PRIVATE OpenSSL::SSL OpenSSL::Crypto) +ENDIF(WT_WITH_SSL) + +include(CTest) +include(Catch) +catch_discover_tests(tests) \ No newline at end of file diff --git a/test/unit-test/action_tests.cpp b/test/unit-test/action_tests.cpp new file mode 100644 index 0000000..a20614c --- /dev/null +++ b/test/unit-test/action_tests.cpp @@ -0,0 +1,99 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include +#include + +using namespace bw::webthing; + +TEST_CASE( "Webthing actions have an json representation", "[action][json]" ) +{ + FIXED_TIME_SCOPED("2023-02-17T01:23:45.000+00:00"); + + Action action("test-action-123", ActionBehavior(), "test-action-name", "test-action-input"); + action.set_href_prefix("test-base"); + + auto expected_json = json::parse(R"( + {"test-action-name": { + "href": "test-base/actions/test-action-name/test-action-123", + "timeRequested": "2023-02-17T01:23:45.000+00:00", + "status": "created", + "input": "test-action-input" + }} + )"); + + REQUIRE( action.as_action_description() == expected_json ); +} + +TEST_CASE( "Webthing specifies action status message fromat", "[action][json]" ) +{ + FIXED_TIME_SCOPED("2023-02-17T02:34:56.000+00:00"); + + Action action("#456", ActionBehavior(), "test-action-name", "test-action-input"); + action.set_href_prefix("test-base"); + + auto expected_json = json::parse(R"( + { + "messageType": "actionStatus", + "data": { + "test-action-name": { + "href": "test-base/actions/test-action-name/#456", + "timeRequested": "2023-02-17T02:34:56.000+00:00", + "status": "created", + "input": "test-action-input" + } + } + } + )"); + + REQUIRE( action_status_message(action) == expected_json ); +} + +SCENARIO( "actions have a stateful lifecycle", "[action]" ) +{ + GIVEN("a custom action") + { + struct CustomAction : public Action + { + CustomAction() : Action("abc123", { + /*notify_thing*/[](json j){ + std::cout << "THING GOT " << j << std::endl; + }, + /*perform_action*/[&]{ + std::cout << "CustomAction perform action with input:" << get_input().value_or(json()) << std::endl; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + work_done = true; + } + }, "my-custom-action", json({"a", "b", "c"})) + {} + bool work_done = false; + }; + + CustomAction action; + + REQUIRE( action.get_id() == "abc123" ); + REQUIRE( action.get_status() == "created" ); + + WHEN("action is performed successfully") + { + std::thread t([&](){ + action.start(); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + + REQUIRE( action.get_status() == "pending" ); + + if(t.joinable()) + t.join(); + + THEN("action will be considered completed") + { + REQUIRE( action.get_status() == "completed" ); + REQUIRE( action.work_done ); + } + } + } + +} \ No newline at end of file diff --git a/test/unit-test/event_tests.cpp b/test/unit-test/event_tests.cpp new file mode 100644 index 0000000..6766ad6 --- /dev/null +++ b/test/unit-test/event_tests.cpp @@ -0,0 +1,23 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include + +using namespace bw::webthing; + +TEST_CASE( "Webthing events have an json representation", "[event][json]" ) +{ + FIXED_TIME_SCOPED("2023-02-17T01:23:45.000+00:00"); + + auto event = std::make_shared(nullptr, "test-event-name", "test-event-data"); + auto expected_json = json::parse(R"( + {"test-event-name":{ + "timestamp":"2023-02-17T01:23:45.000+00:00", + "data":"test-event-data"} + } + )"); + + REQUIRE( event->as_event_description() == expected_json ); +} \ No newline at end of file diff --git a/test/unit-test/json_validator_tests.cpp b/test/unit-test/json_validator_tests.cpp new file mode 100644 index 0000000..8ba0adf --- /dev/null +++ b/test/unit-test/json_validator_tests.cpp @@ -0,0 +1,54 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include + +using namespace bw::webthing; + +json test_thing_scheme = R"( +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "A Thing", + "properties": { + "name": { + "description": "Name", + "type": "string" + } + }, + "required": ["name"], + "type": "object" +} + +)"_json; + +json valid_test_thing_json = {{"name", "a test thing"}}; +json invalid_test_thing_json = {{"description", "This test thing is missing a name..."}}; + +#ifdef WT_USE_JSON_SCHEMA_VALIDATION + +TEST_CASE( "When an invalid json value is validated an exception is thrown", "[json]") +{ + REQUIRE_THROWS_AS(validate_value_by_scheme(123, test_thing_scheme), + InvalidJson); + + REQUIRE_THROWS_AS(validate_value_by_scheme(invalid_test_thing_json, test_thing_scheme), + InvalidJson); +} + +TEST_CASE( "When a valid json value is validated no exceptions will be thrown", "[json]") +{ + REQUIRE_NOTHROW(validate_value_by_scheme(valid_test_thing_json, test_thing_scheme)); +} + +#else // JSON VALIDATION IS DISABLED + +TEST_CASE( "When JSON validation is disabled, all validation attemps will be successful", "[json]") +{ + REQUIRE_NOTHROW(validate_value_by_scheme(123, test_thing_scheme)); + REQUIRE_NOTHROW(validate_value_by_scheme(valid_test_thing_json, test_thing_scheme)); + REQUIRE_NOTHROW(validate_value_by_scheme(invalid_test_thing_json, test_thing_scheme)); +} + +#endif diff --git a/test/unit-test/property_tests.cpp b/test/unit-test/property_tests.cpp new file mode 100644 index 0000000..c56c766 --- /dev/null +++ b/test/unit-test/property_tests.cpp @@ -0,0 +1,178 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include + +using namespace bw::webthing; +using namespace Catch::Matchers; + +struct MessageContains : Catch::Matchers::MatcherGenericBase +{ + MessageContains( const std::string& contains): + contains{ contains } + {} + + bool match(const std::exception& other) const { + + std::string what = other.what(); + + return what.find(contains) != std::string::npos; + + } + + std::string describe() const override { + return "Exception message contains: " + contains; + } + +private: + const std::string& contains; +}; + +template std::shared_ptr> create_value(T initial_value) +{ + return std::make_shared>(initial_value, [](auto v){ + std::stringstream ss; + ss << "Value changed to " << v; + logger::info(ss.str()); + }); +} + +template std::shared_ptr create_proptery(std::string name, std::shared_ptr> value, json metadata = json::object()) +{ + PropertyChangedCallback property_changed_callback = [](json prop){ + logger::info("Property changed: " + prop.dump()); + }; + return std::make_shared>(property_changed_callback, name, value, metadata); +} + +template std::shared_ptr create_proptery(std::string name, T intial_value, json metadata = json::object()) +{ + return create_proptery(name, create_value(intial_value), metadata); +} + + +TEST_CASE( "Properties can be updated", "[property]" ) +{ + auto v1 = create_value("benno"); + auto prop = create_proptery("test-prop-v1", v1); + auto initial = *prop->get_value(); + REQUIRE( initial == "benno" ); + + prop->set_value(std::string("Бennö 森林")); + auto actual = *prop->get_value(); + REQUIRE( actual == "Бennö 森林" ); + REQUIRE( v1->get() == "Бennö 森林" ); +} + +TEST_CASE( "Properties value type can't be changed", "[property]" ) +{ + auto val = create_value("a_string_value"); + auto prop = create_proptery("test-prop", val); + + REQUIRE_THROWS_MATCHES(prop->set_value(123), + PropertyError, + Catch::Matchers::Message("Property value type not matching")); + + REQUIRE( *prop->get_value() == "a_string_value" ); +} + +TEST_CASE( "Properties can only be changed from external when they are not read only properties", "[property]" ) +{ + auto val = create_value(666); + auto prop_ro = create_proptery("read-only-prop", val, {{"readOnly", true}}); + auto prop_w1 = create_proptery("writeable-prop-1", val, {{"readOnly", false}}); + auto prop_w2 = create_proptery("writeable-prop-2", val); + + + REQUIRE_THROWS_MATCHES(prop_ro->set_value(123), + PropertyError, + Catch::Matchers::Message("Read-only property")); + + REQUIRE( *prop_ro->get_value() == 666 ); + + prop_w1->set_value(123); + REQUIRE( *prop_w1->get_value() == 123 ); + + prop_w2->set_value(777); + REQUIRE( *prop_w1->get_value() == 777 ); +} + +TEST_CASE( "Properties can wrap json values", "[property]" ) +{ + json initial_obj = {{"key","value"}}; + auto prop_obj = create_proptery("json-value-test-prop", initial_obj); + auto initial = prop_obj->get_value(); + REQUIRE( (*initial == initial_obj) ); + + json new_obj = {{"name", "bw"}, {"age", 123}, {"colors", std::vector({"r","g","b"})}}; + prop_obj->set_value(new_obj); + auto changed = prop_obj->get_value(); + REQUIRE( (*changed == new_obj) ); +} + +#ifdef WT_USE_JSON_SCHEMA_VALIDATION + +TEST_CASE( "Properties can only be changed from external when provided values are valid", "[property]" ) +{ + SECTION( "Number" ) + { + auto val = create_value(666); + json property_description = {{"type", "integer"}, {"minimum", 555}}; + auto property = create_proptery("writeable-prop", val, property_description); + + REQUIRE_THROWS_MATCHES(property->set_value(123), PropertyError, + MessageContains("Invalid property") && + MessageContains("below minimum of 555") + ); + REQUIRE( *property->get_value() == 666 ); + + property->set_value(1000); + REQUIRE( *property->get_value() == 1000 ); + } + + SECTION( "String" ) + { + auto val = create_value(std::string("first-string-value")); + json property_description = {{"type", "string"}, {"pattern", "string-value"}}; + auto property = create_proptery("writeable-prop", val, property_description); + + REQUIRE_THROWS_MATCHES(property->set_value(std::string("something-invalid")), + PropertyError, + MessageContains("Invalid property") && + MessageContains("does not match regex pattern") + ); + REQUIRE( *property->get_value() == "first-string-value" ); + + property->set_value(std::string("next-string-value")); + REQUIRE( *property->get_value() == "next-string-value" ); + } + + SECTION( "Object" ) + { + json start_json_obj = {{"color", "red"}, {"amount", 42}}; + auto val = create_value(start_json_obj); + json property_description = {{"type", "object"}, {"required", {"color"}}}; + auto property = create_proptery("writeable-prop", val, property_description); + + REQUIRE_THROWS_MATCHES(property->set_value("some-string-value"), PropertyError, + MessageContains("value type not matching") + ); + REQUIRE_THROWS_MATCHES(property->set_value(json::array({1,2,3})), PropertyError, + MessageContains("Invalid property") && + MessageContains("unexpected instance type") + ); + REQUIRE_THROWS_MATCHES(property->set_value(json::object({{"amount", 123}})), PropertyError, + MessageContains("Invalid property") && + MessageContains("required property 'color' not found") + ); + REQUIRE( *property->get_value() == start_json_obj ); + + json new_json_obj = {{"color", "black"}}; + property->set_value(new_json_obj); + REQUIRE( *property->get_value() == new_json_obj ); + } +} + +#endif \ No newline at end of file diff --git a/test/unit-test/server_tests.cpp b/test/unit-test/server_tests.cpp new file mode 100644 index 0000000..79865f7 --- /dev/null +++ b/test/unit-test/server_tests.cpp @@ -0,0 +1,41 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include + +using namespace bw::webthing; + +TEST_CASE( "It can host a single thing" ) +{ + Thing thing("uri:test:1", "single-thing"); + auto thing_container = SingleThing(&thing); + + auto server = WebThingServer::host(thing_container).port(57456).build(); + REQUIRE(server.get_name() == "single-thing"); + + auto t = std::thread([&server]{ + std::this_thread::sleep_for(std::chrono::seconds(1)); + server.stop(); + }); + server.start(); + t.join(); +} + +TEST_CASE( "It can host a multiple things" ) +{ + Thing thing_a("uri:test:a", "thing-a"); + Thing thing_b("uri:test:b", "thing-b"); + auto thing_container = MultipleThings({&thing_a, &thing_b}, "things-a-and-b"); + + auto server = WebThingServer::host(thing_container).port(57123).build(); + REQUIRE(server.get_name() == "things-a-and-b"); + + auto t = std::thread([&server]{ + std::this_thread::sleep_for(std::chrono::seconds(1)); + server.stop(); + }); + server.start(); + t.join(); +} \ No newline at end of file diff --git a/test/unit-test/thing_tests.cpp b/test/unit-test/thing_tests.cpp new file mode 100644 index 0000000..06c56ac --- /dev/null +++ b/test/unit-test/thing_tests.cpp @@ -0,0 +1,160 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include +#include +#include + +using namespace bw::webthing; + +TEST_CASE( "Webthing thing stores all published events", "[event][thing]" ) +{ + auto types = std::vector{"test-type"}; + auto sut = std::make_shared("uri::test.id", "my-test-thing", types, "This is the description of my-test-thing"); + + sut->add_available_event("test-event-a", {{"description", "Event A"}, {"type","string"}}); + sut->add_available_event("test-event-b", {{"description", "Event B"}, {"type","string"}}); + sut->add_available_event("test-event-c", {{"description", "Event C"}, {"type","string"}}); + + auto event1 = std::make_shared(sut.get(), "test-event-a", "test-data-1"); + auto event2 = std::make_shared(sut.get(), "test-event-b", "test-data-2"); + auto event3 = std::make_shared(sut.get(), "test-event-c", "test-data-3"); + auto event4 = std::make_shared(sut.get(), "test-event-a", "test-data-4"); + auto event5 = std::make_shared(sut.get(), "test-event-a", "test-data-5"); + + sut->add_event(event1); + sut->add_event(event2); + sut->add_event(event3); + sut->add_event(event4); + sut->add_event(event5); + + REQUIRE( sut->get_event_descriptions().size() == 5 ); + REQUIRE( sut->get_event_descriptions("test-event-a").size() == 3 ); + REQUIRE( sut->get_event_descriptions("test-event-b").size() == 1 ); + REQUIRE( sut->get_event_descriptions("test-event-c").size() == 1 ); + REQUIRE( sut->get_event_descriptions("test-event-missing").size() == 0 ); +} + +TEST_CASE( "Webthing things performes actions", "[action][thing]" ) +{ + struct TestThing : public Thing + { + struct CustomAction : public Action + { + CustomAction(ActionBehavior action_behavior, std::optional input) + : Action(generate_uuid(), action_behavior, "my-custom-action", input) + {} + + void perform_action() + { + std::cout << "CustomAction perform action with input:" << get_input().value_or(json()) << std::endl; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + }; + + TestThing() + : Thing("uri::test.id", "my-test-thing", std::vector({"test-type"}), "This is the description of my-test-thing") + { + auto supplier = [&](auto input){ + ActionBehavior ab; + ab.notify_thing = [&](auto action_status){this->action_notify(action_status);}; + return std::make_shared(std::move(ab), input); + }; + + json action_meta = { + {"title", "CustomAction"}, + {"description", "A custom action for test purposes."}, + {"input", {{"type", "string"}}} + }; + + this->add_available_action("my-custom-action", action_meta, supplier); + } + }; + + auto test_thing = std::make_shared(); + + auto ttd = test_thing->as_thing_description(); + logger::info(ttd.dump(2)); + REQUIRE( ttd["actions"].size() == 1 ); + + { + FIXED_UUID_SCOPED("abc-123"); + test_thing->perform_action("my-custom-action", "42-as-string"); + } + { + FIXED_UUID_SCOPED("def-456"); + test_thing->perform_action("my-custom-action", "my-input-string"); + } + + auto ads = test_thing->get_action_descriptions(); + logger::info(ads.dump(2)); + REQUIRE( ads.size() == 2 ); + REQUIRE( test_thing->get_action("my-custom-action", "abc-123")->get_status() == "created" ); + REQUIRE( test_thing->get_action("my-custom-action", "def-456")->get_status() == "created" ); + + test_thing->remove_action("my-custom-action", "abc-123"); + REQUIRE_FALSE( test_thing->get_action("my-custom-action", "abc-123") ); +} + +#ifdef WT_USE_JSON_SCHEMA_VALIDATION + +void register_action(std::shared_ptr thing, json action_meta) +{ + std::string action_title = action_meta["title"]; + thing->add_available_action(action_title, std::move(action_meta), [action_title, thing](auto input){ + ActionBehavior ab; + ab.notify_thing = [thing](auto status){ thing->action_notify(status); }; + ab.perform_action = [action_title]{ logger::info("perform " + action_title); }; + return std::make_shared(generate_uuid(), std::move(ab), action_title, input); + }); +} + +TEST_CASE( "A thing validates the action input before execution", "[action][thing]" ) +{ + auto types = std::vector{"test-type"}; + auto thing = std::make_shared("uri::test.id", "my-test-thing", types, "This is the description of my-test-thing"); + + SECTION( "No input required" ) + { + register_action(thing, {{"title", "action-without-input"}}); + REQUIRE(thing->perform_action("action-without-input")); + REQUIRE(thing->perform_action("action-without-input", 42)); + REQUIRE(thing->perform_action("action-without-input", json({{"key", "value"}}))); + } + + SECTION( "Integer input required" ) + { + register_action(thing, {{"title", "action-with-number-input"}, + {"input", {{"type", "integer"}, {"minimum", 42}, {"maximum", 666}}}}); + + // no input + REQUIRE_FALSE(thing->perform_action("action-with-number-input")); + // wrong type + REQUIRE_FALSE(thing->perform_action("action-with-number-input", json({{"key", "value"}}))); + // under minimum + REQUIRE_FALSE(thing->perform_action("action-with-number-input", 1)); + // over maximum + REQUIRE_FALSE(thing->perform_action("action-with-number-input", 1000)); + + REQUIRE(thing->perform_action("action-with-number-input", 123)); + } + + SECTION( "Object input required" ) + { + register_action(thing, {{"title", "action-with-object-input"}, + {"input", {{"type", "object"}, {"required", {"color", "amount"}}}}}); + + // wrong type + REQUIRE_FALSE(thing->perform_action("action-with-object-input", 1)); + // wrong type + REQUIRE_FALSE(thing->perform_action("action-with-object-input", true)); + // wrong object layout + REQUIRE_FALSE(thing->perform_action("action-with-object-input", json({{"key", "value"}}))); + + REQUIRE(thing->perform_action("action-with-object-input", json({{"color", "green"}, {"amount", 42}}))); + } +} + +#endif \ No newline at end of file diff --git a/test/unit-test/utils_tests.cpp b/test/unit-test/utils_tests.cpp new file mode 100644 index 0000000..22bf162 --- /dev/null +++ b/test/unit-test/utils_tests.cpp @@ -0,0 +1,152 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include + +using namespace bw::webthing; + +TEST_CASE( "Utils offer a helper for uuid generation", "[uuid]" ) +{ + std::string uuid = bw::webthing::generate_uuid(); + logger::debug("uuid-test: " + uuid); + REQUIRE( (uuid.find_first_not_of("-0123456789abcdef") == std::string::npos) ); + REQUIRE( uuid.size() == 32 + 4 ); + REQUIRE( uuid[ 8] == '-'); + REQUIRE( uuid[13] == '-'); + REQUIRE( uuid[18] == '-'); + REQUIRE( uuid[23] == '-'); + + int samples = 100'000; + logger::debug("uuid-test: start testing " + std::to_string(samples) + " samples"); + std::map uuid_counter; + for(int i = 0; i < samples; i++) + { + std::string uuid = bw::webthing::generate_uuid(); + uuid_counter[uuid] = uuid_counter[uuid]++; + } + logger::debug("uuid-test: finish sample tests"); + REQUIRE( uuid_counter.size() == samples ); +} + +TEST_CASE( "UUID generation can be (un)fixed", "[uuid]") +{ + { + FIXED_UUID_SCOPED("my-fix-non-uuid"); + REQUIRE( generate_uuid() == "my-fix-non-uuid" ); + REQUIRE( generate_uuid() == "my-fix-non-uuid" ); + REQUIRE( generate_uuid() == "my-fix-non-uuid" ); + REQUIRE( generate_uuid() == "my-fix-non-uuid" ); + REQUIRE( generate_uuid() == "my-fix-non-uuid" ); + } + + // leaving the scope should unfix the uuid + REQUIRE_FALSE( generate_uuid() == "my-fix-non-uuid" ); +} + +TEST_CASE( "Utils offer a simple customizable logging interface", "[log]" ) +{ + SECTION( "Offers a default logging implementation" ) + { + std::vector ts; + + for(int i = 0; i < 3 ; i++ ) + { + ts.push_back(std::thread([i]{ + for(int count = 0; count < 10; count++) + { + logger::warn("tid: " + std::to_string(i) + " - Thing wants to warn you..."); + logger::info("tid: " + std::to_string(i) + " - some test content count: " + std::to_string( count )); + } + })); + } + + for(auto& t : ts) + t.join(); + } + + SECTION( "A custom log implementation can be registered" ) + { + std::map> messages; + auto log_impl_msg_collector = [&](auto level, auto msg) + { + std::string is_ok = level <= log_level::info ? "ok" : "warn"; + std::cout << "msg_collector: " << is_ok << " " << msg << std::endl; + messages[is_ok].push_back(msg); + }; + + logger::error("error-1"); + logger::warn("warn-1"); + logger::info("info-1"); + logger::debug("debug-1"); + REQUIRE(messages.empty()); + + logger::register_implementation(log_impl_msg_collector); + + logger::error("error-2"); + logger::warn("warn-2"); + logger::info("info-2"); + logger::debug("debug-2"); + + std::vector expected_ok {"info-2", "debug-2"}; + REQUIRE_THAT( messages["ok"], Catch::Matchers::Equals(expected_ok)); + + std::vector expected_warn {"error-2", "warn-2"}; + REQUIRE_THAT(messages["warn"], Catch::Matchers::Equals(expected_warn)); + } + + SECTION( "A custom log level can be specified" ) + { + std::vector messages; + logger::register_implementation([&](auto l, auto m){messages.push_back(m);}); + + logger::set_level(log_level::error); + + logger::error("error-3"); + logger::warn("warn-3"); + logger::info("info-3"); + logger::debug("debug-3"); + logger::trace("trace-3"); + + REQUIRE_THAT(messages, Catch::Matchers::Equals( + std::vector{"error-3"})); + + logger::set_level(log_level::trace); + + logger::trace("error-4"); + logger::trace("trace-4"); + + REQUIRE_THAT(messages, Catch::Matchers::Equals( + std::vector{"error-3", "error-4", "trace-4"})); + + } + + // reset logger to defaults, to avoid problems with global state of logger + logger::set_level(log_level::debug); + logger::register_implementation(nullptr); +} + +TEST_CASE( "Utils offer a helper for timestamps", "[time]" ) +{ + std::string ts_first = timestamp(); + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + std::string ts_second = timestamp(); + + FIXED_TIME_SCOPED("1985-08-26T11:11:11.1111+00:02"); + std::string ts_fixed_first = timestamp(); + + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + std::string ts_fixed_second = timestamp(); + + REQUIRE_FALSE(ts_first == ts_second); + REQUIRE_FALSE(ts_first == ts_fixed_first); + REQUIRE_FALSE(ts_first == ts_fixed_second); + + REQUIRE_FALSE(ts_second == ts_fixed_first); + REQUIRE_FALSE(ts_second == ts_fixed_second); + + REQUIRE(ts_fixed_first == ts_fixed_second); + REQUIRE(ts_fixed_first == "1985-08-26T11:11:11.1111+00:02"); +} \ No newline at end of file diff --git a/test/unit-test/webthing_tests.cpp b/test/unit-test/webthing_tests.cpp new file mode 100644 index 0000000..1f69398 --- /dev/null +++ b/test/unit-test/webthing_tests.cpp @@ -0,0 +1,306 @@ +// Webthing-CPP +// SPDX-FileCopyrightText: 2023 Benno Waldhauer +// SPDX-License-Identifier: MIT + +#include +#include + +using namespace bw::webthing; + +TEST_CASE( "It offers an easy api for 'Thing' creation.", "[api][thing]" ) +{ + SECTION( "Generate id and title" ) + { + FIXED_UUID_SCOPED("TEST_THING_UUID"); + + auto thing = make_thing(); + + REQUIRE( thing->get_id() == "uuid:TEST_THING_UUID" ); + REQUIRE( thing->get_id() == "uuid:TEST_THING_UUID" ); + } + + SECTION( "Only provide an id" ) + { + auto thing = make_thing("my-test-id"); + + REQUIRE( thing->get_id() == "my-test-id" ); + REQUIRE( thing->get_title() == "my-test-id" ); + } + + SECTION( "Only provice a single type for the Thing" ) + { + auto thing = make_thing("uri:dev:ops:abc-123", "test-title", "MyTestType", "The description."); + + REQUIRE( thing->get_id() == "uri:dev:ops:abc-123" ); + REQUIRE( thing->get_title() == "test-title" ); + REQUIRE( thing->get_type().size() == 1); + REQUIRE_THAT( thing->get_type(), Catch::Matchers::Contains("MyTestType")); + REQUIRE( thing->get_description() == "The description."); + } +} + +TEST_CASE( "It offers an easy api for 'Value' creation.", "[api][value]" ) +{ + SECTION( "pre initialized value" ) + { + auto v = make_value(123); + REQUIRE(v->get() == 123); + + v->set(42); + REQUIRE(v->get() == 42); + } + + SECTION( "pre initialized value with forwarder" ) + { + std::string forwarded; + + auto v = make_value(std::string("first-string"), [&](auto v){ forwarded = v;}); + REQUIRE(v->get() == "first-string"); + REQUIRE( forwarded == "" ); + + v->set("second-string"); + REQUIRE(v->get() == "second-string"); + REQUIRE( forwarded == "second-string" ); + } + + SECTION( "not initialized value" ) + { + auto v = make_unknown_value(); + REQUIRE(v->get() == std::nullopt); + + v->set(3.14); + REQUIRE(v->get() == 3.14); + } + + SECTION( "not initialized value with forwarder" ) + { + std::string forwarded; + + auto v = make_unknown_value([&](auto v){ forwarded = v ? "true" : "false";}); + REQUIRE(v->get() == std::nullopt); + REQUIRE( forwarded == "" ); + + v->set(true); + REQUIRE(v->get() == std::optional(true)); + REQUIRE( forwarded == "true" ); + } +} + +TEST_CASE( "It offers an easy api for 'Property' creation and linkage.", "[api][property]" ) +{ + auto thing = make_thing(); + + SECTION( "Create property from initial value" ) + { + link_property(thing, "int-prop", 42); + REQUIRE(thing->get_property("int-prop") == 42); + + link_property(thing, "float-prop", 3.14f, {{"title", "Float Property"}}); + REQUIRE_THAT(*thing->get_property("float-prop"), + Catch::Matchers::WithinRel(3.14f)); + } + + SECTION( "Create property by passing 'Value' object" ) + { + auto value = make_unknown_value(); + + link_property(thing, "string-prop", value); + REQUIRE(thing->get_property("string-prop") == std::nullopt); + + thing->set_property("string-prop", std::string("first-value")); + REQUIRE(thing->get_property("string-prop") == "first-value"); + + value->notify_of_external_update("second-value"); + REQUIRE(thing->get_property("string-prop") == "second-value"); + } +} + +TEST_CASE( "It offers an easy api for event linkage as well as emitting events.", "[api][event]" ) +{ + auto thing = make_thing(); + + SECTION( "Build-in events" ) + { + link_event(thing, "event-name", R"({"title":"Event Title", "input":{"type": "number"}})"_json); + emit_event(thing, "event-name", 42); + json events = thing->get_event_descriptions("event-name"); + REQUIRE(events[0]["event-name"]["data"] == 42); + } + + SECTION( "Custom events" ) + { + struct CustomEvent : public Event + { + CustomEvent(std::shared_ptr thing) + : Event(thing.get(), "custom-event", "my-string-input") + {} + }; + + link_event(thing, "custom-event"); + emit_event(thing, CustomEvent(thing)); + json events = thing->get_event_descriptions(); + REQUIRE(events[0]["custom-event"]["data"] == "my-string-input"); + } +} + +TEST_CASE( "It offers an easy api for action linkage as well as action execution.", "[api][action]" ) +{ + SECTION( "Build-in actions" ) + { + auto thing = make_thing(); + + link_action(thing.get(), "action-name", R"({"title":"Action Title", "input":{"type": "number"}})"_json); + auto action_1 = thing->perform_action("action-name", 42); + REQUIRE(action_1); + REQUIRE(action_1->get_name() == "action-name"); + REQUIRE(action_1->get_input() == 42); + + link_action(thing.get(), "no-input-action"); + auto action_2 = thing->perform_action("no-input-action"); + REQUIRE(action_2); + REQUIRE(action_2->get_name() == "no-input-action"); + REQUIRE_FALSE(action_2->get_input()); + } + + SECTION( "Custom action" ) + { + struct CancelableTestAction : public Action + { + CancelableTestAction(Thing* thing, std::optional input) + : Action(generate_uuid(), thing, this, "cancelable-action", input) + { + + } + void perform_action() + { + logger::info("test-action performed"); + } + + void cancel_action() + { + logger::info("test-action canceled"); + } + }; + auto thing = make_thing(); + link_action(thing, "cancelable-action"); + auto ca = thing->perform_action("cancelable-action"); + ca->start(); + ca->cancel(); + + struct SimpleTestAction : public Action + { + SimpleTestAction(Thing* thing, std::optional input) + : Action(generate_uuid(), thing, this, "simple-test-action", input) + { + } + + void perform_action() + { + logger::info("test-action performed - " + get_input()->dump()); + get_thing()->set_property("prop-for-action", *get_input()); + } + }; + + link_property(thing, "prop-for-action", 123); + link_action(thing, "simple-test-action", {{"title","Simple Test Action"}}); + + auto pa = thing->perform_action("simple-test-action", 42); + pa->start(); + pa->cancel(); + + // REQUIRE(action_1->get_name() == "custom-action"); + // REQUIRE(action_1->get_input() == "my-custom-input"); + } + + SECTION( "Action within custom thing" ) + { + struct CustomThing : public Thing + { + struct CustomAction : public Action + { + CustomAction(Thing* thing, std::optional input) + : Action(generate_uuid(), thing, this, "custom-action-name", input) + { + } + + void perform_action() + { + logger::info("CustomAction performed - " + get_input()->dump()); + get_thing()->custom_fun_for_action(*get_input()); + } + }; + + CustomThing() + : Thing("id", "my-title") + { + link_property(this, "my-prop", std::string("initial_string")); + link_action(this, "custom-action-name"); + } + + void custom_fun_for_action(std::string input) + { + logger::warn("MY SUPER CUSTOM ACTION"); + set_property("my-prop", input); + } + }; + + auto ct = std::make_shared(); + auto ca = ct->perform_action("custom-action-name", "my-string-input"); + ca->start(); + } + + SECTION( "A varity of custom action constructors is supported") + { + auto thing = make_thing("thing-for-action-construtor-test"); + + SECTION( "A custom action can ignore input" ) + { + struct CustomActionIgnoreInput : public Action + { + CustomActionIgnoreInput(Thing* thing) + : Action(generate_uuid(), thing, this, "no-action-input") + { + } + + void perform_action() + { + logger::info("perform input ignoring action"); + } + }; + + link_action(thing, "no-action-input"); + auto action = thing->perform_action("no-action-input"); + REQUIRE(action); + action->start(); + } + + SECTION( "A custom action can insist an on input" ) + { + struct CustomActionInsistInput : public Action + { + CustomActionInsistInput(Thing* thing, int input) + : Action(generate_uuid(), thing, this, "insist-on-action-input", input) + { + } + + void perform_action() + { + logger::info("perform input insisting action with: " + get_input()->dump()); + } + }; + + link_action(thing, "insist-on-action-input"); + + // input missing + REQUIRE_FALSE(thing->perform_action("insist-on-action-input")); + // wrong input type + REQUIRE_FALSE(thing->perform_action("insist-on-action-input", "a-failing-string")); + + auto action = thing->perform_action("insist-on-action-input", 42); + REQUIRE(action); + REQUIRE(action->get_input() == 42); + action->start(); + } + + } +} \ No newline at end of file diff --git a/tools/create-pems.sh b/tools/create-pems.sh new file mode 100644 index 0000000..e643164 --- /dev/null +++ b/tools/create-pems.sh @@ -0,0 +1,3 @@ +openssl genrsa -out key.pem 2048 +openssl req -new -key key.pem -out csr.pem +openssl x509 -req -days 365 -in csr.pem -signkey key.pem -out cert.pem \ No newline at end of file diff --git a/tools/install-vcpkg.bat b/tools/install-vcpkg.bat new file mode 100644 index 0000000..e79ffd7 --- /dev/null +++ b/tools/install-vcpkg.bat @@ -0,0 +1,13 @@ +@echo off +setlocal + +set "tools_dir=%~dp0" +set "vcpkg_dir=%tools_dir%vcpkg" + +if not exist "%vcpkg_dir%" ( + echo clone vcpkg from GitHub + git clone https://github.com/Microsoft/vcpkg.git "%vcpkg_dir%" +) + +echo install vcpkg +"%vcpkg_dir%\bootstrap-vcpkg.bat" -disableMetrics \ No newline at end of file diff --git a/tools/install-vcpkg.sh b/tools/install-vcpkg.sh new file mode 100644 index 0000000..cc9c95d --- /dev/null +++ b/tools/install-vcpkg.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +tools_dir="$( dirname -- "$0"; )" +vcpkg_dir=$tools_dir"/vcpkg" + +if [[ ! -d "$vcpkg_dir" ]]; then + echo "clone vcpkg from GitHub" + git clone https://github.com/Microsoft/vcpkg.git $vcpkg_dir +fi + +echo "install vcpkg" +$vcpkg_dir/bootstrap-vcpkg.sh -disableMetrics \ No newline at end of file diff --git a/tools/run-webthing-tester.sh b/tools/run-webthing-tester.sh new file mode 100644 index 0000000..19b11fc --- /dev/null +++ b/tools/run-webthing-tester.sh @@ -0,0 +1,42 @@ +#!/bin/bash -e + +# run this script from within the _tools_ directory + +# clone the webthing-tester +if [ -d "webthing-tester" ]; then + cd "webthing-tester" + git pull origin time-regex-option + cd ".." +else + git clone -b time-regex-option https://github.com/bw-hro/webthing-tester.git +fi + +pip3 install --user -r webthing-tester/requirements.txt + +# build Webthing-CPP with examples when they do not exist +SINGLE_BIN="../build/examples/single-thing" +MULTIS_BIN="../build/examples/multiple-things" +if [ ! -e "$SINGLE_BIN" ] || [ ! -e "$MULTIS_BIN" ]; then + cd ".." + ./build.sh clean release + cd "tools" +fi + +# configure time regex to match timestamps with milliseconds +TIME_REGEX="^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}[+-]\d{2}:\d{2}$" + +# test the single-thing example +"./$SINGLE_BIN" & +EXAMPLE_PID=$! +sleep 15 +./webthing-tester/test-client.py --time-regex $TIME_REGEX --debug +kill -15 $EXAMPLE_PID +wait $EXAMPLE_PID || true + +# test the multiple-things example +"./$MULTIS_BIN" & +EXAMPLE_PID=$! +sleep 15 +./webthing-tester/test-client.py --time-regex $TIME_REGEX --path-prefix "/0" --debug +kill -15 $EXAMPLE_PID +wait $EXAMPLE_PID || true diff --git a/vcpkg-no-ssl.json b/vcpkg-no-ssl.json new file mode 100644 index 0000000..e89ab22 --- /dev/null +++ b/vcpkg-no-ssl.json @@ -0,0 +1,11 @@ +{ + "name": "webthing-cpp", + "version-string": "1.0", + "dependencies": [ + "catch2", + "json-schema-validator", + "mdns", + "nlohmann-json", + "uwebsockets" + ] +} \ No newline at end of file diff --git a/vcpkg-with-ssl.json b/vcpkg-with-ssl.json new file mode 100644 index 0000000..55c0721 --- /dev/null +++ b/vcpkg-with-ssl.json @@ -0,0 +1,13 @@ +{ + "name": "webthing-cpp", + "version-string": "1.0", + "dependencies": [ + "catch2", + "json-schema-validator", + "mdns", + "nlohmann-json", + "openssl", + {"name":"usockets", "features":["ssl"]}, + "uwebsockets" + ] +} \ No newline at end of file diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..e89ab22 --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,11 @@ +{ + "name": "webthing-cpp", + "version-string": "1.0", + "dependencies": [ + "catch2", + "json-schema-validator", + "mdns", + "nlohmann-json", + "uwebsockets" + ] +} \ No newline at end of file