diff options
-rw-r--r-- | CMakeLists.txt | 3 | ||||
-rw-r--r-- | vehicle-signals/CMakeLists.txt | 19 | ||||
-rw-r--r-- | vehicle-signals/qtappfw-vehicle-signals.pc.in | 12 | ||||
-rw-r--r-- | vehicle-signals/vehiclesignals.cpp | 427 | ||||
-rw-r--r-- | vehicle-signals/vehiclesignals.h | 99 |
5 files changed, 560 insertions, 0 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index a766d3f..a4def83 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,7 @@ set(CMAKE_AUTOMOC ON) set(OE_QMAKE_PATH_EXTERNAL_HOST_BINS $ENV{OE_QMAKE_PATH_HOST_BINS}) find_package(Qt5Qml REQUIRED) +find_package(Qt5WebSockets REQUIRED) find_package(PkgConfig REQUIRED) pkg_check_modules(glib REQUIRED IMPORTED_TARGET glib-2.0) @@ -26,6 +27,7 @@ set(PRIVATE_LIBS "${PRIVATE_LIBS} -lqtappfw-bt -lqtappfw-pbap -lqtappfw-radio -lqtappfw-telephony + -lqtappfw-vehicle-signals -lqtappfw-weather") set (SUBDIRS @@ -39,6 +41,7 @@ set (SUBDIRS pbap radio telephony + vehicle-signals weather) foreach(subdir ${SUBDIRS}) diff --git a/vehicle-signals/CMakeLists.txt b/vehicle-signals/CMakeLists.txt new file mode 100644 index 0000000..f7cbf71 --- /dev/null +++ b/vehicle-signals/CMakeLists.txt @@ -0,0 +1,19 @@ + +CONFIGURE_FILE("qtappfw-vehicle-signals.pc.in" "qtappfw-vehicle-signals.pc" @ONLY) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/qtappfw-vehicle-signals.pc + DESTINATION ${CMAKE_INSTALL_PREFIX}/lib/pkgconfig) + +add_library(qtappfw-vehicle-signals SHARED vehiclesignals.cpp) + +target_include_directories(qtappfw-vehicle-signals PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") +target_include_directories(qtappfw-vehicle-signals PUBLIC "${CMAKE_INSTALL_INCLUDEDIR}") + +target_link_libraries(qtappfw-vehicle-signals Qt5::Core Qt5::WebSockets) +set_target_properties(qtappfw-vehicle-signals PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION 1 + PUBLIC_HEADER vehiclesignals.h) + +install(TARGETS qtappfw-vehicle-signals + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/qtappfw-vehicle-signals) diff --git a/vehicle-signals/qtappfw-vehicle-signals.pc.in b/vehicle-signals/qtappfw-vehicle-signals.pc.in new file mode 100644 index 0000000..5afac39 --- /dev/null +++ b/vehicle-signals/qtappfw-vehicle-signals.pc.in @@ -0,0 +1,12 @@ +prefix=@DEST_DIR@ +exec_prefix=${prefix} +libdir=${prefix}/lib +includedir=${prefix}/include + +Name: qtappfw-vehicle-signals +Description: Library providing VIS vehicle signal updates for Qt objects +Version: 1.0.0 + +Requires: Qt5Core Qt5WebSockets +Libs: -L${libdir} -lqtappfw-vehicle-signals +Cflags: -I${includedir}/qtappfw-vehicle-signals diff --git a/vehicle-signals/vehiclesignals.cpp b/vehicle-signals/vehiclesignals.cpp new file mode 100644 index 0000000..f3db521 --- /dev/null +++ b/vehicle-signals/vehiclesignals.cpp @@ -0,0 +1,427 @@ +/* + * Copyright (C) 2022 Konsulko Group + * + * 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 <QDebug> +#include <QSettings> +#include <QUrl> +#include <QFile> +#include <QSslKey> +#include <QTimer> +#include <QVariantMap> +#include <QJsonDocument> +#include <QJsonObject> + +#include "vehiclesignals.h" + +#define DEFAULT_CLIENT_KEY_FILE "/etc/kuksa-val/Client.key" +#define DEFAULT_CLIENT_CERT_FILE "/etc/kuksa-val/Client.pem" +#define DEFAULT_CA_CERT_FILE "/etc/kuksa-val/CA.pem" + +VehicleSignalsConfig::VehicleSignalsConfig(const QString &hostname, + const unsigned port, + const QByteArray &clientKey, + const QByteArray &clientCert, + const QByteArray &caCert, + const QString &authToken, + bool verifyPeer) : + m_hostname(hostname), + m_port(port), + m_clientKey(clientKey), + m_clientCert(clientCert), + m_caCert(caCert), + m_authToken(authToken), + m_verifyPeer(verifyPeer), + m_verbose(0), + m_valid(true) +{ + // Potentially could do some certificate validation here... +} + +VehicleSignalsConfig::VehicleSignalsConfig(const QString &appname) +{ + m_valid = false; + + QSettings *pSettings = new QSettings("AGL", appname); + if (!pSettings) + return; + + m_hostname = pSettings->value("vis-client/server", "localhost").toString(); + if (m_hostname.isEmpty()) { + qCritical() << "Invalid server hostname"; + return; + } + + m_port = pSettings->value("vis-client/port", 8090).toInt(); + if (m_port == 0) { + qCritical() << "Invalid server port"; + return; + } + + // Default to disabling peer verification for now to be able + // to use the default upstream KUKSA.val certificates for + // testing. Wrangling server and CA certificate generation + // and management to be able to verify will require further + // investigation. + m_verifyPeer = pSettings->value("vis-client/verify-server", false).toBool(); + + QString keyFileName = pSettings->value("vis-client/key", DEFAULT_CLIENT_KEY_FILE).toString(); + if (keyFileName.isEmpty()) { + qCritical() << "Invalid client key filename"; + return; + } + QFile keyFile(keyFileName); + if (!keyFile.open(QIODevice::ReadOnly)) { + qCritical() << "Could not open client key file"; + return; + } + QByteArray keyData = keyFile.readAll(); + if (keyData.isEmpty()) { + qCritical() << "Invalid client key file"; + return; + } + m_clientKey = keyData; + + QString certFileName = pSettings->value("vis-client/certificate", DEFAULT_CLIENT_CERT_FILE).toString(); + if (certFileName.isEmpty()) { + qCritical() << "Invalid client certificate filename"; + return; + } + QFile certFile(certFileName); + if (!certFile.open(QIODevice::ReadOnly)) { + qCritical() << "Could not open client certificate file"; + return; + } + QByteArray certData = certFile.readAll(); + if (certData.isEmpty()) { + qCritical() << "Invalid client certificate file"; + return; + } + m_clientCert = certData; + + QString caCertFileName = pSettings->value("vis-client/ca-certificate", DEFAULT_CA_CERT_FILE).toString(); + if (caCertFileName.isEmpty()) { + qCritical() << "Invalid CA certificate filename"; + return; + } + QFile caCertFile(caCertFileName); + if (!caCertFile.open(QIODevice::ReadOnly)) { + qCritical() << "Could not open CA certificate file"; + return; + } + QByteArray caCertData = caCertFile.readAll(); + if (caCertData.isEmpty()) { + qCritical() << "Invalid CA certificate file"; + return; + } + // Pre-check CA certificate + QList<QSslCertificate> newSslCaCerts = QSslCertificate::fromData(caCertData); + if (newSslCaCerts.isEmpty()) { + qCritical() << "Invalid CA certificate"; + return; + } + m_caCert = caCertData; + + QString authTokenFileName = pSettings->value("vis-client/authorization").toString(); + if (authTokenFileName.isEmpty()) { + qCritical() << "Invalid authorization token filename"; + return; + } + QFile authTokenFile(authTokenFileName); + if (!authTokenFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + qCritical() << "Could not open authorization token file"; + return; + } + QTextStream in(&authTokenFile); + QString authToken = in.readLine(); + if (authToken.isEmpty()) { + qCritical() << "Invalid authorization token file"; + return; + } + m_authToken = authToken; + + m_verbose = 0; + QString verbose = pSettings->value("vis-client/verbose").toString(); + if (!verbose.isEmpty()) { + if (verbose == "true" || verbose == "1") + m_verbose = 1; + if (verbose == "2") + m_verbose = 2; + } + + m_valid = true; +} + +VehicleSignals::VehicleSignals(const VehicleSignalsConfig &config, QObject *parent) : + QObject(parent), + m_config(config), + m_request_id(0) +{ + QObject::connect(&m_websocket, &QWebSocket::connected, this, &VehicleSignals::onConnected); + QObject::connect(&m_websocket, QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error), + this, &VehicleSignals::onError); + QObject::connect(&m_websocket, &QWebSocket::disconnected, this, &VehicleSignals::onDisconnected); +} + +VehicleSignals::~VehicleSignals() +{ + m_websocket.close(); +} + +void VehicleSignals::connect() +{ + if (!m_config.valid()) { + qCritical() << "Invalid VIS server configuration"; + return; + } + + QUrl visUrl; + visUrl.setScheme(QStringLiteral("wss")); + visUrl.setHost(m_config.hostname()); + visUrl.setPort(m_config.port()); + + QSslConfiguration sslConfig = QSslConfiguration::defaultConfiguration(); + + // Add client private key + // i.e. kuksa_certificates/Client.key in source tree + QSslKey sslKey(m_config.clientKey(), QSsl::Rsa); + sslConfig.setPrivateKey(sslKey); + + // Add local client certificate + // i.e. kuksa_certificates/Client.pem in source tree + QList<QSslCertificate> sslCerts = QSslCertificate::fromData(m_config.clientCert()); + if (sslCerts.empty()) { + qCritical() << "Invalid client certificate"; + return; + } + sslConfig.setLocalCertificate(sslCerts.first()); + + // Add CA certificate + // i.e. kuksa_certificates/CA.pem in source tree + // Note the following can be simplified with QSslConfiguration::addCaCertificate with Qt 5.15 + QList<QSslCertificate> sslCaCerts = sslConfig.caCertificates(); + QList<QSslCertificate> newSslCaCerts = QSslCertificate::fromData(m_config.caCert()); + if (newSslCaCerts.empty()) { + qCritical() << "Invalid CA certificate"; + return; + } + sslCaCerts.append(newSslCaCerts.first()); + sslConfig.setCaCertificates(sslCaCerts); + + sslConfig.setPeerVerifyMode(m_config.verifyPeer() ? QSslSocket::VerifyPeer : QSslSocket::VerifyNone); + + m_websocket.setSslConfiguration(sslConfig); + + if (m_config.verbose()) + qInfo() << "Opening VIS websocket"; + m_websocket.open(visUrl); +} + +void VehicleSignals::onConnected() +{ + if (m_config.verbose() > 1) + qDebug() << "VehicleSignals::onConnected: enter"; + QObject::connect(&m_websocket, &QWebSocket::textMessageReceived, this, &VehicleSignals::onTextMessageReceived); + emit connected(); +} + +void VehicleSignals::onError(QAbstractSocket::SocketError error) +{ + if (m_config.verbose() > 1) + qDebug() << "VehicleSignals::onError: enter"; + QTimer::singleShot(1000, this, &VehicleSignals::reconnect); +} + +void VehicleSignals::reconnect() +{ + if (m_config.verbose() > 1) + qDebug() << "VehicleSignals::reconnect: enter"; + connect(); +} + +void VehicleSignals::onDisconnected() +{ + if (m_config.verbose() > 1) + qDebug() << "VehicleSignals::onDisconnected: enter"; + QObject::disconnect(&m_websocket, &QWebSocket::textMessageReceived, this, &VehicleSignals::onTextMessageReceived); + emit disconnected(); + + // Try to reconnect + QTimer::singleShot(1000, this, &VehicleSignals::reconnect); +} + +void VehicleSignals::authorize() +{ + QVariantMap map; + map["action"] = QString("authorize"); + map["tokens"] = m_config.authToken(); + map["requestId"] = QString::number(m_request_id++); + QJsonDocument doc = QJsonDocument::fromVariant(map); + m_websocket.sendTextMessage(doc.toJson(QJsonDocument::Compact).data()); +} + +void VehicleSignals::get(const QString &path) +{ + QVariantMap map; + map["action"] = QString("get"); + map["tokens"] = m_config.authToken(); + map["path"] = path; + map["requestId"] = QString::number(m_request_id++); + QJsonDocument doc = QJsonDocument::fromVariant(map); + m_websocket.sendTextMessage(doc.toJson(QJsonDocument::Compact).data()); +} + +void VehicleSignals::set(const QString &path, const QString &value) +{ + QVariantMap map; + map["action"] = QString("set"); + map["tokens"] = m_config.authToken(); + map["path"] = path; + map["value"] = value; + map["requestId"] = QString::number(m_request_id++); + QJsonDocument doc = QJsonDocument::fromVariant(map); + m_websocket.sendTextMessage(doc.toJson(QJsonDocument::Compact).data()); +} + +void VehicleSignals::subscribe(const QString &path) +{ + QVariantMap map; + map["action"] = QString("subscribe"); + map["tokens"] = m_config.authToken(); + map["path"] = path; + map["requestId"] = QString::number(m_request_id++); + QJsonDocument doc = QJsonDocument::fromVariant(map); + m_websocket.sendTextMessage(doc.toJson(QJsonDocument::Compact).data()); +} + +bool VehicleSignals::parseData(const QJsonObject &response, QString &path, QString &value, QString ×tamp) +{ + if (response.contains("error")) { + QString error = response.value("error").toString(); + return false; + } + + if (!(response.contains("data") && response["data"].isObject())) { + qWarning() << "Malformed response (data missing)"; + return false; + } + QJsonObject data = response["data"].toObject(); + if (!(data.contains("path") && data["path"].isString())) { + qWarning() << "Malformed response (path missing)"; + return false; + } + path = data["path"].toString(); + // Convert '/' to '.' in paths to ensure consistency for clients + path.replace(QString("/"), QString(".")); + + if (!(data.contains("dp") && data["dp"].isObject())) { + qWarning() << "Malformed response (datapoint missing)"; + return false; + } + QJsonObject dp = data["dp"].toObject(); + if (!dp.contains("value")) { + qWarning() << "Malformed response (value missing)"; + return false; + } else if (dp["value"].isString()) { + value = dp["value"].toString(); + } else if (dp["value"].isDouble()) { + value.setNum(dp["value"].toDouble()); + } else if (dp["value"].isBool()) { + value = dp["value"].toBool() ? "true" : "false"; + } else { + qWarning() << "Malformed response (unsupported value type)"; + return false; + } + + if (!(dp.contains("ts") && dp["ts"].isString())) { + qWarning() << "Malformed response (timestamp missing)"; + return false; + } + timestamp = dp["ts"].toString(); + + return true; +} + +// +// NOTE: +// +// Ideally request ids would be used to provide some form of mapping +// to callers of get/set for responses/errors. At present the demo +// usecases are simple enough that it does not seem worth implementing +// just yet. +// +void VehicleSignals::onTextMessageReceived(QString msg) +{ + msg = msg.simplified(); + QJsonDocument doc(QJsonDocument::fromJson(msg.toUtf8())); + if (doc.isEmpty()) { + qWarning() << "Received invalid JSON: empty VIS message"; + return; + } + + if (!doc.isObject()) { + qWarning() << "Received invalid JSON: malformed VIS message"; + return; + } + QJsonObject obj = doc.object(); + + if (!obj.contains("action")) { + qWarning() << "Received unknown message (no action), discarding"; + return; + } + + QString action = obj.value("action").toString(); + if (action == "authorize") { + if (obj.contains("error")) { + QString error = obj.value("error").toString(); + qWarning() << "VIS authorization failed: " << error; + } else { + if (m_config.verbose() > 1) + qDebug() << "authorized"; + emit authorized(); + } + } else if (action == "subscribe") { + if (obj.contains("error")) { + QString error = obj.value("error").toString(); + qWarning() << "VIS subscription failed: " << error; + } + } else if (action == "get") { + if (obj.contains("error")) { + QString error = obj.value("error").toString(); + qWarning() << "VIS get failed: " << error; + } else { + QString path, value, ts; + if (parseData(obj, path, value, ts)) { + if (m_config.verbose() > 1) + qDebug() << "VehicleSignals::onTextMessageReceived: emitting response" << path << " = " << value; + emit getSuccessResponse(path, value, ts); + } + } + } else if (action == "set") { + if (obj.contains("error")) { + QString error = obj.value("error").toString(); + qWarning() << "VIS set failed: " << error; + } + } else if (action == "subscription") { + QString path, value, ts; + if (parseData(obj, path, value, ts)) { + if (m_config.verbose() > 1) + qDebug() << "VehicleSignals::onTextMessageReceived: emitting notification" << path << " = " << value; + emit signalNotification(path, value, ts); + } + } else { + qWarning() << "unhandled VIS response of type: " << action; + } +} diff --git a/vehicle-signals/vehiclesignals.h b/vehicle-signals/vehiclesignals.h new file mode 100644 index 0000000..fd5fe9c --- /dev/null +++ b/vehicle-signals/vehiclesignals.h @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2022 Konsulko Group + * + * 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. + */ + +#ifndef VEHICLESIGNALS_H +#define VEHICLESIGNALS_H + +#include <QObject> +#include <QWebSocket> + +// Class to read/hold VIS server configuration + +class VehicleSignalsConfig +{ +public: + explicit VehicleSignalsConfig(const QString &hostname, + const unsigned port, + const QByteArray &clientKey, + const QByteArray &clientCert, + const QByteArray &caCert, + const QString &authToken, + bool verifyPeer = true); + explicit VehicleSignalsConfig(const QString &appname); + ~VehicleSignalsConfig() {}; + + QString hostname() { return m_hostname; }; + unsigned port() { return m_port; }; + QByteArray clientKey() { return m_clientKey; }; + QByteArray clientCert() { return m_clientCert; }; + QByteArray caCert() { return m_caCert; }; + QString authToken() { return m_authToken; }; + bool verifyPeer() { return m_verifyPeer; }; + bool valid() { return m_valid; }; + unsigned verbose() { return m_verbose; }; + +private: + QString m_hostname; + unsigned m_port; + QByteArray m_clientKey; + QByteArray m_clientCert; + QByteArray m_caCert; + QString m_authToken; + bool m_verifyPeer; + bool m_valid; + unsigned m_verbose; +}; + +// VIS signaling interface class + +class VehicleSignals : public QObject +{ + Q_OBJECT + +public: + explicit VehicleSignals(const VehicleSignalsConfig &config, QObject * parent = Q_NULLPTR); + virtual ~VehicleSignals(); + + Q_INVOKABLE void connect(); + Q_INVOKABLE void authorize(); + + Q_INVOKABLE void get(const QString &path); + Q_INVOKABLE void set(const QString &path, const QString &value); + Q_INVOKABLE void subscribe(const QString &path); + +signals: + void connected(); + void authorized(); + void getSuccessResponse(QString path, QString value, QString timestamp); + void signalNotification(QString path, QString value, QString timestamp); + void disconnected(); + +private slots: + void onConnected(); + void onError(QAbstractSocket::SocketError error); + void reconnect(); + void onDisconnected(); + void onTextMessageReceived(QString message); + +private: + VehicleSignalsConfig m_config; + QWebSocket m_websocket; + std::atomic<unsigned int> m_request_id; + + bool parseData(const QJsonObject &response, QString &path, QString &value, QString ×tamp); +}; + +#endif // VEHICLESIGNALS_H |