diff --git a/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTable.java b/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTable.java index f04ab7777f..0e51f0e252 100644 --- a/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTable.java +++ b/ntcore/src/main/java/edu/wpi/first/networktables/NetworkTable.java @@ -5,12 +5,14 @@ package edu.wpi.first.networktables; import java.util.ArrayList; +import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.function.Consumer; /** A network table that knows its subtable path. */ public final class NetworkTable { @@ -20,6 +22,7 @@ public final class NetworkTable { private final String m_path; private final String m_pathWithSep; private final NetworkTableInstance m_inst; + private final MultiSubscriber m_topicSub; /** * Gets the "base name" of a key. For example, "/foo/bar" becomes "bar". If the key has a trailing @@ -112,6 +115,8 @@ public final class NetworkTable { m_path = path; m_pathWithSep = path + PATH_SEPARATOR; m_inst = inst; + m_topicSub = + new MultiSubscriber(inst, new String[] {m_pathWithSep}, PubSubOption.topicsOnly(true)); } /** @@ -445,6 +450,123 @@ public final class NetworkTable { return m_path; } + /** A listener that listens to events on topics in a {@link NetworkTable}. */ + @FunctionalInterface + public interface TableEventListener { + /** + * Called when an event occurs on a topic in a {@link NetworkTable}. + * + * @param table the table the topic exists in + * @param key the key associated with the topic that changed + * @param event the event + */ + void accept(NetworkTable table, String key, NetworkTableEvent event); + } + + /** + * Listen to topics only within this table. + * + * @param eventKinds set of event kinds to listen to + * @param listener listener to add + * @return Listener handle + */ + public int addListener(EnumSet eventKinds, TableEventListener listener) { + final int prefixLen = m_path.length() + 1; + return m_inst.addListener( + new String[] {m_pathWithSep}, + eventKinds, + event -> { + String topicName = null; + if (event.topicInfo != null) { + topicName = event.topicInfo.name; + } else if (event.valueData != null) { + topicName = event.valueData.getTopic().getName(); + } + if (topicName == null) { + return; + } + String relativeKey = topicName.substring(prefixLen); + if (relativeKey.indexOf(PATH_SEPARATOR) != -1) { + // part of a sub table + return; + } + listener.accept(this, relativeKey, event); + }); + } + + /** + * Listen to a single key. + * + * @param key the key name + * @param eventKinds set of event kinds to listen to + * @param listener listener to add + * @return Listener handle + */ + public int addListener( + String key, EnumSet eventKinds, TableEventListener listener) { + NetworkTableEntry entry = getEntry(key); + return m_inst.addListener(entry, eventKinds, event -> listener.accept(this, key, event)); + } + + /** A listener that listens to new tables in a {@link NetworkTable}. */ + @FunctionalInterface + public interface SubTableListener { + /** + * Called when a new table is created within a {@link NetworkTable}. + * + * @param parent the parent of the table + * @param name the name of the new table + * @param table the new table + */ + void tableCreated(NetworkTable parent, String name, NetworkTable table); + } + + /** + * Listen for sub-table creation. This calls the listener once for each newly created sub-table. + * It immediately calls the listener for any existing sub-tables. + * + * @param listener listener to add + * @return Listener handle + */ + public int addSubTableListener(SubTableListener listener) { + final int prefixLen = m_path.length() + 1; + final NetworkTable parent = this; + + return m_inst.addListener( + m_topicSub, + EnumSet.of(NetworkTableEvent.Kind.kPublish, NetworkTableEvent.Kind.kImmediate), + new Consumer() { + final Set m_notifiedTables = new HashSet<>(); + + @Override + public void accept(NetworkTableEvent event) { + if (event.topicInfo == null) { + return; // should not happen + } + String relativeKey = event.topicInfo.name.substring(prefixLen); + int endSubTable = relativeKey.indexOf(PATH_SEPARATOR); + if (endSubTable == -1) { + return; + } + String subTableKey = relativeKey.substring(0, endSubTable); + if (m_notifiedTables.contains(subTableKey)) { + return; + } + m_notifiedTables.add(subTableKey); + listener.tableCreated(parent, subTableKey, parent.getSubTable(subTableKey)); + } + }); + } + + /** + * Remove a listener. + * + * @param listener listener handle + */ + public void removeListener(int listener) { + m_inst.removeListener(listener); + } + @Override public boolean equals(Object other) { if (other == this) { @@ -461,4 +583,8 @@ public final class NetworkTable { public int hashCode() { return Objects.hash(m_inst, m_path); } + + void close() { + m_topicSub.close(); + } } diff --git a/ntcore/src/main/native/cpp/networktables/NetworkTable.cpp b/ntcore/src/main/native/cpp/networktables/NetworkTable.cpp index 9e75c3a85c..16cd04f197 100644 --- a/ntcore/src/main/native/cpp/networktables/NetworkTable.cpp +++ b/ntcore/src/main/native/cpp/networktables/NetworkTable.cpp @@ -25,6 +25,7 @@ #include "networktables/StringArrayTopic.h" #include "networktables/StringTopic.h" #include "ntcore.h" +#include "ntcore_cpp.h" using namespace nt; @@ -87,9 +88,14 @@ std::vector NetworkTable::GetHierarchy(std::string_view key) { NetworkTable::NetworkTable(NT_Inst inst, std::string_view path, const private_init&) - : m_inst(inst), m_path(path) {} + : m_inst(inst), + m_path(path), + m_topicSub{::nt::SubscribeMultiple(inst, {{fmt::format("{}/", path)}}, + {{PubSubOption::TopicsOnly(true)}})} {} -NetworkTable::~NetworkTable() {} +NetworkTable::~NetworkTable() { + ::nt::UnsubscribeMultiple(m_topicSub); +} NetworkTableInstance NetworkTable::GetInstance() const { return NetworkTableInstance{m_inst}; @@ -362,3 +368,64 @@ Value NetworkTable::GetValue(std::string_view key) const { std::string_view NetworkTable::GetPath() const { return m_path; } + +NT_Listener NetworkTable::AddListener(int eventMask, + TableEventListener listener) { + return NetworkTableInstance{m_inst}.AddListener( + {{fmt::format("{}/", m_path)}}, eventMask, + [this, cb = std::move(listener)](const Event& event) { + std::string topicNameStr; + std::string_view topicName; + if (auto topicInfo = event.GetTopicInfo()) { + topicName = topicInfo->name; + } else if (auto valueData = event.GetValueEventData()) { + topicNameStr = Topic{valueData->topic}.GetName(); + topicName = topicNameStr; + } else { + return; + } + auto relative_key = wpi::substr(topicName, m_path.size() + 1); + if (relative_key.find(PATH_SEPARATOR_CHAR) != std::string_view::npos) { + return; + } + cb(this, relative_key, event); + }); +} + +NT_Listener NetworkTable::AddListener(std::string_view key, int eventMask, + TableEventListener listener) { + return NetworkTableInstance{m_inst}.AddListener( + GetEntry(key), eventMask, + [this, cb = std::move(listener), + key = std::string{key}](const Event& event) { cb(this, key, event); }); +} + +NT_Listener NetworkTable::AddSubTableListener(SubTableListener listener) { + // The lambda needs to be copyable, but StringMap is not, so use + // a shared_ptr to it. + auto notified_tables = std::make_shared>(); + + return ::nt::AddListener( + m_topicSub, NT_EVENT_PUBLISH | NT_EVENT_IMMEDIATE, + [this, cb = std::move(listener), notified_tables](const Event& event) { + auto topicInfo = event.GetTopicInfo(); + if (!topicInfo) { + return; + } + auto relative_key = wpi::substr(topicInfo->name, m_path.size() + 1); + auto end_sub_table = relative_key.find(PATH_SEPARATOR_CHAR); + if (end_sub_table == std::string_view::npos) { + return; + } + auto sub_table_key = relative_key.substr(0, end_sub_table); + if (notified_tables->find(sub_table_key) != notified_tables->end()) { + return; + } + notified_tables->insert(std::make_pair(sub_table_key, '\0')); + cb(this, sub_table_key, this->GetSubTable(sub_table_key)); + }); +} + +void NetworkTable::RemoveListener(NT_Listener listener) { + NetworkTableInstance{m_inst}.RemoveListener(listener); +} diff --git a/ntcore/src/main/native/cpp/networktables/NetworkTableInstance.cpp b/ntcore/src/main/native/cpp/networktables/NetworkTableInstance.cpp index 12f36d3257..251e211d58 100644 --- a/ntcore/src/main/native/cpp/networktables/NetworkTableInstance.cpp +++ b/ntcore/src/main/native/cpp/networktables/NetworkTableInstance.cpp @@ -122,7 +122,7 @@ NT_Listener NetworkTableInstance::AddListener(Subscriber& subscriber, std::move(listener)); } -NT_Listener NetworkTableInstance::AddListener(NetworkTableEntry& entry, +NT_Listener NetworkTableInstance::AddListener(const NetworkTableEntry& entry, int eventMask, ListenerCallback listener) { if (::nt::GetInstanceFromHandle(entry.GetHandle()) != m_handle) { diff --git a/ntcore/src/main/native/include/networktables/NetworkTable.h b/ntcore/src/main/native/include/networktables/NetworkTable.h index e1eec5a14a..72ce20a4c6 100644 --- a/ntcore/src/main/native/include/networktables/NetworkTable.h +++ b/ntcore/src/main/native/include/networktables/NetworkTable.h @@ -48,6 +48,7 @@ class NetworkTable final { private: NT_Inst m_inst; std::string m_path; + NT_MultiSubscriber m_topicSub; mutable wpi::mutex m_mutex; mutable wpi::StringMap m_entries; @@ -566,6 +567,64 @@ class NetworkTable final { * @return The path (e.g "", "/foo"). */ std::string_view GetPath() const; + + /** + * Called when an event occurs on a topic in a {@link NetworkTable}. + * + * @param table the table the topic exists in + * @param key the key associated with the topic that changed + * @param event the event + */ + using TableEventListener = std::function; + + /** + * Listen to topics only within this table. + * + * @param eventMask Bitmask of EventFlags values + * @param listener listener to add + * @return Listener handle + */ + NT_Listener AddListener(int eventMask, TableEventListener listener); + + /** + * Listen to a single key. + * + * @param key the key name + * @param eventMask Bitmask of EventFlags values + * @param listener listener to add + * @return Listener handle + */ + NT_Listener AddListener(std::string_view key, int eventMask, + TableEventListener listener); + + /** + * Called when a new table is created within a NetworkTable. + * + * @param parent the parent of the table + * @param name the name of the new table + * @param table the new table + */ + using SubTableListener = + std::function table)>; + + /** + * Listen for sub-table creation. This calls the listener once for each newly + * created sub-table. It immediately calls the listener for any existing + * sub-tables. + * + * @param listener listener to add + * @return Listener handle + */ + NT_Listener AddSubTableListener(SubTableListener listener); + + /** + * Remove a listener. + * + * @param listener listener handle + */ + void RemoveListener(NT_Listener listener); }; } // namespace nt diff --git a/ntcore/src/main/native/include/networktables/NetworkTableInstance.h b/ntcore/src/main/native/include/networktables/NetworkTableInstance.h index 02778b1ee4..a161c2fe64 100644 --- a/ntcore/src/main/native/include/networktables/NetworkTableInstance.h +++ b/ntcore/src/main/native/include/networktables/NetworkTableInstance.h @@ -447,7 +447,7 @@ class NetworkTableInstance final { * @param listener Listener function * @return Listener handle */ - NT_Listener AddListener(NetworkTableEntry& entry, int eventMask, + NT_Listener AddListener(const NetworkTableEntry& entry, int eventMask, ListenerCallback listener); /** diff --git a/ntcore/src/test/java/edu/wpi/first/networktables/TableListenerTest.java b/ntcore/src/test/java/edu/wpi/first/networktables/TableListenerTest.java new file mode 100644 index 0000000000..d59e174f2e --- /dev/null +++ b/ntcore/src/test/java/edu/wpi/first/networktables/TableListenerTest.java @@ -0,0 +1,65 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +package edu.wpi.first.networktables; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumSet; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TableListenerTest { + private NetworkTableInstance m_inst; + + @BeforeEach + void setUp() { + m_inst = NetworkTableInstance.create(); + } + + @AfterEach + void tearDown() { + m_inst.close(); + } + + private void publishTopics() { + m_inst.getDoubleTopic("/foo/foovalue").publish(); + m_inst.getDoubleTopic("/foo/bar/barvalue").publish(); + m_inst.getDoubleTopic("/baz/bazvalue").publish(); + } + + @Test + void testAddListener() { + NetworkTable table = m_inst.getTable("/foo"); + AtomicInteger count = new AtomicInteger(); + table.addListener( + EnumSet.of(NetworkTableEvent.Kind.kTopic), + (atable, key, event) -> { + count.incrementAndGet(); + assertEquals(atable, table); + assertEquals(key, "foovalue"); + }); + publishTopics(); + assertTrue(m_inst.waitForListenerQueue(1.0)); + assertEquals(count.get(), 1); + } + + @Test + void testAddSubTableListener() { + NetworkTable table = m_inst.getTable("/foo"); + AtomicInteger count = new AtomicInteger(); + table.addSubTableListener( + (atable, key, event) -> { + count.incrementAndGet(); + assertEquals(atable, table); + assertEquals(key, "bar"); + }); + publishTopics(); + assertTrue(m_inst.waitForListenerQueue(1.0)); + assertEquals(count.get(), 1); + } +} diff --git a/ntcore/src/test/native/cpp/TableListenerTest.cpp b/ntcore/src/test/native/cpp/TableListenerTest.cpp new file mode 100644 index 0000000000..7fb19476d2 --- /dev/null +++ b/ntcore/src/test/native/cpp/TableListenerTest.cpp @@ -0,0 +1,60 @@ +// Copyright (c) FIRST and other WPILib contributors. +// Open Source Software; you can modify and/or share it under the terms of +// the WPILib BSD license file in the root directory of this project. + +#include + +#include "TestPrinters.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "networktables/DoubleTopic.h" +#include "networktables/NetworkTableInstance.h" +#include "ntcore_cpp.h" + +using ::testing::_; + +using MockTableEventListener = testing::MockFunction; +using MockSubTableListener = + testing::MockFunction table)>; + +class TableListenerTest : public ::testing::Test { + public: + TableListenerTest() : m_inst(nt::NetworkTableInstance::Create()) {} + + ~TableListenerTest() override { nt::NetworkTableInstance::Destroy(m_inst); } + + void PublishTopics(); + + protected: + nt::NetworkTableInstance m_inst; + nt::DoublePublisher m_foovalue; + nt::DoublePublisher m_barvalue; + nt::DoublePublisher m_bazvalue; +}; + +void TableListenerTest::PublishTopics() { + m_foovalue = m_inst.GetDoubleTopic("/foo/foovalue").Publish(); + m_barvalue = m_inst.GetDoubleTopic("/foo/bar/barvalue").Publish(); + m_bazvalue = m_inst.GetDoubleTopic("/baz/bazvalue").Publish(); +} + +TEST_F(TableListenerTest, AddListener) { + auto table = m_inst.GetTable("/foo"); + MockTableEventListener listener; + table->AddListener(NT_EVENT_TOPIC | NT_EVENT_IMMEDIATE, + listener.AsStdFunction()); + EXPECT_CALL(listener, Call(table.get(), "foovalue", _)); + PublishTopics(); + EXPECT_TRUE(m_inst.WaitForListenerQueue(1.0)); +} + +TEST_F(TableListenerTest, AddSubTableListener) { + auto table = m_inst.GetTable("/foo"); + MockSubTableListener listener; + table->AddSubTableListener(listener.AsStdFunction()); + EXPECT_CALL(listener, Call(table.get(), "bar", _)); + PublishTopics(); + EXPECT_TRUE(m_inst.WaitForListenerQueue(1.0)); +}