diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_config.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_config.cpp index d7fac30..8974bc2 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_config.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_config.cpp @@ -1,6 +1,8 @@ #include "cp0_lvgl_app.h" #include "hal_lvgl_bsp.h" +#include "../cp0_config_json.h" + #include #include #include @@ -9,13 +11,16 @@ #include #include #include +#include +#include #include #include -#include -#define CONFIG_DIR "/var/lib/applaunch" -#define CONFIG_FILE CONFIG_DIR "/settings" -#define MAX_ENTRIES 32 +// Unified per-user device config. Everything the launcher persists (app +// visibility, brightness, camera resolution, ...) lives in a single JSON file +// under the user's home, shared with other apps (e.g. the camera app reads +// camera.resolution.{width,height} from here). +#define MAX_ENTRIES 64 #define KEY_MAX 64 #define VAL_MAX 256 @@ -75,14 +80,7 @@ class ConfigSystem { std::lock_guard lock(mutex_); ensure_loaded_locked(); - mkdir(CONFIG_DIR, 0755); - FILE *fp = std::fopen(CONFIG_FILE, "w"); - if (!fp) return; - for (int i = 0; i < count_; i++) { - std::fprintf(fp, "%s=%s\n", entries_[i].key, entries_[i].val); - } - std::fclose(fp); - sync(); + save_locked(); } void api_call(arg_t arg, callback_t callback) @@ -134,31 +132,77 @@ class ConfigSystem bool loaded_ = false; std::mutex mutex_; + static std::string config_dir() + { + const char *home = std::getenv("HOME"); + std::string base = (home && home[0]) ? std::string(home) : std::string("/root"); + return base + "/.config/cardputerzero"; + } + + static std::string config_file() + { + return config_dir() + "/config.json"; + } + void ensure_loaded_locked() { if (!loaded_) load_locked(); } + void add_entry_locked(const std::string &key, const std::string &val) + { + if (key.empty() || count_ >= MAX_ENTRIES) return; + copy_cstr(entries_[count_].key, key.c_str(), KEY_MAX); + copy_cstr(entries_[count_].val, val.c_str(), VAL_MAX); + count_++; + } + void load_locked() { count_ = 0; loaded_ = true; - FILE *fp = std::fopen(CONFIG_FILE, "r"); + std::string text; + if (!read_file(config_file().c_str(), text)) + return; + std::vector> kv; + if (!cp0cfg::from_json(text, kv)) + return; + for (const auto &e : kv) + add_entry_locked(e.first, e.second); + } + + void save_locked() + { + std::vector> kv; + kv.reserve(count_); + for (int i = 0; i < count_; i++) + kv.emplace_back(entries_[i].key, entries_[i].val); + const std::string json = cp0cfg::to_json(kv); + + const std::string dir = config_dir(); + const char *home = std::getenv("HOME"); + std::string base = (home && home[0]) ? std::string(home) : std::string("/root"); + mkdir((base + "/.config").c_str(), 0755); + mkdir(dir.c_str(), 0755); + + FILE *fp = std::fopen(config_file().c_str(), "w"); if (!fp) return; + std::fwrite(json.data(), 1, json.size(), fp); + std::fclose(fp); + sync(); + } - char line[KEY_MAX + VAL_MAX + 4]; - while (std::fgets(line, sizeof(line), fp) && count_ < MAX_ENTRIES) { - line[std::strcspn(line, "\r\n")] = 0; - char *eq = std::strchr(line, '='); - if (!eq) continue; - *eq = 0; - if (line[0] == '\0') continue; - copy_cstr(entries_[count_].key, line, KEY_MAX); - copy_cstr(entries_[count_].val, eq + 1, VAL_MAX); - count_++; - } + static bool read_file(const char *path, std::string &out) + { + FILE *fp = std::fopen(path, "r"); + if (!fp) return false; + char buf[512]; + size_t n; + while ((n = std::fread(buf, 1, sizeof(buf), fp)) > 0) + out.append(buf, n); std::fclose(fp); + return true; } int find_entry_locked(const char *key) const diff --git a/ext_components/cp0_lvgl/src/cp0_config_json.h b/ext_components/cp0_lvgl/src/cp0_config_json.h new file mode 100644 index 0000000..674d399 --- /dev/null +++ b/ext_components/cp0_lvgl/src/cp0_config_json.h @@ -0,0 +1,296 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +// Minimal JSON <-> flat "dotted key" store used by the unified device config +// (~/.config/cardputerzero/config.json). The launcher's config system keeps a +// flat key=value model internally; nested JSON objects map to dotted keys, e.g. +// camera.resolution.width -> { "camera": { "resolution": { "width": .. }}} +// This keeps the shared contract with other apps (the camera app reads +// camera.resolution.{width,height}) while the launcher still uses simple keys. +// +// Only objects, strings and numbers are emitted; arrays/true/false/null are +// tolerated on read but never written (the launcher is the sole writer). + +#include +#include +#include +#include +#include +#include + +namespace cp0cfg { + +inline bool looks_numeric(const std::string &v) +{ + if (v.empty()) + return false; + size_t i = 0; + if (v[0] == '-') { + if (v.size() == 1) + return false; + i = 1; + } + for (; i < v.size(); ++i) + if (!std::isdigit(static_cast(v[i]))) + return false; + // Only treat as a JSON number if it round-trips, so values like "007" keep + // their string form and get_str() returns them unchanged. + long n = std::atol(v.c_str()); + return std::to_string(n) == v; +} + +struct JsonNode { + std::map children; + std::string value; + bool numeric = false; +}; + +inline void json_escape(const std::string &s, std::string &out) +{ + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: out += c; break; + } + } +} + +inline void json_emit(const JsonNode &node, std::string &out, int indent) +{ + if (node.children.empty()) { + if (node.numeric) { + out += node.value; + } else { + out += '"'; + json_escape(node.value, out); + out += '"'; + } + return; + } + const std::string pad(static_cast(indent) * 2, ' '); + const std::string pad2(static_cast(indent + 1) * 2, ' '); + out += "{\n"; + size_t i = 0; + for (const auto &kv : node.children) { + out += pad2; + out += '"'; + json_escape(kv.first, out); + out += "\": "; + json_emit(kv.second, out, indent + 1); + if (++i < node.children.size()) + out += ','; + out += '\n'; + } + out += pad; + out += '}'; +} + +inline std::string to_json(const std::vector> &kv) +{ + JsonNode root; + for (const auto &e : kv) { + const std::string &key = e.first; + if (key.empty()) + continue; + JsonNode *cur = &root; + size_t start = 0; + while (true) { + size_t dot = key.find('.', start); + std::string seg = (dot == std::string::npos) ? key.substr(start) + : key.substr(start, dot - start); + if (seg.empty()) + break; + cur = &cur->children[seg]; + if (dot == std::string::npos) { + cur->value = e.second; + cur->numeric = looks_numeric(e.second); + break; + } + start = dot + 1; + } + } + if (root.children.empty()) + return "{}\n"; + std::string out; + json_emit(root, out, 0); + out += '\n'; + return out; +} + +class JsonReader { +public: + JsonReader(const std::string &text, std::vector> &out) + : p_(text.c_str()), end_(text.c_str() + text.size()), out_(out) + { + } + + bool parse() + { + skip_ws(); + if (p_ >= end_ || *p_ != '{') + return false; + return parse_object(std::string()); + } + +private: + const char *p_; + const char *end_; + std::vector> &out_; + + void skip_ws() + { + while (p_ < end_ && (*p_ == ' ' || *p_ == '\t' || *p_ == '\n' || *p_ == '\r')) + ++p_; + } + + bool parse_string(std::string &s) + { + if (p_ >= end_ || *p_ != '"') + return false; + ++p_; + while (p_ < end_ && *p_ != '"') { + char c = *p_++; + if (c == '\\' && p_ < end_) { + char e = *p_++; + switch (e) { + case 'n': s += '\n'; break; + case 'r': s += '\r'; break; + case 't': s += '\t'; break; + case '"': s += '"'; break; + case '\\': s += '\\'; break; + case '/': s += '/'; break; + default: s += e; break; + } + } else { + s += c; + } + } + if (p_ >= end_) + return false; + ++p_; // closing quote + return true; + } + + bool skip_container(char close) + { + ++p_; // opening bracket/brace + while (p_ < end_) { + skip_ws(); + if (p_ >= end_) + return false; + if (*p_ == close) { + ++p_; + return true; + } + if (*p_ == '"') { + std::string t; + if (!parse_string(t)) + return false; + } else if (*p_ == '{') { + if (!skip_container('}')) + return false; + } else if (*p_ == '[') { + if (!skip_container(']')) + return false; + } else { + ++p_; + } + } + return false; + } + + bool parse_value(const std::string &prefix) + { + skip_ws(); + if (p_ >= end_) + return false; + char c = *p_; + if (c == '{') + return parse_object(prefix); + if (c == '[') + return skip_container(']'); + if (c == '"') { + std::string s; + if (!parse_string(s)) + return false; + record(prefix, s); + return true; + } + const char *start = p_; + while (p_ < end_ && *p_ != ',' && *p_ != '}' && *p_ != ']' && *p_ != ' ' && + *p_ != '\t' && *p_ != '\n' && *p_ != '\r') + ++p_; + std::string tok(start, static_cast(p_ - start)); + if (tok.empty()) + return false; + if (tok == "true") + tok = "1"; + else if (tok == "false") + tok = "0"; + else if (tok == "null") + tok = ""; + record(prefix, tok); + return true; + } + + bool parse_object(const std::string &prefix) + { + skip_ws(); + if (p_ >= end_ || *p_ != '{') + return false; + ++p_; + skip_ws(); + if (p_ < end_ && *p_ == '}') { + ++p_; + return true; + } + while (p_ < end_) { + skip_ws(); + std::string key; + if (!parse_string(key)) + return false; + skip_ws(); + if (p_ >= end_ || *p_ != ':') + return false; + ++p_; + std::string child = prefix.empty() ? key : (prefix + "." + key); + if (!parse_value(child)) + return false; + skip_ws(); + if (p_ < end_ && *p_ == ',') { + ++p_; + continue; + } + if (p_ < end_ && *p_ == '}') { + ++p_; + return true; + } + return false; + } + return false; + } + + void record(const std::string &prefix, const std::string &val) + { + if (!prefix.empty()) + out_.emplace_back(prefix, val); + } +}; + +inline bool from_json(const std::string &text, + std::vector> &out) +{ + JsonReader reader(text, out); + return reader.parse(); +} + +} // namespace cp0cfg diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_config.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_config.cpp index ca98e7b..2959ed5 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_config.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_config.cpp @@ -2,6 +2,8 @@ #include "hal/hal_paths.h" #include "hal_lvgl_bsp.h" +#include "../cp0_config_json.h" + #include #include #include @@ -14,6 +16,7 @@ #include #include #include +#include namespace { @@ -123,11 +126,15 @@ class ConfigSystem { const std::string dir = config_dir(); if (mkdir_p(dir) != 0) return -1; - FILE *fp = std::fopen(join_path(dir, "settings").c_str(), "w"); + std::vector> kv; + kv.reserve(count_); + for (int i = 0; i < count_; ++i) + kv.emplace_back(entries_[i].key, entries_[i].val); + const std::string json = cp0cfg::to_json(kv); + FILE *fp = std::fopen(join_path(dir, "config.json").c_str(), "w"); if (!fp) return -1; - for (int i = 0; i < count_; ++i) - std::fprintf(fp, "%s=%s\n", entries_[i].key, entries_[i].val); + std::fwrite(json.data(), 1, json.size(), fp); std::fclose(fp); return 0; } @@ -181,21 +188,26 @@ class ConfigSystem { { count_ = 0; loaded_ = true; - FILE *fp = std::fopen(join_path(config_dir(), "settings").c_str(), "r"); + FILE *fp = std::fopen(join_path(config_dir(), "config.json").c_str(), "r"); if (!fp) return; - char line[kKeyMax + kValMax + 4]; - while (std::fgets(line, sizeof(line), fp) && count_ < kMaxEntries) { - line[std::strcspn(line, "\r\n")] = '\0'; - char *eq = std::strchr(line, '='); - if (!eq || line[0] == '\0') - continue; - *eq = '\0'; - copy_cstr(entries_[count_].key, sizeof(entries_[count_].key), line); - copy_cstr(entries_[count_].val, sizeof(entries_[count_].val), eq + 1); + std::string text; + char buf[512]; + size_t n; + while ((n = std::fread(buf, 1, sizeof(buf), fp)) > 0) + text.append(buf, n); + std::fclose(fp); + + std::vector> kv; + if (!cp0cfg::from_json(text, kv)) + return; + for (const auto &e : kv) { + if (count_ >= kMaxEntries) + break; + copy_cstr(entries_[count_].key, sizeof(entries_[count_].key), e.first.c_str()); + copy_cstr(entries_[count_].val, sizeof(entries_[count_].val), e.second.c_str()); ++count_; } - std::fclose(fp); } int find_entry_locked(const char *key) const diff --git a/projects/APPLaunch/main/ui/page_app/ui_app_setup.hpp b/projects/APPLaunch/main/ui/page_app/ui_app_setup.hpp index 0b13e42..fed4557 100644 --- a/projects/APPLaunch/main/ui/page_app/ui_app_setup.hpp +++ b/projects/APPLaunch/main/ui/page_app/ui_app_setup.hpp @@ -453,7 +453,7 @@ class UISetupPage : public AppPage { val_title_ = "Resolution"; val_options_ = {"1280x720", "640x480"}; - val_sel_idx_ = 0; + val_sel_idx_ = (config_get_int("camera.resolution.width", 1280) == 640) ? 1 : 0; view_state_ = ViewState::VALUE_SELECT; transition_enter_level(); } @@ -1961,7 +1961,12 @@ class UISetupPage : public AppPage } else if (val_title_ == "Volume") { apply_volume(); } else if (val_title_ == "Resolution") { - config_set_int("cam_resolution", val_sel_idx_); + // Publish the real width/height to the shared user config the + // camera app reads (camera.resolution.{width,height}). + int width = 1280, height = 720; + if (val_sel_idx_ == 1) { width = 640; height = 480; } + config_set_int("camera.resolution.width", width); + config_set_int("camera.resolution.height", height); config_save(); } else if (val_title_ == "Startup") { config_set_int("startup_mode", val_sel_idx_);