QtQuick Map Application (Part 3) : Creating communication between several instances

In this article, the final part in a series of three, our basic QtQuick application will be enhanced with a networking panel allowing it to connect to a remote server by entering an IP and port, and then allowing it to create and edit features, while broadcasting the changes to potential other connected clients.

As the application has grown larger, this article will be slightly different from the others in the series and will focus more on explaining important parts of the application rather than doing a step-by-step edit of the base project.

The application is structured into two different projects:

  • QtQuickClient: our base application, restructured to make it a bit cleaner.
  • QtQuickServer: a simplistic server to store and broadcast drawing data updates, designed as a console application.

At the end of this article, this is what you will be able to do:

ChangeLog

The changes from the previous articles are the following:

  • The existing application is now part of a subfolder called QtQuickClient.
  • The QML has been split into QML components corresponding to each panel:
  • Icons have been moved to a resources folder.

Project Structure

Communications

In this section, we will detail how the server and client components interact with one another.
In essence, our communications can be summarized as maintaining a shared MemoryDataSet across several applications, using GeoJSON as exchange format.

Upon pressing the connect button in the GUI, after setting an IP and port, the socket will connect to the listening server. In return, the server will return all features it currently holds to the connecting client.

When a user creates a Feature using the different tools available through the GUI, the FeatureCreated event is fired. In the event handler, the client serializes the created Feature and sends it to the server.

When a user edits a Feature using the standard tools, the FeatureInteractedHandler is triggered. In this handler, the client serializes the edited Feature to GeoJSON. The server updates its stored copy of the Feature based on its ID and then forwards the changes to other connected clients which are then able to update their local features.

Here, we choose to connect to tools’ events. It is also possible to connect directly to a MemoryDataSet‘s events, after setting its enableEvents property to True. It is a bit more conventional, especially in applications where features can be modified in other ways than using tools. However, this design also needs to disable the events whenever the updates come from the server, in order to avoid infinite loops.

Note that at least two more use-cases could be necessary here but have not been implemented: handling server disconnection & handling feature removal.
Additionally, client disconnection is supported only in a basic way.

Networking

Our components communicate over TCP, using QTcpSocket & QTcpServer. The communication design takes some inspiration from here.

Features are sent between server and clients as GeoJSON strings.

Server

The QtQuickServer console application is made of two classes. The server class is a wrapper around a QTcpServer and handles listening on a port, receiving and sending messages, as well as keeping a list of connected clients as a list of socket addresses. The backend class is connected to server signals and adds handling to the different messages as well as broadcasting any changes.

Client

The QtQuickClient has been extended with a client class which is a wrapper around a QTcpSocket. It connects to an IP and port & can send and receive messages. It is then connected to the controller class through slots and signals to synchronize the shared MemoryDataSet state with the server and across other connected clients.

JSON Serialization

With Carmenta Engine 5.16, it is possible to natively read GeoJSON, making it easier to exchange features on this format. The main serialization and de-serialization work is done in the GeoJSON class directly, namely the GeoJSON::toString & GeoJSON::fromString methods.

More information on the GeoJSON specification.

Networking Implementation Details

Networking Panel in QML

We can see in the QML for the networking panel that the client is connected to the panel, directly setting the IP (client.host) and port (client.port). We also ensure that the IP is properly formatted using a regular expression.

Item {
    Column
    {
        Row
        {
            spacing: 2
            leftPadding: 4
            rightPadding: 4
            bottomPadding: 4
            topPadding: 4
            Text {
                text: "IP: "
                verticalAlignment: Text.AlignVCenter
                anchors.verticalCenter: ipTextField.verticalCenter
                width: 40
            }
            TextField {
                id: ipTextField 
                width: 150
                validator: RegularExpressionValidator {
                    regularExpression: /^((?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5]).){0,3}(?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$/
                }
                text: client.host

                onEditingFinished : {
                    client.host = text
                }
            }
        }

        Row
        {
            id: portRow
            spacing: 2
            leftPadding: 4
            rightPadding: 4
            bottomPadding: 4
            Text {
                text: "Port: "
                verticalAlignment: Text.AlignVCenter
                anchors.verticalCenter: portTextField.verticalCenter
                width: 40
            }
            TextField {
                id: portTextField
                text: client.port
                width: 150
                inputMethodHints: Qt.ImhDigitsOnly

                onEditingFinished : {
                    client.port = text
                }
            }
        }

        RowLayout
        {
            width: parent.width
            Button {
               id: btn_connect
               Layout.preferredWidth: 80
               Layout.leftMargin: 4
               Layout.topMargin: 6
                text: "Connect"
               background: Rectangle {
                   color: parent.enabled ? parent.down ? "#78C37F" : "#87DB8D" : "gray"
                   border.color: "#78C37F"
               }

               onClicked: {
                   client.onConnectClicked();
                   this.enabled = false;
               }
           }

           Button {
               id: btn_disconnect
               enabled: !btn_connect.enabled
               Layout.alignment: Qt.AlignRight
               Layout.rightMargin: 4
               Layout.topMargin: 6
               text: "Disconnect"
               Layout.preferredWidth: 80
               background: Rectangle {
                   color: parent.enabled ? parent.down ? "#DB7A74" : "#FF7E79" : "gray"
                   border.color: "#DB7A74"
               }
               onClicked: {
                   client.closeConnection();
                   btn_connect.enabled = true;
               }
           }
       }
   }
}

In order to be available from the QML, the client class is registered as a context property in the main.cpp file. We also take this opportunity to connect signals and slots between the controller and the client. The IP and port are exposed as Q_PROPERTY, and we will have a closer look at the implementation for the connection and disconnection in the following sections. In main.cpp:

Client client{};
ctxt->setContextProperty("client", &client);
QObject::connect(&Controller::instance(), &Controller::featureInserted, &client, &Client::pushMessageToServer);
QObject::connect(&Controller::instance(), &Controller::featureChanged, &client, &Client::pushMessageToServer);
QObject::connect(&client, &Client::messageReceived, &Controller::instance(), &Controller::onFeaturesReceived);

Finally, before moving on, let’s have a look at the resulting panel:

Networking with Qt

A client connects to a server by setting an IP and port and pressing the Connect button. The client application then sends a connection request through its socket and if the server is listening on the designated IP and port, it will reply with a list of existing features. If the server is not available, the connection will fail. In client.cpp:

void Client::onConnectClicked()
{
    _socket.connectToHost(host, port);
    connect(&_socket, &QTcpSocket::readyRead, this, &Client::onReadyRead);
}

Upon pressing the button, the code above is executed. The socket connects to the host and we bind a slot to the socket’s readyRead() signal. This allows us to read incoming messages. The readyRead() signal is emitted every time a new chunk of data has arrived. bytesAvailable() then returns the number of bytes that are available for reading.

A message can thus be read in the following way, where onReadyRead() is a slot connected to readyRead() in client.cpp:

void Client::onReadyRead()
{
    QDataStream in(&_socket);
    for (;;)
    {
        if (!m_nNextBlockSize)
        {
            if (_socket.bytesAvailable() < sizeof(quint16)) { break; }
            in >> m_nNextBlockSize;
        }

        if (_socket.bytesAvailable() < m_nNextBlockSize) { break; }

        QString str; in >> str;

        if (str == "0")
        {
            str = "Connection closed";
            closeConnection();
        }
        emit messageReceived(str); // for handling by the application
        m_nNextBlockSize = 0;
    }
}

Conversely, it is possible to send message through the socket in the following way:

void Client::pushMessageToServer(QString msg)
{
    if (_socket.state() != QAbstractSocket::ConnectedState) return;
    QByteArray arrBlock;
    QDataStream out(&arrBlock, QIODevice::WriteOnly);
    out << quint16(0) << msg;

    out.device()->seek(0);
    out << quint16(arrBlock.size() - sizeof(quint16));

    _socket.write(arrBlock);
}

Finally, in order for the connection to be established, the server needs to be listening on a given IP address and port and it also needs to accept the incoming connection request and add the socket to a list of clients. This is done in the following way in server.cpp:

void Server::startListening()
{
    if (!_tcpServer.listen(QHostAddress::Any, 6547))
    {
       qDebug() << "Error! The port is taken by some other service";
    }
    else
    {
        connect(&_tcpServer, &QTcpServer::newConnection, this, &Server::onNewConnection);
    }
}

void Server::onNewConnection()
{
    QTcpSocket* clientSocket = _tcpServer.nextPendingConnection();

    connect(clientSocket, &QTcpSocket::disconnected, clientSocket, &QTcpSocket::deleteLater);
    connect(clientSocket, &QTcpSocket::readyRead, this, &Server::readClient);
    connect(clientSocket, &QTcpSocket::disconnected, this, &Server::onDisconnected);

    _connectedClients << clientSocket;

    emit clientConnected(clientSocket);
}

Geospatial Features Handling

On the Server Side

Our server handles two different types of interaction:

  • It accepts client connections and sends back a list of existing features as a GeoJSON FeatureCollection.
  • Upon receiving a GeoJSON Feature, the server updates its own cache and broadcasts the information to other connected clients.

Below is how our server notifies connecting clients of existing features in backend.cpp:

void Backend::onClientConnected(QTcpSocket* clientSocket)
{
    // Send all feature to connecting client
    qDebug() << "Client connected";

    // Collect all cached Features
    auto features = _dataSet->getFeatures();

    FeatureCollectionPtr feats = new FeatureCollection();
    while(features->moveNext())
    {
        auto feature = features->current();
        feats->add(features->current());
    }

    // send them to client
    if (feats->size() > 0)
    {
        // serialize Feautres as GeoJSON
        QString json = QString(Carmenta::Engine::GeoJSON::toString(feats, false).c_str());
        _server.pushToClient(clientSocket, json);
    }
}

And upon receiving a message, the server tries to parse it as a GeoJSON Feature and handles it appropriately.

void Backend::onNewMessage(QTcpSocket* client, QString msg)
{
    //handle message here
    try
    {
        auto features = Carmenta::Engine::GeoJSON::fromString(msg.toStdString().c_str());
        for(auto feature = features->begin(); feature != features->end(); ++feature)
        {
            FeaturePtr f = *feature;

            // get feature quuid
            std::string featId = getAttributeValueString(f->attributes(), "geojson_id", "");
            QUuid uuid = QUuid::fromString(QString(featId.c_str()));

            // Existing feature received, update the cache first
            if (_cache.contains(uuid))
            {
                auto localFeature = _cache[uuid];

                _dataSet->remove(localFeature->id());
            }

            _dataSet->insert(f);
            _cache[uuid] = f;
        }

        // Broadcast the feature update/insertion to other clients
        _server.broadcastToClients(msg, client);
    }
    catch (...)
    {
        qDebug() << "an error occured processing : " << msg;
    }
}

On the Client Side

Our client can both send messages after triggering tools’ events as well as receive messages from our server.

The controller contains the implementations of tool event handlers. The typical flow is to receive the event, serialize the feature contained in the event argument and push the message to server. In controller.cpp:

void Controller::featureCreatedHandler(
    const Carmenta::Engine::EngineObjectPtr&,
    const Carmenta::Engine::FeatureCreatedEventArgs& e,
    void* data)
{
    auto controller = static_cast<Controller*>(data);
    controller->swapToStandardTool();

    // Add feature to our local cache
    auto localFeature = e.feature();

    std::string featId = getAttributeValueString(e.feature()->attributes(), "geojson_id", "");
    QUuid uuid = QUuid::fromString(QString(featId.c_str()));
    controller->_cache.insert(uuid, localFeature);

    // Serialize created feature
    auto featureCollection = new Carmenta::Engine::FeatureCollection();
    featureCollection->add(localFeature);
    auto geojsonF = Carmenta::Engine::GeoJSON::toString(featureCollection, false);

    // Emit signal that will push serialized feature to server
    emit controller->featureInserted(QString(geojsonF.c_str()));
}

void Controller::featureInteractedHandler(
    const EngineObjectPtr&,
    const InteractedEventArgs& e,
    void* data
)
{
    auto controller = static_cast<Controller*>(data);

    // Convert features to geoJSON
    auto geojsonFeature = GeoJSON::toString(e.features(), false);

    // Emit signal that will push serialized feature to server
    emit controller->featureChanged(QString(geojsonFeature.c_str()));
}

In order to avoid duplicate features between clients we tag each feature created with a unique id. For our sample, we use QUuid::createUuid() from the Qt API to create a unique id for us. Our createTools are updated so each time we call them a new QUuid is created:

void Controller::swapToCreateTool(Carmenta::Engine::ToolCreateMode& mode, Carmenta::Engine::CreateToolParametersPtr toolParameters)
{
    // Setting the correct mode on our create tool
    static_cast<CreateTool*>(_createTool.get())->createMode(mode);
    static_cast<CreateTool*>(_createTool.get())->parameters(toolParameters);
    static_cast<CreateTool*>(_createTool.get())->attributes()->set("geojson_id", QUuid::createUuid().toString().toStdString().c_str());
    static_cast<CreateTouchTool*>(_createTouchTool.get())->createMode(mode);
    static_cast<CreateTouchTool*>(_createTouchTool.get())->parameters(toolParameters);
    static_cast<CreateTouchTool*>(_createTouchTool.get())->attributes()->set("geojson_id", QUuid::createUuid().toString().toStdString().c_str());

    // Setting the create tool on the map control
    mapControl->tool(_createTool);
    mapControl->touchTool(_createTouchTool);
}

void Controller::tacticalDrawOnMap(QString sidc)
{
    String sidcString = sidc.toStdString().c_str();
    _createTacticalTool->parameters(new MilStd2525CCreateToolParameters(sidcString));
    _createTacticalTool->attributes()->set("geojson_id", QUuid::createUuid().toString().toStdString().c_str());
    _createTacticalTouchTool->parameters(new MilStd2525CCreateToolParameters(sidcString));
    _createTacticalTouchTool->attributes()->set("geojson_id", QUuid::createUuid().toString().toStdString().c_str());

    mapControl->tool(_createTacticalTool);
    mapControl->touchTool(_createTacticalTouchTool);
}

The other client-server interaction is the ability to receive messages. The client can parse the message argument and convert it to features. Each feature’s geojson_id is tested so we avoid generating duplicate features.

void Controller::onFeaturesReceived(QString message)
{
    auto featureCollection = Carmenta::Engine::GeoJSON::fromString(message.toStdString().c_str());

    for(auto feature = featureCollection->begin(); feature != featureCollection->end(); ++feature)
    {
        FeaturePtr f = *feature;

        // get feature quuid
        std::string featId = getAttributeValueString(f->attributes(), "geojson_id", "");
        QUuid uuid = QUuid::fromString(QString(featId.c_str()));

        // check if we have a sidc (a tactical symbol)
        std::string sidc = getAttributeValueString(f->attributes(), "sidc", "");
        if (sidc != "")
        {
            UpdateCache(uuid, f, _tacticalDrawingsMemDs);
        }
        else
        {
            UpdateCache(uuid, f, _drawnObjectsMemDs);
        }
    }

    mapControl->updateView();
}

void Controller::UpdateCache(QUuid uuid, Carmenta::Engine::FeaturePtr feature, Carmenta::Engine::MemoryDataSetPtr dataset)
{
    if (_cache.contains(uuid))
    {
        Guard g(dataset);
        auto localFeature = _cache[uuid];
        dataset->remove(localFeature->id());
    }

    Guard g(dataset);
    dataset->insert(feature);
    _cache[uuid] = feature;

    dataset->refreshFeaturePresentation(feature, false);
}

And that’s it! We can now connect several clients to a server and have them communicate and synchronize.

Conclusion & Going forward

As you may have noticed by now, we have extended our QtQuickSample to handle communication via TCP sockets between several instances.
It is of course not a fully functional server/client implementation as some use-cases are missing, which we detail right below. The actual information exchange is also quite simplified.

  • There is no identification for clients nor any authorization.
  • There is no way to delete features.
  • Synchronous editing of features is not handled; the last to edit a feature wins!
  • Our server stores everything in memory and its cache isn’t fully functional either. Using MemoryDataSet::save with SaveMode::SaveModeIncludeFeatures we can make some very simple backups to disk.
  • It can also be interesting to look into the MapPackage API to write to OGC GeoPackages, which is in fact a set of standards to interact with an SQLite container.

Although simple, this extension of the sample gives some insight about the way Carmenta Engine-based applications can communicate geospatial information in a networked environment.
In case you are interested in geospatial servers, it would make a lot of sense to check out the following:

  • Our Simple tile server sample, a very basic implementation of a tile server.
  • If you are in need of a reliable and scalable way to publish map services, do check out Carmenta Server, a Carmenta Engine-based map server implementing OGC Web interface standards for maps and analysis results distribution.
  • If you are looking for a lightweight solution for supplying the newer OGC API Tiles, please have a look at our Carmenta Tile Engine technical preview. This is a containerized and trimmed-down platform for network/internet delivery of Carmenta Engine-based map image tiles in a resource-efficient, no-nonsense type of way.

A few other interesting things about this example:

  • It shows a way to deal with dynamic data in a simple client/server environment.
  • It is a good example of how to use Carmenta Engine without any visualization.
  • It is also an example of how to export features from Engine’s data flow to other applications, which in this instance are using Carmenta Engine as well, but don’t necessarily need to.

All that remains now is to wish you good luck in developing applications using Carmenta Engine and QtQuick!