|
Objective: Learn how to integrate a C++ data type into the memory system, create a server with a segment for that data type, and create a client reading from and writing to the server.
Previous Tutorials: Write a Server and Client Communicating via RPC (C++), Write a Publisher and Subscriber Communicating via Topics (C++)
Next Tutorials: none
Reference Code: memory_tutorial
More information in the RobotAPI Wiki.
Create a new package:
Add a library for the new modality:
You should now have a library generated in source/memory_tutorial/object_instance
:
Test whether your package builds:
Use QtCreator or your file browser to create a new .cpp
/.h
pair for the class ObjectInstance
.
The CMakeLists.txt
should look like this:
The ObjectInstance
class could look like this (ObjectInstance.h
):
The file ObjectInstance.cpp
can stay relatively empty.
Add RobotAPI
to your package's dependencies in the top-level CMakeLists.txt
:
Add this at the top of the CMakeLists.txt
of the object_instance
library:
The file content could like this:
FramedPose.xml
:
ObjectInstance.xml
:
A forward declaration is a declaration of a class without definition of its implementation:
This tells the compiler that the symbol ObjectInstance
is a class, but not how it is comprised (i.e. which member variables and methods it has). Even without the definition, the class name can already be used as reference, pointer, function argument and function return type. Using forward declarations can heavily reduce the amount of includes the users of a class or header file pull into their code, and thus can heavily speed up compilation.
To relieve library users from writing their own forward declarations when they would like to use them for your types, it is customary to provide a forward_declarations.h
header file maintained by the library maintainer. Let's add it:
The forward_declarations.h
file can look like this:
As you can see, this file does not pull any other includes, but allows users to use your types where they do not require your classes to be fully defined (e.g. in their own headers).
The aron_conversions.h
can look like this:
As you can see, here we don't require the class definitions as they are only used as function arguments. The definitions are only included in the source file.
The functions are implemented in aron_conversions.cpp
(note the explicit namespace specifications of the free functions to detect signature mismatch):
Your application will usually involve
For the sake of this tutorial, we will stub the data source and sink with a ProducerConsumer
class. The ProducerConsumer
has
produce()
, which generates a an ObjectInstance
(the data source)consume()
, which takes an ObjectInstance
and prints and visualizes it.Add the files ProducerConsumer.{h, cpp}
and change the CMakeLists.txt
to:
The file contents could look like this:
ProducerConsumer.h
:
ProducerConsumer.cpp
:
Add a component that will serve the new memory:
Add the following entries to the CMakeLists.txt
of the new component (memory_tutorial/source/memory_tutorial/components/object_memory/CMakeLists.txt
):
Extend the generated ComponentInterface.ice
by the following lines (you can skip this if your component does not yet implement an ice interface):
Extend the generated Component.h
by the following lines:
Extend the generated Component.cpp
by the following lines:
Extend the generated Component.cpp
by the following lines:
To access the object_instance
library, add it to the CMakeLists.txt
:
That's it! The memory server is ready.
Open the ArmarX Gui.
Open the Scenario Manager.
Open empty GUI
Meta.ScenarioManager
memory_tutorial
).Application Database
.In the MemoryViewer, you should see a two-column layout. The left side shows the different memories and their contents. There should be a single top-level entry named Object
, which is the name you gave your memory server. It should have a child entry Instance
with the type ObjectInstance
and level CoreSegment
. Below it, there should not be other entries yet.
When you see this, your memory server should be up and running and ready to receive data and handle queries by memory clients.
Add a component that will write to and read from the memory server.
Add the following entries to the CMakeLists.txt
of the new component (memory_tutorial/source/memory_tutorial/components/object_memory_client/CMakeLists.txt
):
Extend the generated ComponentInterface.ice
by the following lines (you can skip this if your component does not yet implement an ice interface):
Extend the generated Component.h
by the following lines:
#include <RobotAPI/libraries/armem/client/plugins/PluginUser.h>
and virtual public armarx::armem::ClientPluginUser
instead. In this case, you also don't have to change your component's ice interface.By using the client plugin, your component inherits a [memoryNameSystem()](\ref armarx::armem::client::plugins::PluginUser#memoryNameSystem)
method that you can use to access the Memory Name System (MNS). The MNS is your main entry point to the memory system.
When your component should do something after starting up, this should be done in a separate thread that is started at the end of your onConnectComponent()
. In ArmarX, you can use a SimpleRunningTask
for that. (This is a feature of ArmarX(Core), not of the memory system.)
Extend your component like this:
Component.h
:
Component.cpp
:
Now we can add code to the run()
method that is executed after the component is fully connected.
Add a ProducerConsumer
to your component and enable the ArVizComponentPlugin
to allow 3D visualization:
Component.h
:
This requires us to add the libraries object_instance
(for the PrducerConsumer
class) and RobotAPIComponentPlugins
(for the ArViz component plugin) to our client's dependencies:
Pass the ArViz client to the producerConsumer
in your onConnectComponent()
(not in the run()
method, as this is still part of the "startup process"):
Component.cpp
:
Now we can use producerConsumer.produce()
to get a new object instance (and later producerConsumer.consume()
to log and visualize it):
Component.cpp
:
To write data to a memory, you need an armarx::armem::client::Writer
. To create a Writer
, you need:
You can get the MNS client via the memoryNameSystem()
method in your component. As always in ArmarX, remote operations are ready when onConnectComponent()
is called:
Component.cpp
:
armarx::armem::client::MemoryNameSystem
and armarx::armem::client::Writer
are just forward declared. To use them, add the includes shown above.The Writer
provides a method called [commit()](\ref armarx::armem::client::Writer#commit)
that allows you to send updates to a memory server. Each armarx::armem::EntityUpdate
creates a single entity snapshot (i.e. the state of something at a speciifc point in time). Multiple updates can be bundled in a armarx::armem::Commit
and send together in a single network call.
Committing data to a memory often involves the follow steps:
EntityUpdate
with this data.EntityUpdate
in a commit to the memory, using the Writer
.In full, this can be done e.g. like this:
Component.cpp
:
Add the object_memory_client to your scenario and start it (with ArMemCore and the object_memory still running). After the object_memory_client started (and executed its onConnectComponent()
), the structure in the MemoryViewer should look like this:
When you click on the entity (an amicelli object instance
), the entity snapshot (<timestamp>
) or the entity instance (0
), the contained data should be shown on the right hand side of the MemoryViewer.
To get data from a memory, you need an armarx::armem::client::Reader
. Just as Writer
s allow you to commit data to any memory server, Reader
s allow you to query (i.e. request) data from any memory server. And again, you get a Reader
from the MNS client by specifing the corresponding memory ID. You can get a Reader
as early as your onConnectComponent()
is called:
While a Writer
allows you to write data via a commit, a Reader
allows you to read data via a query. A query is a data request that defines which part of the memory you are interested in. For example, you could say something like:
As you can see, on each level of the hierarchical memory structure (i.e. memory, core segment, provider segment, entity), you can specify what slice of the data at this level you want to be part of the response.
You can build queries via the armarx::armem::client::QueryBuilder
. For example, to build the query above, you could write:
Once told the query builder the object of your desire, you can have it build the query input (i.e. the request to the memory server) and pass it to the Reader
's query()
method. This provides you with a query result (i.e. the response by the memory server).
The armarx::armem::client::QueryResult
contains mainly these things:
success
flag which tells you whether an error occurred during the query (e.g. the memory server was unavailable)errorMessage
which is set when success
is false
memory
of type armarx::armem::wm::Memory
containing the queried data.Always check the success
flag and handle the error appropriately. (Note that if your component/library gracefully handles an error, i.e. an event does not affect its well-defined execution, the event is not worth an ARMARX_ERROR
, and maybe not even an ARMARX_WARNING
).
In case of success
, you can consult memory
to obtain your data. armem::wm::Memory
is basically a multi-level slice through the memory server's hierarchical data structure, i.e. it has the same levels (core segment > provider segment > entity > snapshot > instance > data), but contains only the elements that matched your query.
You can process the query result by iterating through the memory
by
memory
levels using its forEach...()
functions.(1) is often useful when you want to obtain specific entity snapshots or instances, e.g. when another component referred you to a memory element via its memory ID or when you got notified that a new snapshot arrived (see Subscribing Memory Updates below).
(2) is useful when you want to process all elements that match a specific criterion or are part of a specific segment.
For the moment, we will opt for (2) and process the data of all instances in the query result. We will see an example for (1) later.
The data itself is held by entity instances (multiple of which can build an entity snapshot). Thus, we will:
We can do it like this:
Note that the conversion back to the BO class involves these steps:
instance.data()
) to the ARON DTO class generated from the ARON XML (object_instance::arondto::ObjectInstance::FromAron()
)object_instance::ObjectInstance
) via armarx::aron::fromAron<object_instance::ObjectInstance>()
.The function template armarx::aron::fromAron<BO>(const DTO&)
relies on the existence of a free function void fromAron(const DTO& dto, BO& bo)
, which we defined earlier (see Add converter functions above).
Reader
also has shorthand methods for some commonly used queries (such as the latest snapshots of a whole segment). When you look inside these methods, you see that they are just boiling with water, as they say, i.e. they are using a QueryBuilder
to define the queries and use the query()
function to perform the query.When you run the memory client now, it should write a new snapshot first, then read from the memory. You should be able to observe the following things:
Because we are requesting the latest snapshot only, and there is only one entity containing "amicelli"
yet, the consume function should print only one entry in the log. This entry should match the latest snapshot in the memory viewer, which is the snapshot the client committed just before sending the query.
Keep restarting the client component and observe the results. You can see the entity's whole timeline by checking (from) "Begin" and (to) "End" in the query control panel below the memory view (it may be collapsed) when "Index Range" is selected or by switching to "All" in the drop-down menu.
You might agree that the Reader
is great for getting data on-demand, but what if you want to process new data that arrives in the memory server? Do you have to constantly "poll" the memory server for new data? Luckily, no, you do not have to poll. Instead, you can subcribe memory updates.
Each time a memory server receives data in a commit, it stores the data in its working memory and broadcasts a notification in an update topic. The notification contains a list of the memory IDs of all entity snapshots that have been added or updated in this commit.
A (listening) memory client can subscribe to these notifications. When subscribing, the client specifies a memory ID it wants to be notified about as well as a callback function. The callback function is called when a notification contains memory IDs that are "contained by" the subscribed ID (for example, Object
contains Object/Instance
and Object/Instance
contains Object/Instance/client/my_object
).
Similar to creating a writer, you can subscribe to updates of a memory ID using the Memory Name System (MNS) client. For example:
This will log any snapshot IDs committed to the Object/Instance
core segment. You can also just subscribe a memory (e.g. Object
) or a specific entity (e.g. Object/Instance/client/my_object
).
onConnectComponent()
(and, in fact, in the onInitComponent()
), but updates will only be received after the onConnectComponent()
finished.To try this, move the subscribing code above in front of the writing code. Then restart the memory client. The commit should trigger a notification that you receive via the subscription.
The update notification does not provide you the data yet. The idea is that you are notified about the update happening, but the data is not pro-actively (and thus potentially unnecessarily) to you along the update. This allows you to do any custom decision making you need in your code, such as only reading the most recent update or ignoring the update altogether.
Luckily, you already know how to get the data: You use your good old friend, the Reader
. (I got told it already missed you.) And look at that, it even has a "Welcome back old friend" present for you: the method queryMemoryIDs()
.
... yay?
Isn't it great? It's just what you need! It takes a list of memory IDs (especially snapshot IDs) and queries each and every single one of them!
Yay!
Yeah, right? So let's put it into action. Surely, you know can guess how this goes now.
You can just use the same exact code to process the query result.
When you run the example client again, you should now the see log printed by the consume()
function twice (once by the regular query and one by the query triggered by the update).
If you only see one output entry in the LogViewer, this can be due to the built-in spam reduction (which suppresses repeated exact same log entries). In this case, you can also look in the console / terminal window you used to run armarx gui
. There, you should see the two log entries.