Load the settings file (configuration, --config)
Config parses and validates the JSON settings (D34): required device name and broker host, defaults, type checks, device id from ha_unique_id or derived from the name (D31), warnings for unknown keys and for a password in a file others can read. Dashboard::prepare() loads it in overlay and desktop mode; --config selects another file (D22). New static library frameassistant_lib for app logic; tst_config covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,14 @@ else()
|
|||||||
endif()
|
endif()
|
||||||
message(STATUS "Vulkan backend: ${_vulkan_ok}")
|
message(STATUS "Vulkan backend: ${_vulkan_ok}")
|
||||||
|
|
||||||
|
# The app's own logic (no SteamVR, no rendering): linked by the app and its tests.
|
||||||
|
qt_add_library(frameassistant_lib STATIC
|
||||||
|
config/Config.h config/Config.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(frameassistant_lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
target_link_libraries(frameassistant_lib PUBLIC Qt6::Core)
|
||||||
|
target_compile_options(frameassistant_lib PRIVATE -Wall -Wextra)
|
||||||
|
|
||||||
# The app: its UI is the QML module "FrameAssistant" (src/qml/).
|
# The app: its UI is the QML module "FrameAssistant" (src/qml/).
|
||||||
vrdash_add_dashboard(frameassistant
|
vrdash_add_dashboard(frameassistant
|
||||||
OUTPUT_NAME ${VRDASH_EXECUTABLE_NAME}
|
OUTPUT_NAME ${VRDASH_EXECUTABLE_NAME}
|
||||||
@@ -57,6 +65,7 @@ vrdash_add_dashboard(frameassistant
|
|||||||
QML_FILES
|
QML_FILES
|
||||||
qml/Main.qml
|
qml/Main.qml
|
||||||
qml/Card.qml)
|
qml/Card.qml)
|
||||||
|
target_link_libraries(frameassistant PRIVATE frameassistant_lib)
|
||||||
|
|
||||||
install(FILES ${OPENVR_LIBRARY} DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
install(FILES ${OPENVR_LIBRARY} DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||||
|
|
||||||
|
|||||||
+31
-1
@@ -1,6 +1,36 @@
|
|||||||
#include "Dashboard.h"
|
#include "Dashboard.h"
|
||||||
|
|
||||||
|
#include <QCommandLineOption>
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
const QString kConfigOption = QStringLiteral("config");
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
Dashboard::Dashboard(int& argc, char** argv)
|
Dashboard::Dashboard(int& argc, char** argv)
|
||||||
: DashboardApplication(argc, argv) {
|
: DashboardApplication(
|
||||||
|
argc, argv,
|
||||||
|
{QCommandLineOption(kConfigOption,
|
||||||
|
QStringLiteral("Settings file (default: %1).")
|
||||||
|
.arg(Config::defaultPath()),
|
||||||
|
QStringLiteral("path"))}) {
|
||||||
addSurface({.qmlRootType = QStringLiteral("Main")});
|
addSurface({.qmlRootType = QStringLiteral("Main")});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Dashboard::prepare(QString* error) {
|
||||||
|
// Snapshot and benchmark only render the UI: no settings needed.
|
||||||
|
if (mode() != DashboardMode::Overlay && mode() != DashboardMode::Desktop)
|
||||||
|
return true;
|
||||||
|
const QString path = isOptionSet(kConfigOption) ? optionValue(kConfigOption)
|
||||||
|
: Config::defaultPath();
|
||||||
|
QStringList warnings;
|
||||||
|
std::optional<Config> config = Config::load(path, error, &warnings);
|
||||||
|
for (const QString& warning : warnings) qWarning().noquote() << warning;
|
||||||
|
if (!config) return false;
|
||||||
|
m_config = *config;
|
||||||
|
qInfo().noquote() << "Settings from" << path << "- device"
|
||||||
|
<< m_config.deviceName << "(" + m_config.deviceId + ")";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "app/DashboardApplication.h"
|
#include "app/DashboardApplication.h"
|
||||||
|
#include "config/Config.h"
|
||||||
|
|
||||||
// The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt
|
// The dashboard app. Its UI is the QML module set up in src/CMakeLists.txt
|
||||||
// (QML_URI FrameAssistant, files in src/qml/).
|
// (QML_URI FrameAssistant, files in src/qml/).
|
||||||
@@ -11,4 +12,11 @@ class Dashboard : public DashboardApplication {
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
Dashboard(int& argc, char** argv);
|
Dashboard(int& argc, char** argv);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Loads the settings (overlay and desktop mode; docs/DESIGN.md, D22).
|
||||||
|
bool prepare(QString* error) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
Config m_config;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#include "config/Config.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonParseError>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <cmath>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Walks one JSON object: typed getters that record the first error, and a
|
||||||
|
// check for keys nobody asked for.
|
||||||
|
class Section {
|
||||||
|
public:
|
||||||
|
Section(const QJsonObject& object, QString path, QString* error,
|
||||||
|
QStringList* warnings)
|
||||||
|
: m_object(object),
|
||||||
|
m_path(std::move(path)),
|
||||||
|
m_error(error),
|
||||||
|
m_warnings(warnings) {}
|
||||||
|
|
||||||
|
bool has(const QString& key) const { return m_object.contains(key); }
|
||||||
|
|
||||||
|
Section section(const QString& key) {
|
||||||
|
const QJsonValue value = take(key);
|
||||||
|
if (!value.isUndefined() && !value.isObject())
|
||||||
|
fail(key, QStringLiteral("must be an object"));
|
||||||
|
return Section(value.toObject(), name(key), m_error, m_warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString string(const QString& key, bool required = false) {
|
||||||
|
const QJsonValue value = take(key);
|
||||||
|
if (value.isUndefined()) {
|
||||||
|
if (required) fail(key, QStringLiteral("is required"));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!value.isString()) {
|
||||||
|
fail(key, QStringLiteral("must be a string"));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (required && value.toString().trimmed().isEmpty())
|
||||||
|
fail(key, QStringLiteral("must not be empty"));
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool boolean(const QString& key, bool fallback) {
|
||||||
|
const QJsonValue value = take(key);
|
||||||
|
if (value.isUndefined()) return fallback;
|
||||||
|
if (!value.isBool()) {
|
||||||
|
fail(key, QStringLiteral("must be true or false"));
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return value.toBool();
|
||||||
|
}
|
||||||
|
|
||||||
|
// An integer in [min, max]; `fallback` when missing.
|
||||||
|
int integer(const QString& key, int fallback, int min, int max) {
|
||||||
|
const QJsonValue value = take(key);
|
||||||
|
if (value.isUndefined()) return fallback;
|
||||||
|
const double number = value.toDouble();
|
||||||
|
if (!value.isDouble() || number != std::floor(number) || number < min ||
|
||||||
|
number > max) {
|
||||||
|
fail(key, QStringLiteral("must be a whole number from %1 to %2")
|
||||||
|
.arg(min)
|
||||||
|
.arg(max));
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return int(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call last: warns about keys no getter took.
|
||||||
|
void warnUnknown() {
|
||||||
|
for (auto it = m_object.constBegin(); it != m_object.constEnd(); ++it) {
|
||||||
|
if (!m_taken.contains(it.key()))
|
||||||
|
m_warnings->append(
|
||||||
|
QStringLiteral("Unknown setting '%1' ignored").arg(name(it.key())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void fail(const QString& key, const QString& problem) {
|
||||||
|
if (m_error->isEmpty())
|
||||||
|
*m_error = QStringLiteral("Setting '%1' %2").arg(name(key), problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
QJsonValue take(const QString& key) {
|
||||||
|
m_taken.append(key);
|
||||||
|
return m_object.value(key);
|
||||||
|
}
|
||||||
|
QString name(const QString& key) const {
|
||||||
|
return m_path.isEmpty() ? key : m_path + '.' + key;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject m_object;
|
||||||
|
QString m_path;
|
||||||
|
QString* m_error;
|
||||||
|
QStringList* m_warnings;
|
||||||
|
QStringList m_taken;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QString Config::defaultPath() {
|
||||||
|
return QStandardPaths::writableLocation(
|
||||||
|
QStandardPaths::GenericConfigLocation) +
|
||||||
|
QStringLiteral("/frameassistant/config.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString Config::deriveDeviceId(const QString& name) {
|
||||||
|
QString text = name.trimmed().toLower();
|
||||||
|
text.replace(QChar(0x00DF), QStringLiteral("ss")); // ß
|
||||||
|
// Decompose (é → e + accent) and drop the combining marks.
|
||||||
|
text = text.normalized(QString::NormalizationForm_KD);
|
||||||
|
QString id;
|
||||||
|
for (const QChar c : text) {
|
||||||
|
if (c.category() == QChar::Mark_NonSpacing) continue;
|
||||||
|
const bool allowed = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
|
||||||
|
c == '_' || c == '-';
|
||||||
|
const QChar out = allowed ? c : QChar('_');
|
||||||
|
if (out == '_' && (id.isEmpty() || id.endsWith('_'))) continue;
|
||||||
|
id += out;
|
||||||
|
}
|
||||||
|
while (id.endsWith('_')) id.chop(1);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<Config> Config::parse(const QByteArray& json, QString* error,
|
||||||
|
QStringList* warnings) {
|
||||||
|
error->clear();
|
||||||
|
QJsonParseError parseError;
|
||||||
|
const QJsonDocument doc = QJsonDocument::fromJson(json, &parseError);
|
||||||
|
if (parseError.error != QJsonParseError::NoError) {
|
||||||
|
*error = QStringLiteral("Invalid JSON at offset %1: %2")
|
||||||
|
.arg(parseError.offset)
|
||||||
|
.arg(parseError.errorString());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (!doc.isObject()) {
|
||||||
|
*error = QStringLiteral("The settings must be a JSON object");
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
Config config;
|
||||||
|
Section root(doc.object(), {}, error, warnings);
|
||||||
|
|
||||||
|
Section device = root.section(QStringLiteral("device"));
|
||||||
|
if (!root.has(QStringLiteral("device")))
|
||||||
|
root.fail(QStringLiteral("device"), QStringLiteral("is required"));
|
||||||
|
config.deviceName = device.string(QStringLiteral("name"), true).trimmed();
|
||||||
|
config.deviceId = device.string(QStringLiteral("ha_unique_id"));
|
||||||
|
if (device.has(QStringLiteral("ha_unique_id"))) {
|
||||||
|
static const QRegularExpression valid(QStringLiteral("^[a-zA-Z0-9_-]+$"));
|
||||||
|
if (!valid.match(config.deviceId).hasMatch())
|
||||||
|
device.fail(QStringLiteral("ha_unique_id"),
|
||||||
|
QStringLiteral("may only contain a-z, A-Z, 0-9, _ and -"));
|
||||||
|
} else if (!config.deviceName.isEmpty()) {
|
||||||
|
config.deviceId = deriveDeviceId(config.deviceName);
|
||||||
|
if (config.deviceId.isEmpty())
|
||||||
|
device.fail(QStringLiteral("name"),
|
||||||
|
QStringLiteral("gives no usable id; set "
|
||||||
|
"device.ha_unique_id"));
|
||||||
|
}
|
||||||
|
device.warnUnknown();
|
||||||
|
|
||||||
|
Section mqtt = root.section(QStringLiteral("mqtt"));
|
||||||
|
if (!root.has(QStringLiteral("mqtt")))
|
||||||
|
root.fail(QStringLiteral("mqtt"), QStringLiteral("is required"));
|
||||||
|
config.mqtt.host = mqtt.string(QStringLiteral("host"), true).trimmed();
|
||||||
|
config.mqtt.username = mqtt.string(QStringLiteral("username"));
|
||||||
|
config.mqtt.password = mqtt.string(QStringLiteral("password"));
|
||||||
|
Section tls = mqtt.section(QStringLiteral("tls"));
|
||||||
|
config.mqtt.tls.enabled = tls.boolean(QStringLiteral("enabled"), false);
|
||||||
|
config.mqtt.tls.caFile = tls.string(QStringLiteral("ca_file"));
|
||||||
|
config.mqtt.tls.certFile = tls.string(QStringLiteral("cert_file"));
|
||||||
|
config.mqtt.tls.keyFile = tls.string(QStringLiteral("key_file"));
|
||||||
|
tls.warnUnknown();
|
||||||
|
config.mqtt.port = mqtt.integer(
|
||||||
|
QStringLiteral("port"), config.mqtt.tls.enabled ? 8883 : 1883, 1, 65535);
|
||||||
|
mqtt.warnUnknown();
|
||||||
|
|
||||||
|
Section ha = root.section(QStringLiteral("homeassistant"));
|
||||||
|
const QString prefix = ha.string(QStringLiteral("discovery_prefix"));
|
||||||
|
if (ha.has(QStringLiteral("discovery_prefix"))) {
|
||||||
|
if (prefix.trimmed().isEmpty())
|
||||||
|
ha.fail(QStringLiteral("discovery_prefix"),
|
||||||
|
QStringLiteral("must not be empty"));
|
||||||
|
config.discoveryPrefix = prefix.trimmed();
|
||||||
|
}
|
||||||
|
const QString url = ha.string(QStringLiteral("url")).trimmed();
|
||||||
|
if (!url.isEmpty()) {
|
||||||
|
config.haUrl = QUrl(url, QUrl::StrictMode);
|
||||||
|
const QString scheme = config.haUrl.scheme();
|
||||||
|
if (!config.haUrl.isValid() || config.haUrl.host().isEmpty() ||
|
||||||
|
(scheme != QLatin1String("http") && scheme != QLatin1String("https")))
|
||||||
|
ha.fail(QStringLiteral("url"),
|
||||||
|
QStringLiteral("must be an http:// or https:// URL"));
|
||||||
|
}
|
||||||
|
ha.warnUnknown();
|
||||||
|
|
||||||
|
Section notifications = root.section(QStringLiteral("notifications"));
|
||||||
|
config.notificationTimeoutS =
|
||||||
|
notifications.integer(QStringLiteral("timeout_s"), 10, 1, 3600);
|
||||||
|
notifications.warnUnknown();
|
||||||
|
|
||||||
|
root.warnUnknown();
|
||||||
|
if (!error->isEmpty()) return std::nullopt;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<Config> Config::load(const QString& path, QString* error,
|
||||||
|
QStringList* warnings) {
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
|
*error = QStringLiteral("Cannot read settings file %1: %2")
|
||||||
|
.arg(path, file.errorString());
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
std::optional<Config> config = parse(file.readAll(), error, warnings);
|
||||||
|
if (!config) {
|
||||||
|
*error = QStringLiteral("Settings file %1: %2").arg(path, *error);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
const QFileDevice::Permissions others =
|
||||||
|
QFileDevice::ReadGroup | QFileDevice::ReadOther;
|
||||||
|
if (!config->mqtt.password.isEmpty() && (file.permissions() & others))
|
||||||
|
warnings->append(
|
||||||
|
QStringLiteral("Settings file %1 contains a password and is readable "
|
||||||
|
"by other users; restrict it with: chmod 600 %1")
|
||||||
|
.arg(path));
|
||||||
|
return config;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
// FrameAssistant's settings from its JSON file (docs/DESIGN.md, D34).
|
||||||
|
// Loaded once by the app and passed on as an immutable value (D19): each
|
||||||
|
// component gets the part it needs.
|
||||||
|
struct MqttTlsConfig {
|
||||||
|
bool enabled = false;
|
||||||
|
QString caFile; // empty: the system's certificates
|
||||||
|
QString certFile;
|
||||||
|
QString keyFile;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MqttConfig {
|
||||||
|
QString host;
|
||||||
|
int port = 1883; // 8883 with TLS unless given
|
||||||
|
QString username;
|
||||||
|
QString password;
|
||||||
|
MqttTlsConfig tls;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
QString deviceName;
|
||||||
|
// `ha_unique_id`, or derived from the device name (D31).
|
||||||
|
QString deviceId;
|
||||||
|
MqttConfig mqtt;
|
||||||
|
QString discoveryPrefix = QStringLiteral("homeassistant");
|
||||||
|
// HA web UI for the dashboard tab (DSH-1); empty: not configured.
|
||||||
|
QUrl haUrl;
|
||||||
|
int notificationTimeoutS = 10;
|
||||||
|
|
||||||
|
// ~/.config/frameassistant/config.json ($XDG_CONFIG_HOME respected).
|
||||||
|
static QString defaultPath();
|
||||||
|
|
||||||
|
// Reads and validates the file. On failure returns nothing and sets
|
||||||
|
// *error. *warnings gets problems that don't stop the app (unknown keys,
|
||||||
|
// a password in a file others can read).
|
||||||
|
static std::optional<Config> load(const QString& path, QString* error,
|
||||||
|
QStringList* warnings);
|
||||||
|
// The same for JSON text (no file checks).
|
||||||
|
static std::optional<Config> parse(const QByteArray& json, QString* error,
|
||||||
|
QStringList* warnings);
|
||||||
|
|
||||||
|
// A device id from a display name: lower case, letters without
|
||||||
|
// diacritics, everything outside [a-z0-9_-] as '_'. Empty if nothing is
|
||||||
|
// left.
|
||||||
|
static QString deriveDeviceId(const QString& name);
|
||||||
|
};
|
||||||
@@ -35,3 +35,8 @@ add_test(NAME surfaces_prepare_fail COMMAND tst_surfaces --graphics-api opengl)
|
|||||||
set_tests_properties(surfaces_prepare_fail PROPERTIES ENVIRONMENT "VRDASH_TEST_PREPARE_FAIL=1")
|
set_tests_properties(surfaces_prepare_fail PROPERTIES ENVIRONMENT "VRDASH_TEST_PREPARE_FAIL=1")
|
||||||
set_tests_properties(surfaces_opengl surfaces_vulkan surfaces_cli_key
|
set_tests_properties(surfaces_opengl surfaces_vulkan surfaces_cli_key
|
||||||
PROPERTIES SKIP_RETURN_CODE 77)
|
PROPERTIES SKIP_RETURN_CODE 77)
|
||||||
|
|
||||||
|
# The app's settings file (docs/DESIGN.md, D34).
|
||||||
|
qt_add_executable(tst_config tst_config.cpp)
|
||||||
|
target_link_libraries(tst_config PRIVATE frameassistant_lib Qt6::Test)
|
||||||
|
add_test(NAME config COMMAND tst_config)
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// The settings file (docs/DESIGN.md, D34): required values, defaults, types,
|
||||||
|
// device ids, unknown keys, file checks.
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
#include "config/Config.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Test JSON with ' instead of " (moc can't parse raw string literals that
|
||||||
|
// contain "//" and then misses the test class).
|
||||||
|
QByteArray json(const char* text) {
|
||||||
|
return QByteArray(text).replace('\'', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray kMinimal = json(
|
||||||
|
"{'device': {'name': 'Wohnzimmer Frame'}, 'mqtt': {'host': 'broker'}}");
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class TestConfig : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void minimalUsesDefaults() {
|
||||||
|
QString error;
|
||||||
|
QStringList warnings;
|
||||||
|
const std::optional<Config> config =
|
||||||
|
Config::parse(kMinimal, &error, &warnings);
|
||||||
|
QVERIFY2(config, qPrintable(error));
|
||||||
|
QVERIFY(warnings.isEmpty());
|
||||||
|
QCOMPARE(config->deviceName, QStringLiteral("Wohnzimmer Frame"));
|
||||||
|
QCOMPARE(config->deviceId, QStringLiteral("wohnzimmer_frame"));
|
||||||
|
QCOMPARE(config->mqtt.host, QStringLiteral("broker"));
|
||||||
|
QCOMPARE(config->mqtt.port, 1883);
|
||||||
|
QVERIFY(config->mqtt.username.isEmpty());
|
||||||
|
QVERIFY(!config->mqtt.tls.enabled);
|
||||||
|
QCOMPARE(config->discoveryPrefix, QStringLiteral("homeassistant"));
|
||||||
|
QVERIFY(config->haUrl.isEmpty());
|
||||||
|
QCOMPARE(config->notificationTimeoutS, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fullFile() {
|
||||||
|
const QByteArray text = json(
|
||||||
|
"{"
|
||||||
|
"'device': {'name': 'Frame', 'ha_unique_id': 'frame-1'},"
|
||||||
|
"'mqtt': {'host': 'broker', 'port': 8884, 'username': 'u',"
|
||||||
|
"'password': 'p',"
|
||||||
|
"'tls': {'enabled': true, 'ca_file': '/ca.crt',"
|
||||||
|
"'cert_file': '/c.crt', 'key_file': '/c.key'}},"
|
||||||
|
"'homeassistant': {'discovery_prefix': 'ha', 'url': "
|
||||||
|
"'https://ha.local:8123/lovelace'},"
|
||||||
|
"'notifications': {'timeout_s': 5}"
|
||||||
|
"}");
|
||||||
|
QString error;
|
||||||
|
QStringList warnings;
|
||||||
|
const std::optional<Config> config = Config::parse(text, &error, &warnings);
|
||||||
|
QVERIFY2(config, qPrintable(error));
|
||||||
|
QVERIFY(warnings.isEmpty());
|
||||||
|
QCOMPARE(config->deviceId, QStringLiteral("frame-1"));
|
||||||
|
QCOMPARE(config->mqtt.port, 8884);
|
||||||
|
QCOMPARE(config->mqtt.username, QStringLiteral("u"));
|
||||||
|
QCOMPARE(config->mqtt.password, QStringLiteral("p"));
|
||||||
|
QVERIFY(config->mqtt.tls.enabled);
|
||||||
|
QCOMPARE(config->mqtt.tls.caFile, QStringLiteral("/ca.crt"));
|
||||||
|
QCOMPARE(config->mqtt.tls.certFile, QStringLiteral("/c.crt"));
|
||||||
|
QCOMPARE(config->mqtt.tls.keyFile, QStringLiteral("/c.key"));
|
||||||
|
QCOMPARE(config->discoveryPrefix, QStringLiteral("ha"));
|
||||||
|
QCOMPARE(config->haUrl,
|
||||||
|
QUrl(QStringLiteral("https://ha.local:8123/lovelace")));
|
||||||
|
QCOMPARE(config->notificationTimeoutS, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tlsChangesDefaultPort() {
|
||||||
|
QString error;
|
||||||
|
QStringList warnings;
|
||||||
|
const std::optional<Config> config =
|
||||||
|
Config::parse(json("{'device': {'name': 'F'},"
|
||||||
|
"'mqtt': {'host': 'b', 'tls': {'enabled': true}}}"),
|
||||||
|
&error, &warnings);
|
||||||
|
QVERIFY2(config, qPrintable(error));
|
||||||
|
QCOMPARE(config->mqtt.port, 8883);
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalid_data() {
|
||||||
|
QTest::addColumn<QByteArray>("text");
|
||||||
|
QTest::addColumn<QString>("errorPart");
|
||||||
|
QTest::newRow("not JSON") << QByteArray("{device") << "Invalid JSON";
|
||||||
|
QTest::newRow("not an object") << QByteArray("[]") << "JSON object";
|
||||||
|
QTest::newRow("no device")
|
||||||
|
<< json("{'mqtt': {'host': 'b'}}") << "'device' is required";
|
||||||
|
QTest::newRow("no name") << json("{'device': {}, 'mqtt': {'host': 'b'}}")
|
||||||
|
<< "'device.name' is required";
|
||||||
|
QTest::newRow("empty name")
|
||||||
|
<< json("{'device': {'name': ' '}, 'mqtt': {'host': 'b'}}")
|
||||||
|
<< "'device.name' must not be empty";
|
||||||
|
QTest::newRow("name not a string")
|
||||||
|
<< json("{'device': {'name': 3}, 'mqtt': {'host': 'b'}}")
|
||||||
|
<< "'device.name' must be a string";
|
||||||
|
QTest::newRow("no mqtt")
|
||||||
|
<< json("{'device': {'name': 'F'}}") << "'mqtt' is required";
|
||||||
|
QTest::newRow("no host") << json("{'device': {'name': 'F'}, 'mqtt': {}}")
|
||||||
|
<< "'mqtt.host' is required";
|
||||||
|
QTest::newRow("mqtt not an object")
|
||||||
|
<< json("{'device': {'name': 'F'}, 'mqtt': 1}")
|
||||||
|
<< "'mqtt' must be an object";
|
||||||
|
QTest::newRow("port text")
|
||||||
|
<< QByteArray(json(
|
||||||
|
"{'device': {'name': 'F'}, 'mqtt': {'host': 'b', 'port': '1'}}"))
|
||||||
|
<< "'mqtt.port' must be a whole number";
|
||||||
|
QTest::newRow("port fraction")
|
||||||
|
<< QByteArray(json(
|
||||||
|
"{'device': {'name': 'F'}, 'mqtt': {'host': 'b', 'port': 1.5}}"))
|
||||||
|
<< "'mqtt.port' must be a whole number";
|
||||||
|
QTest::newRow("port range")
|
||||||
|
<< QByteArray(json("{'device': {'name': 'F'}, 'mqtt': {'host': 'b', "
|
||||||
|
"'port': 70000}}"))
|
||||||
|
<< "'mqtt.port' must be a whole number";
|
||||||
|
QTest::newRow("tls enabled text")
|
||||||
|
<< json(
|
||||||
|
"{'device': {'name': 'F'},"
|
||||||
|
"'mqtt': {'host': 'b', 'tls': {'enabled': 'yes'}}}")
|
||||||
|
<< "'mqtt.tls.enabled' must be true or false";
|
||||||
|
QTest::newRow("bad unique id")
|
||||||
|
<< json(
|
||||||
|
"{'device': {'name': 'F', 'ha_unique_id': 'a b'},"
|
||||||
|
"'mqtt': {'host': 'b'}}")
|
||||||
|
<< "'device.ha_unique_id' may only contain";
|
||||||
|
QTest::newRow("name without id characters")
|
||||||
|
<< json("{'device': {'name': '!!!'}, 'mqtt': {'host': 'b'}}")
|
||||||
|
<< "set device.ha_unique_id";
|
||||||
|
QTest::newRow("url scheme")
|
||||||
|
<< json(
|
||||||
|
"{'device': {'name': 'F'}, 'mqtt': {'host': 'b'},"
|
||||||
|
"'homeassistant': {'url': 'ftp://ha'}}")
|
||||||
|
<< "'homeassistant.url' must be an http";
|
||||||
|
QTest::newRow("empty prefix")
|
||||||
|
<< json(
|
||||||
|
"{'device': {'name': 'F'}, 'mqtt': {'host': 'b'},"
|
||||||
|
"'homeassistant': {'discovery_prefix': ''}}")
|
||||||
|
<< "'homeassistant.discovery_prefix' must not be empty";
|
||||||
|
QTest::newRow("timeout zero")
|
||||||
|
<< json(
|
||||||
|
"{'device': {'name': 'F'}, 'mqtt': {'host': 'b'},"
|
||||||
|
"'notifications': {'timeout_s': 0}}")
|
||||||
|
<< "'notifications.timeout_s' must be a whole number";
|
||||||
|
}
|
||||||
|
void invalid() {
|
||||||
|
QFETCH(QByteArray, text);
|
||||||
|
QFETCH(QString, errorPart);
|
||||||
|
QString error;
|
||||||
|
QStringList warnings;
|
||||||
|
QVERIFY(!Config::parse(text, &error, &warnings));
|
||||||
|
QVERIFY2(error.contains(errorPart), qPrintable(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
void unknownKeysWarn() {
|
||||||
|
QString error;
|
||||||
|
QStringList warnings;
|
||||||
|
const std::optional<Config> config =
|
||||||
|
Config::parse(json("{'device': {'name': 'F', 'colour': 1},"
|
||||||
|
"'mqtt': {'host': 'b', 'tsl': {}}, 'extra': true}"),
|
||||||
|
&error, &warnings);
|
||||||
|
QVERIFY2(config, qPrintable(error));
|
||||||
|
QCOMPARE(warnings.size(), 3);
|
||||||
|
QVERIFY(warnings.join(' ').contains(QLatin1String("'device.colour'")));
|
||||||
|
QVERIFY(warnings.join(' ').contains(QLatin1String("'mqtt.tsl'")));
|
||||||
|
QVERIFY(warnings.join(' ').contains(QLatin1String("'extra'")));
|
||||||
|
}
|
||||||
|
|
||||||
|
void deriveDeviceId_data() {
|
||||||
|
QTest::addColumn<QString>("name");
|
||||||
|
QTest::addColumn<QString>("id");
|
||||||
|
QTest::newRow("spaces") << "Wohnzimmer Frame" << "wohnzimmer_frame";
|
||||||
|
QTest::newRow("umlauts") << "Küche Süd" << "kuche_sud";
|
||||||
|
QTest::newRow("sharp s") << "Große Halle" << "grosse_halle";
|
||||||
|
QTest::newRow("allowed") << "frame-2_b" << "frame-2_b";
|
||||||
|
QTest::newRow("runs and edges") << " --Frame !! #2 " << "--frame_2";
|
||||||
|
QTest::newRow("nothing usable") << "!!!" << "";
|
||||||
|
}
|
||||||
|
void deriveDeviceId() {
|
||||||
|
QFETCH(QString, name);
|
||||||
|
QFETCH(QString, id);
|
||||||
|
QCOMPARE(Config::deriveDeviceId(name), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadFromFile() {
|
||||||
|
QTemporaryDir dir;
|
||||||
|
const QString path = dir.filePath(QStringLiteral("config.json"));
|
||||||
|
QFile file(path);
|
||||||
|
QVERIFY(file.open(QIODevice::WriteOnly));
|
||||||
|
file.write(kMinimal);
|
||||||
|
file.close();
|
||||||
|
QString error;
|
||||||
|
QStringList warnings;
|
||||||
|
QVERIFY2(Config::load(path, &error, &warnings), qPrintable(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
void missingFile() {
|
||||||
|
QString error;
|
||||||
|
QStringList warnings;
|
||||||
|
QVERIFY(!Config::load(QStringLiteral("/nonexistent/config.json"), &error,
|
||||||
|
&warnings));
|
||||||
|
QVERIFY2(error.contains(QLatin1String("/nonexistent/config.json")),
|
||||||
|
qPrintable(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
void errorNamesFile() {
|
||||||
|
QTemporaryDir dir;
|
||||||
|
const QString path = dir.filePath(QStringLiteral("config.json"));
|
||||||
|
QFile file(path);
|
||||||
|
QVERIFY(file.open(QIODevice::WriteOnly));
|
||||||
|
file.write("{}");
|
||||||
|
file.close();
|
||||||
|
QString error;
|
||||||
|
QStringList warnings;
|
||||||
|
QVERIFY(!Config::load(path, &error, &warnings));
|
||||||
|
QVERIFY2(error.startsWith(QStringLiteral("Settings file ") + path),
|
||||||
|
qPrintable(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
void readablePasswordWarns_data() {
|
||||||
|
QTest::addColumn<bool>("password");
|
||||||
|
QTest::addColumn<bool>("othersRead");
|
||||||
|
QTest::addColumn<bool>("warns");
|
||||||
|
QTest::newRow("password, readable") << true << true << true;
|
||||||
|
QTest::newRow("password, private") << true << false << false;
|
||||||
|
QTest::newRow("no password, readable") << false << true << false;
|
||||||
|
}
|
||||||
|
void readablePasswordWarns() {
|
||||||
|
QFETCH(bool, password);
|
||||||
|
QFETCH(bool, othersRead);
|
||||||
|
QFETCH(bool, warns);
|
||||||
|
QTemporaryDir dir;
|
||||||
|
const QString path = dir.filePath(QStringLiteral("config.json"));
|
||||||
|
QFile file(path);
|
||||||
|
QVERIFY(file.open(QIODevice::WriteOnly));
|
||||||
|
file.write(password ? json("{'device': {'name': 'F'},"
|
||||||
|
"'mqtt': {'host': 'b', 'password': 'x'}}")
|
||||||
|
: kMinimal);
|
||||||
|
file.close();
|
||||||
|
QFileDevice::Permissions permissions =
|
||||||
|
QFileDevice::ReadOwner | QFileDevice::WriteOwner;
|
||||||
|
if (othersRead)
|
||||||
|
permissions |= QFileDevice::ReadGroup | QFileDevice::ReadOther;
|
||||||
|
QVERIFY(file.setPermissions(permissions));
|
||||||
|
QString error;
|
||||||
|
QStringList warnings;
|
||||||
|
QVERIFY2(Config::load(path, &error, &warnings), qPrintable(error));
|
||||||
|
QCOMPARE(!warnings.isEmpty(), warns);
|
||||||
|
}
|
||||||
|
|
||||||
|
void defaultPathFollowsXdg() {
|
||||||
|
qputenv("XDG_CONFIG_HOME", "/tmp/xdg-test");
|
||||||
|
QCOMPARE(Config::defaultPath(),
|
||||||
|
QStringLiteral("/tmp/xdg-test/frameassistant/config.json"));
|
||||||
|
qunsetenv("XDG_CONFIG_HOME");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(TestConfig)
|
||||||
|
#include "tst_config.moc"
|
||||||
Reference in New Issue
Block a user