initial commit

This commit is contained in:
NeilClack
2026-08-07 14:51:41 -05:00
commit 9251b3a196
604 changed files with 464448 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <string>
namespace radiantwave::config {
struct AudioConfig {
bool enabled = true; // audio master enabled toggle
int volume = 80; // 0 - 100
std::string output_device = ""; // empty = system default
std::string current_track = "OriginalAudio.wav"; // active audio track filename
bool dynamic_audio = true; // audio follows the displayed proclamation's solfeggio frequency
};
struct ProclamationConfig {
float scroll_speed = 1.0f; // scroll speed multiplier
int font_size = 32; // font size in pt
std::string current_id = ""; // currently active proclamation ID
};
struct AppConfig {
std::string license_key = "";
std::string email_address = "";
std::string provisioning_status = "FIRST_RUN";
std::string timezone = "UTC"; // IANA timezone code (e.g. America/New_York, UTC)
AudioConfig audio;
ProclamationConfig proclamation;
// Single source of truth for default values
static AppConfig defaults() {
return AppConfig{};
}
};
} // namespace radiantwave::config
+306
View File
@@ -0,0 +1,306 @@
#include "config_manager.h"
#include "yaml_parser.h"
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <pwd.h>
#include <sstream>
#include <unistd.h>
namespace radiantwave::config {
// ─── Constructor & Destructor ──────────────────────────────────────────
ConfigManager::ConfigManager() = default;
ConfigManager::~ConfigManager() {
shutdown();
}
// ─── Path Resolution ───────────────────────────────────────────────────
std::string ConfigManager::resolvePath(const std::string& raw_path) const {
std::string path = raw_path;
if (path.empty()) {
const char* home = std::getenv("HOME");
if (!home) {
struct passwd* pw = getpwuid(getuid());
if (pw) {
home = pw->pw_dir;
}
}
if (home) {
path = std::string(home) + "/.local/radiantwave/config.yaml";
} else {
path = "/tmp/radiantwave/config.yaml";
}
} else if (path.front() == '~') {
const char* home = std::getenv("HOME");
if (!home) {
struct passwd* pw = getpwuid(getuid());
if (pw) {
home = pw->pw_dir;
}
}
if (home) {
path = std::string(home) + path.substr(1);
}
}
return path;
}
// ─── Initialization ────────────────────────────────────────────────────
bool ConfigManager::initialize(const std::string& custom_path) {
file_path_ = resolvePath(custom_path);
// Create parent directories if they don't exist
try {
std::filesystem::path p(file_path_);
if (p.has_parent_path()) {
std::filesystem::create_directories(p.parent_path());
}
} catch (const std::exception& e) {
std::cerr << "[Config] Failed to create directories: " << e.what() << std::endl;
return false;
}
// Load and merge defaults without overwriting existing settings
loadAndMergeDefaults();
// Store initial modification timestamp
try {
if (std::filesystem::exists(file_path_)) {
last_write_time_ = std::filesystem::last_write_time(file_path_);
}
} catch (...) {}
// Start live hot-reload background watcher
startWatcher();
return true;
}
void ConfigManager::shutdown() {
stopWatcher();
}
// ─── Read / Write Operations ───────────────────────────────────────────
AppConfig ConfigManager::get() const {
std::lock_guard<std::mutex> lock(mutex_);
return config_;
}
bool ConfigManager::update(std::function<void(AppConfig& config)> modifier) {
std::vector<ConfigCallback> callbacks_to_notify;
AppConfig updated_config;
bool success = false;
{
std::lock_guard<std::mutex> lock(mutex_);
modifier(config_);
updated_config = config_;
success = saveUnlocked();
}
if (success) {
std::lock_guard<std::mutex> cb_lock(callback_mutex_);
callbacks_to_notify = callbacks_;
}
for (const auto& cb : callbacks_to_notify) {
if (cb) cb(updated_config);
}
return success;
}
bool ConfigManager::save() {
std::lock_guard<std::mutex> lock(mutex_);
return saveUnlocked();
}
bool ConfigManager::saveUnlocked() {
std::string content = serializeConfigToYaml(config_);
std::ofstream file(file_path_, std::ios::out | std::ios::trunc);
if (!file.is_open()) {
std::cerr << "[Config] Failed to open file for writing: " << file_path_ << std::endl;
return false;
}
file << content;
file.close();
// Update timestamp so watcher doesn't trigger a duplicate hot-reload event
try {
if (std::filesystem::exists(file_path_)) {
last_write_time_ = std::filesystem::last_write_time(file_path_);
}
} catch (...) {}
return true;
}
bool ConfigManager::reload() {
std::ifstream file(file_path_);
if (!file.is_open()) {
return false;
}
std::stringstream ss;
ss << file.rdbuf();
file.close();
AppConfig loaded = parseConfigFromYaml(ss.str());
std::vector<ConfigCallback> callbacks_to_notify;
{
std::lock_guard<std::mutex> lock(mutex_);
config_ = loaded;
}
{
std::lock_guard<std::mutex> cb_lock(callback_mutex_);
callbacks_to_notify = callbacks_;
}
for (const auto& cb : callbacks_to_notify) {
if (cb) cb(loaded);
}
return true;
}
void ConfigManager::loadAndMergeDefaults() {
AppConfig defaults = AppConfig::defaults();
if (!std::filesystem::exists(file_path_)) {
// File doesn't exist: write full defaults
std::lock_guard<std::mutex> lock(mutex_);
config_ = defaults;
saveUnlocked();
return;
}
// Read existing file
std::ifstream file(file_path_);
if (!file.is_open()) {
std::lock_guard<std::mutex> lock(mutex_);
config_ = defaults;
return;
}
std::stringstream ss;
ss << file.rdbuf();
file.close();
std::string yaml_content = ss.str();
YamlNode root = parseYaml(yaml_content);
AppConfig merged = defaults;
// Preserving existing values if present
if (root.hasChild("license_key") && !root.getChild("license_key").asString().empty()) {
merged.license_key = root.getChild("license_key").asString();
}
if (root.hasChild("email_address") && !root.getChild("email_address").asString().empty()) {
merged.email_address = root.getChild("email_address").asString();
}
if (root.hasChild("provisioning_status") && !root.getChild("provisioning_status").asString().empty()) {
merged.provisioning_status = root.getChild("provisioning_status").asString();
}
if (root.hasChild("timezone") && !root.getChild("timezone").asString().empty()) {
merged.timezone = root.getChild("timezone").asString();
}
if (root.hasChild("audio")) {
const auto& audio_node = root.getChild("audio");
if (audio_node.hasChild("enabled")) {
merged.audio.enabled = audio_node.getChild("enabled").asBool(defaults.audio.enabled);
}
if (audio_node.hasChild("volume")) {
merged.audio.volume = audio_node.getChild("volume").asInt(defaults.audio.volume);
}
if (audio_node.hasChild("output_device")) {
merged.audio.output_device = audio_node.getChild("output_device").asString(defaults.audio.output_device);
}
if (audio_node.hasChild("current_track")) {
merged.audio.current_track = audio_node.getChild("current_track").asString(defaults.audio.current_track);
}
if (audio_node.hasChild("dynamic_audio")) {
merged.audio.dynamic_audio = audio_node.getChild("dynamic_audio").asBool(defaults.audio.dynamic_audio);
}
}
if (root.hasChild("proclamation")) {
const auto& proc_node = root.getChild("proclamation");
if (proc_node.hasChild("scroll_speed")) {
merged.proclamation.scroll_speed = proc_node.getChild("scroll_speed").asFloat(defaults.proclamation.scroll_speed);
}
if (proc_node.hasChild("font_size")) {
merged.proclamation.font_size = proc_node.getChild("font_size").asInt(defaults.proclamation.font_size);
}
if (proc_node.hasChild("current_id")) {
merged.proclamation.current_id = proc_node.getChild("current_id").asString(defaults.proclamation.current_id);
}
}
{
std::lock_guard<std::mutex> lock(mutex_);
config_ = merged;
saveUnlocked();
}
}
// ─── Live Hot-Reloading Watcher ───────────────────────────────────────
void ConfigManager::onChanged(ConfigCallback callback) {
std::lock_guard<std::mutex> lock(callback_mutex_);
callbacks_.push_back(std::move(callback));
}
std::string ConfigManager::getFilePath() const {
return file_path_;
}
void ConfigManager::startWatcher() {
stopWatcher();
watcher_running_ = true;
watcher_thread_ = std::thread([this]() {
while (watcher_running_) {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
if (!watcher_running_) break;
try {
if (std::filesystem::exists(file_path_)) {
auto current_time = std::filesystem::last_write_time(file_path_);
if (current_time != last_write_time_) {
last_write_time_ = current_time;
std::cout << "[Config] Detected live change in " << file_path_ << ", hot-reloading..." << std::endl;
reload();
}
}
} catch (...) {}
}
});
}
void ConfigManager::stopWatcher() {
if (watcher_running_) {
watcher_running_ = false;
if (watcher_thread_.joinable()) {
watcher_thread_.join();
}
}
}
} // namespace radiantwave::config
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include "app_config.h"
#include <atomic>
#include <filesystem>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace radiantwave::config {
using ConfigCallback = std::function<void(const AppConfig& new_config)>;
class ConfigManager {
public:
ConfigManager();
~ConfigManager();
// Non-copyable, non-movable
ConfigManager(const ConfigManager&) = delete;
ConfigManager& operator=(const ConfigManager&) = delete;
// Initialize with default path (~/.local/radiantwave/config.yaml) or custom path
bool initialize(const std::string& custom_path = "");
// Shutdown background watcher
void shutdown();
// Access current config
AppConfig get() const;
// Update config safely and write to disk
bool update(std::function<void(AppConfig& config)> modifier);
// Save current config to disk
bool save();
// Reload from disk immediately
bool reload();
// Register callback for live config updates (hot-reloading)
void onChanged(ConfigCallback callback);
// Get current config file path
std::string getFilePath() const;
private:
std::string resolvePath(const std::string& raw_path) const;
void loadAndMergeDefaults();
bool saveUnlocked();
void startWatcher();
void stopWatcher();
std::string file_path_;
AppConfig config_;
mutable std::mutex mutex_;
// Callbacks
std::vector<ConfigCallback> callbacks_;
std::mutex callback_mutex_;
// Watcher thread
std::thread watcher_thread_;
std::atomic<bool> watcher_running_{false};
std::filesystem::file_time_type last_write_time_;
};
} // namespace radiantwave::config
+263
View File
@@ -0,0 +1,263 @@
#include "yaml_parser.h"
#include <algorithm>
#include <cctype>
#include <sstream>
#include <vector>
namespace radiantwave::config {
namespace {
std::string trim(const std::string& str) {
auto start = str.find_first_not_of(" \t\r\n");
if (start == std::string::npos) return "";
auto end = str.find_last_not_of(" \t\r\n");
return str.substr(start, end - start + 1);
}
std::string unquote(const std::string& str) {
std::string s = trim(str);
if (s.size() >= 2) {
if ((s.front() == '"' && s.back() == '"') ||
(s.front() == '\'' && s.back() == '\'')) {
return s.substr(1, s.size() - 2);
}
}
return s;
}
std::string removeComments(const std::string& line) {
bool in_single_quote = false;
bool in_double_quote = false;
for (size_t i = 0; i < line.size(); ++i) {
char c = line[i];
if (c == '\'' && !in_double_quote) {
in_single_quote = !in_single_quote;
} else if (c == '"' && !in_single_quote) {
in_double_quote = !in_double_quote;
} else if (c == '#' && !in_single_quote && !in_double_quote) {
return line.substr(0, i);
}
}
return line;
}
int countIndent(const std::string& line) {
int indent = 0;
for (char c : line) {
if (c == ' ') indent++;
else if (c == '\t') indent += 2;
else break;
}
return indent;
}
} // anonymous namespace
// ─── YamlNode Implementation ───────────────────────────────────────────
std::string YamlNode::asString(const std::string& defaultValue) const {
if (type == Type::Scalar && !scalar.empty()) {
return scalar;
}
return defaultValue;
}
int YamlNode::asInt(int defaultValue) const {
if (type == Type::Scalar && !scalar.empty()) {
try {
return std::stoi(scalar);
} catch (...) {}
}
return defaultValue;
}
float YamlNode::asFloat(float defaultValue) const {
if (type == Type::Scalar && !scalar.empty()) {
try {
return std::stof(scalar);
} catch (...) {}
}
return defaultValue;
}
bool YamlNode::asBool(bool defaultValue) const {
if (type == Type::Scalar && !scalar.empty()) {
std::string s = scalar;
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return std::tolower(c); });
if (s == "true" || s == "yes" || s == "1" || s == "on") return true;
if (s == "false" || s == "no" || s == "0" || s == "off") return false;
}
return defaultValue;
}
bool YamlNode::hasChild(const std::string& key) const {
if (type != Type::Mapping) return false;
return children.find(key) != children.end();
}
static const YamlNode EMPTY_NODE;
const YamlNode& YamlNode::getChild(const std::string& key) const {
if (type == Type::Mapping) {
auto it = children.find(key);
if (it != children.end()) {
return it->second;
}
}
return EMPTY_NODE;
}
// ─── Parser ─────────────────────────────────────────────────────────────
YamlNode parseYaml(const std::string& content) {
YamlNode root;
root.type = YamlNode::Type::Mapping;
std::stringstream ss(content);
std::string raw_line;
std::string current_section = "";
while (std::getline(ss, raw_line)) {
std::string line_no_comment = removeComments(raw_line);
std::string line = trim(line_no_comment);
if (line.empty()) continue;
int indent = countIndent(line_no_comment);
auto colon_pos = line.find(':');
if (colon_pos == std::string::npos) continue;
std::string key = trim(line.substr(0, colon_pos));
std::string val = trim(line.substr(colon_pos + 1));
if (indent == 0) {
// Top-level key
if (val.empty()) {
// Start a section mapping
current_section = key;
YamlNode section_node;
section_node.type = YamlNode::Type::Mapping;
root.children[key] = section_node;
} else {
current_section = "";
YamlNode scalar_node;
scalar_node.type = YamlNode::Type::Scalar;
scalar_node.scalar = unquote(val);
root.children[key] = scalar_node;
}
} else {
// Indented key (child of current_section)
if (!current_section.empty() && root.children.find(current_section) != root.children.end()) {
YamlNode scalar_node;
scalar_node.type = YamlNode::Type::Scalar;
scalar_node.scalar = unquote(val);
root.children[current_section].children[key] = scalar_node;
}
}
}
return root;
}
std::string serializeYaml(const YamlNode& root) {
std::stringstream ss;
if (root.type == YamlNode::Type::Mapping) {
for (const auto& [key, node] : root.children) {
if (node.isScalar()) {
ss << key << ": \"" << node.scalar << "\"\n";
} else if (node.isMapping()) {
ss << "\n" << key << ":\n";
for (const auto& [child_key, child_node] : node.children) {
ss << " " << child_key << ": \"" << child_node.scalar << "\"\n";
}
}
}
}
return ss.str();
}
// ─── High-Level AppConfig Conversions ──────────────────────────────────
AppConfig parseConfigFromYaml(const std::string& yamlContent) {
AppConfig config = AppConfig::defaults();
YamlNode root = parseYaml(yamlContent);
if (root.hasChild("license_key")) {
config.license_key = root.getChild("license_key").asString(config.license_key);
}
if (root.hasChild("email_address")) {
config.email_address = root.getChild("email_address").asString(config.email_address);
}
if (root.hasChild("provisioning_status")) {
config.provisioning_status = root.getChild("provisioning_status").asString(config.provisioning_status);
}
if (root.hasChild("timezone")) {
config.timezone = root.getChild("timezone").asString(config.timezone);
}
if (root.hasChild("audio")) {
const auto& audio_node = root.getChild("audio");
if (audio_node.hasChild("enabled")) {
config.audio.enabled = audio_node.getChild("enabled").asBool(config.audio.enabled);
}
if (audio_node.hasChild("volume")) {
config.audio.volume = audio_node.getChild("volume").asInt(config.audio.volume);
}
if (audio_node.hasChild("output_device")) {
config.audio.output_device = audio_node.getChild("output_device").asString(config.audio.output_device);
}
if (audio_node.hasChild("current_track")) {
config.audio.current_track = audio_node.getChild("current_track").asString(config.audio.current_track);
}
if (audio_node.hasChild("dynamic_audio")) {
config.audio.dynamic_audio = audio_node.getChild("dynamic_audio").asBool(config.audio.dynamic_audio);
}
}
if (root.hasChild("proclamation")) {
const auto& proc_node = root.getChild("proclamation");
if (proc_node.hasChild("scroll_speed")) {
config.proclamation.scroll_speed = proc_node.getChild("scroll_speed").asFloat(config.proclamation.scroll_speed);
}
if (proc_node.hasChild("font_size")) {
config.proclamation.font_size = proc_node.getChild("font_size").asInt(config.proclamation.font_size);
}
if (proc_node.hasChild("current_id")) {
config.proclamation.current_id = proc_node.getChild("current_id").asString(config.proclamation.current_id);
}
}
return config;
}
std::string serializeConfigToYaml(const AppConfig& config) {
std::stringstream ss;
ss << "# RadiantWave Kiosk Configuration\n\n";
ss << "license_key: \"" << config.license_key << "\"\n";
ss << "email_address: \"" << config.email_address << "\"\n";
ss << "provisioning_status: \"" << config.provisioning_status << "\"\n";
ss << "timezone: \"" << config.timezone << "\"\n\n";
ss << "audio:\n";
ss << " enabled: " << (config.audio.enabled ? "true" : "false") << "\n";
ss << " volume: " << config.audio.volume << "\n";
ss << " output_device: \"" << config.audio.output_device << "\"\n";
ss << " current_track: \"" << config.audio.current_track << "\"\n";
ss << " dynamic_audio: " << (config.audio.dynamic_audio ? "true" : "false") << "\n\n";
ss << "proclamation:\n";
ss << " scroll_speed: " << config.proclamation.scroll_speed << "\n";
ss << " font_size: " << config.proclamation.font_size << "\n";
ss << " current_id: \"" << config.proclamation.current_id << "\"\n";
return ss.str();
}
} // namespace radiantwave::config
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include "app_config.h"
#include <map>
#include <string>
namespace radiantwave::config {
struct YamlNode {
enum class Type { Scalar, Mapping };
Type type = Type::Scalar;
std::string scalar;
std::map<std::string, YamlNode> children;
bool isScalar() const { return type == Type::Scalar; }
bool isMapping() const { return type == Type::Mapping; }
std::string asString(const std::string& defaultValue = "") const;
int asInt(int defaultValue = 0) const;
float asFloat(float defaultValue = 0.0f) const;
bool asBool(bool defaultValue = false) const;
bool hasChild(const std::string& key) const;
const YamlNode& getChild(const std::string& key) const;
};
// Core parser & serializer functions
YamlNode parseYaml(const std::string& content);
std::string serializeYaml(const YamlNode& root);
// High-level AppConfig conversions
AppConfig parseConfigFromYaml(const std::string& yamlContent);
std::string serializeConfigToYaml(const AppConfig& config);
} // namespace radiantwave::config