diff --git a/common b/common index 25cd53ee6..950857f1d 160000 --- a/common +++ b/common @@ -1 +1 @@ -Subproject commit 25cd53ee6882c3f66746d6d8c27790ef22d18322 +Subproject commit 950857f1d792e2dad81d6e1f2e5b2d8cab60fd71 diff --git a/doc/openapi/components/schemas/config/nodes/api.yaml b/doc/openapi/components/schemas/config/nodes/api.yaml index c682fdd04..b15f4cd1a 100644 --- a/doc/openapi/components/schemas/config/nodes/api.yaml +++ b/doc/openapi/components/schemas/config/nodes/api.yaml @@ -2,14 +2,26 @@ --- allOf: -- type: object - additionalProperties: true - description: | - Additional properties can be retrieved via Rest API call: - - ```bash - curl http://localhost:80/api/v2/universal/api_node_name/config - ``` - - $ref: ../node_signals.yaml - $ref: ../node.yaml +- type: object + properties: + in: + type: object + required: + - signals + properties: + signals: + type: array + items: + $ref: ./signals/api_signal.yaml + + out: + type: object + required: + - signals + properties: + signals: + type: array + items: + $ref: ./signals/api_signal.yaml diff --git a/doc/openapi/components/schemas/config/nodes/signals/api_signal.yaml b/doc/openapi/components/schemas/config/nodes/signals/api_signal.yaml new file mode 100644 index 000000000..df2b9b6ed --- /dev/null +++ b/doc/openapi/components/schemas/config/nodes/signals/api_signal.yaml @@ -0,0 +1,41 @@ +# yaml-language-server: $schema=http://json-schema.org/draft-07/schema +--- + +allOf: +- type: object + properties: + description: + type: string + description: A human readable description of the channel. + + payload: + description: | + Describes the type of information which is exchanged over the channel. + type: string + enum: + - events + - samples + + range: + oneOf: + - type: object + description: Limits for numeric datatypes + properties: + min: + type: number + max: + type: number + + - type: array + description: A list of allowed string values for string datatype + items: + type: string + + rate: + optional: true + type: number + description: | + Expected refresh-rate in Hertz of this channel + Does not apply channels which have event payloads. + +- $ref: ../../signal.yaml diff --git a/etc/examples/api.conf b/etc/examples/api.conf new file mode 100644 index 000000000..0596af7ef --- /dev/null +++ b/etc/examples/api.conf @@ -0,0 +1,102 @@ +http = { + port = 8080 +} + +nodes = { + api_node = { + type = "api" + + in = { + signals = ( + { + name = "sig1_in", + type = "float", + unit = "V", + description = "Signal 1", + rate = 100, + readable = true, + writable = false, + payload = "samples" + }, + { + name = "sig2_in", + type = "float", + unit = "A", + description = "Signal 1", + rate = 100, + readable = true, + writable = false, + payload = "samples" + }, + { + name = "sig3_in", + type = "float", + unit = "A", + description = "Signal 1", + rate = 100, + readable = true, + writable = false, + payload = "samples" + } + ) + } + + out = { + signals = ( + # Output signals have no name, type and unit settings as those are implicitly + # derived from the signals which are routed to this node + { + description = "Signal 1", + rate = 100, + readable = true, + writable = false, + payload = "samples" + }, + { + description = "Signal 1", + rate = 100, + readable = true, + writable = false, + payload = "samples" + }, + { + description = "Signal 1", + rate = 100, + readable = true, + writable = false, + payload = "samples" + } + ) + } + } + + signal_node = { + type = "signal" + + signal = "mixed" + values = 5 + rate = 1.0 + } +} + +paths = ( + { + in = [ + "api_node" + ], + hooks = ( + "print" + ) + }, + { + in = [ + "signal_node" + ] + out = [ + "api_node" + ] + hooks = ( + "print" + ) + } +) diff --git a/etc/examples/nodes/api.conf b/etc/examples/nodes/api.conf index 59b9174d1..02a0f783f 100644 --- a/etc/examples/nodes/api.conf +++ b/etc/examples/nodes/api.conf @@ -2,11 +2,29 @@ nodes = { api_node = { type = "api" - // Additional configuration can be retrieved via Rest API call: - // curl http://localhost:80/api/v2/universal/api_node/config - my_setting = "my_value" - a = { - b = false + in = { + signals = ( + { + name = "" # Same as 'id' in uAPI context + description = "Volts on Bus A" # A human readable description of the channel + type = "float" # Same as 'datatype' in uAPI context + unit = "V" + payload = "events" # or 'samples' + rate = 100.0 # An expected refresh/sample rate of the signal + range = { + min = 20.0 + max = 100.0 + } + readable = true + writable = false + } + ) + } + + out = { + signals = ( + # Similar to above + ) } } } diff --git a/fpga b/fpga index 36be2ddb5..8b994bfb8 160000 --- a/fpga +++ b/fpga @@ -1 +1 @@ -Subproject commit 36be2ddb52e968e9fd7d22f7548c1f3b539ca845 +Subproject commit 8b994bfb84ea8cb96e070f7f7bd3bf84b587bfcf diff --git a/include/villas/api/requests/node.hpp b/include/villas/api/requests/node.hpp index 294f4ec0d..c020af438 100644 --- a/include/villas/api/requests/node.hpp +++ b/include/villas/api/requests/node.hpp @@ -6,6 +6,8 @@ * @license Apache 2.0 *********************************************************************************/ +#pragma once + #include #include diff --git a/include/villas/api/requests/universal.hpp b/include/villas/api/requests/universal.hpp index addc05209..91a86932f 100644 --- a/include/villas/api/requests/universal.hpp +++ b/include/villas/api/requests/universal.hpp @@ -6,6 +6,8 @@ * @license Apache 2.0 *********************************************************************************/ +#pragma once + #include #include diff --git a/include/villas/api/universal.hpp b/include/villas/api/universal.hpp new file mode 100644 index 000000000..00e4500cc --- /dev/null +++ b/include/villas/api/universal.hpp @@ -0,0 +1,57 @@ + +/** Universal Data-exchange API request. + * + * @file + * @author Steffen Vogel + * @copyright 2014-2022, Institute for Automation of Complex Power Systems, EONERC + * @license Apache 2.0 + *********************************************************************************/ + +#pragma once + +#include +#include +#include + +#include + +#include + +namespace villas { +namespace node { +namespace api { +namespace universal { + +enum PayloadType { + SAMPLES = 0, + EVENTS = 1 +}; + +// Channel (uAPI) is a synonym for signal (VILLAS) +class Channel { +public: + std::string description; + PayloadType payload; + double range_min; + double range_max; + std::vector range_options; + double rate; + bool readable; + bool writable; + + using Ptr = std::shared_ptr; + + void parse(json_t *json); + json_t * toJson(Signal::Ptr sig) const; +}; + +class ChannelList : public std::vector { + +public: + void parse(json_t *json, bool readable, bool writable); +}; + +} /* namespace universal */ +} /* namespace api */ +} /* namespace node */ +} /* namespace villas */ diff --git a/include/villas/nodes/api.hpp b/include/villas/nodes/api.hpp index 474a15ec2..e1df60ef3 100644 --- a/include/villas/nodes/api.hpp +++ b/include/villas/nodes/api.hpp @@ -1,5 +1,6 @@ -/** Node type: Universal Data-exchange API +/** Node type: Universal Data-exchange API (v2) * + * @see https://github.com/ERIGrid2/JRA-3.1-api * @file * @author Steffen Vogel * @copyright 2014-2022, Institute for Automation of Complex Power Systems, EONERC @@ -9,6 +10,7 @@ #pragma once #include +#include #include namespace villas { @@ -24,6 +26,7 @@ public: struct Direction { Sample *sample; + api::universal::ChannelList channels; pthread_cond_t cv; pthread_mutex_t mutex; }; @@ -34,7 +37,12 @@ public: virtual int prepare(); + virtual + int check(); + protected: + virtual + int parse(json_t *json, const uuid_t sn_uuid); virtual int _read(struct Sample *smps[], unsigned cnt); diff --git a/lib/api/CMakeLists.txt b/lib/api/CMakeLists.txt index fed7f596a..04d386088 100644 --- a/lib/api/CMakeLists.txt +++ b/lib/api/CMakeLists.txt @@ -10,6 +10,7 @@ set(API_SRC session.cpp request.cpp response.cpp + universal.cpp requests/node.cpp requests/path.cpp @@ -30,10 +31,10 @@ set(API_SRC requests/path_info.cpp requests/path_action.cpp - requests/universal/config.cpp + requests/universal/status.cpp requests/universal/info.cpp - requests/universal/signal.cpp - requests/universal/signals.cpp + requests/universal/channel.cpp + requests/universal/channels.cpp ) if(WITH_GRAPHVIZ) diff --git a/lib/api/requests/universal.cpp b/lib/api/requests/universal.cpp index 578cdc3f5..cee63cec5 100644 --- a/lib/api/requests/universal.cpp +++ b/lib/api/requests/universal.cpp @@ -10,6 +10,7 @@ using namespace villas::node; using namespace villas::node::api; +using namespace villas::node::api::universal; void UniversalRequest::prepare() { diff --git a/lib/api/requests/universal/signal.cpp b/lib/api/requests/universal/channel.cpp similarity index 56% rename from lib/api/requests/universal/signal.cpp rename to lib/api/requests/universal/channel.cpp index 45ef195da..44be6f555 100644 --- a/lib/api/requests/universal/signal.cpp +++ b/lib/api/requests/universal/channel.cpp @@ -14,11 +14,11 @@ namespace node { namespace api { namespace universal { -class SignalRequest : public UniversalRequest { +class ChannelRequest : public UniversalRequest { public: using UniversalRequest::UniversalRequest; - Response * executeGet(const std::string &signalName) + Response * executeGet(const std::string &signalName, PayloadType payload) { if (body != nullptr) throw BadRequest("This endpoint does not accept any body data"); @@ -38,9 +38,17 @@ public: } auto sig = smp->signals->getByIndex(idx); - auto *json_signal = json_pack("{ s: f, s: o }", + auto ch = api_node->write.channels.at(idx); + + if (payload != ch->payload) + throw BadRequest("Mismatching payload type"); + + auto *json_signal = json_pack("{ s: f, s: o, s: s, s: s, s: s }", "timestamp", time_to_double(&smp->ts.origin), - "value", smp->data[idx].toJson(sig->type) + "value", smp->data[idx].toJson(sig->type), + "validity", "unknown", + "source", "unknown", + "timesource", "unknown" ); if (smp->length <= (unsigned) idx) @@ -53,7 +61,7 @@ public: return new JsonResponse(session, HTTP_STATUS_OK, json_signal); } - Response * executePut(const std::string &signalName) + Response * executePut(const std::string &signalName, PayloadType payload) { int ret; @@ -72,22 +80,46 @@ public: } auto sig = smp->signals->getByIndex(idx); + auto ch = api_node->read.channels.at(idx); + + if (payload != ch->payload) + throw BadRequest("Mismatching payload type"); double timestamp = 0; - double value = 0; + json_t *json_value; + const char *validity = nullptr; + const char *source = nullptr; + const char *timesource = nullptr; json_error_t err; - ret = json_unpack_ex(body, &err, 0, "{ s: F, s: F }", + ret = json_unpack_ex(body, &err, 0, "{ s: F, s: o, s?: s, s?: s, s?: s }", "timestamp", ×tamp, - "value", &value + "value", &json_value, + "validity", &validity, + "timesource", ×ource, + "source", &source ); if (ret) { pthread_mutex_unlock(&api_node->read.mutex); throw BadRequest("Malformed body: {}", err.text); } + if (validity) + logger->warn("Attribute 'validity' is not supported by VILLASnode"); + + if (source) + logger->warn("Attribute 'source' is not supported by VILLASnode"); + + if (timesource) + logger->warn("Attribute 'timesource' is not supported by VILLASnode"); + + ret = smp->data[idx].parseJson(sig->type, json_value); + if (ret) { + pthread_mutex_unlock(&api_node->read.mutex); + throw BadRequest("Malformed value"); + } + smp->ts.origin = time_from_double(timestamp); - smp->data[idx].f = value; pthread_cond_signal(&api_node->read.cv); pthread_mutex_unlock(&api_node->read.mutex); @@ -98,12 +130,21 @@ public: virtual Response * execute() { auto const &signalName = matches[2]; + auto const &subResource = matches[3]; + + PayloadType payload; + if (subResource == "event") + payload = PayloadType::EVENTS; + else if (subResource == "sample") + payload = PayloadType::EVENTS; + else + throw BadRequest("Unsupported sub-resource: {}", subResource); switch (method) { case Session::Method::GET: - return executeGet(signalName); + return executeGet(signalName, payload); case Session::Method::PUT: - return executePut(signalName); + return executePut(signalName, payload); default: throw InvalidMethod(this); } @@ -111,10 +152,10 @@ public: }; /* Register API requests */ -static char n[] = "universal/signal"; -static char r[] = "/universal/(" RE_NODE_NAME ")/signal/([a-z0-9_-]+)/state"; -static char d[] = "get or set signal of universal data-exchange API"; -static RequestPlugin p; +static char n[] = "universal/channel/sample"; +static char r[] = "/universal/(" RE_NODE_NAME ")/channel/([a-z0-9_-]+)/(sample|event)"; +static char d[] = "retrieve or send samples via universal data-exchange API"; +static RequestPlugin p; } /* namespace universal */ } /* namespace api */ diff --git a/lib/api/requests/universal/signals.cpp b/lib/api/requests/universal/channels.cpp similarity index 59% rename from lib/api/requests/universal/signals.cpp rename to lib/api/requests/universal/channels.cpp index 50be76a76..28cc8bcda 100644 --- a/lib/api/requests/universal/signals.cpp +++ b/lib/api/requests/universal/channels.cpp @@ -5,6 +5,7 @@ * @license Apache 2.0 *********************************************************************************/ +#include #include #include @@ -26,26 +27,19 @@ public: throw BadRequest("This endpoint does not accept any body data"); auto *json_sigs = json_array(); - for (auto const &sig : *node->getOutputSignals()) { - auto *json_sig = json_pack("{ s: s, s: s, s: b, s: b, s: o }", - "id", fmt::format("out/{}", sig->name).c_str(), - "source", node->getNameShort().c_str(), - "readable", true, - "writable", false, - "value", json_null() - ); + + for (size_t i = 0; i < MIN(api_node->getOutputSignals()->size(), api_node->write.channels.size()); i++) { + auto sig = api_node->getOutputSignals()->at(i); + auto ch = api_node->write.channels.at(i); + auto *json_sig = ch->toJson(sig); json_array_append(json_sigs, json_sig); } - for (auto const &sig : *node->getInputSignals()) { - auto *json_sig = json_pack("{ s: s, s: s, s: b, s: b, s: o }", - "id", fmt::format("in/{}", sig->name).c_str(), - "source", node->getNameShort().c_str(), - "readable", false, - "writable", true, - "value", json_null() - ); + for (size_t i = 0; i < MIN(api_node->getInputSignals()->size(), api_node->read.channels.size()); i++) { + auto sig = api_node->getInputSignals()->at(i); + auto ch = api_node->read.channels.at(i); + auto *json_sig = ch->toJson(sig); json_array_append(json_sigs, json_sig); } @@ -55,9 +49,9 @@ public: }; /* Register API requests */ -static char n[] = "universal/signals"; -static char r[] = "/universal/(" RE_NODE_NAME ")/signals"; -static char d[] = "get signals of universal data-exchange API node"; +static char n[] = "universal/channels"; +static char r[] = "/universal/(" RE_NODE_NAME ")/channels"; +static char d[] = "get channels of universal data-exchange API node"; static RequestPlugin p; } /* namespace universal */ diff --git a/lib/api/requests/universal/config.cpp b/lib/api/requests/universal/status.cpp similarity index 65% rename from lib/api/requests/universal/config.cpp rename to lib/api/requests/universal/status.cpp index ca3b4c551..a0edea12e 100644 --- a/lib/api/requests/universal/config.cpp +++ b/lib/api/requests/universal/status.cpp @@ -14,7 +14,7 @@ namespace node { namespace api { namespace universal { -class ConfigRequest : public UniversalRequest { +class StatusRequest : public UniversalRequest { public: using UniversalRequest::UniversalRequest; @@ -26,15 +26,20 @@ public: if (body != nullptr) throw BadRequest("This endpoint does not accept any body data"); - return new JsonResponse(session, HTTP_STATUS_OK, node->getConfig()); + auto *json_response = json_pack("{ s: s }", + // TODO: Add connectivity check or heuristic here. + "connected", "unknown" + ); + + return new JsonResponse(session, HTTP_STATUS_OK, json_response); } }; /* Register API requests */ -static char n[] = "universal/config"; -static char r[] = "/universal/(" RE_NODE_NAME ")/config"; -static char d[] = "get configuration of universal data-exchange API"; -static RequestPlugin p; +static char n[] = "universal/status"; +static char r[] = "/universal/(" RE_NODE_NAME ")/status"; +static char d[] = "get status of universal data-exchange API"; +static RequestPlugin p; } /* namespace universal */ } /* namespace api */ diff --git a/lib/api/universal.cpp b/lib/api/universal.cpp new file mode 100644 index 000000000..6f09dca25 --- /dev/null +++ b/lib/api/universal.cpp @@ -0,0 +1,145 @@ +/** API Response. + * + * @file + * @author Steffen Vogel + * @copyright 2014-2022, Institute for Automation of Complex Power Systems, EONERC + * @license Apache 2.0 + *********************************************************************************/ + +#include +#include + +#include +#include + +using namespace villas::node::api::universal; + +void ChannelList::parse(json_t *json, bool readable, bool writable) +{ + if (!json_is_array(json)) + throw ConfigError(json, "node-config-node-api-signals", "Signal list of API node must be an array"); + + clear(); + + size_t i; + json_t *json_channel; + json_array_foreach(json, i, json_channel) { + auto channel = std::make_shared(); + + channel->parse(json_channel); + channel->readable = readable; + channel->writable = writable; + + push_back(channel); + } +} + +void Channel::parse(json_t *json) +{ + const char *desc = nullptr; + const char *pl = nullptr; + json_t *json_range = nullptr; + + json_error_t err; + int ret = json_unpack_ex(json, &err, 0, "{ s?: s, s?: s, s?: o, s?: F }", + "description", &desc, + "payload", &pl, + "range", &json_range, + "rate", &rate + ); + if (ret) + throw ConfigError(json, err, "node-config-node-api-signals"); + + if (desc) + description = desc; + + if (pl) { + if (!strcmp(pl, "samples")) + payload = PayloadType::SAMPLES; + else if (!strcmp(pl, "events")) + payload = PayloadType::EVENTS; + else + throw ConfigError(json, "node-config-node-api-signals-payload", "Invalid payload type: {}", pl); + } + + range_min = std::numeric_limits::quiet_NaN(); + range_max = std::numeric_limits::quiet_NaN(); + if (json_range) { + if (json_is_array(json_range)) { + ret = json_unpack_ex(json, &err, 0, "{ s?: F, s?: F }", + "min", &range_min, + "max", &range_max + ); + if (ret) + throw ConfigError(json, err, "node-config-node-api-signals-range", "Failed to parse channel range"); + } else if (json_is_object(json_range)) { + size_t i; + json_t *json_option; + + range_options.clear(); + + json_array_foreach(json_range, i, json_option) { + if (!json_is_string(json_option)) + throw ConfigError(json, err, "node-config-node-api-signals-range", "Channel range options must be strings"); + + auto *option = json_string_value(json_option); + range_options.push_back(option); + } + } else + throw ConfigError(json, "node-config-node-api-signals-range", "Channel range must be an array or object"); + } +} + +json_t * Channel::toJson(Signal::Ptr sig) const +{ + json_error_t err; + json_t *json_ch = json_pack_ex(&err, 0, "{ s: s, s: s, s: b, s: b }", + "id", sig->name.c_str(), + "datatype", signalTypeToString(sig->type).c_str(), + "readable", (int) readable, + "writable", (int) writable + ); + + if (!description.empty()) + json_object_set(json_ch, "description", json_string(description.c_str())); + + if (!sig->unit.empty()) + json_object_set(json_ch, "unit", json_string(sig->unit.c_str())); + + if (rate > 0) + json_object_set(json_ch, "rate", json_real(rate)); + + switch (payload) { + case PayloadType::EVENTS: + json_object_set(json_ch, "payload", json_string("events")); + break; + + case PayloadType::SAMPLES: + json_object_set(json_ch, "payload", json_string("samples")); + break; + + default: {} + } + + switch (sig->type) { + case SignalType::FLOAT: { + if (std::isnan(range_min) && std::isnan(range_max)) + break; + + json_t *json_range = json_object(); + + if (!std::isnan(range_min)) + json_object_set(json_range, "min", json_real(range_min)); + + if (!std::isnan(range_max)) + json_object_set(json_range, "max", json_real(range_max)); + + json_object_set(json_ch, "range", json_range); + break; + } + + default: {} + } + + return json_ch; +} diff --git a/lib/nodes/api.cpp b/lib/nodes/api.cpp index 34dedb09e..20b2ce55b 100644 --- a/lib/nodes/api.cpp +++ b/lib/nodes/api.cpp @@ -1,16 +1,20 @@ -/** Node type: Universal Data-exchange API +/** Node type: Universal Data-exchange API (v2) * + * @see https://github.com/ERIGrid2/JRA-3.1-api * @author Steffen Vogel * @copyright 2014-2022, Institute for Automation of Complex Power Systems, EONERC * @license Apache 2.0 *********************************************************************************/ #include + #include +#include #include using namespace villas; using namespace villas::node; +using namespace villas::node::api::universal; APINode::APINode(const std::string &name) : Node(name), @@ -53,6 +57,21 @@ int APINode::prepare() return Node::prepare(); } +int APINode::check() +{ + for (auto &ch : read.channels) { + if (ch->payload != PayloadType::SAMPLES) + return -1; + } + + for (auto &ch : write.channels) { + if (ch->payload != PayloadType::SAMPLES) + return -1; + } + + return 0; +} + int APINode::_read(struct Sample *smps[], unsigned cnt) { assert(cnt == 1); @@ -75,6 +94,34 @@ int APINode::_write(struct Sample *smps[], unsigned cnt) return 1; } +int APINode::parse(json_t *json, const uuid_t sn_uuid) +{ + int ret = Node::parse(json, sn_uuid); + if (ret) + return ret; + + json_t *json_signals_in = nullptr; + json_t *json_signals_out = nullptr; + + json_error_t err; + ret = json_unpack_ex(json, &err, 0, "{ s?: { s?: o }, s?: { s?: o } }", + "in", + "signals", &json_signals_in, + "out", + "signals", &json_signals_out + ); + if (ret) + throw ConfigError(json, err, "node-config-node-api"); + + if (json_signals_in) + read.channels.parse(json_signals_in, false, true); + + if (json_signals_out) + write.channels.parse(json_signals_out, true, false); + + return 0; +} + static char n[] = "api"; static char d[] = "A node providing a HTTP REST interface"; static NodePlugin p; diff --git a/lib/signal_data.cpp b/lib/signal_data.cpp index 4e9fa1d2a..befb38121 100644 --- a/lib/signal_data.cpp +++ b/lib/signal_data.cpp @@ -187,14 +187,23 @@ int SignalData::parseJson(enum SignalType type, json_t *json) switch (type) { case SignalType::FLOAT: + if (!json_is_number(json)) + return -1; + this->f = json_number_value(json); break; case SignalType::INTEGER: + if (!json_is_integer(json)) + return -1; + this->i = json_integer_value(json); break; case SignalType::BOOLEAN: + if (!json_is_boolean(json)) + return -1; + this->b = json_boolean_value(json); break; @@ -207,14 +216,14 @@ int SignalData::parseJson(enum SignalType type, json_t *json) "imag", &imag ); if (ret) - return -2; + return -1; this->z = std::complex(real, imag); break; } case SignalType::INVALID: - return -1; + return -2; } return 0;