feat(firmware): add 'user data' section to model yaml for Lua scripts (#7562)

Co-authored-by: Peter Feerick <peter.feerick@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
philmoz
2026-09-18 09:04:48 +10:00
committed by GitHub
co-authored by Peter Feerick Claude Sonnet 5
parent abeed9f23b
commit ed71a38726
45 changed files with 1105 additions and 16 deletions
+1
View File
@@ -82,6 +82,7 @@ inline void SYSTEM_RESET()
inline void MODEL_RESET()
{
memset(&g_model, 0, sizeof(g_model));
g_model.clearUserData();
anaResetFiltered();
extern uint8_t s_mixer_first_run_done;
s_mixer_first_run_done = false;
+57
View File
@@ -283,4 +283,61 @@ TEST(Lua, ioSeek)
std::filesystem::remove(simuFatfsGetRealPath("seek-test.txt"));
}
// Adapted from the sample script in PR #7562 (minus the LVGL UI part):
// populates the same app/key/value set and exercises the full Lua API
// through the real binding layer, not just the underlying C++ store.
TEST(Lua, testUserData)
{
MODEL_RESET();
const char userdata_tst[] =
"model.setUserData('App', 'K1', 'String')\n"
"model.setUserData('App', 'K2', 12345)\n"
"model.setUserData('App', 'K3', 12.345)\n"
"assert(model.getUserData('App', 'K1') == 'String')\n"
"assert(model.getUserData('App', 'K2') == 12345)\n"
"assert(math.abs(model.getUserData('App', 'K3') - 12.345) < 0.001)\n"
"assert(model.getUserData('Other', 'K1') == nil)\n"
"local ud = model.getAllUserData('App')\n"
"local n = 0\n"
"for k in pairs(ud) do n = n + 1 end\n"
"assert(n == 3)\n"
"assert(ud.K1 == 'String')\n"
"local all = model.getAllUserData()\n"
"assert(all['App|K1'] == 'String')\n"
"model.deleteUserData('App', 'K1')\n"
"assert(model.getUserData('App', 'K1') == nil)\n"
"assert(model.getUserData('App', 'K2') == 12345)\n";
luaExecStr(userdata_tst);
}
// string.char() builds the value at runtime, sidestepping the fact that
// luaL_loadstring() (used to load this test's own Lua source) is itself
// strlen()-based and couldn't carry a literal embedded NUL through.
TEST(Lua, testUserDataEmbeddedNul)
{
MODEL_RESET();
const char userdata_nul_tst[] =
"local v = string.char(65, 0, 66)\n"
"assert(#v == 3)\n"
"model.setUserData('App', 'NulKey', v)\n"
"local got = model.getUserData('App', 'NulKey')\n"
"assert(#got == 3)\n"
"assert(got == v)\n"
"assert(string.byte(got, 2) == 0)\n"
"local all = model.getAllUserData('App')\n"
"assert(#all.NulKey == 3)\n"
"assert(all.NulKey == v)\n";
luaExecStr(userdata_nul_tst);
}
#endif // #if defined(LUA)
+210
View File
@@ -0,0 +1,210 @@
/*
* Copyright (C) EdgeTX
*
* Based on code named
* opentx - https://github.com/opentx/opentx
* th9x - http://code.google.com/p/th9x
* er9x - http://code.google.com/p/er9x
* gruvin9x - http://code.google.com/p/gruvin9x
*
* License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
#include "gtests.h"
#include "storage/yaml/yaml_tree_walker.h"
#include "storage/yaml/yaml_parser.h"
#include "storage/yaml/yaml_datastructs.h"
class UserDataTest : public EdgeTxTest {};
TEST_F(UserDataTest, SetGetString)
{
EXPECT_TRUE(g_model.setUserData("key1", "hello"));
auto ud = g_model.getUserData("key1");
ASSERT_NE(ud, nullptr);
EXPECT_EQ(ud->type, UD_STRING);
EXPECT_EQ(ud->value, "hello");
}
TEST_F(UserDataTest, SetGetInt)
{
EXPECT_TRUE(g_model.setUserData("key1", (int32_t)12345));
auto ud = g_model.getUserData("key1");
ASSERT_NE(ud, nullptr);
EXPECT_EQ(ud->type, UD_INT);
EXPECT_EQ(strtol(ud->value.c_str(), nullptr, 10), 12345);
}
TEST_F(UserDataTest, FloatRoundTrip)
{
// std::to_string() fixes 6 decimal places and would truncate this to
// "0.000000" - %.9g must preserve enough precision to round-trip.
float f = 1.234567e-5f;
EXPECT_TRUE(g_model.setUserData("key1", f));
auto ud = g_model.getUserData("key1");
ASSERT_NE(ud, nullptr);
EXPECT_EQ(ud->type, UD_FLOAT);
EXPECT_NEAR(strtof(ud->value.c_str(), nullptr), f, f * 1e-5f);
}
TEST_F(UserDataTest, UpdateExistingChangesType)
{
// A value/type change on an existing key must persist even when the
// string representation happens to be identical either side.
EXPECT_TRUE(g_model.setUserData("key1", (int32_t)123));
EXPECT_TRUE(g_model.setUserData("key1", "123"));
auto ud = g_model.getUserData("key1");
ASSERT_NE(ud, nullptr);
EXPECT_EQ(ud->type, UD_STRING);
}
TEST_F(UserDataTest, DeleteRemovesEntry)
{
g_model.setUserData("key1", "a");
g_model.deleteUserData("key1");
EXPECT_EQ(g_model.getUserData("key1"), nullptr);
EXPECT_EQ(g_model.getUserDataCount(), 0);
}
TEST_F(UserDataTest, MaxEntriesRespected)
{
char key[16];
for (int i = 0; i < MAX_USER_DATA; i += 1) {
snprintf(key, sizeof(key), "k%d", i);
EXPECT_TRUE(g_model.setUserData(key, i));
}
EXPECT_FALSE(g_model.setUserData("overflow", 0));
EXPECT_EQ(g_model.getUserDataCount(), MAX_USER_DATA);
}
TEST_F(UserDataTest, GetOrCreateSparseIndex)
{
// Simulates the YAML reader encountering a non-contiguous index
// (e.g. a hand-edited or Companion-generated model file): the entry
// must land at the requested index, not be appended elsewhere.
auto ud = g_model.getOrCreateUserData(3);
ASSERT_NE(ud, nullptr);
ud->key = "k3";
EXPECT_EQ(g_model.getUserDataCount(), 4);
EXPECT_EQ(g_model.getUserData(3)->key, "k3");
// Gap slots exist but are inactive (empty key)
EXPECT_FALSE(g_model.hasUserData(0));
EXPECT_TRUE(g_model.hasUserData(3));
}
TEST_F(UserDataTest, GetOrCreateRespectsMax)
{
EXPECT_EQ(g_model.getOrCreateUserData(MAX_USER_DATA), nullptr);
}
TEST_F(UserDataTest, SetUserDataReusesGapSlot)
{
// A sparse YAML load can leave the store full of inactive gap slots
// (empty key) well before MAX_USER_DATA active entries exist; setUserData
// must reuse a gap rather than spuriously refusing the new entry.
auto ud = g_model.getOrCreateUserData(MAX_USER_DATA - 1);
ASSERT_NE(ud, nullptr);
ud->key = "last";
EXPECT_EQ(g_model.getUserDataCount(), MAX_USER_DATA);
EXPECT_TRUE(g_model.setUserData("newkey", "value"));
EXPECT_EQ(g_model.getUserDataCount(), MAX_USER_DATA);
auto found = g_model.getUserData("newkey");
ASSERT_NE(found, nullptr);
EXPECT_EQ(found->value, "value");
}
TEST_F(UserDataTest, SetGetEmbeddedNulString)
{
const char raw[] = {'a', '\0', 'b', 'c'};
EXPECT_TRUE(g_model.setUserData("key1", raw, sizeof(raw)));
auto ud = g_model.getUserData("key1");
ASSERT_NE(ud, nullptr);
EXPECT_EQ(ud->value.size(), sizeof(raw));
EXPECT_EQ(memcmp(ud->value.data(), raw, sizeof(raw)), 0);
}
static bool yaml_string_writer(void* opaque, const char* str, size_t len)
{
static_cast<std::string*>(opaque)->append(str, len);
return true;
}
TEST_F(UserDataTest, YamlRoundTripPreservesEmbeddedNul)
{
const char raw[] = {'e', 'm', 'b', '\0', 'X'};
ASSERT_TRUE(g_model.setUserData("nulkey", raw, sizeof(raw)));
// Pin the no-regression case alongside it: a value over 255 bytes with
// no embedded NUL must still round-trip in full.
std::string longVal(300, 'Y');
ASSERT_TRUE(g_model.setUserData("longkey", longVal.c_str(), longVal.size()));
std::string yaml;
{
YamlTreeWalker tree;
tree.reset(get_modeldata_nodes(), (uint8_t*)&g_model);
ASSERT_TRUE(tree.generate(yaml_string_writer, &yaml));
}
g_model.clearUserData();
ASSERT_EQ(g_model.getUserDataCount(), 0);
{
YamlTreeWalker tree;
tree.reset(get_modeldata_nodes(), (uint8_t*)&g_model);
YamlParser yp;
yp.init(YamlTreeWalker::get_parser_calls(), &tree);
yp.parse(yaml.c_str(), yaml.size());
}
auto ud = g_model.getUserData("nulkey");
ASSERT_NE(ud, nullptr);
EXPECT_EQ(ud->value.size(), sizeof(raw));
EXPECT_EQ(memcmp(ud->value.data(), raw, sizeof(raw)), 0);
auto udLong = g_model.getUserData("longkey");
ASSERT_NE(udLong, nullptr);
EXPECT_EQ(udLong->value, longVal);
}
// Characterization test: 255 bytes is the largest value for which the
// embedded-NUL fix is guaranteed exact (see userdata_val_len() in
// yaml_datastructs_funcs.cpp) - pin the boundary explicitly.
TEST_F(UserDataTest, YamlRoundTripPreserves255ByteEmbeddedNul)
{
std::string raw(255, 'Z');
raw[100] = '\0';
ASSERT_TRUE(g_model.setUserData("boundary", raw.data(), raw.size()));
std::string yaml;
{
YamlTreeWalker tree;
tree.reset(get_modeldata_nodes(), (uint8_t*)&g_model);
ASSERT_TRUE(tree.generate(yaml_string_writer, &yaml));
}
g_model.clearUserData();
{
YamlTreeWalker tree;
tree.reset(get_modeldata_nodes(), (uint8_t*)&g_model);
YamlParser yp;
yp.init(YamlTreeWalker::get_parser_calls(), &tree);
yp.parse(yaml.c_str(), yaml.size());
}
auto ud = g_model.getUserData("boundary");
ASSERT_NE(ud, nullptr);
EXPECT_EQ(ud->value, raw);
}