diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 16df083a1..cce111ed4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -136,6 +136,16 @@ build:nix: --print-build-logs ".#villas-node-${SYSTEM}" +build:openapi: + stage: build + needs: [] + image: node:24-alpine + script: + - npx -y @redocly/cli build-docs doc/openapi/openapi.yaml --output openapi.html + artifacts: + paths: + - openapi.html + # Stage: test test:pre-commit: diff --git a/common/include/villas/timing.hpp b/common/include/villas/timing.hpp index 8952dd480..c23fbe36d 100644 --- a/common/include/villas/timing.hpp +++ b/common/include/villas/timing.hpp @@ -7,7 +7,7 @@ #pragma once -#include +#include #include #include @@ -33,3 +33,14 @@ double time_to_double(const struct timespec *ts); // Convert double containing seconds after 1970 to timespec. struct timespec time_from_double(double secs); + +// Convert timespec to an std::chrono::time_point. +template +std::chrono::time_point +time_to_timepoint(const struct timespec *ts) { + auto dur = + std::chrono::seconds(ts->tv_sec) + std::chrono::nanoseconds(ts->tv_nsec); + + return std::chrono::time_point( + std::chrono::duration_cast(dur)); +} diff --git a/common/lib/tool.cpp b/common/lib/tool.cpp index d00992d52..7776e0db1 100644 --- a/common/lib/tool.cpp +++ b/common/lib/tool.cpp @@ -40,6 +40,9 @@ int Tool::run() { try { int ret; + // Setup environment + std::setlocale(LC_ALL, "en_US.UTF-8"); + logger->info("This is VILLASnode {} (built on {}, {})", CLR_BLD(CLR_YEL(PROJECT_VERSION)), CLR_BLD(CLR_MAG(__DATE__)), CLR_BLD(CLR_MAG(__TIME__))); diff --git a/doc/openapi/components/schemas/config/duration.yaml b/doc/openapi/components/schemas/config/duration.yaml new file mode 100644 index 000000000..70ce17ec8 --- /dev/null +++ b/doc/openapi/components/schemas/config/duration.yaml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=http://json-schema.org/draft-07/schema +# SPDX-FileCopyrightText: 2014-2023 Institute for Automation of Complex Power Systems, RWTH Aachen University +# SPDX-License-Identifier: Apache-2.0 +--- +oneOf: +- type: string + description: | + Duration as a string, e.g., "1h30m", "45s", "200ms". + pattern: (\d+d)?(\d+h)?(\d+m)?(\d+s)?(\d+ms)?(\d+us)?(\d+ns)? + examples: + - "6d23h30m50s40ms" + - "45s" + - "2d200ms" +- type: integer + description: | + Duration as integer. + minimum: 0 + examples: + - 5400000 + - 45000 + - 200 diff --git a/doc/openapi/components/schemas/config/hooks/frame.yaml b/doc/openapi/components/schemas/config/hooks/frame.yaml index 4d0f872b8..6d5c6b4f9 100644 --- a/doc/openapi/components/schemas/config/hooks/frame.yaml +++ b/doc/openapi/components/schemas/config/hooks/frame.yaml @@ -8,28 +8,14 @@ allOf: trigger: description: The trigger for new frames. type: string - default: sequence + default: timestamp enum: - sequence - timestamp - unit: - description: The type of a timestamp trigger. - type: string - enum: - - milliseconds - - seconds - - minutes - - hours - interval: description: The interval in which frames are annotated. - type: number - default: 1 - - offset: - description: An offset in the interval for the annotation of new frames. - type: number - default: 0 + default: "1s" + $ref: ../../duration.yaml - $ref: ../hook.yaml diff --git a/etc/examples/hooks/digest.conf b/etc/examples/hooks/digest.conf index 5789f25ca..22d288727 100644 --- a/etc/examples/hooks/digest.conf +++ b/etc/examples/hooks/digest.conf @@ -10,11 +10,18 @@ paths = ( hooks = ( # Use a frame hook to generate NEW_FRAME annotations - "frame", + { + type = "frame" + + # The interval at which frames & digests are generated + interval = "1s" + }, { type = "digest" + # The algorithm used for digest calculation algorithm = "sha256" + # The output file for digests uri = "sequence.digest" } diff --git a/etc/examples/hooks/frame.conf b/etc/examples/hooks/frame.conf index 84918074a..12bfeab76 100644 --- a/etc/examples/hooks/frame.conf +++ b/etc/examples/hooks/frame.conf @@ -13,8 +13,7 @@ paths = ( type = "frame" trigger = "timestamp" - unit = "seconds" - interval = 10 + interval = "3s" } ) } diff --git a/include/villas/config_helper.hpp b/include/villas/config_helper.hpp index c55bc5b03..61d7f2269 100644 --- a/include/villas/config_helper.hpp +++ b/include/villas/config_helper.hpp @@ -7,11 +7,17 @@ #pragma once +#include +#include +#include + #include #include #include +#include "villas/exceptions.hpp" + #ifdef WITH_CONFIG #include #endif @@ -42,5 +48,95 @@ int json_object_extend(json_t *orig, json_t *merge); json_t *json_load_cli(int argc, const char *argv[]); +template +Duration parse_duration(std::string_view input) { + using namespace std::literals::chrono_literals; + + // Map unit strings to their corresponding chrono durations + static const std::unordered_map + unit_map = { + {"d", 24h}, // days + {"h", 1h}, // hours + {"m", 1min}, // minutes + {"s", 1s}, // seconds + {"ms", 1ms}, // milliseconds + {"us", 1us}, // microseconds + {"ns", 1ns} // nanoseconds + }; + + std::regex token_re(R"((\d+)([a-z]+))"); + auto begin = std::regex_iterator(input.begin(), input.end(), token_re); + auto end = std::regex_iterator(); + + std::chrono::nanoseconds total_duration{0}; + + for (auto match = begin; match != end; ++match) { + if (match->size() != 3) { + throw RuntimeError("Invalid duration format: {}", match->str()); + } + + auto number_str = match->str(1); + auto unit_str = match->str(2); + + auto it = unit_map.find(unit_str); + if (it == unit_map.end()) { + throw RuntimeError("Unknown duration unit: {}", unit_str); + } + + auto unit = it->second; + + int64_t number; + try { + number = std::stoul(number_str); + } catch (const std::invalid_argument &e) { + throw RuntimeError("Invalid number in duration: {}", match->str()); + } catch (const std::out_of_range &e) { + throw RuntimeError("Duration overflows maximum representable value: {}", + match->str()); + } + + auto duration = unit * number; + + if (duration > Duration::zero() && duration < Duration(1)) + throw RuntimeError("Duration underflows minimum representable value"); + + if (unit.count() != 0 && + duration.count() / unit.count() != number) // Check for overflow. + throw RuntimeError("Duration overflows maximum representable value: {}", + match->str()); + + total_duration += duration; + } + + return std::chrono::duration_cast(total_duration); +} + +template +Duration parse_duration(json_t *json) { + switch (json_typeof(json)) { + case JSON_INTEGER: { + int64_t value = json_integer_value(json); + if (value < 0) { + throw ConfigError(json, "duration-negative", "Negative duration value"); + } + + return Duration(value); + } + + case JSON_STRING: { + try { + return parse_duration( + std::string_view(json_string_value(json), json_string_length(json))); + } catch (const RuntimeError &e) { + throw ConfigError(json, "duration", "{}", e.what()); + } + } + + default: + throw ConfigError(json, "duration", + "Expected a string or integer for duration"); + } +} + } // namespace node } // namespace villas diff --git a/include/villas/sample.hpp b/include/villas/sample.hpp index e79908896..32e2c8c19 100644 --- a/include/villas/sample.hpp +++ b/include/villas/sample.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -54,7 +55,12 @@ enum class SampleFlags { ALL = -1 }; +// Decrease reference count and release memory if last reference was held. +int sample_decref(struct Sample *s); + struct Sample { + using PtrUnique = std::unique_ptr; + uint64_t sequence; // The sequence number of this sample. unsigned length; // The number of values in sample::values which are valid. unsigned @@ -115,9 +121,6 @@ void sample_free_many(struct Sample *smps[], int cnt); // Increase reference count of sample int sample_incref(struct Sample *s); -// Decrease reference count and release memory if last reference was held. -int sample_decref(struct Sample *s); - int sample_copy(struct Sample *dst, const struct Sample *src); // Dump all details about a sample to debug log diff --git a/include/villas/signal_list.hpp b/include/villas/signal_list.hpp index 9a326abfd..210a11bbb 100644 --- a/include/villas/signal_list.hpp +++ b/include/villas/signal_list.hpp @@ -7,7 +7,6 @@ #pragma once -#include #include #include diff --git a/lib/hooks/frame.cpp b/lib/hooks/frame.cpp index 531196d87..261ce5e45 100644 --- a/lib/hooks/frame.cpp +++ b/lib/hooks/frame.cpp @@ -5,59 +5,30 @@ * SPDX-License-Identifier: Apache-2.0 */ +#include #include #include -#include -#include -#include +#include #include +#include #include #include #include +#include "villas/timing.hpp" + namespace villas { namespace node { -enum class Trigger { - TIMESTAMP, - SEQUENCE, -}; - -enum class Unit { - MILLISECONDS, - SECONDS, - MINUTES, - HOURS, -}; - -uint64_t unit_wrap(std::optional unit) { - if (unit) { - switch (*unit) { - case Unit::HOURS: - return 24; - case Unit::MINUTES: - return 60; - case Unit::SECONDS: - return 60; - case Unit::MILLISECONDS: - return 1000; - } - } - - return std::numeric_limits::max(); -} - class FrameHook : public Hook { - using sample_ptr = std::unique_ptr; + using TimeInterval = std::chrono::milliseconds; + using SequenceInterval = uint64_t; private: - Trigger trigger; - uint64_t interval; - uint64_t offset; - std::optional unit; - sample_ptr last_smp; + std::variant interval; + Sample::PtrUnique last_smp; bool updateInterval(Sample const *next_smp) { bool changed = false; @@ -65,113 +36,34 @@ class FrameHook : public Hook { if (!last_smp.get() || (next_smp->flags & (int)SampleFlags::NEW_SIMULATION)) { changed = true; - } else if (trigger == Trigger::SEQUENCE) { + } else if (auto *i = std::get_if(&interval)) { if (!(next_smp->flags & (int)SampleFlags::HAS_SEQUENCE)) - throw RuntimeError{"Missing sequence number."}; + throw RuntimeError("Missing sequence number"); + + auto last_interval = (last_smp->sequence + *i) / *i; + auto next_interval = (next_smp->sequence + *i) / *i; - auto last_interval = (last_smp->sequence + interval - offset) / interval; - auto next_interval = (next_smp->sequence + interval - offset) / interval; changed = last_interval != next_interval; - } else { + } else if (auto *i = std::get_if(&interval)) { if (!(next_smp->flags & (int)SampleFlags::HAS_TS_ORIGIN)) - throw RuntimeError{"Missing origin timestamp."}; - - switch (unit.value()) { - case Unit::HOURS: { - auto last_hour = last_smp->ts.origin.tv_sec / 3'600; - auto next_hour = next_smp->ts.origin.tv_sec / 3'600; - - auto last_day = last_hour / 24; - auto next_day = next_hour / 24; - if (last_day != next_day) { - changed = true; - break; - } - - auto last_hour_of_day = last_hour - 24 * last_day; - auto next_hour_of_day = next_hour - 24 * next_day; - auto last_interval_of_day = - (last_hour_of_day + interval - offset) / interval; - auto next_interval_of_day = - (next_hour_of_day + interval - offset) / interval; - changed = last_interval_of_day != next_interval_of_day; - break; - } + throw RuntimeError("Missing origin timestamp"); - case Unit::MINUTES: { - auto last_minute = last_smp->ts.origin.tv_sec / 60; - auto next_minute = next_smp->ts.origin.tv_sec / 60; + auto last_ts = time_to_timepoint(&last_smp->ts.origin); + auto next_ts = time_to_timepoint(&next_smp->ts.origin); - auto last_hour = last_minute / 60; - auto next_hour = next_minute / 60; - if (last_hour != next_hour) { - changed = true; - break; - } + auto last_interval = (last_ts.time_since_epoch() + *i) / *i; + auto next_interval = (next_ts.time_since_epoch() + *i) / *i; - auto last_minute_of_hour = last_minute - 60 * last_hour; - auto next_minute_of_hour = next_minute - 60 * next_hour; - auto last_interval_of_hour = - (last_minute_of_hour + interval - offset) / interval; - auto next_interval_of_hour = - (next_minute_of_hour + interval - offset) / interval; - changed = last_interval_of_hour != next_interval_of_hour; - break; - } - - case Unit::SECONDS: { - auto last_second = last_smp->ts.origin.tv_sec; - auto next_second = next_smp->ts.origin.tv_sec; - - auto last_minute = last_second / 60; - auto next_minute = next_second / 60; - if (last_minute != next_minute) { - changed = true; - break; - } - - auto last_second_of_minute = last_second - 60 * last_minute; - auto next_second_of_minute = next_second - 60 * next_minute; - auto last_interval_of_minute = - (last_second_of_minute + interval - offset) / interval; - auto next_interval_of_minute = - (next_second_of_minute + interval - offset) / interval; - changed = last_interval_of_minute != next_interval_of_minute; - break; - } - - case Unit::MILLISECONDS: { - auto last_second = last_smp->ts.origin.tv_sec; - auto next_second = next_smp->ts.origin.tv_sec; - if (last_second != next_second) { - changed = true; - break; - } - - auto last_millisecond_of_second = - last_smp->ts.origin.tv_nsec / 1'000'000; - auto next_millisecond_of_second = - next_smp->ts.origin.tv_nsec / 1'000'000; - auto last_interval_of_second = - (last_millisecond_of_second + interval - offset) / interval; - auto next_interval_of_second = - (next_millisecond_of_second + interval - offset) / interval; - changed = last_interval_of_second != next_interval_of_second; - break; - } - } + changed = last_interval != next_interval; } - if (changed) - logger->debug("new frame"); - return changed; } public: FrameHook(Path *p, Node *n, int fl, int prio, bool en = true) - : Hook(p, n, fl, prio, en), trigger(Trigger::SEQUENCE), interval(1), - offset(0), unit{std::nullopt}, last_smp{nullptr, &sample_decref} {} + : Hook(p, n, fl, prio, en), interval(TimeInterval(0)), + last_smp{nullptr, &sample_decref} {} virtual ~FrameHook() { (void)last_smp.release(); } @@ -179,51 +71,26 @@ class FrameHook : public Hook { Hook::parse(json); char *trigger_str = nullptr; - char *unit_str = nullptr; - int interval_int = -1; - int offset_int = -1; + json_t *json_interval = nullptr; json_error_t err; - auto ret = json_unpack_ex(json, &err, 0, "{ s?: s, s?: s, s?: i, s?: i }", - "trigger", &trigger_str, "unit", &unit_str, - "interval", &interval_int, "offset", &offset_int); + auto ret = json_unpack_ex(json, &err, 0, "{ s?: s, s: o }", "trigger", + &trigger_str, "interval", &json_interval); if (ret) throw ConfigError(json, err, "node-config-hook-frame"); - if (trigger_str) { - if (!strcmp(trigger_str, "sequence")) - trigger = Trigger::SEQUENCE; - else if (!strcmp(trigger_str, "timestamp")) - trigger = Trigger::TIMESTAMP; - else - throw ConfigError(json, "node-config-hook-frame-unit"); - } - - if (trigger == Trigger::TIMESTAMP) { - if (!strcmp(unit_str, "milliseconds")) - unit = Unit::MILLISECONDS; - else if (!strcmp(unit_str, "seconds")) - unit = Unit::SECONDS; - else if (!strcmp(unit_str, "minutes")) - unit = Unit::MINUTES; - else if (!strcmp(unit_str, "hours")) - unit = Unit::HOURS; - else - throw ConfigError(json, "node-config-hook-frame-unit"); - } - - if (interval_int != -1) { - if (interval_int <= 0 || (uint64_t)interval_int > unit_wrap(unit)) - throw ConfigError(json, "node-config-hook-frame-interval"); - - interval = interval_int; - } + if (trigger_str == nullptr || !strcmp(trigger_str, "timestamp")) { + interval = parse_duration(json_interval); - if (offset_int != -1) { - if (offset_int < 0 || (uint64_t)offset_int >= unit_wrap(unit)) - throw ConfigError(json, "node-config-hook-frame-offset"); + if (std::get(interval) == TimeInterval::zero()) + throw ConfigError(json, "node-config-hook-frame-interval", + "Interval must be greater than zero"); + } else if (!strcmp(trigger_str, "sequence")) { + if (!json_is_integer(json_interval)) + throw ConfigError(json_interval, "node-config-hook-frame-interval", + "Interval must be an integer"); - offset = offset_int; + interval = SequenceInterval(json_integer_value(json_interval)); } } @@ -236,7 +103,7 @@ class FrameHook : public Hook { smp->flags &= ~(int)SampleFlags::NEW_FRAME; sample_incref(smp); - last_smp = sample_ptr{smp, &sample_decref}; + last_smp = Sample::PtrUnique{smp, &sample_decref}; return Reason::OK; } diff --git a/lib/sample.cpp b/lib/sample.cpp index d865f6f14..a2c9c4cf9 100644 --- a/lib/sample.cpp +++ b/lib/sample.cpp @@ -193,7 +193,7 @@ int villas::node::sample_copy_many(struct Sample *const dsts[], int villas::node::sample_cmp(struct Sample *a, struct Sample *b, double epsilon, int flags) { - if ((a->flags & b->flags & flags) != flags) { + if ((a->flags & flags) != (b->flags & flags)) { printf("flags: a=%#x, b=%#x, wanted=%#x\n", a->flags, b->flags, flags); return -1; } diff --git a/tests/integration/hook-digest.sh b/tests/integration/hook-digest.sh index bc07f21d2..45fe68c1f 100755 --- a/tests/integration/hook-digest.sh +++ b/tests/integration/hook-digest.sh @@ -50,7 +50,7 @@ cat > expect.digest < frame.dat +villas hook frame -o trigger=sequence -o interval=2 < input.dat > frame.dat villas hook digest -o algorithm=sha256 -o uri=output.digest < frame.dat > output.dat villas compare output.dat expect.dat diff --git a/tools/pre-commit-dco-hook.sh b/tools/pre-commit-dco-hook.sh index 7cfc630c7..fea1abc05 100755 --- a/tools/pre-commit-dco-hook.sh +++ b/tools/pre-commit-dco-hook.sh @@ -11,7 +11,7 @@ GIT_AUTHOR="$(git var GIT_AUTHOR_IDENT | sed -n 's|^\(.*>\).*$|\1|p')" SIGNOFF="Signed-off-by: $GIT_AUTHOR" if ! grep --quiet --fixed-strings --line-regexp "$SIGNOFF" "$MESSAGE_FILE" ; then - printf "\n%s" "$SIGNOFF" >> "$MESSAGE_FILE" + printf "\n%s\n" "$SIGNOFF" >> "$MESSAGE_FILE" fi exit 0