From ba26bd0cb71386ef847328167258c94d20b2bd1f Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Thu, 26 Mar 2026 22:37:53 +0000 Subject: [PATCH] Add config module: INI loader with schema-driven defaults Config_Def schema tables declare section/key/type/default per module. Typed getters: config_get_str, _u16, _u32, _flags. FLAGS type parses space/comma-separated tokens via a Config_Flag_Def table. config_defaults() gives schema defaults without a file. config_dump() prints effective values for diagnostics. config_cli: load a file or --defaults and dump effective config. dev/example.cfg: sample config covering node, discovery, transport. Co-Authored-By: Claude Sonnet 4.6 --- dev/cli/Makefile | 11 +- dev/cli/config_cli.c | 73 ++++++++++ dev/example.cfg | 17 +++ include/config.h | 76 +++++++++++ planning.md | 2 +- src/modules/config/Makefile | 17 +++ src/modules/config/config.c | 264 ++++++++++++++++++++++++++++++++++++ 7 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 dev/cli/config_cli.c create mode 100644 dev/example.cfg create mode 100644 include/config.h create mode 100644 src/modules/config/Makefile create mode 100644 src/modules/config/config.c diff --git a/dev/cli/Makefile b/dev/cli/Makefile index 5bb8cbf..22bafca 100644 --- a/dev/cli/Makefile +++ b/dev/cli/Makefile @@ -8,6 +8,7 @@ V4L2_CTRL_OBJ = $(BUILD)/v4l2_ctrl/v4l2_ctrl.o SERIAL_OBJ = $(BUILD)/serial/serial.o TRANSPORT_OBJ = $(BUILD)/transport/transport.o DISCOVERY_OBJ = $(BUILD)/discovery/discovery.o +CONFIG_OBJ = $(BUILD)/config/config.o .PHONY: all clean modules @@ -15,7 +16,8 @@ all: modules \ $(CLI_BUILD)/media_ctrl_cli \ $(CLI_BUILD)/v4l2_ctrl_cli \ $(CLI_BUILD)/transport_cli \ - $(CLI_BUILD)/discovery_cli + $(CLI_BUILD)/discovery_cli \ + $(CLI_BUILD)/config_cli modules: $(MAKE) -C $(ROOT)/src/modules/common @@ -24,6 +26,7 @@ modules: $(MAKE) -C $(ROOT)/src/modules/serial $(MAKE) -C $(ROOT)/src/modules/transport $(MAKE) -C $(ROOT)/src/modules/discovery + $(MAKE) -C $(ROOT)/src/modules/config $(CLI_BUILD)/media_ctrl_cli: media_ctrl_cli.c $(COMMON_OBJ) $(MEDIA_CTRL_OBJ) | $(CLI_BUILD) $(CC) $(CFLAGS) -o $@ $^ @@ -37,6 +40,9 @@ $(CLI_BUILD)/transport_cli: transport_cli.c $(COMMON_OBJ) $(SERIAL_OBJ) $(TRANSP $(CLI_BUILD)/discovery_cli: discovery_cli.c $(COMMON_OBJ) $(SERIAL_OBJ) $(DISCOVERY_OBJ) | $(CLI_BUILD) $(CC) $(CFLAGS) -o $@ $^ -lpthread +$(CLI_BUILD)/config_cli: config_cli.c $(COMMON_OBJ) $(CONFIG_OBJ) | $(CLI_BUILD) + $(CC) $(CFLAGS) -o $@ $^ + $(CLI_BUILD): mkdir -p $@ @@ -45,4 +51,5 @@ clean: $(CLI_BUILD)/media_ctrl_cli \ $(CLI_BUILD)/v4l2_ctrl_cli \ $(CLI_BUILD)/transport_cli \ - $(CLI_BUILD)/discovery_cli + $(CLI_BUILD)/discovery_cli \ + $(CLI_BUILD)/config_cli diff --git a/dev/cli/config_cli.c b/dev/cli/config_cli.c new file mode 100644 index 0000000..fc9a1d4 --- /dev/null +++ b/dev/cli/config_cli.c @@ -0,0 +1,73 @@ +#include +#include + +#include "config.h" +#include "discovery.h" +#include "transport.h" +#include "error.h" + +/* + * Example schema covering node identity, discovery, and transport. + * Each module would normally provide its own schema table; they are + * combined here for the CLI demo. + */ + +static const struct Config_Flag_Def function_flag_defs[] = { + { "source", DISCOVERY_FLAG_SOURCE }, + { "relay", DISCOVERY_FLAG_RELAY }, + { "sink", DISCOVERY_FLAG_SINK }, + { "controller", DISCOVERY_FLAG_CONTROLLER }, + { NULL, 0 } +}; + +static const struct Config_Def schema[] = { + /* section key type default */ + { "node", "name", CONFIG_STRING, "unnamed:0", NULL }, + { "node", "site_id", CONFIG_UINT16, "0", NULL }, + { "node", "tcp_port", CONFIG_UINT16, "8000", NULL }, + { "node", "function", CONFIG_FLAGS, "source", function_flag_defs }, + + { "discovery", "interval_ms", CONFIG_UINT32, "5000", NULL }, + { "discovery", "timeout_intervals", CONFIG_UINT32, "3", NULL }, + { "discovery", "connect_mask", CONFIG_FLAGS, "", function_flag_defs }, + + { "transport", "max_connections", CONFIG_UINT32, "16", NULL }, + { "transport", "max_payload", CONFIG_UINT32, "16777216", NULL }, + + { NULL } +}; + +static void usage(void) { + fprintf(stderr, + "usage: config_cli \n" + " config_cli --defaults\n" + "\n" + " load config file and print effective values\n" + " --defaults print schema defaults without a file\n"); +} + +int main(int argc, char **argv) { + if (argc < 2) { + usage(); + return 1; + } + + struct Config *cfg; + struct App_Error err; + + if (argv[1][0] == '-') { + err = config_defaults(&cfg, schema); + } else { + err = config_load(&cfg, argv[1], schema); + } + + if (!APP_IS_OK(err)) { + fprintf(stderr, "config_load: errno %d\n", err.detail.syscall.err_no); + return 1; + } + + config_dump(cfg); + + config_free(cfg); + return 0; +} diff --git a/dev/example.cfg b/dev/example.cfg new file mode 100644 index 0000000..abff5fe --- /dev/null +++ b/dev/example.cfg @@ -0,0 +1,17 @@ +# Example node configuration. +# All keys are optional — missing keys use schema defaults. + +[node] +name = v4l2:microscope +site_id = 0 +tcp_port = 8001 +function = source + +[discovery] +interval_ms = 5000 +timeout_intervals = 3 +connect_mask = relay, controller ; auto-connect to relays and controllers + +[transport] +max_connections = 16 +max_payload = 16777216 ; 16 MB diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..a7fc332 --- /dev/null +++ b/include/config.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include "error.h" + +/* + * INI-style config file loader with schema-driven defaults and validation. + * + * File format: + * [section] + * key = value ; inline comments with ; or # + * key = value # same + * + * Whitespace around keys and values is stripped. + * Commas in values are treated as whitespace (useful for flag lists). + */ + +typedef enum Config_Type { + CONFIG_STRING, /* arbitrary string value */ + CONFIG_UINT16, /* decimal integer, fits u16 */ + CONFIG_UINT32, /* decimal integer, fits u32 */ + CONFIG_FLAGS, /* space/comma-separated tokens mapped via a flag table */ +} Config_Type; + +/* + * One entry in a flags table — maps a token string to a bitmask value. + * The table must be terminated by an entry with token = NULL. + */ +struct Config_Flag_Def { + const char *token; + uint32_t value; +}; + +/* + * One entry in a config schema. + * Provide a table of these terminated by an entry with section = NULL. + */ +struct Config_Def { + const char *section; + const char *key; + Config_Type type; + const char *default_val; /* string form of the default */ + const struct Config_Flag_Def *flags; /* required when type = CONFIG_FLAGS */ +}; + +struct Config; + +/* + * Load a config file and validate it against the schema. + * Keys present in the file but absent from the schema are ignored. + * Keys absent from the file are filled with their schema default. + */ +struct App_Error config_load(struct Config **out, + const char *path, + const struct Config_Def *schema); + +/* + * Load defaults only (no file). Useful when no config file is present + * but you still want schema-driven defaults. + */ +struct App_Error config_defaults(struct Config **out, + const struct Config_Def *schema); + +void config_free(struct Config *cfg); + +/* + * Typed getters. Return the effective value (file value or default). + * Caller must not free the returned string — it is owned by the Config. + */ +const char *config_get_str (struct Config *cfg, const char *section, const char *key); +uint16_t config_get_u16 (struct Config *cfg, const char *section, const char *key); +uint32_t config_get_u32 (struct Config *cfg, const char *section, const char *key); +uint32_t config_get_flags(struct Config *cfg, const char *section, const char *key); + +/* Print all effective key/value pairs to stdout — useful for diagnostics. */ +void config_dump(struct Config *cfg); diff --git a/planning.md b/planning.md index 3727b3a..a6ebd2e 100644 --- a/planning.md +++ b/planning.md @@ -57,7 +57,7 @@ Modules are listed in intended build order. Each depends only on modules above i | 14 | `xorg` | not started | X11 screen geometry queries (XRandR), screen grab source (calls codec), frame viewer sink — see architecture.md | | 15 | `web node` | not started | Node.js/Express peer — speaks binary protocol on socket side, HTTP/WebSocket to browser; `protocol.mjs` mirrors C protocol module | | — | `mjpeg_scan` | future | EOI marker scanner for misbehaving hardware that does not deliver clean per-buffer frames; not part of the primary pipeline | -| — | `config` | future | Unified config file reader; nodes currently configured via CLI args — needed before production deployment | +| — | `config` | done | INI file loader with schema-driven defaults, typed getters, FLAGS type for bitmask values | --- diff --git a/src/modules/config/Makefile b/src/modules/config/Makefile new file mode 100644 index 0000000..58610ab --- /dev/null +++ b/src/modules/config/Makefile @@ -0,0 +1,17 @@ +ROOT := $(abspath ../../..) +include $(ROOT)/common.mk + +MODULE_BUILD = $(BUILD)/config + +.PHONY: all clean + +all: $(MODULE_BUILD)/config.o + +$(MODULE_BUILD)/config.o: config.c $(ROOT)/include/config.h | $(MODULE_BUILD) + $(CC) $(CFLAGS) -c -o $@ $< + +$(MODULE_BUILD): + mkdir -p $@ + +clean: + rm -f $(MODULE_BUILD)/config.o diff --git a/src/modules/config/config.c b/src/modules/config/config.c new file mode 100644 index 0000000..00cc76c --- /dev/null +++ b/src/modules/config/config.c @@ -0,0 +1,264 @@ +#include +#include +#include +#include +#include + +#include "config.h" + +#define MAX_ENTRIES 256 +#define MAX_LINE 512 +#define MAX_STR 256 + +struct Config_Entry { + char section[MAX_STR]; + char key[MAX_STR]; + char value[MAX_STR]; +}; + +struct Config { + struct Config_Entry entries[MAX_ENTRIES]; + int count; + const struct Config_Def *schema; +}; + +/* -- string helpers -------------------------------------------------------- */ + +static void strip(char *s) { + /* strip leading whitespace */ + char *start = s; + while (*start && isspace((unsigned char)*start)) { start++; } + + /* strip trailing whitespace */ + char *end = start + strlen(start); + while (end > start && isspace((unsigned char)*(end - 1))) { end--; } + *end = '\0'; + + if (start != s) { memmove(s, start, (size_t)(end - start) + 1); } +} + +/* strip inline comment (# or ;) that appears outside a value */ +static void strip_comment(char *s) { + for (char *p = s; *p; p++) { + if (*p == '#' || *p == ';') { + *p = '\0'; + break; + } + } +} + +/* replace commas with spaces so flag lists parse uniformly */ +static void commas_to_spaces(char *s) { + for (char *p = s; *p; p++) { + if (*p == ',') { *p = ' '; } + } +} + +/* -- schema lookup --------------------------------------------------------- */ + +static const struct Config_Def *find_def(const struct Config_Def *schema, + const char *section, const char *key) +{ + for (const struct Config_Def *d = schema; d->section; d++) { + if (strcmp(d->section, section) == 0 && strcmp(d->key, key) == 0) { + return d; + } + } + return NULL; +} + +/* -- entry lookup ---------------------------------------------------------- */ + +static struct Config_Entry *find_entry(struct Config *cfg, + const char *section, const char *key) +{ + for (int i = 0; i < cfg->count; i++) { + if (strcmp(cfg->entries[i].section, section) == 0 + && strcmp(cfg->entries[i].key, key) == 0) { + return &cfg->entries[i]; + } + } + return NULL; +} + +static struct Config_Entry *add_entry(struct Config *cfg, + const char *section, const char *key, const char *value) +{ + if (cfg->count >= MAX_ENTRIES) { return NULL; } + struct Config_Entry *e = &cfg->entries[cfg->count++]; + strncpy(e->section, section, MAX_STR - 1); + strncpy(e->key, key, MAX_STR - 1); + strncpy(e->value, value, MAX_STR - 1); + e->section[MAX_STR - 1] = '\0'; + e->key [MAX_STR - 1] = '\0'; + e->value [MAX_STR - 1] = '\0'; + return e; +} + +/* -- fill defaults --------------------------------------------------------- */ + +static void fill_defaults(struct Config *cfg) { + for (const struct Config_Def *d = cfg->schema; d->section; d++) { + if (!find_entry(cfg, d->section, d->key)) { + add_entry(cfg, d->section, d->key, + d->default_val ? d->default_val : ""); + } + } +} + +/* -- parse ----------------------------------------------------------------- */ + +static struct App_Error parse_file(struct Config *cfg, const char *path) { + FILE *f = fopen(path, "r"); + if (!f) { return APP_SYSCALL_ERROR(); } + + char line[MAX_LINE]; + char section[MAX_STR] = ""; + + while (fgets(line, sizeof(line), f)) { + strip_comment(line); + strip(line); + + if (line[0] == '\0') { continue; } + + if (line[0] == '[') { + /* section header */ + char *end = strchr(line, ']'); + if (!end) { continue; } + *end = '\0'; + strncpy(section, line + 1, MAX_STR - 1); + section[MAX_STR - 1] = '\0'; + strip(section); + continue; + } + + char *eq = strchr(line, '='); + if (!eq || section[0] == '\0') { continue; } + + *eq = '\0'; + char *key = line; + char *val = eq + 1; + strip(key); + strip(val); + + if (key[0] == '\0') { continue; } + + /* only store keys that appear in the schema */ + const struct Config_Def *def = find_def(cfg->schema, section, key); + if (!def) { continue; } + + /* normalise flag values: commas → spaces */ + char normalised[MAX_STR]; + strncpy(normalised, val, MAX_STR - 1); + normalised[MAX_STR - 1] = '\0'; + if (def->type == CONFIG_FLAGS) { commas_to_spaces(normalised); } + + struct Config_Entry *e = find_entry(cfg, section, key); + if (e) { + strncpy(e->value, normalised, MAX_STR - 1); + } else { + add_entry(cfg, section, key, normalised); + } + } + + fclose(f); + return APP_OK; +} + +/* -- public API ------------------------------------------------------------ */ + +struct App_Error config_load(struct Config **out, const char *path, + const struct Config_Def *schema) +{ + struct Config *cfg = calloc(1, sizeof(*cfg)); + if (!cfg) { return APP_SYSCALL_ERROR(); } + cfg->schema = schema; + + struct App_Error err = parse_file(cfg, path); + if (!APP_IS_OK(err)) { + free(cfg); + return err; + } + + fill_defaults(cfg); + *out = cfg; + return APP_OK; +} + +struct App_Error config_defaults(struct Config **out, + const struct Config_Def *schema) +{ + struct Config *cfg = calloc(1, sizeof(*cfg)); + if (!cfg) { return APP_SYSCALL_ERROR(); } + cfg->schema = schema; + fill_defaults(cfg); + *out = cfg; + return APP_OK; +} + +void config_free(struct Config *cfg) { + free(cfg); +} + +const char *config_get_str(struct Config *cfg, + const char *section, const char *key) +{ + struct Config_Entry *e = find_entry(cfg, section, key); + return e ? e->value : ""; +} + +uint16_t config_get_u16(struct Config *cfg, + const char *section, const char *key) +{ + struct Config_Entry *e = find_entry(cfg, section, key); + if (!e || e->value[0] == '\0') { return 0; } + return (uint16_t)strtoul(e->value, NULL, 10); +} + +uint32_t config_get_u32(struct Config *cfg, + const char *section, const char *key) +{ + struct Config_Entry *e = find_entry(cfg, section, key); + if (!e || e->value[0] == '\0') { return 0; } + return (uint32_t)strtoul(e->value, NULL, 10); +} + +uint32_t config_get_flags(struct Config *cfg, + const char *section, const char *key) +{ + struct Config_Entry *e = find_entry(cfg, section, key); + if (!e || e->value[0] == '\0') { return 0; } + + const struct Config_Def *def = find_def(cfg->schema, section, key); + if (!def || !def->flags) { return 0; } + + uint32_t result = 0; + char buf[MAX_STR]; + strncpy(buf, e->value, MAX_STR - 1); + buf[MAX_STR - 1] = '\0'; + + char *token = strtok(buf, " \t"); + while (token) { + for (const struct Config_Flag_Def *fd = def->flags; fd->token; fd++) { + if (strcmp(token, fd->token) == 0) { + result |= fd->value; + break; + } + } + token = strtok(NULL, " \t"); + } + + return result; +} + +void config_dump(struct Config *cfg) { + const char *cur_section = NULL; + for (int i = 0; i < cfg->count; i++) { + struct Config_Entry *e = &cfg->entries[i]; + if (!cur_section || strcmp(cur_section, e->section) != 0) { + printf("[%s]\n", e->section); + cur_section = e->section; + } + printf(" %-24s = %s\n", e->key, e->value); + } +}