Простое устройство, куча установочных параметров, как красиво каждый параметр устанавливать/проверять/сохранять?

этого тезиса не пойму. Почему параметры могут быть только всей кучей, либо все врознь? Почему на каждый логический блок нельзя сделать свой класс, или свою связку структура + функция_инициализатор, которые и таскать между проектами? Уже прям хочется попросить вас конкретный пример логического (!) разделения на модули.

Да и в чем собственно вопрос? В хранении параметров, выводе на экран и вводе значений пользователем? То есть вы хотите, чтобы параметры и их валидаторы хранились каждый в своей сущности своего блока, но могли доставаться/устанавливаться одним и тем же набором функций/методов?

Как бы, логично что бы один модуль имел дело только со своими параметрами. И ничего не знал о других. С другой стороны, проще что бы этими всеми параметрами занимался отдельный модуль - загружал, проверял, сохранял. А вот это мне и не нравится. Т.е. выбор между проще и логичнее. И я уже сделал как проще, но склоняюсь всё же к другому варианту.

Как известно, лень - двигатель прогресса. Но, известно так же, поленишься сейчас - потом дороже выйдет. Вот и в раздумьях. Хочется аккуратненько и красивенько, но это не значит что лучше.

тут как ни крути, а придется искать компромисс. Оно либо где-то под капотом будет сложнее, но проще в дальнейшем использовании, либо наоборот.

Если у вас всегда один параметр определяется одним значением, а функциональный блок в коде инициализируется ровно 1 раз, то вроде бы (!) можно описать структуру:

{ имя_параметра, // скорее всего подойдет только строка или массив char
ссылка_на_функцию,
ссылка_на_следующий_элемент };

Глобально хранится только ссылка на такую структуру, в которой изначально NULL, но по мере добавления параметров будет адрес на заполненную структуру, в которой на следующую и так далее (в последней будет NULL)
При инициализации блока (например конструктор класса) создается элемент структуры такого же типа, заполняется имя_параметра и ссылка_на_функцию - валидатор/присвоятор значения параметра. Ссылка на созданную структуру вяжется в последний элемент цепочки.
Если надо задать параметр - ищем его по имени и присваиваем значение через функцию по ссылке.
Создадите методы на добавление параметра, добавление значения параметра и чтение ранее установленного параметра - по идее достаточно. Но граблей в этом подходе навалом

Да, кругом одни компромисы. А без этого никак. Вся жизнь состоит из компромисов.) Иначе, очень трудно.)

где-то выше я предлагал синтаксис файла настроек, типа такого:

настройки {
  газовая_плита {
     gas_flow=0.6
     stoves_num=4
  }
  утюг {
    enabled=0
    max_temperature=666
  }
  кухонная_доска {
    features=wooden
  }
}

и обращение к ним по таким вот именам:

"настройки.утюг.enabled"

В таком формате очень легко добавлять новые настройки в файл, не разрушая работоспособность старого софта. Изменять никаких структур не нужно, что удобно, когда выпускаются новые версии софта и меняется немного конфигурационный файл

Обращение к настройкам по именам - тоже удобнее, чем по каким-нибудь индексам или прочим идентификаторам. Код читать проще.

Я от нечего делать написал крохотную библиотеку для ардуины, которая как раз вышеописанное реализует.

Но есть проблема - я понятия не имею, как приаттачить сюда файл исходного кода. На сайте про программирование. Мда уж.

prefs.h : прототипы функций из библиотеки

#ifndef prefs_h
#define prefs_h
//
// Мини-библиотека реализующая простенький функционал "Файл настроек"
//
// Терминология:
//
// Файл настроек (или файл параметров) - это не обязательно физический файл в файловой системе. 
// Библиотека работает с буфером, с областью памяти, в которой и лежит этот файл настроек. 
// Фактически, это просто строка, которая, как и положено в Си, должна заканчиваться нулём.
//
//
// Файл настроек это текстовая строка, выглядит, например, так:
//
// esp32 {
//  core {
//     clocks {
//        pll = 240
//        xtal=40
//        apb =  -80
//        features=
//        extra_flags=777
//     }
//     pwm {
//       enabled = yes
//       freq = 666
//       duty = 0.78
//     }
//  }
//}
//esp32s3 {
//  core {
//     clocks 
//     {
//        pll = 480
//        xtal=80
//        apb =  160
//        features=
//     }
//     pwm {
//       enabled = yes
//       freq = 6666
//       duty = 0.87
//     }
//   }
// }
//
// Эта строка подается на вход функции pref_reload(), которая создает **список параметров** или **список настроек**,
// после чего сам файл настроек становится больше не нужным и память, которую он занимал, можно освободить.
// Таких файлов настроек можно загрузить несколько: функция pref_append() ДОБАВЛЯЕТ новый фал настроек к предыдующему,
// pref_reload() - затирает старые настройки новыми.
//
// Окей, данные загружены (вызовом pref_reload() или pref_append()). Как считывать значения? 
// -----------------------------------------------------------------------------------------
//
// Используя функции pref_string(), pref_uint(), pref_int() и pref_float(), чтобы получить значение в виде
// строчки, беззнакового и знакового целых или числа с плавающей точкой. В качестве имени параметра указывается
// полный путь до этого параметра. Так, например, в файл-примере выше, в качестве имени параметра extra_flags
// следует указать "esp32.core.clocks.extra_flags"
// 
// Пример получения значения параметра:
//          const char *value = pref_string("esp32.core.clocks.apb",    "80");
//                                           ^------ название параметра ^-- значение по умолчанию
//
// Результат выполнения perf_string() - строка, со значением (см. пример файла выше) "-80". Если бы параметр 
// "esp32.core.clocks.apb" в файле отсутствовал, тогда результатом была бы строка "80" - второй аргумент функции
// pref_string(). Втрой аргумент этой функции может быть чем угодно, и это значение в неизменненном виде будет 
// возвращено, если указанная настройка в файле не будет найдена.
//
// Если нужна не строчка, а число, то можно использовать одну из функций ниже:
//
//  uint32_t pref_uint (const char *name, uint32_t default)
//  int32_t  pref_int  (const char *name,  int32_t default)
//  uint32_t pref_float(const char *name,    float default)
//
// Проверить, есть ли настройка в файле можно так:
//
//  if (pref_string("some.pref.value",NULL)) {
//    // Настройка в файле имеется
//    ...
//  }
// 
// ПАМЯТЬ
// ------
// После разбора файла настроек, они сохраняются в памяти, в относительно компактном виде: имя настройки сжимается
// до двух байт (вне зависимости от длины имени :) привет BABOSик!)
//
// Значение хранится в виде строки, и дополнительная память, необъодимая библиотеке составляет 8 байт на каждый
// параметр + сам параметр, в виде строчки
//

#include <stdint.h>
#include <stdbool.h>

#ifdef __cplusplus
extern "C" {
#endif


// Прочитать файл с настройками, (лучше использовать макросы ниже)
//
// Аргументы:
//  /p/ - указатель на память, где находится файл с настройками. 
//        Функция ожидает, что файл должен заканчиваться нулем!
//
//
//  /append/ - удалять уже существующие параметры или добавить новые к существуюшим?
//             true - если нужно объеденить содержимое нескольких файлов
//             false - если нужно "перечитать" файл настроек
//
//  ЕСЛИ оба параметра функции равны нулю, то такой вызов высвобождает память, 
//  занятую под хранение параметров. Параметры становятся недоступны.
//
// Возвращает:
//
//  Возвращает /false/ если была вызвана с обоими аргументами равными нулю. В остальных
//  случаях возвращает /true/
//
bool pref_parse(const char *p, bool append);

#define pref_append(_P) pref_parse(_P,true)       // прочитать новые настройки в дополнение к старым
#define pref_reload(_P) pref_parse(_P,false)      // перезаписать старые настройки новыми
#define pref_purge()    pref_parse(0,0)           // освободить всю память используемую библиотекой


// Возвращает значение-строчку, по имени настройки /name/.
// Если запрошенного параметра (с именем /name/) в файле настроек не было, то функция
// вернет свой второй аргумент, "значение по умолчанию". Функция не проверяет
// и не использует значение /def/, поэтому там может быть что угодно, включая
// 0 и указатели на несуществющую память. С помощью второго параметра, в частности, можно 
// проверять, есть ли нужный параметр в файле или нет.
//
// Аргументы:
//  /name/ - указатель на строчку-название параметра (например "firmware.spi_settings.bus_freq") или NULL
//  /def/  - значение по умолчанию: значение, которое вернет функция, если параметр в файле отсутствует
// Возвращает:
//  
//  Указатель на строчку, содержащую *значение* параметра /name/
//  Возвращает /def/, если имя /name/ не найдено
//  Может вернуть NULL только если /def/ == NULL
//
const char *pref_string(const char *name, const char *def);


// То же самое, что и pref_string, но результат сконвертирован к 32-битному значению
//
// Аргументы:
// /name/ - указатель на строчку-название параметра (например "firmware.spi_settings.bus_freq") или NULL
// /def/  - значение по умолчанию: значение, которое вернет функция, если параметр в файле отсутствует
// Возвращает:
//  Беззнаковое 32-битное целое число, значение параметра /name/
//  Возвращает /def/, если имя /name/ не найдено
//
uint32_t pref_uint(const char *name, uint32_t def);

// То же самое, что и pref_uint(), но для чисел со знаком
//
int32_t pref_int(const char *name, int32_t def);

// И еще разок, но теперь уже для чисел с плавающей точкой
float pref_float(const char *name, float def);
#ifdef __cplusplus
};
#endif
#endif // prefs_h

pref.c : сама библиотека

//#define TEST 1

#include <stdint.h>
#include <stdbool.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

#include "prefs.h"

#pragma GCC diagnostic ignored "-Wimplicit-fallthrough"

// Список настроек, который строит парсер (pref_parse()) на основании файла настроек. После того,
// как этот список создан (т.е. функция pref_parse() была вызвана), нужды в файле настроек больше нет, 
// память, которую он занимал можно освобождать.
//
// Список настроек не содержит названий настроек. Вместо этого используется 16-ти битовый хэш (MCU friendly code yay!)
//
static struct prefentry {

  struct prefentry *next;     // 
  uint16_t          lvhash;   // хэш имени параметра
  char              rval[0];  // значение параметра в виде С-строчки (можно отдавать atoi(), atol() или atof())

} *prefs = NULL;


// Проверить, является ли символ /_X/ значимым
#define is_space(_X) ((_X) <= ' ')

// Проверить, является ли символ /c/ концом строки.
static inline __attribute__((const)) bool is_eol(const char c) {
    return c == '\n' || c == '\r' || c == '\0';
}


// Вычислить 16-битный хэш для строки /s/.
// 
static uint16_t lval_hash(const char *s) {

  uint32_t hash = 0;
  char ch;

  while ((ch = *s++) != '\0')
    hash = hash + ((hash) << 5) + ch + (ch << 7);

  return (uint16_t)(((hash) ^ (hash >> 16)) & 0xffff);
}


// Добавить очередной символ к имени параметра (т.е. к lvalue), добавить терминатор
//
static int lval_add_sym(char *lval, char c, uint8_t wp) {
  if (wp == 255) {
#if TEST
    printf("lval_add_sym() : scratch buffer overflow (c=%u)\r\n",  c);
#endif
  }
  else {
    lval[wp++] = c;
    lval[wp] = '\0';
  }
  return wp;
}

// Отрезать от lvalue последний компонент, оставить точку
//
// "kitty.poo.r." превращается в "kitty.poo."
// "cat.whiskers.amount" превращается в "cat.whiskers."
//
static int lval_strip_last(char *lval, uint8_t wp) {
  if (wp > 0) {
    if (lval[wp - 1] == '.')
      wp--;
  
    while(wp > 0 && lval[wp - 1] != '.')
      wp--;
  }
  lval[wp] = '\0';
  return wp;
}



//  Найти начало (первые осмысленные символы) значения параметра (т.е. rvalue).
//  "     some random   text   here     "  -- значение параметра
//        ^-начало                         -- то, что найдет функция

static const char *rval_begin(const char *p) {
  while (!is_eol(*p) && is_space(*p))
    p++;
  return p;
}

//  Ищем конец текста, возвращает указатель на символ следующий за последним символом текста.
//  "     some random   text   here     "
//                                 ^--end
static const char *rval_end(const char *p) {
  char const * candidate = 0;

  for (; !is_eol(*p); p++) {

    if (is_space(*p)) {
      if (!candidate)
        candidate = p;
    } else
      candidate = 0;
  }
  return candidate ? candidate : p;
}


// Распознали очередную опцию - сохраним ее в виде элемента списка prefs
// Память выделяем одним блоком, имя настройки (lvalue) не сохраняется. Вместо него сохраняется 16-битный хэш, для экономии памяти.
// Параметр /rval/ содержит "сырой" rval - с пробелами 
// Параметр /lval/ не содержит пробелов (символов с кодом <= ' ').
//
static const char *pref_add_value(char *lval, const char *rval) {

  struct prefentry *pe;
  int siz;
  const char *begin, *end;

  end = begin = rval_begin(rval); // end - begin == 0

  
  if (!is_space(*begin))
    end = rval_end(begin + 1);

  siz = end - begin;

  if ((pe = (struct prefentry *)malloc(sizeof(struct prefentry) + siz + 1)) != NULL) {

    pe->next = prefs;
    prefs = pe;

    if (siz)
      memcpy(pe->rval, begin, siz);

    pe->rval[siz] = '\0';

    pe->lvhash = lval_hash(lval);
#if TEST
    printf("pref_add_value() : \"%s\" (hash: %04x) = \"%s\"\r\n", lval, pe->lvhash, pe->rval);
#endif
  } else {
    // нету памяти. скорее всего и printf отсюда не выполнится.
    // ничего не поделать, молча продолжаем пытаться читать другие параметры
  }

  return end;
}


// Прочитать файл с настройками.
//
bool pref_parse(const char *p, bool append) {

  char     c,         // очередная буковка
           cp = 0;    // прежняя буковка (или 0)

  char     lval[256]; 
  uint8_t  wp = 0;    

  lval[0] = '\0';

  // /append/ == true : добавляем новые настройки к существующим
  // /append/ == false : читаем новые настройки, старые - удаляем
  if (!append && prefs) {

    struct prefentry *pe = prefs;
    while (pe) {
      struct prefentry *pn = pe->next;
      free(pe);
      pe = pn;
    }
    prefs = NULL;
  }

  // специальный случай: pref_parse(0,0) - высвобождает всю память занятую
  // настройками.
  if (p == NULL)
    return false;

  // пробегаемся по всему файлу, до конца
  // /c/ - это очередной прочитанный символ, а /cp/ - тот, что был перед ним
  // 
  for( ;(c = *p) != '\0'; cp = c, p++) {

    // пропускаем пустое место (т.н. "пробелы", символы с кодами меньше чем 33)
    if (is_space(c))
        continue;

    // Если прошлый символ был "\", то текущий символ никак не обрабатывается (т.е. является обычной буквой) 
    // Если же прошлый символ был другой, то текущий проверяется на специальные значения; {,},=
    // Если хочется использовать специальные символы в качестве имени (lvalue), то следует вставить "\" 
    // перед специальным символом (например так;  value\{1\}=666).
 
    if ( cp != '\\' ) {
      switch( c ) {
        case '=' : p = pref_add_value(lval,p + 1); // добрались до конца lvalue, создаем и добавляем новый элемент в список настроек.
        case '}' : wp = lval_strip_last(lval, wp); // в аккумуляторе lvalue отрезаем последний элемент с точкой ("sample.pref." --> "sample.")
        case '\\': continue;                       // к следующему символу
        case '{' : c = '.'; 
        default  :                                 // остальные символы идут без обработки
      }
    }
    // добавить очередной символ к lvalue
    wp = lval_add_sym(lval, c, wp);
  }
  return true;
}


// Возвращает значение-строчку, по имени настройки /name/.
// Если запрошенного параметра (с именем /name/) в файле настроек не было, то функция
// вернет свой второй аргумент, "значение по умолчанию".
//
const char *pref_string(const char *name, const char *def) {
  struct prefentry *pe;
  uint16_t hash;
  if (name)
    for (hash = lval_hash(name), pe = prefs; pe; pe = pe->next)
      if (pe->lvhash == hash)
        return pe->rval;
  return def;
}

// То же самое, что и pref_string, но результат сконвертирован к 32-битному значению
//
uint32_t pref_uint(const char *name, uint32_t def) {
  const char *s = pref_string(name, NULL);
  return s ? (uint32_t )atol(s) : def;
}

// То же самое, что и pref_uint(), но для чисел со знаком
//
int32_t pref_int(const char *name, int32_t def) {
  const char *s = pref_string(name, NULL);
  return s ? (int32_t )atoi(s) : def;
}

// И еще разок, но теперь уже для чисел с плавающей точкой
float pref_float(const char *name, float def) {
  const char *s = pref_string(name, NULL);
  return s ? (float )atof(s) : def;
}

Пример использования (загрузка параметров, добавление параметров к загруженным, удаление параметров, чтение значений параметров, альтернативный синтаксис для файла настроек):

#include <Arduino.h>

#include "prefs.h"


// файл с параметрами
//
static const char *config1 = 
"esp32 {\n"
"\n"
"  core {\n"
"     clocks {\n"
"        pll = 240\n"
"        xtal=40\n"
"        apb =  -80\n"
"        features=\n"
"     }\n"
"     pwm {\n"
"       enabled = yes\n"
"       freq = 666   \n"
"       duty = 0.78   \n"
"     }\n"
"  }\n"
"}\n";



// еще один
//
static const char *config2 = 
"esp32s3{\n"
"         \n"
"  core {  \n"
"           \n"
"     clocks \n"
"      {pll = 480\n"
"        xtal=80\n"
"        apb =  160\n"
"        features=\n"
"          extraa_flags=10\n"
"     }\n"
"\n"
"     pwm {\n"
"       enabled = yes\n"
"       freq = 6666\n"
"       duty = 0.87\n"
"     }\n"
"  }\n"
"}";


// да, так тоже можно. просто имя = значение, по одному параметру на строчку, точки в именах запрещены!
//
static const char *config3 = 
"esp32s3_core_clocks_pll  = 480\n"
"esp32s3_core_clocks_xtal =  80\n"
"esp32s3_core_clocks_apb=160\n"
"esp32s3_core_pwm_duty = 0.87\n";


void setup() {
  Serial.begin(115200);
}

void loop() {

  // загружаем первый файл
  pref_reload(config1);

  // добавляем еше один файл
  pref_append(config2);

  // печатаем три значения в виде unsigned, signed и float
  Serial.printf("PLL=%u    APB=:%d     DUTY=%f\r\n",
      pref_uint("esp32.core.clocks.pll",0),
      pref_int("esp32.core.clocks.apb",0),
      pref_float("esp32.core.pwm.duty",0));


  // два значения наугад, а одно - несуществующее. Несуществующее (duty1) будет прочитано как 0.666 (см. ниже)
  Serial.printf("PLL=:%u    APB=%d     DUTY1:%f\r\n",
      pref_uint("esp32s3.core.clocks.pll",0),
      pref_int("esp32s3.core.clocks.apb",0),
      pref_float("esp32s3.core.pwm.duty1",0.666));

 // удаляем старые настройки и загружаем новые.
 // в этот раз - еще и в другом формате, без скобочек и иерархии.
  pref_reload(config3);

  Serial.printf("PLL=%u    APB=%d     DUTY=%f\r\n",
      pref_uint("esp32s3_core_clocks_pll",0),
      pref_int("esp32s3_core_clocks_apb",0),
      pref_float("esp32s3_core_pwm_duty",0));

  pref_purge();

  delay(10000);
}

Утюг, плита, доска - это всё понятно. Идея в том держать ли всю кухню в голове, либо же держать отдельные инструменты.)