From 828e23856ce82f3e15c74fa15340a039697a29de Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:59:57 -0600 Subject: [PATCH 1/3] fix(events): a saved event or conditional without its id no longer stops the driver from starting lib/conditionals.lua and lib/events.lua looped over their saved records indexing by each one's id, so a record without one, or one that was not a table, threw "table index is nil" from OnDriverLateInit. Each loop now skips any saved entry without its id. --- template/src/lib/conditionals.lua | 39 ++++++---- template/src/lib/events.lua | 41 +++++----- .../test/test_saved_entries_without_ids.lua | 76 +++++++++++++++++++ 3 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 template/test/test_saved_entries_without_ids.lua diff --git a/template/src/lib/conditionals.lua b/template/src/lib/conditionals.lua index 5f1d372..51abfa5 100644 --- a/template/src/lib/conditionals.lua +++ b/template/src/lib/conditionals.lua @@ -28,6 +28,19 @@ local CONDITIONAL_ID_START = 10 --- @field conditionalId number --- @field name string +--- Calls `fn` with each saved conditional that has its ID, skipping any other saved entry. +--- @param saved table> The saved conditionals. +--- @param fn fun(conditional: Conditional) +local function eachConditional(saved, fn) + for _, keys in pairs(saved) do + for _, conditional in pairs(type(keys) == "table" and keys or {}) do + if Select(conditional, "conditionalId") ~= nil then + fn(conditional) + end + end + end +end + --- Creates a new instance of the `Conditionals` class. --- @return Conditionals conditionals A new instance of the `Conditionals` class. function Conditionals:new() @@ -96,11 +109,9 @@ end function Conditionals:_getNextConditionalId() log:trace("Conditionals:_getNextConditionalId()") local currentConditionals = {} - for _, keys in pairs(self:getConditionals()) do - for _, conditional in pairs(keys) do - currentConditionals[conditional.conditionalId] = true - end - end + eachConditional(self:getConditionals(), function(conditional) + currentConditionals[conditional.conditionalId] = true + end) local nextId = CONDITIONAL_ID_START while currentConditionals[nextId] ~= nil do nextId = nextId + 1 @@ -128,12 +139,10 @@ end --- Resets all conditionals, removing them from the system and clearing persisted storage. function Conditionals:reset() log:trace("Conditionals:reset()") - for _, nsConditionals in pairs(self:getConditionals()) do - for _, conditional in pairs(nsConditionals) do - log:debug("Removing conditional '%s' (id=%s)", conditional.name, conditional.conditionalId) - TC[conditional.name] = nil - end - end + eachConditional(self:getConditionals(), function(conditional) + log:debug("Removing conditional '%s' (id=%s)", conditional.name, conditional.conditionalId) + TC[conditional.name] = nil + end) self:_saveConditionals(nil) end @@ -144,11 +153,9 @@ local conditionals = Conditionals:new() function GetConditionals() log:trace("GetConditionals()") local progConditionals = {} - for _, keys in pairs(conditionals:getConditionals()) do - for _, conditional in pairs(keys) do - progConditionals[tostring(conditional.conditionalId)] = conditional - end - end + eachConditional(conditionals:getConditionals(), function(conditional) + progConditionals[tostring(conditional.conditionalId)] = conditional + end) return progConditionals end diff --git a/template/src/lib/events.lua b/template/src/lib/events.lua index dd9e399..818e488 100644 --- a/template/src/lib/events.lua +++ b/template/src/lib/events.lua @@ -28,6 +28,19 @@ local EVENT_ID_END = 999 --- @field name string --- @field description string +--- Calls `fn` with each saved event that has its ID, skipping any other saved entry. +--- @param saved table> The saved events. +--- @param fn fun(event: Event) +local function eachEvent(saved, fn) + for _, keys in pairs(saved) do + for _, event in pairs(type(keys) == "table" and keys or {}) do + if Select(event, "eventId") ~= nil then + fn(event) + end + end + end +end + --- Creates a new `Events` instance. --- @return Events events A new `Events` instance. function Events:new() @@ -117,12 +130,10 @@ function Events:restoreEvents() log:trace("Events:restoreEvents()") --- @type table local usedEventIds = {} - for _, keys in pairs(self:getEvents()) do - for _, event in pairs(keys) do - usedEventIds[event.eventId] = true - C4:AddEvent(event.eventId, event.name, event.description) - end - end + eachEvent(self:getEvents(), function(event) + usedEventIds[event.eventId] = true + C4:AddEvent(event.eventId, event.name, event.description) + end) for id = EVENT_ID_START, EVENT_ID_END do if usedEventIds[id] == nil then log:trace("Deleting non-configured event %s, if it exists", id) @@ -138,11 +149,9 @@ function Events:_getNextEventId() log:trace("Events:_getNextEventId()") --- @type table local currentEvents = {} - for _, keys in pairs(self:getEvents()) do - for _, event in pairs(keys) do - currentEvents[event.eventId] = true - end - end + eachEvent(self:getEvents(), function(event) + currentEvents[event.eventId] = true + end) local nextId = EVENT_ID_START while currentEvents[nextId] ~= nil do nextId = nextId + 1 @@ -170,12 +179,10 @@ end --- Resets all dynamic events, removing them from the system and clearing persisted storage. function Events:reset() log:trace("Events:reset()") - for _, nsEvents in pairs(self:getEvents()) do - for _, event in pairs(nsEvents) do - log:debug("Removing event '%s' (id=%s)", event.name, event.eventId) - C4:DeleteEvent(event.eventId) - end - end + eachEvent(self:getEvents(), function(event) + log:debug("Removing event '%s' (id=%s)", event.name, event.eventId) + C4:DeleteEvent(event.eventId) + end) self:_saveEvents(nil) end diff --git a/template/test/test_saved_entries_without_ids.lua b/template/test/test_saved_entries_without_ids.lua new file mode 100644 index 0000000..b0a4bc6 --- /dev/null +++ b/template/test/test_saved_entries_without_ids.lua @@ -0,0 +1,76 @@ +-- What lib/conditionals.lua and lib/events.lua do with a saved record that comes back without its id, or as +-- something other than a table. Each loop over the saved records indexed by that id, so one such record threw +-- "table index is nil" at the start of OnDriverLateInit and stopped the driver from starting. +-- +-- Run from the driver root: +-- make test +-- or: +-- ./test/run_test.sh test_saved_entries_without_ids.lua + +local T = require("testlib") + +require("c4_shim") +require("drivers-common-public.global.handlers") -- TC + +local persist = require("lib.persist") + +--- Saves records under key the way a driver might find them: one whole, one without its id, one that is not a +--- table, and a namespace that is not a table either. Then fresh modules read them back, as after a restart. +local function savedAfterRestart(key, whole, noId) + persist:set(key, { Buttons = { whole = whole, noId = noId, notATable = "x" }, Broken = "x" }) + ShimPersistLand() + T.unload("^lib%.") + ShimResetEvents() + persist = require("lib.persist") +end + +--- The keys of t, sorted. +local function keys(t) + local out = {} + for k in pairs(t or {}) do + out[#out + 1] = k + end + table.sort(out) + return out +end + +T.section("a saved conditional without its id is skipped") +savedAfterRestart( + "Conditionals", + { conditionalId = 10, name = "CONDITIONAL_10", type = "BOOL", condition_statement = "IS", description = "Whole" }, + { name = "CONDITIONAL_LOST", type = "BOOL", condition_statement = "IS", description = "Lost its id" } +) +local conditionals = require("lib.conditionals") +local ok, listed = pcall(GetConditionals) +T.truthy("GetConditionals runs", ok, listed) +T.eq("and lists only the whole one", keys(ok and listed or nil), { "10" }) +local added +ok, added = pcall(conditionals.upsertConditional, conditionals, "Buttons", "new", { + type = "BOOL", + condition_statement = "IS", + description = "New", +}, function() + return true +end) +T.truthy("a new one is added", ok, added) +T.eq("under the next free id", ok and added.conditionalId, 11) +T.eq("with its test", type(TC.CONDITIONAL_11), "function") +T.truthy("reset runs", pcall(conditionals.reset, conditionals)) +T.eq("and removes the test", TC.CONDITIONAL_11, nil) + +T.section("a saved event without its id is skipped") +savedAfterRestart( + "Events", + { eventId = 10, name = "Whole", description = "When whole" }, + { name = "Lost", description = "Lost its id" } +) +local events = require("lib.events") +T.truthy("restoreEvents runs", pcall(events.restoreEvents, events)) +T.eq("and declares only the whole one", keys(ShimEvents()), { 10 }) +ok, added = pcall(events.getOrAddEvent, events, "Buttons", "new", "New", "When new") +T.truthy("a new one is added", ok, added) +T.eq("under the next free id", ok and added.eventId, 11) +T.truthy("reset runs", pcall(events.reset, events)) +T.eq("and deletes both", keys(ShimEvents()), {}) + +T.finish() From 407f5251c555916d076de290a43009a8640b4932 Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:00:55 -0600 Subject: [PATCH 2/3] test(renders): the snapshots list test_saved_entries_without_ids.lua --- tests/template-renders/crypto-bundles.files | 1 + tests/template-renders/dc-product.files | 1 + tests/template-renders/default.files | 1 + tests/template-renders/diagnostics.files | 1 + tests/template-renders/oauth.files | 1 + tests/template-renders/stale-issues-no-exempt.files | 1 + tests/template-renders/stale-issues.files | 1 + tests/template-renders/ui-expressions.files | 1 + tests/template-renders/ui.files | 1 + tests/template-renders/variant-readme.files | 1 + 10 files changed, 10 insertions(+) diff --git a/tests/template-renders/crypto-bundles.files b/tests/template-renders/crypto-bundles.files index d2a6f66..5f713da 100644 --- a/tests/template-renders/crypto-bundles.files +++ b/tests/template-renders/crypto-bundles.files @@ -71,6 +71,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua diff --git a/tests/template-renders/dc-product.files b/tests/template-renders/dc-product.files index 40981c0..194238e 100644 --- a/tests/template-renders/dc-product.files +++ b/tests/template-renders/dc-product.files @@ -67,6 +67,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua diff --git a/tests/template-renders/default.files b/tests/template-renders/default.files index 40981c0..194238e 100644 --- a/tests/template-renders/default.files +++ b/tests/template-renders/default.files @@ -67,6 +67,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua diff --git a/tests/template-renders/diagnostics.files b/tests/template-renders/diagnostics.files index b4f98cb..0bf7eba 100644 --- a/tests/template-renders/diagnostics.files +++ b/tests/template-renders/diagnostics.files @@ -70,6 +70,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua diff --git a/tests/template-renders/oauth.files b/tests/template-renders/oauth.files index 6b70a96..0c23065 100644 --- a/tests/template-renders/oauth.files +++ b/tests/template-renders/oauth.files @@ -69,6 +69,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua diff --git a/tests/template-renders/stale-issues-no-exempt.files b/tests/template-renders/stale-issues-no-exempt.files index dc0416f..ae089fa 100644 --- a/tests/template-renders/stale-issues-no-exempt.files +++ b/tests/template-renders/stale-issues-no-exempt.files @@ -68,6 +68,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua diff --git a/tests/template-renders/stale-issues.files b/tests/template-renders/stale-issues.files index dc0416f..ae089fa 100644 --- a/tests/template-renders/stale-issues.files +++ b/tests/template-renders/stale-issues.files @@ -68,6 +68,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua diff --git a/tests/template-renders/ui-expressions.files b/tests/template-renders/ui-expressions.files index ef3d30f..aff3b6e 100644 --- a/tests/template-renders/ui-expressions.files +++ b/tests/template-renders/ui-expressions.files @@ -82,6 +82,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua diff --git a/tests/template-renders/ui.files b/tests/template-renders/ui.files index 9038d70..0b27804 100644 --- a/tests/template-renders/ui.files +++ b/tests/template-renders/ui.files @@ -75,6 +75,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua diff --git a/tests/template-renders/variant-readme.files b/tests/template-renders/variant-readme.files index 40981c0..194238e 100644 --- a/tests/template-renders/variant-readme.files +++ b/tests/template-renders/variant-readme.files @@ -67,6 +67,7 @@ test/test_persist_missing_key.lua test/test_persist_reload.lua test/test_persist_undecodable.lua test/test_persist_write_behind.lua +test/test_saved_entries_without_ids.lua test/test_secret_sends_trace.lua test/test_sensor_params.lua test/test_shim_bindings.lua From eabba5f29fdabd0ca0dd79b63985ffa418a3a065 Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:06:59 -0600 Subject: [PATCH 3/3] fix(events): guard only restoring events and finding the next free id, as Home Connect #114 does --- template/src/lib/conditionals.lua | 41 ++++++++--------- template/src/lib/events.lua | 45 +++++++++---------- .../test/test_saved_entries_without_ids.lua | 21 +++------ 3 files changed, 45 insertions(+), 62 deletions(-) diff --git a/template/src/lib/conditionals.lua b/template/src/lib/conditionals.lua index 51abfa5..18c321e 100644 --- a/template/src/lib/conditionals.lua +++ b/template/src/lib/conditionals.lua @@ -28,19 +28,6 @@ local CONDITIONAL_ID_START = 10 --- @field conditionalId number --- @field name string ---- Calls `fn` with each saved conditional that has its ID, skipping any other saved entry. ---- @param saved table> The saved conditionals. ---- @param fn fun(conditional: Conditional) -local function eachConditional(saved, fn) - for _, keys in pairs(saved) do - for _, conditional in pairs(type(keys) == "table" and keys or {}) do - if Select(conditional, "conditionalId") ~= nil then - fn(conditional) - end - end - end -end - --- Creates a new instance of the `Conditionals` class. --- @return Conditionals conditionals A new instance of the `Conditionals` class. function Conditionals:new() @@ -109,9 +96,13 @@ end function Conditionals:_getNextConditionalId() log:trace("Conditionals:_getNextConditionalId()") local currentConditionals = {} - eachConditional(self:getConditionals(), function(conditional) - currentConditionals[conditional.conditionalId] = true - end) + for _, keys in pairs(self:getConditionals()) do + for _, conditional in pairs(keys) do + if Select(conditional, "conditionalId") ~= nil then + currentConditionals[conditional.conditionalId] = true + end + end + end local nextId = CONDITIONAL_ID_START while currentConditionals[nextId] ~= nil do nextId = nextId + 1 @@ -139,10 +130,12 @@ end --- Resets all conditionals, removing them from the system and clearing persisted storage. function Conditionals:reset() log:trace("Conditionals:reset()") - eachConditional(self:getConditionals(), function(conditional) - log:debug("Removing conditional '%s' (id=%s)", conditional.name, conditional.conditionalId) - TC[conditional.name] = nil - end) + for _, nsConditionals in pairs(self:getConditionals()) do + for _, conditional in pairs(nsConditionals) do + log:debug("Removing conditional '%s' (id=%s)", conditional.name, conditional.conditionalId) + TC[conditional.name] = nil + end + end self:_saveConditionals(nil) end @@ -153,9 +146,11 @@ local conditionals = Conditionals:new() function GetConditionals() log:trace("GetConditionals()") local progConditionals = {} - eachConditional(conditionals:getConditionals(), function(conditional) - progConditionals[tostring(conditional.conditionalId)] = conditional - end) + for _, keys in pairs(conditionals:getConditionals()) do + for _, conditional in pairs(keys) do + progConditionals[tostring(conditional.conditionalId)] = conditional + end + end return progConditionals end diff --git a/template/src/lib/events.lua b/template/src/lib/events.lua index 818e488..2d5e156 100644 --- a/template/src/lib/events.lua +++ b/template/src/lib/events.lua @@ -28,19 +28,6 @@ local EVENT_ID_END = 999 --- @field name string --- @field description string ---- Calls `fn` with each saved event that has its ID, skipping any other saved entry. ---- @param saved table> The saved events. ---- @param fn fun(event: Event) -local function eachEvent(saved, fn) - for _, keys in pairs(saved) do - for _, event in pairs(type(keys) == "table" and keys or {}) do - if Select(event, "eventId") ~= nil then - fn(event) - end - end - end -end - --- Creates a new `Events` instance. --- @return Events events A new `Events` instance. function Events:new() @@ -130,10 +117,14 @@ function Events:restoreEvents() log:trace("Events:restoreEvents()") --- @type table local usedEventIds = {} - eachEvent(self:getEvents(), function(event) - usedEventIds[event.eventId] = true - C4:AddEvent(event.eventId, event.name, event.description) - end) + for _, keys in pairs(self:getEvents()) do + for _, event in pairs(keys) do + if Select(event, "eventId") ~= nil then + usedEventIds[event.eventId] = true + C4:AddEvent(event.eventId, event.name, event.description) + end + end + end for id = EVENT_ID_START, EVENT_ID_END do if usedEventIds[id] == nil then log:trace("Deleting non-configured event %s, if it exists", id) @@ -149,9 +140,13 @@ function Events:_getNextEventId() log:trace("Events:_getNextEventId()") --- @type table local currentEvents = {} - eachEvent(self:getEvents(), function(event) - currentEvents[event.eventId] = true - end) + for _, keys in pairs(self:getEvents()) do + for _, event in pairs(keys) do + if Select(event, "eventId") ~= nil then + currentEvents[event.eventId] = true + end + end + end local nextId = EVENT_ID_START while currentEvents[nextId] ~= nil do nextId = nextId + 1 @@ -179,10 +174,12 @@ end --- Resets all dynamic events, removing them from the system and clearing persisted storage. function Events:reset() log:trace("Events:reset()") - eachEvent(self:getEvents(), function(event) - log:debug("Removing event '%s' (id=%s)", event.name, event.eventId) - C4:DeleteEvent(event.eventId) - end) + for _, nsEvents in pairs(self:getEvents()) do + for _, event in pairs(nsEvents) do + log:debug("Removing event '%s' (id=%s)", event.name, event.eventId) + C4:DeleteEvent(event.eventId) + end + end self:_saveEvents(nil) end diff --git a/template/test/test_saved_entries_without_ids.lua b/template/test/test_saved_entries_without_ids.lua index b0a4bc6..350a67c 100644 --- a/template/test/test_saved_entries_without_ids.lua +++ b/template/test/test_saved_entries_without_ids.lua @@ -1,6 +1,6 @@ -- What lib/conditionals.lua and lib/events.lua do with a saved record that comes back without its id, or as --- something other than a table. Each loop over the saved records indexed by that id, so one such record threw --- "table index is nil" at the start of OnDriverLateInit and stopped the driver from starting. +-- something other than a table. Restoring events and finding the next free id indexed by that id, so one such +-- record threw "table index is nil" at the start of OnDriverLateInit and stopped the driver from starting. -- -- Run from the driver root: -- make test @@ -14,10 +14,10 @@ require("drivers-common-public.global.handlers") -- TC local persist = require("lib.persist") ---- Saves records under key the way a driver might find them: one whole, one without its id, one that is not a ---- table, and a namespace that is not a table either. Then fresh modules read them back, as after a restart. +--- Saves records under key the way a driver might find them: one whole, one without its id and one that is not a +--- table. Then fresh modules read them back, as after a restart. local function savedAfterRestart(key, whole, noId) - persist:set(key, { Buttons = { whole = whole, noId = noId, notATable = "x" }, Broken = "x" }) + persist:set(key, { Buttons = { whole = whole, noId = noId, notATable = "x" } }) ShimPersistLand() T.unload("^lib%.") ShimResetEvents() @@ -41,11 +41,7 @@ savedAfterRestart( { name = "CONDITIONAL_LOST", type = "BOOL", condition_statement = "IS", description = "Lost its id" } ) local conditionals = require("lib.conditionals") -local ok, listed = pcall(GetConditionals) -T.truthy("GetConditionals runs", ok, listed) -T.eq("and lists only the whole one", keys(ok and listed or nil), { "10" }) -local added -ok, added = pcall(conditionals.upsertConditional, conditionals, "Buttons", "new", { +local ok, added = pcall(conditionals.upsertConditional, conditionals, "Buttons", "new", { type = "BOOL", condition_statement = "IS", description = "New", @@ -54,9 +50,6 @@ ok, added = pcall(conditionals.upsertConditional, conditionals, "Buttons", "new" end) T.truthy("a new one is added", ok, added) T.eq("under the next free id", ok and added.conditionalId, 11) -T.eq("with its test", type(TC.CONDITIONAL_11), "function") -T.truthy("reset runs", pcall(conditionals.reset, conditionals)) -T.eq("and removes the test", TC.CONDITIONAL_11, nil) T.section("a saved event without its id is skipped") savedAfterRestart( @@ -70,7 +63,5 @@ T.eq("and declares only the whole one", keys(ShimEvents()), { 10 }) ok, added = pcall(events.getOrAddEvent, events, "Buttons", "new", "New", "When new") T.truthy("a new one is added", ok, added) T.eq("under the next free id", ok and added.eventId, 11) -T.truthy("reset runs", pcall(events.reset, events)) -T.eq("and deletes both", keys(ShimEvents()), {}) T.finish()