control: Create MappedFloor action

This action can be used to set a floor value based on 2 or more groups
having values within certain ranges, where the key group chooses the set
of tables in which to check the remaining group values.

For example, with the following JSON:

    {
    "name": "mapped_floor",
    "key_group": "ambient_temp",
    "fan_floors": [
        {
        "key": 27,
        "floors": [
          {
            "group": "altitude",
            "floors": [
               {
                 "value": 5000,
                 "floor": 2000
               },
               {
                 "value": 7000,
                 "floor": 6000
               }
            ]
          },
          {
            "group": "power_mode",
            "floors": [
               {
                 "value": "PowerSave",
                 "floor": 3000
               },
               {
                 "value": "MaximumPerformance",
                 "floor": 5000
               }
            ]
          }
        ]
        }
      ]
    }

If the ambient_temp group has a value less than 27, then it looks up the
values for the altitude and power_mode groups, where for altitude, since
it's numeric, it will use a <= operator, and for power_mode, since it's
a string, it will use an == operator when comparing to the values in the
JSON.  It will then choose the largest floor value between the altitude
and power_mode results.

There are several scenarios that result in a default floor being set.

Full action documentation is in the class header file.

Signed-off-by: Matt Spinler <spinler@us.ibm.com>
Change-Id: I8e0ff3a97ff73dd20018473c1993b2e043276099
diff --git a/control/Makefile.am b/control/Makefile.am
index 1114b8f..9ae9b47 100644
--- a/control/Makefile.am
+++ b/control/Makefile.am
@@ -54,7 +54,8 @@
 	json/actions/count_state_target.cpp \
 	json/actions/net_target_increase.cpp \
 	json/actions/net_target_decrease.cpp \
-	json/actions/timer_based_actions.cpp
+	json/actions/timer_based_actions.cpp \
+	json/actions/mapped_floor.cpp
 else
 phosphor_fan_control_SOURCES += \
 	argument.cpp \
diff --git a/control/json/actions/mapped_floor.cpp b/control/json/actions/mapped_floor.cpp
new file mode 100644
index 0000000..bca3bf5
--- /dev/null
+++ b/control/json/actions/mapped_floor.cpp
@@ -0,0 +1,297 @@
+/**
+ * Copyright © 2021 IBM Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+#include "mapped_floor.hpp"
+
+#include "../manager.hpp"
+#include "../zone.hpp"
+#include "group.hpp"
+#include "sdeventplus.hpp"
+
+#include <fmt/format.h>
+
+#include <nlohmann/json.hpp>
+
+#include <algorithm>
+
+namespace phosphor::fan::control::json
+{
+
+using json = nlohmann::json;
+
+MappedFloor::MappedFloor(const json& jsonObj,
+                         const std::vector<Group>& groups) :
+    ActionBase(jsonObj, groups)
+{
+    setKeyGroup(jsonObj);
+    setFloorTable(jsonObj);
+}
+
+const Group* MappedFloor::getGroup(const std::string& name)
+{
+    auto groupIt =
+        find_if(_groups.begin(), _groups.end(),
+                [name](const auto& group) { return name == group.getName(); });
+
+    if (groupIt == _groups.end())
+    {
+        throw ActionParseError{
+            ActionBase::getName(),
+            fmt::format("Group name {} is not a valid group", name)};
+    }
+
+    return &(*groupIt);
+}
+
+void MappedFloor::setKeyGroup(const json& jsonObj)
+{
+    if (!jsonObj.contains("key_group"))
+    {
+        throw ActionParseError{ActionBase::getName(),
+                               "Missing required 'key_group' entry"};
+    }
+    _keyGroup = getGroup(jsonObj["key_group"].get<std::string>());
+}
+
+void MappedFloor::setFloorTable(const json& jsonObj)
+{
+    if (!jsonObj.contains("fan_floors"))
+    {
+        throw ActionParseError{ActionBase::getName(),
+                               "Missing fan_floors JSON entry"};
+    }
+
+    const auto& fanFloors = jsonObj.at("fan_floors");
+
+    for (const auto& floors : fanFloors)
+    {
+        if (!floors.contains("key") || !floors.contains("floors"))
+        {
+            throw ActionParseError{
+                ActionBase::getName(),
+                "Missing key or floors entries in actions/fan_floors JSON"};
+        }
+
+        FanFloors ff;
+        ff.keyValue = getJsonValue(floors["key"]);
+
+        for (const auto& groupEntry : floors["floors"])
+        {
+            if (!groupEntry.contains("group") || !groupEntry.contains("floors"))
+            {
+                throw ActionParseError{ActionBase::getName(),
+                                       "Missing group or floors entries in "
+                                       "actions/fan_floors/floors JSON"};
+            }
+
+            FloorGroup fg;
+            fg.group = getGroup(groupEntry["group"].get<std::string>());
+
+            for (const auto& floorEntry : groupEntry["floors"])
+            {
+                if (!floorEntry.contains("value") ||
+                    !floorEntry.contains("floor"))
+                {
+
+                    throw ActionParseError{
+                        ActionBase::getName(),
+                        "Missing value or floor entries in "
+                        "actions/fan_floors/floors/floors JSON"};
+                }
+
+                auto value = getJsonValue(floorEntry["value"]);
+                auto floor = floorEntry["floor"].get<uint64_t>();
+
+                fg.floorEntries.emplace_back(std::move(value),
+                                             std::move(floor));
+            }
+
+            ff.floorGroups.push_back(std::move(fg));
+        }
+
+        _fanFloors.push_back(std::move(ff));
+    }
+}
+
+/**
+ * @brief Converts the variant to a double if it's a
+ *        int32_t or int64_t.
+ */
+void tryConvertToDouble(PropertyVariantType& value)
+{
+    std::visit(
+        [&value](auto&& val) {
+            using V = std::decay_t<decltype(val)>;
+            if constexpr (std::is_same_v<int32_t, V> ||
+                          std::is_same_v<int64_t, V>)
+            {
+                value = static_cast<double>(val);
+            }
+        },
+        value);
+}
+
+std::optional<PropertyVariantType>
+    MappedFloor::getMaxGroupValue(const Group& group, const Manager& manager)
+{
+    std::optional<PropertyVariantType> max;
+    bool checked = false;
+
+    for (const auto& member : group.getMembers())
+    {
+        try
+        {
+            auto value = Manager::getObjValueVariant(
+                member, group.getInterface(), group.getProperty());
+
+            // Only allow a group to have multiple members if it's numeric.
+            // Unlike std::is_arithmetic, bools are not considered numeric here.
+            if (!checked && (group.getMembers().size() > 1))
+            {
+                std::visit(
+                    [&group, this](auto&& val) {
+                        using V = std::decay_t<decltype(val)>;
+                        if constexpr (!std::is_same_v<double, V> &&
+                                      !std::is_same_v<int32_t, V> &&
+                                      !std::is_same_v<int64_t, V>)
+                        {
+                            throw std::runtime_error{fmt::format(
+                                "{}: Group {} has more than one member but "
+                                "isn't numeric",
+                                ActionBase::getName(), group.getName())};
+                        }
+                    },
+                    value);
+                checked = true;
+            }
+
+            if (max && (value > max))
+            {
+                max = value;
+            }
+            else if (!max)
+            {
+                max = value;
+            }
+        }
+        catch (const std::out_of_range& e)
+        {
+            // Property not there, continue on
+        }
+    }
+
+    if (max)
+    {
+        tryConvertToDouble(*max);
+    }
+
+    return max;
+}
+
+void MappedFloor::run(Zone& zone)
+{
+    std::optional<uint64_t> newFloor;
+    bool missingGroupProperty = false;
+    auto& manager = *zone.getManager();
+
+    auto keyValue = getMaxGroupValue(*_keyGroup, manager);
+    if (!keyValue)
+    {
+        zone.setFloor(zone.getDefaultFloor());
+        return;
+    }
+
+    for (const auto& floorTable : _fanFloors)
+    {
+        // First, find the floorTable entry to use based on the key value.
+        auto tableKeyValue = floorTable.keyValue;
+
+        // Convert numeric values from the JSON to doubles so they can
+        // be compared to values coming from D-Bus.
+        tryConvertToDouble(tableKeyValue);
+
+        // The key value from D-Bus must be less than the value
+        // in the table for this entry to be valid.
+        if (*keyValue >= tableKeyValue)
+        {
+            continue;
+        }
+
+        // Now check each group in the tables
+        for (const auto& [group, floorGroups] : floorTable.floorGroups)
+        {
+            auto propertyValue = getMaxGroupValue(*group, manager);
+            if (!propertyValue)
+            {
+                // Couldn't successfully get a value.  Results in default floor.
+                missingGroupProperty = true;
+                break;
+            }
+
+            // Do either a <= or an == check depending on the data type to get
+            // the floor value based on this group.
+            std::optional<uint64_t> floor;
+            for (const auto& [tableValue, tableFloor] : floorGroups)
+            {
+                PropertyVariantType value{tableValue};
+                tryConvertToDouble(value);
+
+                if (std::holds_alternative<double>(*propertyValue))
+                {
+                    if (*propertyValue <= value)
+                    {
+                        floor = tableFloor;
+                        break;
+                    }
+                }
+                else if (*propertyValue == value)
+                {
+                    floor = tableFloor;
+                    break;
+                }
+            }
+
+            // Keep track of the highest floor value found across all
+            // entries/groups
+            if (floor)
+            {
+                if ((newFloor && (floor > *newFloor)) || !newFloor)
+                {
+                    newFloor = floor;
+                }
+            }
+            else
+            {
+                // No match found in this group's table.
+                // Results in default floor.
+                missingGroupProperty = true;
+            }
+        }
+
+        // Valid key value for this entry, so done
+        break;
+    }
+
+    if (newFloor && !missingGroupProperty)
+    {
+        zone.setFloor(*newFloor);
+    }
+    else
+    {
+        zone.setFloor(zone.getDefaultFloor());
+    }
+}
+
+} // namespace phosphor::fan::control::json
diff --git a/control/json/actions/mapped_floor.hpp b/control/json/actions/mapped_floor.hpp
new file mode 100644
index 0000000..baf2701
--- /dev/null
+++ b/control/json/actions/mapped_floor.hpp
@@ -0,0 +1,194 @@
+/**
+ * Copyright © 2021 IBM Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+#pragma once
+
+#include "../zone.hpp"
+#include "action.hpp"
+#include "group.hpp"
+
+#include <nlohmann/json.hpp>
+
+namespace phosphor::fan::control::json
+{
+
+using json = nlohmann::json;
+
+/**
+ * @class MappedFloor - Action to set a fan floor based on ranges of
+ *                      multiple sensor values.
+ * For example, consider the following config:
+ *
+ *    {
+ *    "name": "mapped_floor",
+ *    "key_group": "ambient_temp",
+ *    "fan_floors": [
+ *        {
+ *        "key": 27,
+ *        "floors": [
+ *          {
+ *            "group": "altitude",
+ *            "floors": [
+ *               {
+ *                 "value": 5000,
+ *                 "floor": 4500
+ *               }
+ *            ]
+ *          },
+ *          {
+ *            "group": "power_mode",
+ *            "floors": [
+ *               {
+ *                 "value": "MaximumPerformance",
+ *                 "floor": 5000
+ *               }
+ *            ]
+ *          }
+ *        ]
+ *        }
+ *      ]
+ *    }
+ *
+ * When it runs, it will:
+ *
+ * 1. Evaluate the key_group
+ *   - Find the max D-Bus property value (if numeric) of the member properties
+ *     in this group.
+ *   - Check it against each 'key' value in the fan_floor entries until
+ *     the key_group property value < key value, so:
+ *        max ambient temp < 27.
+ *   - If the above check passes, the rest of that entry will be evaluated
+ *     and then the action will be done.
+ *
+ * 2. Evaluate the group values in each floors array entry for this key value.
+ *   - Find the max D-Bus property value (if numeric) of the member properties
+ *     of this group - in this case 'altitude'.
+ *   - Depending on the data type of that group, compare to the 'value' entry:
+ *     - If numeric, check if the group's value is <= the 'value' one
+ *     - Otherwise, check if it is ==
+ *   - If that passes, save the value from the 'floor' entry and continue to
+ *     the next entry in the floors array, which, if present, would specify
+ *     another group to check.  In this case, 'power_mode'.  Repeat the above
+ *     step.
+ *   - After all the group compares are done, choose the largest floor value
+ *     to set the fan floor to.  If any group check results doesn't end in
+ *     a match being found, then the default floor will be set.
+ *
+ * Cases where the default floor will be set:
+ *  - A table entry can't be found based on a key group's value.
+ *  - A table entry can't be found based on a group's value.
+ *  - A value can't be obtained for the 'key_group' D-Bus property group.
+ *  - A value can't be obtained for any of the 'group' property groups.
+ *  - A value is NaN, as no <, <=, or == checks would succeed.
+ *
+ * Other notes:
+ *  - If a group has multiple members, they must be numeric or else
+ *    the code will throw an exception.
+ */
+
+class MappedFloor : public ActionBase, public ActionRegister<MappedFloor>
+{
+  public:
+    /* Name of this action */
+    static constexpr auto name = "mapped_floor";
+
+    MappedFloor() = delete;
+    MappedFloor(const MappedFloor&) = delete;
+    MappedFloor(MappedFloor&&) = delete;
+    MappedFloor& operator=(const MappedFloor&) = delete;
+    MappedFloor& operator=(MappedFloor&&) = delete;
+    ~MappedFloor() = default;
+
+    /**
+     * @brief Parse the JSON to set the members
+     *
+     * @param[in] jsonObj - JSON configuration of this action
+     * @param[in] groups - Groups of dbus objects the action uses
+     */
+    MappedFloor(const json& jsonObj, const std::vector<Group>& groups);
+
+    /**
+     * @brief Run the action.  See description above.
+     *
+     * @param[in] zone - Zone to run the action on
+     */
+    void run(Zone& zone) override;
+
+  private:
+    /**
+     * @brief Parse and set the key group
+     *
+     * @param[in] jsonObj - JSON object for the action
+     */
+    void setKeyGroup(const json& jsonObj);
+
+    /**
+     * @brief Parses and sets the floor group data members
+     *
+     * @param[in] jsonObj - JSON object for the action
+     */
+    void setFloorTable(const json& jsonObj);
+
+    /**
+     * @brief Determines the maximum value of the property specified
+     *        for the group of all members in the group.
+     *
+     * If not numeric, and more than one member, will throw an exception.
+     * Converts numeric values to doubles so they can be compared later.
+     *
+     * If cannot get at least one valid value, returns std::nullopt.
+     *
+     * @param[in] group - The group to get the max value of
+     *
+     * @param[in] manager - The Manager object
+     *
+     * @return optional<PropertyVariantType> - The value, or std::nullopt
+     */
+    std::optional<PropertyVariantType> getMaxGroupValue(const Group& group,
+                                                        const Manager& manager);
+
+    /**
+     * @brief Returns a pointer to the group object specified
+     *
+     * Throws ActionParseError if no group found
+     *
+     * @param[in] name - The group name
+     *
+     * @return const Group* - Pointer to the group
+     */
+    const Group* getGroup(const std::string& name);
+
+    /* Key group pointer */
+    const Group* _keyGroup;
+
+    using FloorEntry = std::tuple<PropertyVariantType, uint64_t>;
+
+    struct FloorGroup
+    {
+        const Group* group;
+        std::vector<FloorEntry> floorEntries;
+    };
+
+    struct FanFloors
+    {
+        PropertyVariantType keyValue;
+        std::vector<FloorGroup> floorGroups;
+    };
+
+    /* The fan floors action data, loaded from JSON */
+    std::vector<FanFloors> _fanFloors;
+};
+
+} // namespace phosphor::fan::control::json