HardwareState: HWMon data reader

Ported HWMon driver from
cla-sysrepo@19163e50ab062a8b5b75f754b30c22fdbd62b03a.

In cla-sysrepo, these drivers provided a property-based API, i.e., you
asked for a specific property (i.e., a filename) and the contents of the
file was returned.

In velia, we do not need this one-by-one property access. We provide all
attributes with a single call.

Change-Id: Ife783f21d4552e3b8bc69436d2f8fa53eb6745b7
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a29a1c8..f2e5a67 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -93,11 +93,14 @@
 add_library(velia-ietf-hardware STATIC
     src/ietf-hardware/sysfs/EMMC.cpp
     src/ietf-hardware/sysfs/EMMC.h
+    src/ietf-hardware/sysfs/HWMon.cpp
+    src/ietf-hardware/sysfs/HWMon.h
     )
 target_link_libraries(velia-ietf-hardware
     PUBLIC
         velia-utils
         ${STD_FILESYSTEM_LIBRARY}
+        Boost::boost
     )
 target_include_directories(velia-ietf-hardware PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src/velia-hardware)
 
@@ -131,6 +134,17 @@
             SDBusCpp::sdbus-c++
         )
 
+    add_library(FsTestUtils STATIC
+        tests/fs-helpers/FileInjector.cpp
+        tests/fs-helpers/FileInjector.h
+        tests/fs-helpers/utils.cpp
+        tests/fs-helpers/utils.h
+        )
+    target_link_libraries(FsTestUtils
+        PUBLIC
+            ${STD_FILESYSTEM_LIBRARY}
+        )
+
     function(velia_test name)
         add_executable(test-${name} ${CMAKE_SOURCE_DIR}/tests/${name}.cpp)
         target_link_libraries(test-${name} DoctestIntegration ${ARGN})
@@ -151,7 +165,8 @@
     velia_test(health_input-systemd velia-health DbusTesting)
     velia_test(health_output-led velia-health)
 
-    velia_test(hardware_emmc velia-ietf-hardware)
+    velia_test(hardware_emmc velia-ietf-hardware FsTestUtils)
+    velia_test(hardware_hwmon velia-ietf-hardware FsTestUtils)
 endif()
 
 if(WITH_DOCS)
diff --git a/src/ietf-hardware/sysfs/HWMon.cpp b/src/ietf-hardware/sysfs/HWMon.cpp
new file mode 100644
index 0000000..a34607f
--- /dev/null
+++ b/src/ietf-hardware/sysfs/HWMon.cpp
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2017-2020 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Miroslav Mareš <mmares@cesnet.cz>
+ * Written by Tomáš Pecka <tomas.pecka@fit.cvut.cz>
+ *
+*/
+#include <algorithm>
+#include <boost/algorithm/string/predicate.hpp>
+#include <filesystem>
+#include "HWMon.h"
+#include "utils/io.h"
+#include "utils/log.h"
+
+namespace velia::ietf_hardware::sysfs {
+
+using namespace std::literals;
+
+/** @class HWMon
+
+@short Implements access to sensor chips data in specific hwmon directory.
+
+This class provides property-like access to various sensoric data from kernel hwmon subsystem.
+
+Docs: https://www.kernel.org/doc/Documentation/hwmon/sysfs-interface
+Kernel: https://github.com/torvalds/linux/tree/master/drivers/hwmon
+*/
+
+
+namespace {
+
+/** @brief Only files from hwmon directory that end with these suffixes are considered */
+static const std::vector<std::string> ACCEPTED_FILE_ENDINGS {
+    "_input",
+    "_crit",
+    "_min",
+    "_max",
+    "_average",
+    "_highest",
+    "_lowest",
+};
+}
+
+/**
+ * @short Constructs a HWMon driver for hwmon entries
+ * @param root A path to the hwmon using specific device directory from /sys/devices/ or /sys/bus/i2c, e.g.: /sys/devices/platform/soc/soc:internal-regs/f1011100.i2c/i2c-1/1-002e/hwmon or /sys/bus/i2c/devices/2-0025/hwmon
+ * */
+HWMon::HWMon(std::filesystem::path hwmonDir)
+    : m_log(spdlog::get("hardware"))
+{
+    // Find root directory (should be called hwmonX)
+    std::vector<std::filesystem::path> rootCandidates;
+    if (std::filesystem::exists(hwmonDir)) {
+        for (const auto& e : std::filesystem::directory_iterator(hwmonDir)) {
+            // only directories hwmon<int> with a file named 'name' (required by kernel docs) are valid
+            if (e.is_directory() && boost::algorithm::starts_with(std::string(e.path().filename()), "hwmon") && std::filesystem::exists(e.path() / "name"s)) {
+                rootCandidates.push_back(e.path());
+                m_log->trace("hwmon: Found a candidate: {}", std::string(e.path()));
+            }
+        }
+    }
+
+    if (rootCandidates.size() != 1)
+        throw std::invalid_argument("Invalid hwmon directory ('" + std::string(hwmonDir) + "')");
+    m_root = rootCandidates.front();
+
+    m_log->trace("HWMon() driver initialized for '{}'", std::string(m_root));
+
+    // Scan through files in root directory, discard directories, non-readable files and non-interesting (see accepted_endings) files
+    for (const auto& entry : std::filesystem::directory_iterator(m_root)) {
+        if (!std::filesystem::is_regular_file(entry.path())) {
+            continue;
+        }
+
+        if (std::any_of(ACCEPTED_FILE_ENDINGS.cbegin(), ACCEPTED_FILE_ENDINGS.cend(), [&entry](const auto& ending) { return boost::algorithm::ends_with(std::string(entry.path().filename()), ending); })) {
+            m_properties.push_back(entry.path().filename());
+        }
+    }
+}
+
+HWMon::~HWMon() = default;
+
+/** @brief Return attributes read by this hwmon.
+ *
+ * For the return value discussion @see HWMon::readFile
+ */
+HWMon::Attributes HWMon::attributes() const
+{
+    Attributes result;
+
+    for (const auto& propertyName : m_properties) {
+        // Read int64_t value because kernel seems to print numeric values as signed long ints (@see linux/drivers/hwmon/hwmon.c)
+        result.insert(std::make_pair(propertyName, velia::utils::readFileInt64(m_root / propertyName)));
+    }
+
+    return result;
+}
+}
diff --git a/src/ietf-hardware/sysfs/HWMon.h b/src/ietf-hardware/sysfs/HWMon.h
new file mode 100644
index 0000000..e3b3932
--- /dev/null
+++ b/src/ietf-hardware/sysfs/HWMon.h
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2017-2020 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Miroslav Mareš <mmares@cesnet.cz>
+ * Written by Tomáš Pecka <tomas.pecka@fit.cvut.cz>
+ *
+*/
+
+#pragma once
+
+#include <filesystem>
+#include <map>
+#include <vector>
+#include "utils/log-fwd.h"
+
+namespace velia::ietf_hardware::sysfs {
+
+class HWMon {
+public:
+    using Attributes = std::map<std::string, int64_t>;
+
+    explicit HWMon(std::filesystem::path hwmonDir);
+    ~HWMon();
+
+    Attributes attributes() const;
+
+private:
+    velia::Log m_log;
+
+    /** @brief path to the real hwmon directory */
+    std::filesystem::path m_root;
+
+    /** @brief keys of entries that are exported via this hwmon. Filled by constructor. */
+    std::vector<std::string> m_properties;
+};
+}
diff --git a/src/utils/io.cpp b/src/utils/io.cpp
index 4a18668..86dbd7c 100644
--- a/src/utils/io.cpp
+++ b/src/utils/io.cpp
@@ -53,4 +53,17 @@
     return bytes;
 }
 
+/** @brief Reads a int64_t number from a file. */
+int64_t readFileInt64(const std::filesystem::path& path)
+{
+    std::ifstream ifs(openStream(path));
+    int64_t res;
+
+    if (ifs >> res) {
+        return res;
+    }
+
+    throw std::domain_error("Could not read int64_t value from '" + std::string(path) + "'.");
+}
+
 }
diff --git a/src/utils/io.h b/src/utils/io.h
index a23069d..6d8123a 100644
--- a/src/utils/io.h
+++ b/src/utils/io.h
@@ -14,5 +14,6 @@
 namespace velia::utils {
 
 std::string readFileString(const std::filesystem::path& path);
+int64_t readFileInt64(const std::filesystem::path& path);
 std::vector<uint32_t> readFileWords(const std::filesystem::path& path, int valuesCount);
 }
diff --git a/tests/fs-helpers/FileInjector.cpp b/tests/fs-helpers/FileInjector.cpp
new file mode 100644
index 0000000..a3a80f2
--- /dev/null
+++ b/tests/fs-helpers/FileInjector.cpp
@@ -0,0 +1,26 @@
+#include <fstream>
+#include "FileInjector.h"
+
+/** @short Creates a file with specific permissions and content */
+FileInjector::FileInjector(const std::filesystem::path& path, const std::filesystem::perms permissions, const std::string& content)
+    : path(path)
+{
+    auto fileStream = std::ofstream(path, std::ios_base::out | std::ios_base::trunc);
+    if (!fileStream.is_open()) {
+        throw std::invalid_argument("FileInjector could not open file " + std::string(path) + " for writing");
+    }
+    fileStream << content;
+    std::filesystem::permissions(path, permissions);
+}
+
+/** @short Removes file associated with this FileInjector instance (if exists) */
+FileInjector::~FileInjector() noexcept(false)
+{
+    std::filesystem::remove(path);
+}
+
+/** @short Sets file permissions */
+void FileInjector::setPermissions(const std::filesystem::perms permissions)
+{
+    std::filesystem::permissions(path, permissions);
+}
diff --git a/tests/fs-helpers/FileInjector.h b/tests/fs-helpers/FileInjector.h
new file mode 100644
index 0000000..873d3b6
--- /dev/null
+++ b/tests/fs-helpers/FileInjector.h
@@ -0,0 +1,14 @@
+#pragma once
+#include <filesystem>
+#include <string>
+
+/** @short Represents a temporary file whose lifetime is bound by lifetime of the FileInjector instance */
+class FileInjector {
+private:
+    const std::string path;
+
+public:
+    FileInjector(const std::filesystem::path& path, const std::filesystem::perms permissions, const std::string& content);
+    ~FileInjector() noexcept(false);
+    void setPermissions(const std::filesystem::perms permissions);
+};
diff --git a/tests/fs-helpers/utils.cpp b/tests/fs-helpers/utils.cpp
new file mode 100644
index 0000000..1fc018a
--- /dev/null
+++ b/tests/fs-helpers/utils.cpp
@@ -0,0 +1,9 @@
+#include "utils.h"
+
+/** @short Remove directory tree at 'rootDir' path (if exists) */
+void removeDirectoryTreeIfExists(const std::filesystem::path& rootDir)
+{
+    if (std::filesystem::exists(rootDir)) {
+        std::filesystem::remove_all(rootDir);
+    }
+}
diff --git a/tests/fs-helpers/utils.h b/tests/fs-helpers/utils.h
new file mode 100644
index 0000000..93196f2
--- /dev/null
+++ b/tests/fs-helpers/utils.h
@@ -0,0 +1,4 @@
+#pragma once
+#include <filesystem>
+
+void removeDirectoryTreeIfExists(const std::filesystem::path& rootDir);
diff --git a/tests/hardware_emmc.cpp b/tests/hardware_emmc.cpp
index 91b06c0..4707686 100644
--- a/tests/hardware_emmc.cpp
+++ b/tests/hardware_emmc.cpp
@@ -7,6 +7,7 @@
 
 #include "trompeloeil_doctest.h"
 #include <filesystem>
+#include "fs-helpers/utils.h"
 #include "ietf-hardware/sysfs/EMMC.h"
 #include "pretty_printers.h"
 #include "test_log_setup.h"
@@ -14,17 +15,6 @@
 
 using namespace std::literals;
 
-namespace {
-
-/** @short Remove directory tree at 'rootDir' path (if exists) */
-void removeDirectoryTreeIfExists(const std::string& rootDir)
-{
-    if (std::filesystem::exists(rootDir)) {
-        std::filesystem::remove_all(rootDir);
-    }
-}
-}
-
 TEST_CASE("EMMC driver")
 {
     TEST_INIT_LOGS;
diff --git a/tests/hardware_hwmon.cpp b/tests/hardware_hwmon.cpp
new file mode 100644
index 0000000..3f9fb63
--- /dev/null
+++ b/tests/hardware_hwmon.cpp
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2017-2018 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Miroslav Mareš <mmares@cesnet.cz>
+ *
+*/
+
+#include "trompeloeil_doctest.h"
+#include <filesystem>
+#include <fstream>
+#include "fs-helpers/FileInjector.h"
+#include "fs-helpers/utils.h"
+#include "ietf-hardware/sysfs/HWMon.h"
+#include "pretty_printers.h"
+#include "test_log_setup.h"
+#include "tests/configure.cmake.h"
+
+using namespace std::literals;
+
+TEST_CASE("HWMon class")
+{
+    TEST_INIT_LOGS;
+
+    const auto fakeHwmonRoot = CMAKE_CURRENT_BINARY_DIR + "/tests/hwmon/"s;
+    removeDirectoryTreeIfExists(fakeHwmonRoot);
+    velia::ietf_hardware::sysfs::HWMon::Attributes expected;
+
+    SECTION("Test hwmon/device1")
+    {
+        std::filesystem::copy(CMAKE_CURRENT_SOURCE_DIR + "/tests/sysfs/hwmon/device1/hwmon"s, fakeHwmonRoot, std::filesystem::copy_options::recursive);
+        auto hwmon = velia::ietf_hardware::sysfs::HWMon(fakeHwmonRoot);
+        expected = {
+            {"temp1_crit", 105'000},
+            {"temp1_input", 66'600},
+            {"temp2_crit", 105'000},
+            {"temp2_input", 29'800},
+            {"temp10_crit", 666'777},
+            {"temp10_input", 66'600},
+            {"temp11_input", 111'222'333'444'555},
+        };
+
+        REQUIRE(hwmon.attributes() == expected);
+    }
+
+    SECTION("Test hwmon/device1 + one of the files unreadable")
+    {
+        std::filesystem::copy(CMAKE_CURRENT_SOURCE_DIR + "/tests/sysfs/hwmon/device1/hwmon"s, fakeHwmonRoot, std::filesystem::copy_options::recursive);
+
+        // Inject temporary file for "no read permission" test
+        auto injected_noread = std::make_unique<FileInjector>(fakeHwmonRoot + "/hwmon0/temp3_input", std::filesystem::perms::owner_write, "-42001");
+
+        auto hwmon = velia::ietf_hardware::sysfs::HWMon(fakeHwmonRoot);
+        expected = {
+            {"temp1_crit", 105'000},
+            {"temp1_input", 66'600},
+            {"temp2_crit", 105'000},
+            {"temp2_input", 29'800},
+            {"temp3_input", -42'001},
+            {"temp10_crit", 666'777},
+            {"temp10_input", 66'600},
+            {"temp11_input", 111'222'333'444'555},
+        };
+
+        // no read permission now
+        REQUIRE_THROWS_AS(hwmon.attributes(), std::invalid_argument);
+
+        // read permission granted
+        injected_noread->setPermissions(std::filesystem::perms::owner_all);
+        REQUIRE(hwmon.attributes() == expected);
+    }
+
+    SECTION("Test hwmon/device1 + one of the files disappears after construction")
+    {
+        std::filesystem::copy(CMAKE_CURRENT_SOURCE_DIR + "/tests/sysfs/hwmon/device1/hwmon"s, fakeHwmonRoot, std::filesystem::copy_options::recursive);
+
+        // Inject temporary file for "file does not exist" test
+        auto injected_notexist = std::make_unique<FileInjector>(fakeHwmonRoot + "/hwmon0/temp3_input", std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, "-42001");
+
+        auto hwmon = velia::ietf_hardware::sysfs::HWMon(fakeHwmonRoot);
+
+        expected = {
+            {"temp1_crit", 105'000},
+            {"temp1_input", 66'600},
+            {"temp2_crit", 105'000},
+            {"temp2_input", 29'800},
+            {"temp3_input", -42'001},
+            {"temp10_crit", 666'777},
+            {"temp10_input", 66'600},
+            {"temp11_input", 111'222'333'444'555},
+        };
+
+        // file exists, should be OK
+        REQUIRE(hwmon.attributes() == expected);
+
+        // file deleted
+        injected_notexist.reset();
+        REQUIRE_THROWS_AS(hwmon.attributes(), std::invalid_argument);
+    }
+
+    SECTION("Test hwmon/device1 + invalid values")
+    {
+        std::filesystem::copy(CMAKE_CURRENT_SOURCE_DIR + "/tests/sysfs/hwmon/device1/hwmon"s, fakeHwmonRoot, std::filesystem::copy_options::recursive);
+
+        SECTION("Invalid content")
+        {
+            auto injected = std::make_unique<FileInjector>(fakeHwmonRoot + "/hwmon0/temp3_input", std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, "cus bus");
+            auto hwmon = velia::ietf_hardware::sysfs::HWMon(fakeHwmonRoot);
+            REQUIRE_THROWS_AS(hwmon.attributes(), std::domain_error);
+        }
+
+        SECTION("Invalid value range")
+        {
+            auto injected = std::make_unique<FileInjector>(fakeHwmonRoot + "/hwmon0/temp3_input", std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, "-99999999999999999999999999999999");
+            auto hwmon = velia::ietf_hardware::sysfs::HWMon(fakeHwmonRoot);
+            REQUIRE_THROWS_AS(hwmon.attributes(), std::domain_error);
+        }
+    }
+
+    SECTION("Test hwmon/device2")
+    {
+        std::filesystem::copy(CMAKE_CURRENT_SOURCE_DIR + "/tests/sysfs/hwmon/device2/hwmon"s, fakeHwmonRoot, std::filesystem::copy_options::recursive);
+
+        auto hwmon = velia::ietf_hardware::sysfs::HWMon(fakeHwmonRoot);
+        expected = {
+            {"temp1_crit", std::numeric_limits<int64_t>::max()},
+            {"temp1_input", -34'000},
+            {"temp1_max", 80'000},
+            {"temp2_crit", std::numeric_limits<int64_t>::min()}, // we can't write an integer literal for int64_t min value (see https://gcc.gnu.org/bugzilla/show_bug.cgi?id=52661)
+            {"temp2_input", -34'000},
+            {"temp2_max", 80'000},
+            {"temp3_crit", 100'000},
+            {"temp3_input", 30'000},
+            {"temp3_max", 80'000},
+            {"temp4_crit", 100'000},
+            {"temp4_input", 26'000},
+            {"temp4_max", 80'000},
+            {"temp5_crit", 100'000},
+            {"temp5_input", 29'000},
+            {"temp5_max", 80'000},
+        };
+
+        REQUIRE(hwmon.attributes() == expected);
+    }
+
+    SECTION("Test wrong directory structure")
+    {
+        std::string sourceDir;
+        SECTION("No hwmonX directory")
+        {
+            sourceDir = "tests/sysfs/hwmon/device4/hwmon"s;
+        }
+        SECTION("Multiple hwmonX directories")
+        {
+            sourceDir = "tests/sysfs/hwmon/device3/hwmon"s;
+        }
+
+        std::filesystem::copy(CMAKE_CURRENT_SOURCE_DIR + "/"s + sourceDir, fakeHwmonRoot, std::filesystem::copy_options::recursive);
+
+        REQUIRE_THROWS_AS(velia::ietf_hardware::sysfs::HWMon(fakeHwmonRoot), std::invalid_argument);
+    }
+}
diff --git a/tests/pretty_printers.h b/tests/pretty_printers.h
index 8fea584..eb1399c 100644
--- a/tests/pretty_printers.h
+++ b/tests/pretty_printers.h
@@ -12,7 +12,6 @@
 #include <sstream>
 #include <trompeloeil.hpp>
 
-
 namespace doctest {
 
 template <>
@@ -29,4 +28,18 @@
     }
 };
 
+template <>
+struct StringMaker<std::map<std::string, int64_t>> {
+    static String convert(const std::map<std::string, int64_t>& map)
+    {
+        std::ostringstream os;
+        os << "{" << std::endl;
+        for (const auto& [key, value] : map) {
+            os << "  \"" << key << "\": " << value << "," << std::endl;
+        }
+        os << "}";
+        return os.str().c_str();
+    }
+};
+
 }
diff --git a/tests/sysfs/hwmon/device2/hwmon/hwmon33/name b/tests/sysfs/hwmon/device2/hwmon/hwmon33/name
index 2ec2faf..5f10796 100755
--- a/tests/sysfs/hwmon/device2/hwmon/hwmon33/name
+++ b/tests/sysfs/hwmon/device2/hwmon/hwmon33/name
@@ -1 +1 @@
-satan temperatures
+some small or large numbers are to be read here
diff --git a/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp1_crit b/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp1_crit
index 363ca95..2045006 100755
--- a/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp1_crit
+++ b/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp1_crit
@@ -1 +1 @@
-1000000000000000000005666
+9223372036854775807
diff --git a/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp2_crit b/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp2_crit
index 0277850..7928ab8 100755
--- a/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp2_crit
+++ b/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp2_crit
@@ -1 +1 @@
-nazdar bazar carodej Merlin
+-9223372036854775808
diff --git a/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp2_input b/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp2_input
index 782a512..acab4b6 100755
--- a/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp2_input
+++ b/tests/sysfs/hwmon/device2/hwmon/hwmon33/temp2_input
@@ -1 +1 @@
--1234567891234567896666
+-34000