hw: read static serial numbers from EEPROMs

On the most recent HW revisions of Clearfog (both the SoM and the
carrier board), there should be some EEPROMs that are factory-populated
with ONIE-compatible TLV data. The EEPROMs also happen to be these
semi-magic models which embed some unique data at the end of the memory
space (and within a HW-protected, read-only range).

On some of these boards, the EEPROMs are not populated at all (which is
expected on pre-1.3 "base" carrier and on some older SoM). The majority
of our HW actually has this old revision, so we cannot really hard-fail
with an error message. This is easily detectable by a missing "eeprom"
file in sysfs (but the dir entry for the I2C slave is always present, as
long as the Device Tree is not borked).

The other source of breakage is that these EEPROMs are documented to
contain some ONIE-compatible TLV data, but I have yet to see an EEPROM
with *any* data burned in; the ones which we have are usually empty.
However, in future we *will* probably get our hands on a ClearFog Base
or a SoM with these TLV data built in, and on these models, we *should*
show serial numbers from these EEPROMs which will, hopefully, match the
printed barcode that's surely found somewhere on the PCBs.

In the meanwhile, though, we can leverage that unique UID/EUI-48 code
that's available at the end of the EEPROM space, and in fact, there's no
harm in showing both on HW which has both the magic EEPROM and some
actual TLV data. So I felt that the cleanest solution for this is to put
a new node just for the EEPROM into ietf-hardware, and put the S/N from
the unique part of each EEPROM in there. This required some extra
handling of the `oper-state` leaf so as not to confuse the downstream
consumers on legacy boards with no populated EEPROM.

Compared to all that mess, the EEPROMs which we're putting on our PCBs
are also magic, but in a different way: these EEPROM chips occupy a pair
of I2C addresses, one for the usual EEPROM, and one for the magic one
which only holds the S/N. We *know* that this EEPROM is always present,
so we don't have to mess with nested ietf-hardware hardware instances,
and it's OK to simply read this S/N and propagate that as a piece of
static data to the YANG datastore. Except when working with the
hotpluggable fans, but man, this starts getting a wee bit involved,
isn't it?

Change-Id: I36f810e4ca57b28405926da20a3c50bd520c480f
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 1b9b380..2ea7ee4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -404,6 +404,7 @@
     velia_test(NAME system_lldp LIBRARIES velia-system DbusTesting)
 
     velia_test(NAME hardware_thresholds LIBRARIES velia-ietf-hardware)
+    velia_test(NAME hardware_eeprom LIBRARIES velia-ietf-hardware)
     velia_test(NAME hardware_emmc LIBRARIES velia-ietf-hardware FsTestUtils)
     velia_test(NAME hardware_hwmon LIBRARIES velia-ietf-hardware FsTestUtils)
     velia_test(NAME hardware_fspyh LIBRARIES velia-ietf-hardware FsTestUtils)
diff --git a/src/ietf-hardware/Factory.cpp b/src/ietf-hardware/Factory.cpp
index 9a8f401..d1402b4 100644
--- a/src/ietf-hardware/Factory.cpp
+++ b/src/ietf-hardware/Factory.cpp
@@ -7,6 +7,7 @@
 
 namespace velia::ietf_hardware {
 using velia::ietf_hardware::data_reader::EMMC;
+using velia::ietf_hardware::data_reader::EepromWithUid;
 using velia::ietf_hardware::data_reader::Fans;
 using velia::ietf_hardware::data_reader::SensorType;
 using velia::ietf_hardware::data_reader::StaticData;
@@ -69,11 +70,28 @@
         auto emmc = std::make_shared<velia::ietf_hardware::sysfs::EMMC>("/sys/block/mmcblk0/device/");
 
         /* FIXME:
-         * Publish more properties for ne element. We have an EEPROM at the PCB for storing serial numbers (etc.), but it's so far unused. We could also use U-Boot env variables
+         * - handle dynamic hot plug of the ne:fans, read (and re-read) its EEPROM for the S/N
          */
         ietfHardware->registerDataReader(StaticData("ne", std::nullopt, {{"class", "iana-hardware:chassis"}}));
 
-        ietfHardware->registerDataReader(StaticData("ne:ctrl", "ne", {{"class", "iana-hardware:module"}}));
+        ietfHardware->registerDataReader(StaticData{"ne:ctrl",
+                                                    "ne",
+                                                    {
+                                                        {"class", "iana-hardware:module"},
+                                                        {"serial-num", *hexEEPROM("/sys", 1, 0x5b, 16, 0, 16)},
+                                                    }});
+        try {
+            auto eeprom = hexEEPROM("/sys", 1, 0x5a, 16, 0, 16);
+            ietfHardware->registerDataReader(StaticData{"ne:voa-sw",
+                                                        "ne",
+                                                        {
+                                                            {"class", "iana-hardware:module"},
+                                                            {"serial-num", *eeprom},
+                                                        }});
+        } catch (std::runtime_error& e) {
+            // this EEPROM is only present on regular inline amplifiers and on ROADM line/degree boxes
+            // -> silently ignore any failures
+        }
         ietfHardware->registerDataReader(Fans("ne:fans",
                                               "ne",
                                               fans,
@@ -84,6 +102,20 @@
                                                   .warningHigh = std::nullopt,
                                                   .criticalHigh = std::nullopt,
                                               }));
+        ietfHardware->registerDataReader(StaticData{"ne:ctrl:som",
+                                                    "ne:ctrl",
+                                                    {
+                                                        {"class", "iana-hardware:module"},
+                                                        {"model-name", "ClearFog A388 SOM"},
+                                                    }});
+        ietfHardware->registerDataReader(EepromWithUid{"ne:ctrl:som:eeprom", "ne:ctrl:som", "/sys", 0, 0x53, 256, 256 - 6, 6});
+        ietfHardware->registerDataReader(StaticData{"ne:ctrl:carrier",
+                                                    "ne:ctrl",
+                                                    {
+                                                        {"class", "iana-hardware:module"},
+                                                        {"model-name", "ClearFog Base"},
+                                                    }});
+        ietfHardware->registerDataReader(EepromWithUid{"ne:ctrl:carrier:eeprom", "ne:ctrl:carrier", "/sys", 0, 0x52, 256, 256 - 6, 6});
         ietfHardware->registerDataReader(SysfsValue<SensorType::Temperature>("ne:ctrl:temperature-front", "ne:ctrl", tempMainBoard, 1));
         ietfHardware->registerDataReader(SysfsValue<SensorType::Temperature>("ne:ctrl:temperature-cpu", "ne:ctrl", tempCpu, 1));
         ietfHardware->registerDataReader(SysfsValue<SensorType::Temperature>("ne:ctrl:temperature-rear", "ne:ctrl", tempFans, 1));
diff --git a/src/ietf-hardware/IETFHardware.cpp b/src/ietf-hardware/IETFHardware.cpp
index b7f290d..e983604 100644
--- a/src/ietf-hardware/IETFHardware.cpp
+++ b/src/ietf-hardware/IETFHardware.cpp
@@ -5,7 +5,9 @@
  *
  */
 
+#include <boost/algorithm/hex.hpp>
 #include <chrono>
+#include <filesystem>
 #include <libyang-cpp/Time.hpp>
 #include <utility>
 #include "IETFHardware.h"
@@ -24,7 +26,7 @@
 }
 
 /** @brief Prefix all properties from values DataTree with a component name (calculated from @p componentName) and push them into the DataTree */
-void addComponent(velia::ietf_hardware::DataTree& res, const std::string& componentName, const std::optional<std::string>& parent, const velia::ietf_hardware::DataTree& values)
+void addComponent(velia::ietf_hardware::DataTree& res, const std::string& componentName, const std::optional<std::string>& parent, const velia::ietf_hardware::DataTree& values, const std::string& operState = "enabled")
 {
     auto componentPrefix = xpathForComponent(componentName);
 
@@ -35,7 +37,7 @@
         res[componentPrefix + k] = v;
     }
 
-    res[componentPrefix + "state/oper-state"] = "enabled";
+    res[componentPrefix + "state/oper-state"] = operState;
 }
 
 void writeSensorValue(velia::ietf_hardware::DataTree& res, const std::string& componentName, const std::string& value, const std::string& operStatus)
@@ -353,5 +355,68 @@
 
     return {data, ThresholdsBySensorPath{{xpathForComponent(m_componentName + ":lifetime") + "sensor-data/value", m_thresholds}}, {}};
 }
+
+EepromWithUid::EepromWithUid(std::string componentName, std::optional<std::string> parent, const std::string& sysfsPrefix, const uint8_t bus, const uint8_t address, const uint32_t totalSize, const uint32_t offset, const uint32_t length)
+    : DataReader(std::move(componentName), std::move(parent))
+{
+    DataTree tree{
+        {"class", "iana-hardware:module"},
+    };
+
+    if (auto sn = hexEEPROM(sysfsPrefix, bus, address, totalSize, offset, length)) {
+        tree["serial-num"] = *sn;
+    }
+
+    addComponent(m_staticData,
+                 m_componentName,
+                 m_parent,
+                 tree,
+                 tree.count("serial-num") ? "enabled" : "disabled");
+}
+
+SensorPollData EepromWithUid::operator()() const { return {m_staticData, {}, {}}; }
+}
+
+std::optional<std::string> hexEEPROM(const std::string& sysfsPrefix,
+                                     const uint8_t bus,
+                                     const uint8_t address,
+                                     const uint32_t totalSize,
+                                     const uint32_t offset,
+                                     const uint32_t length)
+{
+    namespace fs = std::filesystem;
+    auto log = spdlog::get("hardware");
+
+    if (offset + length > totalSize) {
+        throw std::logic_error{"EEPROM: region out of range"};
+    }
+
+    auto dirname = fs::path{fmt::format("{}/bus/i2c/devices/{}-{:04x}", sysfsPrefix, bus, address)};
+    if (!fs::is_directory(dirname)) {
+        // this is a hard error because the device is expected to always exist, even when it fails to probe
+        throw std::runtime_error{fmt::format("EEPROM: no I2C device defined at bus {} address 0x{:02x}", bus, address)};
+    }
+    auto filename = dirname / "eeprom";
+    try {
+        // any errors are "soft errors": older clearfog boards don't have these EEPROMs populated at all
+        if (!fs::is_regular_file(filename)) {
+            throw std::runtime_error{"sysfs entry missing"};
+        }
+        std::ifstream stream;
+        stream.exceptions(std::ifstream::badbit | std::ifstream::failbit);
+        stream.open(filename, std::ios_base::in | std::ios_base::binary);
+        std::vector<uint8_t> buf(std::istreambuf_iterator<char>{stream}, {});
+        if (buf.size() != totalSize) {
+            throw std::runtime_error{fmt::format("expected {} bytes of data, got {}", totalSize, buf.size())};
+        }
+        std::string res;
+        res.reserve(length * 2 /* two hex characters per byte */);
+        boost::algorithm::hex(buf.begin() + offset, buf.end(), std::back_inserter(res));
+        log->trace("I2C EEPROM at bus {} address {:#02x}: UID/EUI {}", bus, address, res);
+        return res;
+    } catch (std::exception& e) {
+        log->error("EEPROM: cannot read from {}: {}", filename.string(), e.what());
+    }
+    return std::nullopt;
 }
 }
diff --git a/src/ietf-hardware/IETFHardware.h b/src/ietf-hardware/IETFHardware.h
index 95b514e..f097e9b 100644
--- a/src/ietf-hardware/IETFHardware.h
+++ b/src/ietf-hardware/IETFHardware.h
@@ -90,6 +90,11 @@
 };
 
 /**
+ * @brief Read a range of bytes from an EEPROM in hex
+ */
+std::optional<std::string> hexEEPROM(const std::string& sysfsPrefix, const uint8_t bus, const uint8_t address, const uint32_t totalSize, const uint32_t offset, const uint32_t length);
+
+/**
  * This namespace contains several predefined data readers for IETFHardware.
  * They are implemented as functors and fulfill the required interface -- std::function<DataTree()>
  *
@@ -169,5 +174,11 @@
     SensorPollData operator()() const;
 };
 
+/** brief Static data and a serial number read from the trailing part of the EEPROM */
+struct EepromWithUid : private DataReader {
+    EepromWithUid(std::string componentName, std::optional<std::string> parent, const std::string& sysfsPrefix, const uint8_t bus, const uint8_t address, const uint32_t totalSize, const uint32_t offset, const uint32_t length);
+    SensorPollData operator()() const;
+};
+
 }
 }
diff --git a/tests/hardware_eeprom.cpp b/tests/hardware_eeprom.cpp
new file mode 100644
index 0000000..1732f2d
--- /dev/null
+++ b/tests/hardware_eeprom.cpp
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2024 CESNET, https://photonics.cesnet.cz/
+ *
+ * Written by Jan Kundrát <jan.kundrat@cesnet.cz>
+ *
+*/
+
+#include "trompeloeil_doctest.h"
+#include <filesystem>
+#include "ietf-hardware/IETFHardware.h"
+#include "pretty_printers.h"
+#include "test_log_setup.h"
+#include "tests/configure.cmake.h"
+
+using namespace std::literals;
+
+TEST_CASE("EEPROM with UID/EID")
+{
+    using namespace velia::ietf_hardware;
+    using namespace data_reader;
+    TEST_INIT_LOGS;
+    const auto sysfs = CMAKE_CURRENT_SOURCE_DIR + "/tests/sysfs/"s;
+
+    REQUIRE(*hexEEPROM(sysfs, 1, 0x5c, 16, 0, 16) == "1E70C61C941000628C2EA000A000000C");
+
+    auto working = EepromWithUid("x:eeprom", "x", sysfs, 0, 0x52, 256, 256 - 6, 6);
+    REQUIRE(working().data == DataTree{
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/class", "iana-hardware:module"},
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/parent", "x"},
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/serial-num", "294100B13DA3"},
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/state/oper-state", "enabled"},
+            });
+
+    auto missing = EepromWithUid("x:eeprom", "x", sysfs, 0, 0x53, 256, 256 - 6, 6);
+    REQUIRE(missing().data == DataTree{
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/class", "iana-hardware:module"},
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/parent", "x"},
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/state/oper-state", "disabled"},
+            });
+
+    auto corrupted = EepromWithUid("x:eeprom", "x", sysfs, 0, 0x53, 16, 2, 6);
+    REQUIRE(corrupted().data == DataTree{
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/class", "iana-hardware:module"},
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/parent", "x"},
+                {"/ietf-hardware:hardware/component[name='x:eeprom']/state/oper-state", "disabled"},
+            });
+
+    REQUIRE_THROWS_WITH(EepromWithUid("x:eeprom", "x", sysfs, 0, 0x20, 256, 256 - 6, 6),
+                        "EEPROM: no I2C device defined at bus 0 address 0x20");
+
+    REQUIRE_THROWS_WITH(hexEEPROM(sysfs, 0, 0, 10, 5, 6),
+                        "EEPROM: region out of range");
+}
diff --git a/tests/sysfs/bus/i2c/devices/0-0052/eeprom b/tests/sysfs/bus/i2c/devices/0-0052/eeprom
new file mode 100644
index 0000000..6b2e886
--- /dev/null
+++ b/tests/sysfs/bus/i2c/devices/0-0052/eeprom
Binary files differ
diff --git a/tests/sysfs/bus/i2c/devices/0-0053/waiting_for_supplier b/tests/sysfs/bus/i2c/devices/0-0053/waiting_for_supplier
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/sysfs/bus/i2c/devices/0-0053/waiting_for_supplier
diff --git a/tests/sysfs/bus/i2c/devices/1-005c/eeprom b/tests/sysfs/bus/i2c/devices/1-005c/eeprom
new file mode 100644
index 0000000..aecf2f5
--- /dev/null
+++ b/tests/sysfs/bus/i2c/devices/1-005c/eeprom
Binary files differ